Repository: zauberzeug/nicegui
Pull request #1819 (2023-10-17 14:02:06)

enable new input in select, new-value-mode="add-unique"
Hi,

My tiny PR to address #1045 . I added a parameter `add_new_unique_values: bool` to the `__init__` of `ui.select`, corresponds to `new-value-mode="add-unique"` in Quasar terms. Tests cover both single selection and `multiple=True`. Please, let me know if missed anything.


Patch:
diff --git a/nicegui/elements/select.py b/nicegui/elements/select.py
index 7c94c8d3..54113a52 100644
--- a/nicegui/elements/select.py
+++ b/nicegui/elements/select.py
@@ -1,5 +1,5 @@
 from copy import deepcopy
-from typing import Any, Callable, Dict, List, Optional, Union
+from typing import Any, Callable, Dict, List, Literal, Optional, Union
 
 from ..events import GenericEventArguments
 from .choice_element import ChoiceElement
@@ -14,6 +14,7 @@ class Select(ChoiceElement, DisableableElement, component='select.js'):
                  value: Any = None,
                  on_change: Optional[Callable[..., Any]] = None,
                  with_input: bool = False,
+                 new_value_mode: Optional[Literal['add', 'add-unique', 'toggle']] = None,
                  multiple: bool = False,
                  clearable: bool = False,
                  ) -> None:
@@ -24,11 +25,17 @@ class Select(ChoiceElement, DisableableElement, component='select.js'):
         The options can be specified as a list of values, or as a dictionary mapping values to labels.
         After manipulating the options, call `update()` to update the options in the UI.
 
+        If `with_input` is True, an input field is shown to filter the options.
+
+        If `new_value_mode` is not None, it implies `with_input=True` and the user can enter new values in the input field.
+        See `Quasar's documentation <https://quasar.dev/vue-components/select#the-new-value-mode-prop>`_ for details.
+
         :param options: a list ['value1', ...] or dictionary `{'value1':'label1', ...}` specifying the options
         :param label: the label to display above the selection
         :param value: the initial value
         :param on_change: callback to execute when selection changes
         :param with_input: whether to show an input field to filter the options
+        :param new_value_mode: handle new values from user input (default: None, i.e. no new values)
         :param multiple: whether to allow multiple selections
         :param clearable: whether to add a button to clear the selection
         """
@@ -41,6 +48,11 @@ class Select(ChoiceElement, DisableableElement, component='select.js'):
         super().__init__(options=options, value=value, on_change=on_change)
         if label is not None:
             self._props['label'] = label
+        if new_value_mode is not None:
+            if isinstance(options, dict) and new_value_mode == 'add':
+                raise ValueError('new_value_mode "add" is not supported for dict options')
+            self._props['new-value-mode'] = new_value_mode
+            with_input = True
         if with_input:
             self.original_options = deepcopy(options)
             self._props['use-input'] = True
@@ -55,11 +67,21 @@ class Select(ChoiceElement, DisableableElement, component='select.js'):
         if self.multiple:
             if e.args is None:
                 return []
-            return [self._values[arg['value']] for arg in e.args]
+            else:
+                args = [self._values[arg['value']] if isinstance(arg, dict) else arg for arg in e.args]
+                for arg in e.args:
+                    if isinstance(arg, str):
+                        self._handle_new_value(arg)
+                return [arg for arg in args if arg in self._values]
         else:
             if e.args is None:
                 return None
-            return self._values[e.args['value']]
+            else:
+                if isinstance(e.args, str):
+                    self._handle_new_value(e.args)
+                    return e.args if e.args in self._values else None
+                else:
+                    return self._values[e.args['value']]
 
     def _value_to_model_value(self, value: Any) -> Any:
         # pylint: disable=no-else-return
@@ -78,3 +100,28 @@ class Select(ChoiceElement, DisableableElement, component='select.js'):
                 return {'value': index, 'label': self._labels[index]}
             except ValueError:
                 return None
+
+    def _handle_new_value(self, value: str) -> None:
+        mode = self._props['new-value-mode']
+        if isinstance(self.options, list):
+            if mode == 'add':
+                self.options.append(value)
+            elif mode == 'add-unique':
+                if value not in self.options:
+                    self.options.append(value)
+            elif mode == 'toggle':
+                if value in self.options:
+                    self.options.remove(value)
+                else:
+                    self.options.append(value)
+            # NOTE: self._labels and self._values are updated via self.options since they share the same references
+        else:
+            if mode in 'add-unique':
+                if value not in self.options:
+                    self.options[value] = value
+            elif mode == 'toggle':
+                if value in self.options:
+                    self.options.pop(value)
+                else:
+                    self.options.update({value: value})
+            self._update_values_and_labels()


Test patch:
diff --git a/tests/screen.py b/tests/screen.py
index e2b63331..6b6266d7 100644
--- a/tests/screen.py
+++ b/tests/screen.py
@@ -151,13 +151,17 @@ class Screen:
         except NoSuchElementException as e:
             raise AssertionError(f'Could not find "{text}"') from e
 
+    def find_all(self, text: str) -> List[WebElement]:
+        query = f'//*[not(self::script) and not(self::style) and text()[contains(., "{text}")]]'
+        return self.selenium.find_elements(By.XPATH, query)
+
     def find_element(self, element: ui.element) -> WebElement:
         return self.selenium.find_element(By.ID, f'c{element.id}')
 
     def find_by_class(self, name: str) -> WebElement:
         return self.selenium.find_element(By.CLASS_NAME, name)
 
-    def find_all_by_class(self, name: str) -> WebElement:
+    def find_all_by_class(self, name: str) -> List[WebElement]:
         return self.selenium.find_elements(By.CLASS_NAME, name)
 
     def find_by_tag(self, name: str) -> WebElement:
diff --git a/tests/test_select.py b/tests/test_select.py
index ac7e8aa6..a4feab6f 100644
--- a/tests/test_select.py
+++ b/tests/test_select.py
@@ -1,3 +1,8 @@
+from typing import Optional
+
+import pytest
+from selenium.webdriver import Keys
+
 from nicegui import ui
 
 from .screen import Screen
@@ -32,6 +37,10 @@ def test_select_with_input(screen: Screen):
     screen.should_contain('AB')
     screen.should_not_contain('XYZ')
 
+    screen.find_by_tag('input').send_keys('ABC' + Keys.ENTER)
+    screen.find_by_tag('input').click()
+    screen.should_not_contain('ABC')
+
 
 def test_replace_select(screen: Screen):
     with ui.row() as container:
@@ -86,3 +95,48 @@ def test_set_options(screen:  Screen):
     screen.click('4')
     screen.should_contain('5')
     screen.should_contain('6')
+
+
+@pytest.mark.parametrize('option_dict', [False, True])
+@pytest.mark.parametrize('multiple', [False, True])
+@pytest.mark.parametrize('new_value_mode', ['add', 'add-unique', 'toggle', None])
+def test_add_new_values(screen:  Screen, option_dict: bool, multiple: bool, new_value_mode: Optional[str]):
+    options = {'a': 'A', 'b': 'B', 'c': 'C'} if option_dict else ['a', 'b', 'c']
+    if option_dict and new_value_mode == 'add':
+        with pytest.raises(ValueError, match='new_value_mode "add" is not supported for dict options'):
+            ui.select(options=options, multiple=multiple, new_value_mode=new_value_mode)
+        return
+
+    s = ui.select(options=options, multiple=multiple, new_value_mode=new_value_mode)
+    ui.label().bind_text_from(s, 'value', lambda v: f'value = {v}')
+    ui.label().bind_text_from(s, 'options', lambda v: f'options = {v}')
+
+    screen.open('/')
+    screen.should_contain('value = []' if multiple else 'value = None')
+    screen.should_contain("options = {'a': 'A', 'b': 'B', 'c': 'C'}" if option_dict else "options = ['a', 'b', 'c']")
+
+    screen.find_by_class('q-select').click()
+    screen.wait(0.5)
+    screen.find_all('A' if option_dict else 'a')[-1].click()
+    screen.should_contain("value = ['a']" if multiple else 'value = a')
+
+    if new_value_mode:
+        for _ in range(2):
+            screen.find_by_tag('input').send_keys(Keys.BACKSPACE + 'd')
+            screen.wait(0.5)
+            screen.find_by_tag('input').click()
+            screen.wait(0.5)
+            screen.find_by_tag('input').send_keys(Keys.ENTER)
+            screen.wait(0.5)
+        if new_value_mode == 'add':
+            screen.should_contain("value = ['a', 'd', 'd']" if multiple else 'value = d')
+            screen.should_contain("options = {'a': 'A', 'b': 'B', 'c': 'C', 'd': 'd', 'd': 'd'}" if option_dict else
+                                  "options = ['a', 'b', 'c', 'd', 'd']")
+        elif new_value_mode == 'add-unique':
+            screen.should_contain("value = ['a', 'd', 'd']" if multiple else 'value = d')
+            screen.should_contain("options = {'a': 'A', 'b': 'B', 'c': 'C', 'd': 'd'}" if option_dict else
+                                  "options = ['a', 'b', 'c', 'd']")
+        elif new_value_mode == 'toggle':
+            screen.should_contain("value = ['a']" if multiple else 'value = None')
+            screen.should_contain("options = {'a': 'A', 'b': 'B', 'c': 'C'}" if option_dict else
+                                  "options = ['a', 'b', 'c']")