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 năm trước cách đây
mục cha
commit
5c444ff538

+ 3 - 0
nicegui/client.py

@@ -212,6 +212,9 @@ class Client:
             self.outbox.enqueue_message('run_javascript', {'code': code}, target_id)
 
         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)
             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 {
   mounted() {
-    this.notification = Quasar.Notify.create(this.options);
+    this.notification = Quasar.Notify.create(this.convertedOptions);
   },
   updated() {
-    this.notification(this.options);
+    this.notification(this.convertedOptions);
   },
   methods: {
     dismiss() {
       this.notification();
     },
   },
+  computed: {
+    convertedOptions() {
+      convertDynamicProperties(this.options, true);
+      const options = {
+        ...this.options,
+        onDismiss: () => this.$emit("dismiss"),
+      };
+      return options;
+    },
+  },
   props: {
     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 ..element import Element
-from .timer import Timer
+from ..events import UiEventArguments, handle_event
 
 NotificationPosition = Literal[
     'top-left',
@@ -37,6 +40,7 @@ class Notification(Element, component='notification.js'):
                  icon: Optional[str] = None,
                  spinner: bool = False,
                  timeout: Optional[float] = 5.0,
+                 on_dismiss: Optional[Callable] = None,
                  **kwargs: Any,
                  ) -> None:
         """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 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 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>`_.
         """
@@ -76,17 +81,18 @@ class Notification(Element, component='notification.js'):
         if icon is not None:
             self._props['options']['icon'] = icon
         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
     def message(self) -> str:
@@ -177,6 +183,11 @@ class Notification(Element, component='notification.js'):
         self._props['options']['closeBtn'] = value
         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:
         """Dismiss the notification."""
         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):
-    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.click('Alice')
@@ -234,12 +236,14 @@ def test_run_row_method(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.click('Print Row 0')

+ 6 - 4
tests/test_clipboard.py

@@ -3,11 +3,13 @@ from nicegui.testing import 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.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):
     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.click('Fetch')
     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):
-    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.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):
-    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.click('compute')
@@ -57,27 +59,46 @@ def test_response_from_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.click('run')
     screen.should_contain('42')
 
 
 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.click('runA')
     screen.click('runB')
     screen.should_contain('A: 1')
     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):
-    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.should_contain('text')

+ 5 - 3
tests/test_lifecycle.py

@@ -1,3 +1,4 @@
+import asyncio
 from typing import List
 
 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):
     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)
 
     screen.open('/')
-    screen.should_contain('42')
+    screen.should_contain('Connected')
 
 
 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', '''
     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.
 ''')
 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', '''