Browse Source

Raise Exception when awaiting JavaScript on auto-index page (#2983)

* raise RuntimeError when awaiting JavaScript on auto-index page

* add pytest

* fix other failing tests (most of them)

* improve deletion of dismissed `ui.notification` elements

* update clipboard documentation

* fix codemirror test
Falko Schindler 1 year ago
parent
commit
5c444ff538

+ 3 - 0
nicegui/client.py

@@ -212,6 +212,9 @@ class Client:
             self.outbox.enqueue_message('run_javascript', {'code': code}, target_id)
             self.outbox.enqueue_message('run_javascript', {'code': code}, target_id)
 
 
         async def send_and_wait():
         async def send_and_wait():
+            if self is self.auto_index_client:
+                raise RuntimeError('Cannot await JavaScript responses on the auto-index page. '
+                                   'There could be multiple clients connected and it is not clear which one to wait for.')
             self.outbox.enqueue_message('run_javascript', {'code': code, 'request_id': request_id}, target_id)
             self.outbox.enqueue_message('run_javascript', {'code': code, 'request_id': request_id}, target_id)
             return await JavaScriptRequest(request_id, timeout=timeout)
             return await JavaScriptRequest(request_id, timeout=timeout)
 
 

+ 14 - 2
nicegui/elements/notification.js

@@ -1,15 +1,27 @@
+import { convertDynamicProperties } from "../../static/utils/dynamic_properties.js";
+
 export default {
 export default {
   mounted() {
   mounted() {
-    this.notification = Quasar.Notify.create(this.options);
+    this.notification = Quasar.Notify.create(this.convertedOptions);
   },
   },
   updated() {
   updated() {
-    this.notification(this.options);
+    this.notification(this.convertedOptions);
   },
   },
   methods: {
   methods: {
     dismiss() {
     dismiss() {
       this.notification();
       this.notification();
     },
     },
   },
   },
+  computed: {
+    convertedOptions() {
+      convertDynamicProperties(this.options, true);
+      const options = {
+        ...this.options,
+        onDismiss: () => this.$emit("dismiss"),
+      };
+      return options;
+    },
+  },
   props: {
   props: {
     options: Object,
     options: Object,
   },
   },

+ 22 - 11
nicegui/elements/notification.py

@@ -1,8 +1,11 @@
-from typing import Any, Literal, Optional, Union
+import asyncio
+from typing import Any, Callable, Literal, Optional, Union
+
+from typing_extensions import Self
 
 
 from ..context import context
 from ..context import context
 from ..element import Element
 from ..element import Element
-from .timer import Timer
+from ..events import UiEventArguments, handle_event
 
 
 NotificationPosition = Literal[
 NotificationPosition = Literal[
     'top-left',
     'top-left',
@@ -37,6 +40,7 @@ class Notification(Element, component='notification.js'):
                  icon: Optional[str] = None,
                  icon: Optional[str] = None,
                  spinner: bool = False,
                  spinner: bool = False,
                  timeout: Optional[float] = 5.0,
                  timeout: Optional[float] = 5.0,
+                 on_dismiss: Optional[Callable] = None,
                  **kwargs: Any,
                  **kwargs: Any,
                  ) -> None:
                  ) -> None:
         """Notification element
         """Notification element
@@ -54,6 +58,7 @@ class Notification(Element, component='notification.js'):
         :param icon: optional name of an icon to be displayed in the notification (default: `None`)
         :param icon: optional name of an icon to be displayed in the notification (default: `None`)
         :param spinner: display a spinner in the notification (default: False)
         :param spinner: display a spinner in the notification (default: False)
         :param timeout: optional timeout in seconds after which the notification is dismissed (default: 5.0)
         :param timeout: optional timeout in seconds after which the notification is dismissed (default: 5.0)
+        :param on_dismiss: optional callback to be invoked when the notification is dismissed
 
 
         Note: You can pass additional keyword arguments according to `Quasar's Notify API <https://quasar.dev/quasar-plugins/notify#notify-api>`_.
         Note: You can pass additional keyword arguments according to `Quasar's Notify API <https://quasar.dev/quasar-plugins/notify#notify-api>`_.
         """
         """
@@ -76,17 +81,18 @@ class Notification(Element, component='notification.js'):
         if icon is not None:
         if icon is not None:
             self._props['options']['icon'] = icon
             self._props['options']['icon'] = icon
         self._props['options'].update(kwargs)
         self._props['options'].update(kwargs)
-        with self:
-            def delete():
-                self.clear()
-                self.delete()
 
 
-            async def try_delete():
-                query = f'''!!document.querySelector("[data-id='nicegui-dialog-{self.id}']")'''
-                if not await self.client.run_javascript(query):
-                    delete()
+        if on_dismiss:
+            self.on_dismiss(on_dismiss)
 
 
-            Timer(1.0, try_delete)
+        async def handle_dismiss() -> None:
+            if self.client.is_auto_index_client:
+                self.dismiss()
+                await asyncio.sleep(1.0)  # NOTE: sent dismiss message to all browsers before deleting the element
+            if not self._deleted:
+                self.clear()
+                self.delete()
+        self.on('dismiss', handle_dismiss)
 
 
     @property
     @property
     def message(self) -> str:
     def message(self) -> str:
@@ -177,6 +183,11 @@ class Notification(Element, component='notification.js'):
         self._props['options']['closeBtn'] = value
         self._props['options']['closeBtn'] = value
         self.update()
         self.update()
 
 
+    def on_dismiss(self, callback: Callable[..., Any]) -> Self:
+        """Add a callback to be invoked when the notification is dismissed."""
+        self.on('dismiss', lambda _: handle_event(callback, UiEventArguments(sender=self, client=self.client)), [])
+        return self
+
     def dismiss(self) -> None:
     def dismiss(self) -> None:
         """Dismiss the notification."""
         """Dismiss the notification."""
         self.run_method('dismiss')
         self.run_method('dismiss')

+ 21 - 17
tests/test_aggrid.py

@@ -122,19 +122,21 @@ def test_run_column_method_with_argument(screen: Screen):
 
 
 
 
 def test_get_selected_rows(screen: Screen):
 def test_get_selected_rows(screen: Screen):
-    grid = ui.aggrid({
-        'columnDefs': [{'field': 'name'}],
-        'rowData': [{'name': 'Alice'}, {'name': 'Bob'}, {'name': 'Carol'}],
-        'rowSelection': 'multiple',
-    })
-
-    async def get_selected_rows():
-        ui.label(str(await grid.get_selected_rows()))
-    ui.button('Get selected rows', on_click=get_selected_rows)
-
-    async def get_selected_row():
-        ui.label(str(await grid.get_selected_row()))
-    ui.button('Get selected row', on_click=get_selected_row)
+    @ui.page('/')
+    def page():
+        grid = ui.aggrid({
+            'columnDefs': [{'field': 'name'}],
+            'rowData': [{'name': 'Alice'}, {'name': 'Bob'}, {'name': 'Carol'}],
+            'rowSelection': 'multiple',
+        })
+
+        async def get_selected_rows():
+            ui.label(str(await grid.get_selected_rows()))
+        ui.button('Get selected rows', on_click=get_selected_rows)
+
+        async def get_selected_row():
+            ui.label(str(await grid.get_selected_row()))
+        ui.button('Get selected row', on_click=get_selected_row)
 
 
     screen.open('/')
     screen.open('/')
     screen.click('Alice')
     screen.click('Alice')
@@ -234,12 +236,14 @@ def test_run_row_method(screen: Screen):
 
 
 
 
 def test_run_method_with_function(screen: Screen):
 def test_run_method_with_function(screen: Screen):
-    grid = ui.aggrid({'columnDefs': [{'field': 'name'}], 'rowData': [{'name': 'Alice'}, {'name': 'Bob'}]})
+    @ui.page('/')
+    def page():
+        grid = ui.aggrid({'columnDefs': [{'field': 'name'}], 'rowData': [{'name': 'Alice'}, {'name': 'Bob'}]})
 
 
-    async def print_row(index: int) -> None:
-        ui.label(f'Row {index}: {await grid.run_grid_method(f"(g) => g.getDisplayedRowAtIndex({index}).data")}')
+        async def print_row(index: int) -> None:
+            ui.label(f'Row {index}: {await grid.run_grid_method(f"(g) => g.getDisplayedRowAtIndex({index}).data")}')
 
 
-    ui.button('Print Row 0', on_click=lambda: print_row(0))
+        ui.button('Print Row 0', on_click=lambda: print_row(0))
 
 
     screen.open('/')
     screen.open('/')
     screen.click('Print Row 0')
     screen.click('Print Row 0')

+ 6 - 4
tests/test_clipboard.py

@@ -3,11 +3,13 @@ from nicegui.testing import Screen
 
 
 
 
 def test_clipboard(screen: Screen):
 def test_clipboard(screen: Screen):
-    ui.button('Copy to clipboard', on_click=lambda: ui.clipboard.write('Hello, World!'))
+    @ui.page('/')
+    def page():
+        ui.button('Copy to clipboard', on_click=lambda: ui.clipboard.write('Hello, World!'))
 
 
-    async def read_clipboard():
-        ui.notify('Clipboard: ' + await ui.clipboard.read())
-    ui.button('Read from clipboard', on_click=read_clipboard)
+        async def read_clipboard():
+            ui.notify('Clipboard: ' + await ui.clipboard.read())
+        ui.button('Read from clipboard', on_click=read_clipboard)
 
 
     screen.open('/')
     screen.open('/')
     screen.selenium.set_permissions('clipboard-read', 'granted')
     screen.selenium.set_permissions('clipboard-read', 'granted')

+ 13 - 8
tests/test_codemirror.py

@@ -13,16 +13,21 @@ def test_codemirror(screen: Screen):
 
 
 def test_supported_values(screen: Screen):
 def test_supported_values(screen: Screen):
     values: dict[str, List[str]] = {}
     values: dict[str, List[str]] = {}
-    editor = ui.codemirror()
 
 
-    async def fetch():
-        values['languages'] = await editor.run_method('getLanguages')
-        values['themes'] = await editor.run_method('getThemes')
-        ui.label('Done')
-    ui.button('Fetch', on_click=fetch)
+    @ui.page('/')
+    def page():
+        editor = ui.codemirror()
+
+        async def fetch():
+            values['languages'] = await editor.run_method('getLanguages')
+            values['themes'] = await editor.run_method('getThemes')
+            values['supported_themes'] = editor.supported_themes
+            values['supported_languages'] = editor.supported_languages
+            ui.label('Done')
+        ui.button('Fetch', on_click=fetch)
 
 
     screen.open('/')
     screen.open('/')
     screen.click('Fetch')
     screen.click('Fetch')
     screen.wait_for('Done')
     screen.wait_for('Done')
-    assert values['languages'] == editor.supported_languages
-    assert values['themes'] == editor.supported_themes
+    assert values['languages'] == values['supported_languages']
+    assert values['themes'] == values['supported_themes']

+ 11 - 9
tests/test_echart.py

@@ -67,15 +67,17 @@ def test_nested_expansion(screen: Screen):
 
 
 
 
 def test_run_method(screen: Screen):
 def test_run_method(screen: Screen):
-    echart = ui.echart({
-        'xAxis': {'type': 'value'},
-        'yAxis': {'type': 'category', 'data': ['A', 'B', 'C']},
-        'series': [{'type': 'line', 'data': [0.1, 0.2, 0.3]}],
-    }).classes('w-[600px]')
-
-    async def get_width():
-        ui.label(f'Width: {await echart.run_chart_method("getWidth")}px')
-    ui.button('Get Width', on_click=get_width)
+    @ui.page('/')
+    def page():
+        echart = ui.echart({
+            'xAxis': {'type': 'value'},
+            'yAxis': {'type': 'category', 'data': ['A', 'B', 'C']},
+            'series': [{'type': 'line', 'data': [0.1, 0.2, 0.3]}],
+        }).classes('w-[600px]')
+
+        async def get_width():
+            ui.label(f'Width: {await echart.run_chart_method("getWidth")}px')
+        ui.button('Get Width', on_click=get_width)
 
 
     screen.open('/')
     screen.open('/')
     screen.click('Get Width')
     screen.click('Get Width')

+ 38 - 17
tests/test_javascript.py

@@ -45,11 +45,13 @@ def test_run_javascript_before_client_connected(screen: Screen):
 
 
 
 
 def test_response_from_javascript(screen: Screen):
 def test_response_from_javascript(screen: Screen):
-    async def compute() -> None:
-        response = await ui.run_javascript('1 + 41')
-        ui.label(response)
+    @ui.page('/')
+    def page():
+        async def compute() -> None:
+            response = await ui.run_javascript('1 + 41')
+            ui.label(response)
 
 
-    ui.button('compute', on_click=compute)
+        ui.button('compute', on_click=compute)
 
 
     screen.open('/')
     screen.open('/')
     screen.click('compute')
     screen.click('compute')
@@ -57,27 +59,46 @@ def test_response_from_javascript(screen: Screen):
 
 
 
 
 def test_async_javascript(screen: Screen):
 def test_async_javascript(screen: Screen):
-    async def run():
-        result = await ui.run_javascript('await new Promise(r => setTimeout(r, 100)); return 42')
-        ui.label(result)
-    ui.button('run', on_click=run)
+    @ui.page('/')
+    def page():
+        async def run():
+            result = await ui.run_javascript('await new Promise(r => setTimeout(r, 100)); return 42')
+            ui.label(result)
+
+        ui.button('run', on_click=run)
+
     screen.open('/')
     screen.open('/')
     screen.click('run')
     screen.click('run')
     screen.should_contain('42')
     screen.should_contain('42')
 
 
 
 
 def test_simultaneous_async_javascript(screen: Screen):
 def test_simultaneous_async_javascript(screen: Screen):
-    async def runA():
-        result = await ui.run_javascript('await new Promise(r => setTimeout(r, 500)); return 1')
-        ui.label(f'A: {result}')
-
-    async def runB():
-        result = await ui.run_javascript('await new Promise(r => setTimeout(r, 250)); return 2')
-        ui.label(f'B: {result}')
-    ui.button('runA', on_click=runA)
-    ui.button('runB', on_click=runB)
+    @ui.page('/')
+    def page():
+        async def runA():
+            result = await ui.run_javascript('await new Promise(r => setTimeout(r, 500)); return 1')
+            ui.label(f'A: {result}')
+
+        async def runB():
+            result = await ui.run_javascript('await new Promise(r => setTimeout(r, 250)); return 2')
+            ui.label(f'B: {result}')
+
+        ui.button('runA', on_click=runA)
+        ui.button('runB', on_click=runB)
+
     screen.open('/')
     screen.open('/')
     screen.click('runA')
     screen.click('runA')
     screen.click('runB')
     screen.click('runB')
     screen.should_contain('A: 1')
     screen.should_contain('A: 1')
     screen.should_contain('B: 2')
     screen.should_contain('B: 2')
+
+
+def test_raise_on_auto_index_page(screen: Screen):
+    async def await_answer():
+        await ui.run_javascript('return 42')
+    ui.button('Ask', on_click=await_answer)
+
+    screen.open('/')
+    screen.click('Ask')
+    screen.assert_py_logger('ERROR', 'Cannot await JavaScript responses on the auto-index page. '
+                            'There could be multiple clients connected and it is not clear which one to wait for.')

+ 7 - 5
tests/test_json_editor.py

@@ -3,12 +3,14 @@ from nicegui.testing import Screen
 
 
 
 
 def test_json_editor_methods(screen: Screen):
 def test_json_editor_methods(screen: Screen):
-    editor = ui.json_editor({'content': {'json': {'a': 1, 'b': 2}}})
+    @ui.page('/')
+    def page():
+        editor = ui.json_editor({'content': {'json': {'a': 1, 'b': 2}}})
 
 
-    async def get_data():
-        data = await editor.run_editor_method('get')
-        ui.label(f'Data: {data}')
-    ui.button('Get Data', on_click=get_data)
+        async def get_data():
+            data = await editor.run_editor_method('get')
+            ui.label(f'Data: {data}')
+        ui.button('Get Data', on_click=get_data)
 
 
     screen.open('/')
     screen.open('/')
     screen.should_contain('text')
     screen.should_contain('text')

+ 5 - 3
tests/test_lifecycle.py

@@ -1,3 +1,4 @@
+import asyncio
 from typing import List
 from typing import List
 
 
 from nicegui import app, ui
 from nicegui import app, ui
@@ -22,12 +23,13 @@ def test_adding_elements_during_onconnect_on_auto_index_page(screen: Screen):
 
 
 def test_async_connect_handler(screen: Screen):
 def test_async_connect_handler(screen: Screen):
     async def run_js():
     async def run_js():
-        result.text = await ui.run_javascript('41 + 1')
-    result = ui.label()
+        await asyncio.sleep(0.1)
+        status.text = 'Connected'
+    status = ui.label()
     app.on_connect(run_js)
     app.on_connect(run_js)
 
 
     screen.open('/')
     screen.open('/')
-    screen.should_contain('42')
+    screen.should_contain('Connected')
 
 
 
 
 def test_connect_disconnect_is_called_for_each_client(screen: Screen):
 def test_connect_disconnect_is_called_for_each_client(screen: Screen):

+ 11 - 4
website/documentation/content/clipboard_documentation.py

@@ -5,14 +5,21 @@ from . import doc
 
 
 @doc.demo('Read and write to the clipboard', '''
 @doc.demo('Read and write to the clipboard', '''
     The following demo shows how to use `ui.clipboard.read()` and `ui.clipboard.write()` to interact with the clipboard.
     The following demo shows how to use `ui.clipboard.read()` and `ui.clipboard.write()` to interact with the clipboard.
+
+    Because auto-index page can be accessed by multiple browser tabs simultaneously, reading the clipboard is not supported on this page.
+    This is only possible within page-builder functions decorated with `ui.page`, as shown in this demo.
+
     Note that your browser may ask for permission to access the clipboard or may not support this feature at all.
     Note that your browser may ask for permission to access the clipboard or may not support this feature at all.
 ''')
 ''')
 def main_demo() -> None:
 def main_demo() -> None:
-    ui.button('Write', on_click=lambda: ui.clipboard.write('Hi!'))
+    # @ui.page('/')
+    # async def index():
+    with ui.column():  # HIDE
+        ui.button('Write', on_click=lambda: ui.clipboard.write('Hi!'))
 
 
-    async def read() -> None:
-        ui.notify(await ui.clipboard.read())
-    ui.button('Read', on_click=read)
+        async def read() -> None:
+            ui.notify(await ui.clipboard.read())
+        ui.button('Read', on_click=read)
 
 
 
 
 @doc.demo('Client-side clipboard', '''
 @doc.demo('Client-side clipboard', '''