Bläddra i källkod

Merge branch 'main' into feature/dependencies

# Conflicts:
#	nicegui/dependencies.py
Falko Schindler 1 år sedan
förälder
incheckning
566392aca2

+ 3 - 2
nicegui/dependencies.py

@@ -75,8 +75,9 @@ def generate_resources(prefix: str, elements: List[Element]) -> Tuple[str, str,
         for key in element.components:
             if key in js_components and key not in done_components:
                 name = js_components[key]['name']
-                js_imports += f'import {{ default as {key} }} from "{prefix}/_nicegui/{__version__}/components/{key}";\n'
-                js_imports += f'app.component("{name}", {key});\n'
+                var = key.replace('-', '_')
+                js_imports += f'import {{ default as {var} }} from "{prefix}/_nicegui/{__version__}/components/{key}";\n'
+                js_imports += f'app.component("{name}", {var});\n'
                 done_components.add(key)
 
     vue_styles = f'<style>{vue_styles}</style>'

+ 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();
+      }
+    },
+  },
+};

+ 12 - 20
nicegui/elements/input.py

@@ -1,11 +1,16 @@
+from pathlib import Path
 from typing import Any, Callable, Dict, List, Optional
 
+from ..dependencies import register_vue_component
 from .icon import Icon
 from .mixins.disableable_element import DisableableElement
 from .mixins.validation_element import ValidationElement
 
+register_vue_component('nicegui-input', Path(__file__).parent / 'input.js')
+
 
 class Input(ValidationElement, DisableableElement):
+    VALUE_PROP: str = 'value'
     LOOPBACK = False
 
     def __init__(self,
@@ -37,7 +42,7 @@ class Input(ValidationElement, DisableableElement):
         :param autocomplete: optional list of strings for autocompletion
         :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, validation=validation)
+        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,24 +57,11 @@ class Input(ValidationElement, DisableableElement):
                     self.props(f'type={"text" if is_hidden else "password"}')
                 icon = Icon('visibility_off').classes('cursor-pointer').on('click', toggle_type)
 
-        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):]}"')
+        self._props['autocomplete'] = autocomplete or []
 
-            def complete_input() -> None:
-                match = find_autocompletion()
-                if match:
-                    self.set_value(match)
-                self.props('shadow-text=""')
+        self.use_component('nicegui-input')
 
-            self.on('keyup', autocomplete_input)
-            self.on('keydown.tab', complete_input)
+    def set_autocomplete(self, autocomplete: Optional[List[str]]) -> None:
+        """Set the autocomplete list."""
+        self._props['autocomplete'] = autocomplete
+        self.update()

+ 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;
+      });
+    },
+  },
+};

+ 7 - 1
nicegui/elements/table.py

@@ -1,11 +1,15 @@
+from pathlib import Path
 from typing import Any, Callable, Dict, List, Optional
 
 from typing_extensions import Literal
 
+from ..dependencies import register_vue_component
 from ..element import Element
 from ..events import TableSelectionEventArguments, handle_event
 from .mixins.filter_element import FilterElement
 
+register_vue_component('nicegui-table', Path(__file__).parent / 'table.js')
+
 
 class Table(FilterElement):
 
@@ -32,7 +36,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
@@ -59,6 +63,8 @@ class Table(FilterElement):
             handle_event(on_select, arguments)
         self.on('selection', handle_selection)
 
+        self.use_component('nicegui-table')
+
     def add_rows(self, *rows: Dict) -> None:
         """Add rows to the table."""
         self.rows.extend(rows)

+ 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();

+ 9 - 4
tests/test_input.py

@@ -83,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"]')
@@ -100,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):

+ 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')

+ 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')