Repository: zauberzeug/nicegui
Pull request #4449 (2025-03-09 13:10:48)

Better support select element with multiple values and dictionaries in user simulation
First things first: Thanks for your wonderful project!

I have implemented a small application and have just begun adding tests.
My very first test is failing with error:
```
[...]
.tox/py313/lib/python3.13/site-packages/nicegui/testing/user.py:110: in should_see
    if self.notify.contains(target) or self._gather_elements(target, kind, marker, content):
.tox/py313/lib/python3.13/site-packages/nicegui/testing/user.py:211: in _gather_elements
    elements = set(ElementFilter(marker=target)).union(ElementFilter(content=target))
_ _ _ _ _ _ _ _ _ _ _


self = <nicegui.element_filter.ElementFilter object at 0x7fb35113c2d0>

    def __iter__(self) -> Iterator[T]:
        for element in self._scope.descendants():
            if self._kind and not isinstance(element, self._kind):
                continue
            if self._exclude_kinds and isinstance(element, tuple(self._exclude_kinds)):
                continue
    
            if any(marker not in element._markers for marker in self._markers):
                continue
            if any(marker in element._markers for marker in self._exclude_markers):
                continue
    
            if self._contents or self._exclude_content:
                element_contents = [content for content in (
                    element.props.get('text'),
                    element.props.get('label'),
                    element.props.get('icon'),
                    element.props.get('placeholder'),
                    element.props.get('value'),
                    element.props.get('error-message'),
                    element.text if isinstance(element, TextElement) else None,
                    element.content if isinstance(element, ContentElement) else None,
                    element.source if isinstance(element, SourceElement) else None,
                ) if content]
                if isinstance(element, Notification):
                    element_contents.append(element.message)
                if isinstance(element, (Select, Radio, Toggle)):
                    options = {option['value']: option['label'] for option in element.props.get('options', [])}
>                   element_contents.append(options.get(element.value, ''))
E                   TypeError: unhashable type: 'list'

.tox/py313/lib/python3.13/site-packages/nicegui/element_filter.py:123: TypeError
```

I investigated the issue and managed to fix it and create a dedicated test case too, let me know what you think!

rodja: Wow. I finally found the time to look at your PR @Daemo00. It seems that the select element is very poorly supported at the moment. I extended your test and added two more to uncover a lot of problems with the current implementation. I do not have a solution yet...
rodja: Ok. I think I found an implementation which passes the tests. The code is just a first shot; there might be better ways to implement the behaviour we are seeking. And it's worth noting that the props are not updated. `model-value` for example will always stay at index `0`. Is that a problem for the user simulation?

Patch:
diff --git a/nicegui/element_filter.py b/nicegui/element_filter.py
index b26242aa..bc073950 100644
--- a/nicegui/element_filter.py
+++ b/nicegui/element_filter.py
@@ -6,13 +6,12 @@ from typing_extensions import Self
 
 from .context import context
 from .element import Element
+from .elements.choice_element import ChoiceElement
 from .elements.mixins.content_element import ContentElement
 from .elements.mixins.source_element import SourceElement
 from .elements.mixins.text_element import TextElement
 from .elements.notification import Notification
-from .elements.radio import Radio
 from .elements.select import Select
-from .elements.toggle import Toggle
 
 T = TypeVar('T', bound=Element)
 
@@ -118,11 +117,14 @@ class ElementFilter(Generic[T]):
                 ) if content]
                 if isinstance(element, Notification):
                     element_contents.append(element.message)
-                if isinstance(element, (Select, Radio, Toggle)):
-                    options = {option['value']: option['label'] for option in element.props.get('options', [])}
-                    element_contents.append(options.get(element.value, ''))
+                if isinstance(element, ChoiceElement):
+                    if isinstance(element, Select):
+                        values = element.value if element.multiple else [element.value]
+                        labels = [value if isinstance(element.options, list) else element.options.get(value, '')
+                                  for value in values]
+                        element_contents.extend(labels)
                     if not isinstance(element, Select) or element.is_showing_popup:
-                        element_contents.extend(options.values())
+                        element_contents.extend(element._labels)  # pylint: disable=protected-access
                 if any(all(needle not in str(haystack) for haystack in element_contents) for needle in self._contents):
                     continue
                 if any(needle in str(haystack) for haystack in element_contents for needle in self._exclude_content):
diff --git a/nicegui/functions/navigate.py b/nicegui/functions/navigate.py
index 95c5bd71..37017c5a 100644
--- a/nicegui/functions/navigate.py
+++ b/nicegui/functions/navigate.py
@@ -14,6 +14,9 @@ class Navigate:
     *Added in version 2.0.0*
     """
 
+    def __init__(self) -> None:
+        self.history = History()
+
     def back(self) -> None:
         """ui.navigate.back
 
@@ -66,4 +69,29 @@ class Navigate:
         context.client.open(path, new_tab)
 
 
+class History:
+
+    def push(self, url: str) -> None:
+        """Push a URL to the browser navigation history.
+
+        See JavaScript's `pushState <https://developer.mozilla.org/en-US/docs/Web/API/History/pushState>`_ for more information.
+
+        *Added in version 2.13.0*
+
+        :param url: relative or absolute URL
+        """
+        run_javascript(f'history.pushState({{}}, "", "{url}");')
+
+    def replace(self, url: str) -> None:
+        """Replace the current URL in the browser history.
+
+        See JavaScript's `replaceState <https://developer.mozilla.org/en-US/docs/Web/API/History/replaceState>`_ for more information.
+
+        *Added in version 2.13.0*
+
+        :param url: relative or absolute URL
+        """
+        run_javascript(f'history.replaceState({{}}, "", "{url}");')
+
+
 navigate = Navigate()
diff --git a/website/documentation/content/dark_mode_documentation.py b/website/documentation/content/dark_mode_documentation.py
index ddaa602a..d13295d1 100644
--- a/website/documentation/content/dark_mode_documentation.py
+++ b/website/documentation/content/dark_mode_documentation.py
@@ -23,4 +23,17 @@ def main_demo() -> None:
     ))
 
 
+@doc.demo('Binding to a switch', '''
+    The value of the `ui.dark_mode` element can be bound to other elements like a `ui.switch`.
+''')
+def bind_to_switch() -> None:
+    # dark = ui.dark_mode()
+    # ui.switch('Dark mode').bind_value(dark)
+    # END OF DEMO
+    ui.switch('Dark mode', on_change=lambda e: (
+        e.sender.style('color: white' if e.value else 'color: black'),
+        e.sender.parent_slot.parent.style(f'background-color: {WINDOW_BG_COLORS["browser"][1 if e.value else 0]}'),
+    ))
+
+
 doc.reference(ui.dark_mode)
diff --git a/website/documentation/content/doc/api.py b/website/documentation/content/doc/api.py
index 58bd54c6..aee0a5f2 100644
--- a/website/documentation/content/doc/api.py
+++ b/website/documentation/content/doc/api.py
@@ -11,6 +11,7 @@ from typing import Any, Callable, Dict, Optional, Union, overload
 import nicegui
 from nicegui import app as nicegui_app
 from nicegui import ui as nicegui_ui
+from nicegui.functions.navigate import Navigate
 from nicegui.elements.markdown import remove_indentation
 
 from .page import DocumentationPage
@@ -69,7 +70,7 @@ def demo(element: type, /,
 
 
 @overload
-def demo(function: Callable, /,
+def demo(function: Union[Callable, Navigate], /,
          tab: Optional[Union[str, Callable]] = None,
          lazy: bool = True,
          ) -> Callable[[Callable], Callable]:
diff --git a/website/documentation/content/navigate_documentation.py b/website/documentation/content/navigate_documentation.py
index b64211f0..ea62683e 100644
--- a/website/documentation/content/navigate_documentation.py
+++ b/website/documentation/content/navigate_documentation.py
@@ -17,3 +17,18 @@ def main_demo() -> None:
 def open_github() -> None:
     url = 'https://github.com/zauberzeug/nicegui/'
     ui.button('Open GitHub', on_click=lambda: ui.navigate.to(url, new_tab=True))
+
+
+@doc.demo('Push and replace URLs', '''
+    The `history` API allows you to push and replace URLs to the browser history.
+
+    While the `history.push` method pushes a new URL to the history,
+    the `history.replace` method replaces the current URL.
+
+    See `JavaScript's History API <https://developer.mozilla.org/en-US/docs/Web/API/History>`_ for more information.
+
+    *Added in version 2.13.0*
+''')
+def history_demo() -> None:
+    ui.button('Push URL', on_click=lambda: ui.navigate.history.push('/a'))
+    ui.button('Replace URL', on_click=lambda: ui.navigate.history.replace('/b'))


Test patch:
diff --git a/nicegui/testing/user_interaction.py b/nicegui/testing/user_interaction.py
index cdf8e141..dd98087c 100644
--- a/nicegui/testing/user_interaction.py
+++ b/nicegui/testing/user_interaction.py
@@ -74,18 +74,27 @@ class UserInteraction(Generic[T]):
                     href = element.props.get('href', '#')
                     background_tasks.create(self.user.open(href))
                     return self
+
                 if isinstance(element, ui.select):
                     if element.is_showing_popup:
-                        assert isinstance(self.target, str), 'Target must be string when clicking on ui.select options'
-                        element.set_value(self.target)
+                        if isinstance(element.options, dict):
+                            target_value = next((k for k, v in element.options.items() if v == self.target), '')
+                        else:
+                            target_value = self.target
+                        if element.multiple:
+                            if target_value in element.value:
+                                element.value = [v for v in element.value if v != target_value]
+                            else:
+                                element.value = [*element.value, target_value]
+                        else:
+                            element.value = target_value
                     element._is_showing_popup = not element.is_showing_popup  # pylint: disable=protected-access
                     return self
+
                 for listener in element._event_listeners.values():  # pylint: disable=protected-access
                     if listener.element_id != element.id:
                         continue
-                    args = None
-                    if isinstance(element, (ui.checkbox, ui.switch)):
-                        args = not element.value
+                    args = not element.value if isinstance(element, (ui.checkbox, ui.switch)) else None
                     event_arguments = events.GenericEventArguments(sender=element, client=self.user.client, args=args)
                     events.handle_event(listener.handler, event_arguments)
         return self
diff --git a/nicegui/testing/user_navigate.py b/nicegui/testing/user_navigate.py
index 7cd36764..6c475428 100644
--- a/nicegui/testing/user_navigate.py
+++ b/nicegui/testing/user_navigate.py
@@ -13,6 +13,7 @@ if TYPE_CHECKING:
 class UserNavigate(Navigate):
 
     def __init__(self, user: User) -> None:
+        super().__init__()
         self.user = user
 
     def to(self, target: Union[Callable[..., Any], str, Element], new_tab: bool = False) -> None:
diff --git a/tests/test_user_simulation.py b/tests/test_user_simulation.py
index 5750b5a5..c499335c 100644
--- a/tests/test_user_simulation.py
+++ b/tests/test_user_simulation.py
@@ -361,6 +361,67 @@ async def test_select(user: User) -> None:
     await user.should_not_see('C')
 
 
+async def test_select_from_dict(user: User) -> None:
+    ui.select(options={'value A': 'label A', 'value B': 'label B', 'value C': 'label C'},
+              on_change=lambda e: ui.notify(f'Notify: {e.value}'))
+
+    await user.open('/')
+    await user.should_not_see('label A')
+    await user.should_not_see('label B')
+    await user.should_not_see('label C')
+
+    user.find(ui.select).click()
+    await user.should_see('label A')
+    await user.should_see('label B')
+    await user.should_see('label C')
+
+    user.find('label A').click()
+    await user.should_see('Notify: value A')
+
+
+async def test_select_multiple_from_dict(user: User) -> None:
+    ui.select(options={'value A': 'label A', 'value B': 'label B', 'value C': 'label C'},
+              multiple=True, on_change=lambda e: ui.notify(f'Notify: {e.value}'))
+
+    await user.open('/')
+    await user.should_not_see('label A')
+    await user.should_not_see('label B')
+    await user.should_not_see('label C')
+
+    user.find(ui.select).click()
+    await user.should_see('label A')
+    await user.should_see('label B')
+    await user.should_see('label C')
+
+    user.find('label A').click()
+    await user.should_see("Notify: ['value A']")
+
+    user.find(ui.select).click()
+    user.find('label B').click()
+    await user.should_see("Notify: ['value A', 'value B']")
+
+
+async def test_select_multiple_values(user: User):
+    select = ui.select(['A', 'B'], value='A',
+                       multiple=True, on_change=lambda e: ui.notify(f'Notify: {e.value}'))
+    ui.label().bind_text_from(select, 'value', backward=lambda v: f'value = {v}')
+
+    await user.open('/')
+    await user.should_see("value = ['A']")
+
+    user.find(ui.select).click()
+    user.find('B').click()
+    await user.should_see("Notify: ['A', 'B']")
+    await user.should_see("value = ['A', 'B']")
+    assert select.value == ['A', 'B']
+
+    user.find(ui.select).click()
+    user.find('A').click()
+    await user.should_see("Notify: ['B']")
+    await user.should_see("value = ['B']")
+    assert select.value == ['B']
+
+
 async def test_upload_table(user: User) -> None:
     def receive_file(e: events.UploadEventArguments) -> None:
         reader = csv.DictReader(e.content.read().decode('utf-8').splitlines())