Bläddra i källkod

Merge pull request #500 from dclause/feature/qtable

Table: implementation of Quasar's Qtable element
Falko Schindler 2 år sedan
förälder
incheckning
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('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('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('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'):
     with ui.row().classes('bg-primary w-full min-h-screen mt-16'):
         link_target('why')
         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
 from nicegui.events import ValueChangeEventArguments, handle_event
 
 
@@ -7,7 +7,7 @@ from ..element import Element
 
 
 class Tree(Element):
 class Tree(Element):
 
 
-    def __init__(self, nodes: list, *,
+    def __init__(self, nodes: List, *,
                  node_key: str = 'id',
                  node_key: str = 'id',
                  label_key: str = 'label',
                  label_key: str = 'label',
                  children_key: str = 'children',
                  children_key: str = 'children',

+ 5 - 0
nicegui/events.py

@@ -79,6 +79,11 @@ class ValueChangeEventArguments(EventArguments):
     value: Any
     value: Any
 
 
 
 
+@dataclass
+class TableSelectionEventArguments(EventArguments):
+    selection: List[Any]
+
+
 @dataclass
 @dataclass
 class KeyboardAction:
 class KeyboardAction:
     keydown: bool
     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.slider import Slider as slider
 from .elements.spinner import Spinner as spinner
 from .elements.spinner import Spinner as spinner
 from .elements.switch import Switch as switch
 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 Tab as tab
 from .elements.tabs import TabPanel as tab_panel
 from .elements.tabs import TabPanel as tab_panel
 from .elements.tabs import TabPanels as tab_panels
 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 import run
 from .run_with import run_with
 from .run_with import run_with
 
 
-table = deprecated(aggrid, 'ui.table', 'ui.aggrid', 370)
-
 if os.environ.get('MATPLOTLIB', 'true').lower() == 'true':
 if os.environ.get('MATPLOTLIB', 'true').lower() == 'true':
     from .elements.line_plot import LinePlot as line_plot
     from .elements.line_plot import LinePlot as line_plot
     from .elements.pyplot import Pyplot as pyplot
     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')
     h3('Data Elements')
 
 
     @example(ui.aggrid, menu)
     @example(ui.aggrid, menu)
-    def table_example():
+    def aggrid_example():
         grid = ui.aggrid({
         grid = ui.aggrid({
             'columnDefs': [
             'columnDefs': [
                 {'headerName': 'Name', 'field': 'name'},
                 {'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('Update', on_click=update)
         ui.button('Select all', on_click=lambda: grid.call_api_method('selectAll'))
         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)
     @example(ui.chart, menu)
     def chart_example():
     def chart_example():
         from numpy.random import random
         from numpy.random import random