Pārlūkot izejas kodu

Merge commit '79d9a1a381c16f356ad504f633ebe94044fa5e5d' into on-air

Rodja Trappe 1 gadu atpakaļ
vecāks
revīzija
f3382a34ed

+ 0 - 4
main.py

@@ -331,10 +331,6 @@ def documentation_page() -> None:
     ui.add_head_html('<style>html {scroll-behavior: auto;}</style>')
     with ui.column().classes('w-full p-8 lg:p-16 max-w-[1250px] mx-auto'):
         section_heading('Reference, Demos and more', '*NiceGUI* Documentation')
-        ui.markdown('''
-            This is the documentation for NiceGUI >= 1.0.
-            Documentation for older versions can be found at [https://0.9.nicegui.io/](https://0.9.nicegui.io/reference).
-        ''').classes('bold-links arrow-links')
         documentation.create_full()
 
 

+ 0 - 2
mypy.ini

@@ -1,2 +0,0 @@
-[mypy]
-ignore_missing_imports = True

+ 3 - 2
nicegui/dependencies.py

@@ -82,6 +82,7 @@ def generate_js_imports(prefix: str) -> str:
     for name, component in js_components.items():
         if name in globals.excludes:
             continue
-        result += f'import {{ default as {name} }} from "{prefix}{component.import_path}";\n'
-        result += f'app.component("{name}", {name});\n'
+        var_name = name.replace('-', '_')
+        result += f'import {{ default as {var_name} }} from "{prefix}{component.import_path}";\n'
+        result += f'app.component("{name}", {var_name});\n'
     return result

+ 57 - 0
nicegui/elements/input.js

@@ -0,0 +1,57 @@
+export default {
+  template: `
+    <q-input
+      v-bind="$attrs"
+      v-model="inputValue"
+      :shadow-text="shadowText"
+      @keydown.tab="perform_autocomplete"
+      :list="id + '-datalist'"
+    >
+      <template v-for="(_, slot) in $slots" v-slot:[slot]="slotProps">
+        <slot :name="slot" v-bind="slotProps || {}" />
+      </template>
+    </q-input>
+    <datalist v-if="withDatalist" :id="id + '-datalist'">
+      <option v-for="option in autocomplete" :value="option"></option>
+    </datalist>
+  `,
+  props: {
+    id: String,
+    autocomplete: Array,
+    value: String,
+  },
+  data() {
+    return {
+      inputValue: this.value,
+    };
+  },
+  watch: {
+    value(newValue) {
+      this.inputValue = newValue;
+    },
+    inputValue(newValue) {
+      this.$emit("update:value", newValue);
+    },
+  },
+  computed: {
+    shadowText() {
+      if (!this.inputValue) return "";
+      const matchingOption = this.autocomplete.find((option) =>
+        option.toLowerCase().startsWith(this.inputValue.toLowerCase())
+      );
+      return matchingOption ? matchingOption.slice(this.inputValue.length) : "";
+    },
+    withDatalist() {
+      const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
+      return isMobile && this.autocomplete && this.autocomplete.length > 0;
+    },
+  },
+  methods: {
+    perform_autocomplete(e) {
+      if (this.shadowText) {
+        this.inputValue += this.shadowText;
+        e.preventDefault();
+      }
+    },
+  },
+};

+ 13 - 35
nicegui/elements/input.py

@@ -1,11 +1,15 @@
 from typing import Any, Callable, Dict, List, Optional
 
+from ..dependencies import register_component
 from .icon import Icon
 from .mixins.disableable_element import DisableableElement
-from .mixins.value_element import ValueElement
+from .mixins.validation_element import ValidationElement
 
+register_component('nicegui-input', __file__, 'input.js')
 
-class Input(ValueElement, DisableableElement):
+
+class Input(ValidationElement, DisableableElement):
+    VALUE_PROP: str = 'value'
     LOOPBACK = False
 
     def __init__(self,
@@ -35,9 +39,9 @@ class Input(ValueElement, DisableableElement):
         :param password_toggle_button: whether to show a button to toggle the password visibility (default: False)
         :param on_change: callback to execute when the value changes
         :param autocomplete: optional list of strings for autocompletion
-        :param validation: dictionary of validation rules, e.g. ``{'Too short!': lambda value: len(value) < 3}``
+        :param validation: dictionary of validation rules, e.g. ``{'Too long!': lambda value: len(value) < 3}``
         """
-        super().__init__(tag='q-input', value=value, on_value_change=on_change)
+        super().__init__(tag='nicegui-input', value=value, on_value_change=on_change, validation=validation)
         if label is not None:
             self._props['label'] = label
         if placeholder is not None:
@@ -52,35 +56,9 @@ class Input(ValueElement, DisableableElement):
                     self.props(f'type={"text" if is_hidden else "password"}')
                 icon = Icon('visibility_off').classes('cursor-pointer').on('click', toggle_type)
 
-        self.validation = validation
-
-        if autocomplete:
-            def find_autocompletion() -> Optional[str]:
-                if self.value:
-                    needle = str(self.value).casefold()
-                    for item in autocomplete or []:
-                        if item.casefold().startswith(needle):
-                            return item
-                return None  # required by mypy
-
-            def autocomplete_input() -> None:
-                match = find_autocompletion() or ''
-                self.props(f'shadow-text="{match[len(self.value):]}"')
-
-            def complete_input() -> None:
-                match = find_autocompletion()
-                if match:
-                    self.set_value(match)
-                self.props(f'shadow-text=""')
-
-            self.on('keyup', autocomplete_input)
-            self.on('keydown.tab', complete_input)
+        self._props['autocomplete'] = autocomplete or []
 
-    def on_value_change(self, value: Any) -> None:
-        super().on_value_change(value)
-        for message, check in self.validation.items():
-            if not check(value):
-                self.props(f'error error-message="{message}"')
-                break
-        else:
-            self.props(remove='error')
+    def set_autocomplete(self, autocomplete: Optional[List[str]]) -> None:
+        """Set the autocomplete list."""
+        self._props['autocomplete'] = autocomplete
+        self.update()

+ 27 - 0
nicegui/elements/mixins/validation_element.py

@@ -0,0 +1,27 @@
+from typing import Any, Callable, Dict, Optional
+
+from .value_element import ValueElement
+
+
+class ValidationElement(ValueElement):
+
+    def __init__(self, validation: Dict[str, Callable[..., bool]], **kwargs: Any) -> None:
+        super().__init__(**kwargs)
+        self.validation = validation
+        self._error: Optional[str] = None
+
+    @property
+    def error(self) -> Optional[str]:
+        """The latest error message from the validation functions."""
+        return self._error
+
+    def on_value_change(self, value: Any) -> None:
+        super().on_value_change(value)
+        for message, check in self.validation.items():
+            if not check(value):
+                self._error = message
+                self.props(f'error error-message="{message}"')
+                break
+        else:
+            self._error = None
+            self.props(remove='error')

+ 31 - 16
nicegui/elements/number.py

@@ -1,10 +1,10 @@
 from typing import Any, Callable, Dict, Optional
 
 from .mixins.disableable_element import DisableableElement
-from .mixins.value_element import ValueElement
+from .mixins.validation_element import ValidationElement
 
 
-class Number(ValueElement, DisableableElement):
+class Number(ValidationElement, DisableableElement):
     LOOPBACK = False
 
     def __init__(self,
@@ -37,10 +37,10 @@ class Number(ValueElement, DisableableElement):
         :param suffix: a suffix to append to the displayed value
         :param format: a string like "%.2f" to format the displayed value
         :param on_change: callback to execute when the value changes
-        :param validation: dictionary of validation rules, e.g. ``{'Too small!': lambda value: value < 3}``
+        :param validation: dictionary of validation rules, e.g. ``{'Too large!': lambda value: value < 3}``
         """
         self.format = format
-        super().__init__(tag='q-input', value=value, on_value_change=on_change)
+        super().__init__(tag='q-input', value=value, on_value_change=on_change, validation=validation)
         self._props['type'] = 'number'
         if label is not None:
             self._props['label'] = label
@@ -56,24 +56,39 @@ class Number(ValueElement, DisableableElement):
             self._props['prefix'] = prefix
         if suffix is not None:
             self._props['suffix'] = suffix
-        self.validation = validation
         self.on('blur', self.sanitize)
 
+    @property
+    def min(self) -> float:
+        """The minimum value allowed."""
+        return self._props.get('min', -float('inf'))
+
+    @min.setter
+    def min(self, value: float) -> None:
+        self._props['min'] = value
+        self.sanitize()
+
+    @property
+    def max(self) -> float:
+        """The maximum value allowed."""
+        return self._props.get('max', float('inf'))
+
+    @max.setter
+    def max(self, value: float) -> None:
+        self._props['max'] = value
+        self.sanitize()
+
+    @property
+    def out_of_limits(self) -> bool:
+        """Whether the current value is out of the allowed limits."""
+        return not self.min <= self.value <= self.max
+
     def sanitize(self) -> None:
         value = float(self.value or 0)
-        value = max(value, self._props.get('min', -float('inf')))
-        value = min(value, self._props.get('max', float('inf')))
+        value = max(value, self.min)
+        value = min(value, self.max)
         self.set_value(float(self.format % value) if self.format else value)
 
-    def on_value_change(self, value: Any) -> None:
-        super().on_value_change(value)
-        for message, check in self.validation.items():
-            if not check(value):
-                self.props(f'error error-message="{message}"')
-                break
-        else:
-            self.props(remove='error')
-
     def _msg_to_value(self, msg: Dict) -> Any:
         return float(msg['args']) if msg['args'] else None
 

+ 1 - 1
nicegui/elements/select.py

@@ -44,7 +44,7 @@ class Select(ChoiceElement, DisableableElement):
         if with_input:
             self.original_options = deepcopy(options)
             self._props['use-input'] = True
-            self._props['hide-selected'] = True
+            self._props['hide-selected'] = not multiple
             self._props['fill-input'] = True
             self._props['input-debounce'] = 0
         self._props['multiple'] = multiple

+ 30 - 0
nicegui/elements/table.js

@@ -0,0 +1,30 @@
+export default {
+  template: `
+    <q-table v-bind="$attrs" :columns="convertedColumns">
+      <template v-for="(_, slot) in $slots" v-slot:[slot]="slotProps">
+        <slot :name="slot" v-bind="slotProps || {}" />
+      </template>
+    </q-table>
+  `,
+  props: {
+    columns: Array,
+  },
+  computed: {
+    convertedColumns() {
+      return this.columns.map((column) => {
+        const convertedColumn = { ...column };
+        for (const attr in convertedColumn) {
+          if (attr.startsWith(":")) {
+            try {
+              convertedColumn[attr.slice(1)] = new Function("return " + convertedColumn[attr])();
+              delete convertedColumn[attr];
+            } catch (e) {
+              console.error(`Error while converting ${attr} attribute to function:`, e);
+            }
+          }
+        }
+        return convertedColumn;
+      });
+    },
+  },
+};

+ 4 - 1
nicegui/elements/table.py

@@ -2,10 +2,13 @@ from typing import Any, Callable, Dict, List, Optional
 
 from typing_extensions import Literal
 
+from ..dependencies import register_component
 from ..element import Element
 from ..events import TableSelectionEventArguments, handle_event
 from .mixins.filter_element import FilterElement
 
+register_component('nicegui-table', __file__, 'table.js')
+
 
 class Table(FilterElement):
 
@@ -32,7 +35,7 @@ class Table(FilterElement):
 
         If selection is 'single' or 'multiple', then a `selected` property is accessible containing the selected rows.
         """
-        super().__init__(tag='q-table')
+        super().__init__(tag='nicegui-table')
 
         self.rows = rows
         self.row_key = row_key

+ 4 - 1
nicegui/elements/textarea.py

@@ -17,11 +17,14 @@ class Textarea(Input):
         This element is based on Quasar's `QInput <https://quasar.dev/vue-components/input>`_ component.
         The ``type`` is set to ``textarea`` to create a multi-line text input.
 
+        You can use the `validation` parameter to define a dictionary of validation rules.
+        The key of the first rule that fails will be displayed as an error message.
+
         :param label: displayed name for the textarea
         :param placeholder: text to show if no value is entered
         :param value: the initial value of the field
         :param on_change: callback to execute when the value changes
-        :param validation: dictionary of validation rules, e.g. ``{'Too short!': lambda value: len(value) < 3}``
+        :param validation: dictionary of validation rules, e.g. ``{'Too long!': lambda value: len(value) < 3}``
         """
         super().__init__(label, placeholder=placeholder, value=value, on_change=on_change, validation=validation)
         self._props['type'] = 'textarea'

+ 1 - 1
nicegui/elements/upload.js

@@ -7,7 +7,7 @@ export default {
     </q-uploader>
   `,
   mounted() {
-    setTimeout(() => compute_url, 0); // NOTE: wait for window.path_prefix to be set in app.mounted()
+    setTimeout(() => this.compute_url(), 0); // NOTE: wait for window.path_prefix to be set in app.mounted()
   },
   updated() {
     this.compute_url();

+ 11 - 3
nicegui/functions/timer.py

@@ -40,6 +40,14 @@ class Timer:
         else:
             globals.app.on_startup(coroutine)
 
+    def activate(self) -> None:
+        """Activate the timer."""
+        self.active = True
+
+    def deactivate(self) -> None:
+        """Deactivate the timer."""
+        self.active = False
+
     async def _run_once(self) -> None:
         try:
             if not await self._connected():
@@ -50,7 +58,7 @@ class Timer:
                 if globals.state not in {globals.State.STOPPING, globals.State.STOPPED}:
                     await self._invoke_callback()
         finally:
-            self.cleanup()
+            self._cleanup()
 
     async def _run_in_loop(self) -> None:
         try:
@@ -75,7 +83,7 @@ class Timer:
                         globals.handle_exception(e)
                         await asyncio.sleep(self.interval)
         finally:
-            self.cleanup()
+            self._cleanup()
 
     async def _invoke_callback(self) -> None:
         try:
@@ -104,6 +112,6 @@ class Timer:
                 globals.log.error(f'Timer cancelled because client is not connected after {timeout} seconds')
                 return False
 
-    def cleanup(self) -> None:
+    def _cleanup(self) -> None:
         self.slot = None
         self.callback = None

+ 7 - 0
pyproject.toml

@@ -53,3 +53,10 @@ requires = [
     "poetry-core>=1.0.0"
 ]
 build-backend = "poetry.core.masonry.api"
+
+[tool.pytest.ini_options]
+addopts = "--driver Chrome"
+asyncio_mode = "auto"
+
+[tool.mypy]
+ignore_missing_imports = true

+ 0 - 3
pytest.ini

@@ -1,3 +0,0 @@
-[pytest]
-addopts = --driver Chrome
-asyncio_mode = auto

+ 12 - 5
tests/test_input.py

@@ -55,7 +55,7 @@ def test_toggle_button(screen: Screen):
 
 
 def test_input_validation(screen: Screen):
-    ui.input('Name', validation={'Too short': lambda value: len(value) >= 5})
+    input = ui.input('Name', validation={'Too short': lambda value: len(value) >= 5})
 
     screen.open('/')
     screen.should_contain('Name')
@@ -63,10 +63,12 @@ def test_input_validation(screen: Screen):
     element = screen.selenium.find_element(By.XPATH, '//*[@aria-label="Name"]')
     element.send_keys('John')
     screen.should_contain('Too short')
+    assert input.error == 'Too short'
 
     element.send_keys(' Doe')
     screen.wait(0.5)
     screen.should_not_contain('Too short')
+    assert input.error is None
 
 
 def test_input_with_multi_word_error_message(screen: Screen):
@@ -81,7 +83,7 @@ def test_input_with_multi_word_error_message(screen: Screen):
 
 
 def test_autocompletion(screen: Screen):
-    ui.input('Input', autocomplete=['foo', 'bar', 'baz'])
+    input = ui.input('Input', autocomplete=['foo', 'bar', 'baz'])
 
     screen.open('/')
     element = screen.selenium.find_element(By.XPATH, '//*[@aria-label="Input"]')
@@ -98,16 +100,21 @@ def test_autocompletion(screen: Screen):
     element.send_keys(Keys.TAB)
     screen.wait(0.2)
     assert element.get_attribute('value') == 'foo'
+    assert input.value == 'foo'
 
     element.send_keys(Keys.BACKSPACE)
-    screen.wait(0.2)
     element.send_keys(Keys.BACKSPACE)
-    screen.wait(0.2)
     element.send_keys('x')
-    screen.wait(0.2)
     element.send_keys(Keys.TAB)
     screen.wait(0.5)
     assert element.get_attribute('value') == 'fx'
+    assert input.value == 'fx'
+
+    input.set_autocomplete(['one', 'two'])
+    element.send_keys(Keys.BACKSPACE)
+    element.send_keys(Keys.BACKSPACE)
+    element.send_keys('o')
+    screen.should_contain('ne')
 
 
 def test_clearable_input(screen: Screen):

+ 14 - 0
tests/test_number.py

@@ -39,3 +39,17 @@ def test_clearable_number(screen: Screen):
     screen.should_contain('value: 42')
     screen.click('cancel')
     screen.should_contain('value: None')
+
+
+def test_out_of_limits(screen: Screen):
+    number = ui.number('Number', min=0, max=10, value=5)
+    ui.label().bind_text_from(number, 'out_of_limits', lambda value: f'out_of_limits: {value}')
+
+    screen.open('/')
+    screen.should_contain('out_of_limits: False')
+
+    number.value = 11
+    screen.should_contain('out_of_limits: True')
+
+    number.max = 15
+    screen.should_contain('out_of_limits: False')

+ 8 - 0
tests/test_table.py

@@ -102,3 +102,11 @@ def test_single_selection(screen: Screen):
     screen.find('Bob').find_element(By.XPATH, 'preceding-sibling::td').click()
     screen.wait(0.5)
     screen.should_contain('1 record selected.')
+
+
+def test_dynamic_column_attributes(screen: Screen):
+    ui.table(columns=[{'name': 'age', 'label': 'Age', 'field': 'age', ':format': 'value => value + " years"'}],
+             rows=[{'name': 'Alice', 'age': 18}])
+
+    screen.open('/')
+    screen.should_contain('18 years')

+ 15 - 0
website/more_documentation/aggrid_documentation.py

@@ -138,3 +138,18 @@ def more() -> None:
 
         df = pd.DataFrame(data={'col1': [1, 2], 'col2': [3, 4]})
         ui.aggrid.from_pandas(df).classes('max-h-40')
+
+    @text_demo('Render columns as HTML', '''
+        You can render columns as HTML by passing a list of column indices to the `html_columns` argument.
+    ''')
+    def aggrid_with_html_columns():
+        ui.aggrid({
+            'columnDefs': [
+                {'headerName': 'Name', 'field': 'name'},
+                {'headerName': 'URL', 'field': 'url'},
+            ],
+            'rowData': [
+                {'name': 'Google', 'url': '<a href="https://google.com">https://google.com</a>'},
+                {'name': 'Facebook', 'url': '<a href="https://facebook.com">https://facebook.com</a>'},
+            ],
+        }, html_columns=[1])

+ 30 - 0
website/more_documentation/table_documentation.py

@@ -160,3 +160,33 @@ def more() -> None:
             {'name': 'count', 'label': 'Count', 'field': 'count'},
         ]
         table = ui.table(columns=columns, rows=[], row_key='id').classes('w-full')
+
+    @text_demo('Custom sorting and formatting', '''
+        You can define dynamic column attributes using a `:` prefix.
+        This way you can define custom sorting and formatting functions.
+
+        The following example allows sorting the `name` column by length.
+        The `age` column is formatted to show the age in years.
+    ''')
+    def custom_formatting():
+        columns = [
+            {
+                'name': 'name',
+                'label': 'Name',
+                'field': 'name',
+                'sortable': True,
+                ':sort': '(a, b, rowA, rowB) => b.length - a.length',
+            },
+            {
+                'name': 'age',
+                'label': 'Age',
+                'field': 'age',
+                ':format': 'value => value + " years"',
+            },
+        ]
+        rows = [
+            {'name': 'Alice', 'age': 18},
+            {'name': 'Bob', 'age': 21},
+            {'name': 'Carl', 'age': 42},
+        ]
+        ui.table(columns=columns, rows=rows, row_key='name')

+ 12 - 9
website/search.py

@@ -1,4 +1,4 @@
-from nicegui import events, ui
+from nicegui import background_tasks, events, ui
 
 
 class Search:
@@ -48,14 +48,17 @@ class Search:
         if e.key == 'k' and (e.modifiers.ctrl or e.modifiers.meta):
             self.dialog.open()
 
-    async def handle_input(self, e: events.ValueChangeEventArguments) -> None:
-        self.results.clear()
-        with self.results:
-            for result in await ui.run_javascript(f'return window.fuse.search("{e.value}").slice(0, 50)'):
-                href: str = result['item']['url']
-                with ui.element('q-item').props(f'clickable').on('click', lambda href=href: self.open_url(href)):
-                    with ui.element('q-item-section'):
-                        ui.label(result['item']['title'])
+    def handle_input(self, e: events.ValueChangeEventArguments) -> None:
+        async def handle_input():
+            with self.results:
+                results = await ui.run_javascript(f'return window.fuse.search("{e.value}").slice(0, 50)')
+                self.results.clear()
+                for result in results:
+                    href: str = result['item']['url']
+                    with ui.element('q-item').props(f'clickable').on('click', lambda href=href: self.open_url(href)):
+                        with ui.element('q-item-section'):
+                            ui.label(result['item']['title'])
+        background_tasks.create_lazy(handle_input(), name='handle_search_input')
 
     async def open_url(self, url: str) -> None:
         await ui.run_javascript(f'''