Quellcode durchsuchen

Improve the API of `ui.table` (#3525)

* improve API of `table.add_rows()` and `table.remove_rows()`

* allow omitting column definitions

* allow updating a table with a dataframe

* add support for default column parameters

* introduce `column_defaults`

* improve updating columns via update_from_pandas
Falko Schindler vor 9 Monaten
Ursprung
Commit
b394216e03

+ 2 - 2
examples/table_and_slots/main.py

@@ -25,7 +25,7 @@ with ui.table(title='My Team', columns=columns, rows=rows, selection='multiple',
         with table.row():
             with table.cell():
                 ui.button(on_click=lambda: (
-                    table.add_rows({'id': time.time(), 'name': new_name.value, 'age': new_age.value}),
+                    table.add_row({'id': time.time(), 'name': new_name.value, 'age': new_age.value}),
                     new_name.set_value(None),
                     new_age.set_value(None),
                 ), icon='add').props('flat fab-mini')
@@ -35,7 +35,7 @@ with ui.table(title='My Team', columns=columns, rows=rows, selection='multiple',
                 new_age = ui.number('Age')
 
 ui.label().bind_text_from(table, 'selected', lambda val: f'Current selection: {val}')
-ui.button('Remove', on_click=lambda: table.remove_rows(*table.selected)) \
+ui.button('Remove', on_click=lambda: table.remove_rows(table.selected)) \
     .bind_visibility_from(table, 'selected', backward=lambda val: bool(val))
 
 ui.run()

+ 95 - 16
nicegui/elements/table.py

@@ -1,10 +1,11 @@
-from typing import Any, Callable, Dict, List, Literal, Optional, Union
+from typing import Any, Callable, Dict, List, Literal, Optional, Tuple, Union
 
 from typing_extensions import Self
 
 from .. import optional_features
 from ..element import Element
 from ..events import GenericEventArguments, TableSelectionEventArguments, ValueChangeEventArguments, handle_event
+from ..helpers import warn_once
 from .mixins.filter_element import FilterElement
 
 try:
@@ -17,8 +18,10 @@ except ImportError:
 class Table(FilterElement, component='table.js'):
 
     def __init__(self,
-                 columns: List[Dict],
+                 *,
                  rows: List[Dict],
+                 columns: Optional[List[Dict]] = None,
+                 column_defaults: Optional[Dict] = None,
                  row_key: str = 'id',
                  title: Optional[str] = None,
                  selection: Optional[Literal['single', 'multiple']] = None,
@@ -30,8 +33,9 @@ class Table(FilterElement, component='table.js'):
 
         A table based on Quasar's `QTable <https://quasar.dev/vue-components/table>`_ component.
 
-        :param columns: list of column objects
         :param rows: list of row objects
+        :param columns: list of column objects (defaults to the columns of the first row)
+        :param column_defaults: optional default column properties
         :param row_key: name of the column containing unique data identifying the row (default: "id")
         :param title: title of the table
         :param selection: selection type ("single" or "multiple"; default: `None`)
@@ -43,7 +47,13 @@ class Table(FilterElement, component='table.js'):
         """
         super().__init__()
 
-        self._props['columns'] = columns
+        if columns is None:
+            first_row = rows[0] if rows else {}
+            columns = [{'name': key, 'label': str(key).upper(), 'field': key, 'sortable': True} for key in first_row]
+
+        self._column_defaults = column_defaults
+        self._use_columns_from_df = False
+        self._props['columns'] = self._normalize_columns(columns)
         self._props['rows'] = rows
         self._props['row-key'] = row_key
         self._props['title'] = title
@@ -86,9 +96,14 @@ class Table(FilterElement, component='table.js'):
         self._pagination_change_handlers.append(callback)
         return self
 
+    def _normalize_columns(self, columns: List[Dict]) -> List[Dict]:
+        return [{**self._column_defaults, **column} for column in columns] if self._column_defaults else columns
+
     @classmethod
     def from_pandas(cls,
-                    df: 'pd.DataFrame',
+                    df: 'pd.DataFrame', *,
+                    columns: Optional[List[Dict]] = None,
+                    column_defaults: Optional[Dict] = None,
                     row_key: str = 'id',
                     title: Optional[str] = None,
                     selection: Optional[Literal['single', 'multiple']] = None,
@@ -103,6 +118,8 @@ class Table(FilterElement, component='table.js'):
         See `issue 1698 <https://github.com/zauberzeug/nicegui/issues/1698>`_ for more information.
 
         :param df: Pandas DataFrame
+        :param columns: list of column objects (defaults to the columns of the dataframe)
+        :param column_defaults: optional default column properties
         :param row_key: name of the column containing unique data identifying the row (default: "id")
         :param title: title of the table
         :param selection: selection type ("single" or "multiple"; default: `None`)
@@ -110,6 +127,49 @@ class Table(FilterElement, component='table.js'):
         :param on_select: callback which is invoked when the selection changes
         :return: table element
         """
+        rows, columns_from_df = cls._df_to_rows_and_columns(df)
+        table = cls(
+            rows=rows,
+            columns=columns or columns_from_df,
+            column_defaults=column_defaults,
+            row_key=row_key,
+            title=title,
+            selection=selection,
+            pagination=pagination,
+            on_select=on_select,
+        )
+        table._use_columns_from_df = columns is None
+        return table
+
+    def update_from_pandas(self,
+                           df: 'pd.DataFrame', *,
+                           clear_selection: bool = True,
+                           columns: Optional[List[Dict]] = None,
+                           column_defaults: Optional[Dict] = None) -> None:
+        """Update the table from a Pandas DataFrame.
+
+        See `from_pandas()` for more information about the conversion of non-serializable columns.
+
+        If `columns` is not provided and the columns had been inferred from a DataFrame,
+        the columns will be updated to match the new DataFrame.
+
+        :param df: Pandas DataFrame
+        :param clear_selection: whether to clear the selection (default: True)
+        :param columns: list of column objects (defaults to the columns of the dataframe)
+        :param column_defaults: optional default column properties
+        """
+        rows, columns_from_df = self._df_to_rows_and_columns(df)
+        self.rows[:] = rows
+        if column_defaults is not None:
+            self._column_defaults = column_defaults
+        if columns or self._use_columns_from_df:
+            self.columns[:] = self._normalize_columns(columns or columns_from_df)
+        if clear_selection:
+            self.selected.clear()
+        self.update()
+
+    @staticmethod
+    def _df_to_rows_and_columns(df: 'pd.DataFrame') -> Tuple[List[Dict], List[Dict]]:
         def is_special_dtype(dtype):
             return (pd.api.types.is_datetime64_any_dtype(dtype) or
                     pd.api.types.is_timedelta64_dtype(dtype) or
@@ -125,14 +185,7 @@ class Table(FilterElement, component='table.js'):
                              'You can convert them to strings using something like '
                              '`df.columns = ["_".join(col) for col in df.columns.values]`.')
 
-        return cls(
-            columns=[{'name': col, 'label': col, 'field': col} for col in df.columns],
-            rows=df.to_dict('records'),
-            row_key=row_key,
-            title=title,
-            selection=selection,
-            pagination=pagination,
-            on_select=on_select)
+        return df.to_dict('records'), [{'name': col, 'label': col, 'field': col} for col in df.columns]
 
     @property
     def rows(self) -> List[Dict]:
@@ -151,9 +204,19 @@ class Table(FilterElement, component='table.js'):
 
     @columns.setter
     def columns(self, value: List[Dict]) -> None:
-        self._props['columns'][:] = value
+        self._props['columns'][:] = self._normalize_columns(value)
         self.update()
 
+    @property
+    def column_defaults(self) -> Optional[Dict]:
+        """Default column properties."""
+        return self._column_defaults
+
+    @column_defaults.setter
+    def column_defaults(self, value: Optional[Dict]) -> None:
+        self._column_defaults = value
+        self.columns = self.columns  # re-normalize columns
+
     @property
     def row_key(self) -> str:
         """Name of the column containing unique data identifying the row."""
@@ -203,18 +266,34 @@ class Table(FilterElement, component='table.js'):
         """Toggle fullscreen mode."""
         self.is_fullscreen = not self.is_fullscreen
 
-    def add_rows(self, *rows: Dict) -> None:
+    def add_rows(self, rows: List[Dict], *args: Any) -> None:
         """Add rows to the table."""
+        if isinstance(rows, dict):  # DEPRECATED
+            warn_once('Calling add_rows() with variable-length arguments is deprecated. '
+                      'Pass a list instead or use add_row() for a single row.')
+            rows = [rows, *args]
         self.rows.extend(rows)
         self.update()
 
-    def remove_rows(self, *rows: Dict) -> None:
+    def add_row(self, row: Dict) -> None:
+        """Add a single row to the table."""
+        self.add_rows([row])
+
+    def remove_rows(self, rows: List[Dict], *args: Any) -> None:
         """Remove rows from the table."""
+        if isinstance(rows, dict):  # DEPRECATED
+            warn_once('Calling remove_rows() with variable-length arguments is deprecated. '
+                      'Pass a list instead or use remove_row() for a single row.')
+            rows = [rows, *args]
         keys = [row[self.row_key] for row in rows]
         self.rows[:] = [row for row in self.rows if row[self.row_key] not in keys]
         self.selected[:] = [row for row in self.selected if row[self.row_key] not in keys]
         self.update()
 
+    def remove_row(self, row: Dict) -> None:
+        """Remove a single row from the table."""
+        self.remove_rows([row])
+
     def update_rows(self, rows: List[Dict], *, clear_selection: bool = True) -> None:
         """Update rows in the table.
 

+ 96 - 8
tests/test_table.py

@@ -72,8 +72,8 @@ def test_filter(screen: Screen):
 
 def test_add_remove(screen: Screen):
     table = ui.table(columns=columns(), rows=rows())
-    ui.button('Add', on_click=lambda: table.add_rows({'id': 3, 'name': 'Carol', 'age': 32}))
-    ui.button('Remove', on_click=lambda: table.remove_rows(table.rows[0]))
+    ui.button('Add', on_click=lambda: table.add_row({'id': 3, 'name': 'Carol', 'age': 32}))
+    ui.button('Remove', on_click=lambda: table.remove_row(table.rows[0]))
 
     screen.open('/')
     screen.click('Add')
@@ -129,7 +129,7 @@ def test_dynamic_column_attributes(screen: Screen):
 
 def test_remove_selection(screen: Screen):
     t = ui.table(columns=columns(), rows=rows(), selection='single')
-    ui.button('Remove first row', on_click=lambda: t.remove_rows(t.rows[0]))
+    ui.button('Remove first row', on_click=lambda: t.remove_row(t.rows[0]))
 
     screen.open('/')
     screen.find('Alice').find_element(By.XPATH, 'preceding-sibling::td').click()
@@ -171,17 +171,24 @@ def test_replace_rows(screen: Screen):
     screen.should_contain('Daniel')
 
 
-def test_create_from_pandas(screen: Screen):
-    df = pd.DataFrame({'name': ['Alice', 'Bob'], 'age': [18, 21], 42: 'answer'})
-    ui.table.from_pandas(df)
+def test_create_and_update_from_pandas(screen: Screen):
+    df = pd.DataFrame({'name': ['Alice', 'Bob'], 'age': [18, 21]})
+    table = ui.table.from_pandas(df)
+
+    def update():
+        df.loc[2] = ['Lionel', 19]
+        table.update_from_pandas(df)
+    ui.button('Update', on_click=update)
 
     screen.open('/')
     screen.should_contain('Alice')
     screen.should_contain('Bob')
     screen.should_contain('18')
     screen.should_contain('21')
-    screen.should_contain('42')
-    screen.should_contain('answer')
+
+    screen.click('Update')
+    screen.should_contain('Lionel')
+    screen.should_contain('19')
 
 
 def test_problematic_datatypes(screen: Screen):
@@ -230,3 +237,84 @@ def test_table_computed_props(screen: Screen):
     screen.should_contain('Lionel')
     screen.should_not_contain('Alice')
     screen.should_not_contain('Bob')
+
+
+def test_infer_columns(screen: Screen):
+    ui.table(rows=[
+        {'name': 'Alice', 'age': 18},
+        {'name': 'Bob', 'age': 21},
+    ])
+
+    screen.open('/')
+    screen.should_contain('NAME')
+    screen.should_contain('AGE')
+    screen.should_contain('Alice')
+    screen.should_contain('Bob')
+    screen.should_contain('18')
+    screen.should_contain('21')
+
+
+def test_default_column_parameters(screen: Screen):
+    ui.table(rows=[
+        {'name': 'Alice', 'age': 18, 'city': 'London'},
+        {'name': 'Bob', 'age': 21, 'city': 'Paris'},
+    ], columns=[
+        {'name': 'name', 'label': 'Name', 'field': 'name'},
+        {'name': 'age', 'label': 'Age', 'field': 'age'},
+        {'name': 'city', 'label': 'City', 'field': 'city', 'sortable': False},
+    ], column_defaults={'sortable': True})
+
+    screen.open('/')
+    screen.should_contain('Name')
+    screen.should_contain('Age')
+    screen.should_contain('Alice')
+    screen.should_contain('Bob')
+    screen.should_contain('18')
+    screen.should_contain('21')
+    screen.should_contain('London')
+    screen.should_contain('Paris')
+    assert len(screen.find_all_by_class('sortable')) == 2
+
+
+def test_columns_from_df(screen: Screen):
+    persons = ui.table.from_pandas(pd.DataFrame({'name': ['Alice', 'Bob'], 'age': [18, 21]}))
+    cars = ui.table.from_pandas(pd.DataFrame({'make': ['Ford', 'Toyota'], 'model': ['Focus', 'Corolla']}),
+                                columns=[{'name': 'make', 'label': 'make', 'field': 'make'}])
+
+    ui.button('Update persons without columns',
+              on_click=lambda: persons.update_from_pandas(pd.DataFrame({'name': ['Dan'], 'age': [5], 'sex': ['male']})))
+
+    ui.button('Update persons with columns',
+              on_click=lambda: persons.update_from_pandas(pd.DataFrame({'name': ['Stephen'], 'age': [33]}),
+                                                          columns=[{'name': 'name', 'label': 'Name', 'field': 'name'}]))
+
+    ui.button('Update cars without columns',
+              on_click=lambda: cars.update_from_pandas(pd.DataFrame({'make': ['Honda'], 'model': ['Civic']})))
+
+    ui.button('Update cars with columns',
+              on_click=lambda: cars.update_from_pandas(pd.DataFrame({'make': ['Hyundai'], 'model': ['i30']}),
+                                                       columns=[{'name': 'make', 'label': 'make', 'field': 'make'},
+                                                                {'name': 'model', 'label': 'model', 'field': 'model'}]))
+
+    screen.open('/')
+    screen.should_contain('name')
+    screen.should_contain('age')
+    screen.should_contain('make')
+    screen.should_not_contain('model')
+
+    screen.click('Update persons without columns')  # infer columns (like during instantiation)
+    screen.should_contain('Dan')
+    screen.should_contain('5')
+    screen.should_contain('male')
+
+    screen.click('Update persons with columns')  # updated columns via parameter
+    screen.should_contain('Stephen')
+    screen.should_not_contain('32')
+
+    screen.click('Update cars without columns')  # don't change columns
+    screen.should_contain('Honda')
+    screen.should_not_contain('Civic')
+
+    screen.click('Update cars with columns')  # updated columns via parameter
+    screen.should_contain('Hyundai')
+    screen.should_contain('i30')

+ 31 - 2
website/documentation/content/table_documentation.py

@@ -17,6 +17,35 @@ def main_demo() -> None:
     ui.table(columns=columns, rows=rows, row_key='name')
 
 
+@doc.demo('Omitting columns', '''
+    If you omit the `columns` parameter, the table will automatically generate columns from the first row.
+    Labels are uppercased and sorting is enabled.
+''')
+def omitting_columns():
+    ui.table(rows=[
+        {'make': 'Toyota', 'model': 'Celica', 'price': 35000},
+        {'make': 'Ford', 'model': 'Mondeo', 'price': 32000},
+        {'make': 'Porsche', 'model': 'Boxster', 'price': 72000},
+    ])
+
+
+@doc.demo('Default column parameters', '''
+    You can define default column parameters that apply to all columns.
+    In this example, all columns are left-aligned by default and have a blue uppercase header.
+''')
+def default_column_parameters():
+    ui.table(rows=[
+        {'name': 'Alice', 'age': 18},
+        {'name': 'Bob', 'age': 21},
+    ], columns=[
+        {'name': 'name', 'label': 'Name', 'field': 'name'},
+        {'name': 'age', 'label': 'Age', 'field': 'age'},
+    ], column_defaults={
+        'align': 'left',
+        'headerClasses': 'uppercase text-primary',
+    })
+
+
 @doc.demo('Table with expandable rows', '''
     Scoped slots can be used to insert buttons that toggle the expand state of a table row.
     See the [Quasar documentation](https://quasar.dev/vue-components/table#expanding-rows) for more information.
@@ -138,14 +167,14 @@ def table_from_pandas_demo():
 
 
 @doc.demo('Adding rows', '''
-    It's simple to add new rows with the `add_rows(dict)` method.
+    It's simple to add new rows with the `add_row(dict)` and `add_rows(list[dict])` methods.
     With the "virtual-scroll" prop set, the table can be programmatically scrolled with the `scrollTo` JavaScript function.
 ''')
 def adding_rows():
     from datetime import datetime
 
     def add():
-        table.add_rows({'date': datetime.now().strftime('%c')})
+        table.add_row({'date': datetime.now().strftime('%c')})
         table.run_method('scrollTo', len(table.rows)-1)
 
     columns = [{'name': 'date', 'label': 'Date', 'field': 'date'}]