Browse Source

Merge pull request #500 from dclause/feature/qtable

Table: implementation of Quasar's Qtable element
Falko Schindler 2 năm trước cách đây
mục cha
commit
0563d370eb

+ 41 - 0
examples/table_and_slots/main.py

@@ -0,0 +1,41 @@
+#!/usr/bin/env python3
+import time
+
+from nicegui import ui
+
+columns = [
+    {'name': 'name', 'label': 'Name', 'field': 'name', 'required': True},
+    {'name': 'age', 'label': 'Age', 'field': 'age', 'sortable': True},
+]
+rows = [
+    {'id': 0, 'name': 'Alice', 'age': 18},
+    {'id': 1, 'name': 'Bob', 'age': 21},
+    {'id': 2, 'name': 'Lionel', 'age': 19},
+    {'id': 3, 'name': 'Michael', 'age': 32},
+    {'id': 4, 'name': 'Julie', 'age': 12},
+    {'id': 5, 'name': 'Livia', 'age': 25},
+    {'id': 6, 'name': 'Carol'},
+]
+
+with ui.table(title='My Team', columns=columns, rows=rows, selection='multiple', pagination=10).classes('w-96') as table:
+    with table.add_slot('top-right'):
+        with ui.input(placeholder='Search').props('type=search').bind_value(table, 'filter').add_slot('append'):
+            ui.icon('search')
+    with table.add_slot('bottom-row'):
+        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}),
+                    new_name.set_value(None),
+                    new_age.set_value(None),
+                )).props('flat fab-mini icon=add')
+            with table.cell():
+                new_name = ui.input('Name')
+            with table.cell():
+                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)) \
+    .bind_visibility_from(table, 'selected', backward=lambda val: bool(val))
+
+ui.run()

+ 1 - 0
main.py

@@ -245,6 +245,7 @@ The command searches for `main.py` in in your current directory and makes the ap
             example_link('Menu and Tabs', 'uses Quasar to create foldable menu and tabs inside a header bar')
             example_link('Trello Cards', 'shows Trello-like cards that can be dragged and dropped into columns')
             example_link('Slots', 'shows how to use scoped slots to customize Quasar elements')
+            example_link('Table and slots', 'shows how to use component slots in a table')
 
     with ui.row().classes('bg-primary w-full min-h-screen mt-16'):
         link_target('why')

+ 34 - 0
nicegui/elements/mixins/filter_element.py

@@ -0,0 +1,34 @@
+from typing import Any, Callable, Optional
+
+from ...binding import BindableProperty, bind, bind_from, bind_to
+from ...element import Element
+
+
+class FilterElement(Element):
+    FILTER_PROP = 'filter'
+    filter = BindableProperty(on_change=lambda sender, filter: sender.on_filter_change(filter))
+
+    def __init__(self, *, filter: Optional[str] = None, **kwargs) -> None:
+        super().__init__(**kwargs)
+        self.filter = filter
+        self._props[self.FILTER_PROP] = filter
+
+    def bind_filter_to(self, target_object: Any, target_name: str = 'filter', forward: Callable = lambda x: x):
+        bind_to(self, 'filter', target_object, target_name, forward)
+        return self
+
+    def bind_filter_from(self, target_object: Any, target_name: str = 'filter', backward: Callable = lambda x: x):
+        bind_from(self, 'filter', target_object, target_name, backward)
+        return self
+
+    def bind_filter(self, target_object: Any, target_name: str = 'filter', *,
+                    forward: Callable = lambda x: x, backward: Callable = lambda x: x):
+        bind(self, 'filter', target_object, target_name, forward=forward, backward=backward)
+        return self
+
+    def set_filter(self, filter: str) -> None:
+        self.filter = filter
+
+    def on_filter_change(self, filter: str) -> None:
+        self._props[self.FILTER_PROP] = filter
+        self.update()

+ 83 - 0
nicegui/elements/table.py

@@ -0,0 +1,83 @@
+from typing import Callable, Dict, List, Optional
+
+from typing_extensions import Literal
+
+from ..element import Element
+from ..events import TableSelectionEventArguments, handle_event
+from .mixins.filter_element import FilterElement
+
+
+class Table(FilterElement):
+
+    def __init__(self,
+                 columns: List[Dict],
+                 rows: List[Dict],
+                 row_key: str = 'id',
+                 title: Optional[str] = None,
+                 selection: Optional[Literal['single', 'multiple']] = None,
+                 pagination: Optional[int] = None,
+                 on_select: Optional[Callable] = None,
+                 ) -> None:
+        """Table
+
+        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 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`)
+        :param pagination: number of rows per page (`None` hides the pagination, 0 means "infinite"; default: `None`)
+        :param on_select: callback which is invoked when the selection changes
+
+        If selection is 'single' or 'multiple', then a `selection` property is accessible containing the selected rows.
+        """
+        super().__init__(tag='q-table')
+
+        self.rows = rows
+        self.row_key = row_key
+        self.selected: List[Dict] = []
+
+        self._props['columns'] = columns
+        self._props['rows'] = rows
+        self._props['row-key'] = row_key
+        self._props['title'] = title
+        self._props['hide-pagination'] = pagination is None
+        self._props['pagination'] = {'rowsPerPage': pagination or 0}
+        self._props['selection'] = selection or 'none'
+        self._props['selected'] = self.selected
+
+        def handle_selection(msg: Dict) -> None:
+            if msg['args']['added']:
+                self.selected.extend(msg['args']['rows'])
+            else:
+                self.selected[:] = [row for row in self.selected if row[row_key] not in msg['args']['keys']]
+            self.update()
+            arguments = TableSelectionEventArguments(sender=self, client=self.client, selection=self.selected)
+            handle_event(on_select, arguments)
+        self.on('selection', handle_selection)
+
+    def add_rows(self, *rows: Dict) -> None:
+        """Add rows to the table."""
+        self.rows.extend(rows)
+        self.update()
+
+    def remove_rows(self, *rows: Dict) -> None:
+        """Remove rows from the table."""
+        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.update()
+
+    class row(Element):
+        def __init__(self) -> None:
+            super().__init__('q-tr')
+
+    class header(Element):
+        def __init__(self) -> None:
+            super().__init__('q-th')
+
+    class cell(Element):
+        def __init__(self, key: str = '') -> None:
+            super().__init__('q-td')
+            if key:
+                self._props['key'] = key

+ 2 - 2
nicegui/elements/tree.py

@@ -1,4 +1,4 @@
-from typing import Any, Callable, Dict, Optional
+from typing import Any, Callable, Dict, List, Optional
 
 from nicegui.events import ValueChangeEventArguments, handle_event
 
@@ -7,7 +7,7 @@ from ..element import Element
 
 class Tree(Element):
 
-    def __init__(self, nodes: list, *,
+    def __init__(self, nodes: List, *,
                  node_key: str = 'id',
                  label_key: str = 'label',
                  children_key: str = 'children',

+ 5 - 0
nicegui/events.py

@@ -79,6 +79,11 @@ class ValueChangeEventArguments(EventArguments):
     value: Any
 
 
+@dataclass
+class TableSelectionEventArguments(EventArguments):
+    selection: List[Any]
+
+
 @dataclass
 class KeyboardAction:
     keydown: bool

+ 1 - 2
nicegui/ui.py

@@ -47,6 +47,7 @@ from .elements.separator import Separator as separator
 from .elements.slider import Slider as slider
 from .elements.spinner import Spinner as spinner
 from .elements.switch import Switch as switch
+from .elements.table import Table as table
 from .elements.tabs import Tab as tab
 from .elements.tabs import TabPanel as tab_panel
 from .elements.tabs import TabPanels as tab_panels
@@ -74,8 +75,6 @@ from .page_layout import RightDrawer as right_drawer
 from .run import run
 from .run_with import run_with
 
-table = deprecated(aggrid, 'ui.table', 'ui.aggrid', 370)
-
 if os.environ.get('MATPLOTLIB', 'true').lower() == 'true':
     from .elements.line_plot import LinePlot as line_plot
     from .elements.pyplot import Pyplot as pyplot

+ 88 - 0
tests/test_table.py

@@ -0,0 +1,88 @@
+from selenium.webdriver.common.by import By
+
+from nicegui import ui
+
+from .screen import Screen
+
+columns = [
+    {'name': 'name', 'label': 'Name', 'field': 'name', 'required': True},
+    {'name': 'age', 'label': 'Age', 'field': 'age', 'sortable': True},
+]
+
+rows = [
+    {'id': 0, 'name': 'Alice', 'age': 18},
+    {'id': 1, 'name': 'Bob', 'age': 21},
+    {'id': 2, 'name': 'Lionel', 'age': 19},
+]
+
+
+def test_table(screen: Screen):
+    ui.table(title='My Team', columns=columns, rows=rows)
+
+    screen.open('/')
+    screen.should_contain('My Team')
+    screen.should_contain('Name')
+    screen.should_contain('Alice')
+    screen.should_contain('Bob')
+    screen.should_contain('Lionel')
+
+
+def test_pagination(screen: Screen):
+    ui.table(columns=columns, rows=rows, pagination=2)
+
+    screen.open('/')
+    screen.should_contain('Alice')
+    screen.should_contain('Bob')
+    screen.should_not_contain('Lionel')
+    screen.should_contain('1-2 of 3')
+
+
+def test_filter(screen: Screen):
+    table = ui.table(columns=columns, rows=rows)
+    ui.input('Search by name').bind_value(table, 'filter')
+
+    screen.open('/')
+    screen.should_contain('Alice')
+    screen.should_contain('Bob')
+    screen.should_contain('Lionel')
+
+    element = screen.selenium.find_element(By.XPATH, '//*[@aria-label="Search by name"]')
+    element.send_keys('e')
+    screen.should_contain('Alice')
+    screen.should_not_contain('Bob')
+    screen.should_contain('Lionel')
+
+
+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]))
+
+    screen.open('/')
+    screen.click('Add')
+    screen.should_contain('Carol')
+
+    screen.click('Remove')
+    screen.should_not_contain('Alice')
+
+
+def test_slots(screen: Screen):
+    with ui.table(columns=columns, rows=rows) as table:
+        with table.add_slot('top-row'):
+            with table.row():
+                with table.cell():
+                    ui.label('This is the top slot.')
+        table.add_slot('body', '''
+            <q-tr :props="props">
+                <q-td key="name" :props="props">overridden</q-td>
+                <q-td key="age" :props="props">
+                    <q-badge color="green">{{ props.row.age }}</q-badge>
+                </q-td>
+            </q-tr>
+        ''')
+
+    screen.open('/')
+    screen.should_contain('This is the top slot.')
+    screen.should_not_contain('Alice')
+    screen.should_contain('overridden')
+    screen.should_contain('21')

+ 14 - 1
website/reference.py

@@ -271,7 +271,7 @@ To overlay an SVG, make the `viewBox` exactly the size of the image and provide
     h3('Data Elements')
 
     @example(ui.aggrid, menu)
-    def table_example():
+    def aggrid_example():
         grid = ui.aggrid({
             'columnDefs': [
                 {'headerName': 'Name', 'field': 'name'},
@@ -292,6 +292,19 @@ To overlay an SVG, make the `viewBox` exactly the size of the image and provide
         ui.button('Update', on_click=update)
         ui.button('Select all', on_click=lambda: grid.call_api_method('selectAll'))
 
+    @example(ui.table, menu)
+    def table_example():
+        columns = [
+            {'name': 'name', 'label': 'Name', 'field': 'name', 'required': True, 'align': 'left'},
+            {'name': 'age', 'label': 'Age', 'field': 'age', 'sortable': True},
+        ]
+        rows = [
+            {'name': 'Alice', 'age': 18},
+            {'name': 'Bob', 'age': 21},
+            {'name': 'Carol'},
+        ]
+        ui.table(columns=columns, rows=rows, row_key='name')
+
     @example(ui.chart, menu)
     def chart_example():
         from numpy.random import random