Ver Fonte

Merge branch 'main' into trello-demo

Rodja Trappe há 2 anos atrás
pai
commit
c2fb319a82

+ 1 - 0
examples/fastapi/frontend.py

@@ -1,4 +1,5 @@
 from fastapi import FastAPI
+
 from nicegui import ui
 
 

+ 1 - 2
examples/fastapi/main.py

@@ -1,6 +1,5 @@
 #!/usr/bin/env python3
 import frontend
-import uvicorn
 from fastapi import FastAPI
 
 app = FastAPI()
@@ -14,4 +13,4 @@ def read_root():
 frontend.init(app)
 
 if __name__ == '__main__':
-    uvicorn.run(app, host='0.0.0.0', port=8000)
+    print('Please start the app with the "uvicorn" command as shown in the start.sh script')

+ 21 - 2
examples/fastapi/start.sh

@@ -1,4 +1,23 @@
 #!/usr/bin/env bash
 
-# Start the FastAPI app
-uvicorn main:app --reload
+if [ "$#" -ne 1 ]; then
+  echo "Usage: $0 <prod|dev>"
+  exit 1
+fi
+
+# use path of this demo as working directory; enables starting this script from anywhere
+cd "$(dirname "$0")" 
+
+if [ "$1" = "prod" ]; then
+  echo "Starting Uvicorn server in production mode..."
+  # we also use a single worker in production mode so socket.io connections are always handled by the same worker
+  uvicorn main:app --workers 1 --log-level info --port 80
+elif [ "$1" = "dev" ]; then
+  echo "Starting Uvicorn server in development mode..."
+  # reload implies workers = 1
+  uvicorn main:app --reload --log-level debug --port 8000
+else
+  echo "Invalid parameter. Use 'prod' or 'dev'."
+  exit 1
+fi
+

+ 4 - 1
examples/nginx_subpath/README.md

@@ -12,4 +12,7 @@ Just run
 docker-compose up
 ```
 
-Then you can access http://localhost/nicegui.
+Then you can access http://localhost/nicegui/.
+Note the trailing / in the url.
+It is important.
+We welcome suggestion on how to make it optional.

+ 17 - 0
examples/nginx_subpath/app/main.py

@@ -0,0 +1,17 @@
+from nicegui import ui
+
+
+@ui.page('/subpage')
+def subpage():
+    ui.label('This is a subpage')
+
+
+@ui.page('/')
+def index():
+    with ui.card().classes('mx-auto px-24 pt-12 pb-24 items-center'):
+        ui.label('this demonstrates hosting of a NiceGUI app on a subpath').classes('text-h5')
+        ui.label('as you can see the entire app is available below /nicegui but the code here does not need to know that').classes('text-lg')
+        ui.link('navigate to a subpage', subpage).classes('text-lg')
+
+
+ui.run()

+ 4 - 5
examples/nginx_subpath/docker-compose.yml

@@ -2,12 +2,11 @@ version: "3.9"
 services:
   app:
     image: zauberzeug/nicegui:latest
-    ports:
-      - "3000:8080"
-
+    volumes:
+      - ./app:/app # mount local app directory
   proxy:
     image: nginx:1.16.0-alpine
     ports:
-      - "80:80"
+      - "80:80" # map internal port 80 to external port 80
     volumes:
-      - ./nginx.conf:/etc/nginx/nginx.conf
+      - ./nginx.conf:/etc/nginx/nginx.conf # use custom nginx config

+ 1 - 0
examples/nginx_subpath/nginx.conf

@@ -40,6 +40,7 @@ http {
     server {
         listen 80 default_server;
         server_name _;
+        resolver 127.0.0.11; # see https://github.com/docker/compose/issues/3412
 
         location ~ ^/nicegui/(.*)$ {
             proxy_http_version 1.1;

+ 41 - 0
examples/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')

+ 7 - 0
nicegui/__init__.py

@@ -1,3 +1,10 @@
+try:
+    import importlib.metadata as importlib_metadata
+except ModuleNotFoundError:
+    import importlib_metadata
+
+__version__ = importlib_metadata.version('nicegui')
+
 from . import elements, globals, ui
 from .client import Client
 from .nicegui import app

+ 1 - 0
nicegui/elements/menu.py

@@ -12,6 +12,7 @@ class Menu(ValueElement):
         """Menu
 
         Creates a menu.
+        The menu should be placed inside the element where it should be shown.
 
         :param value: whether the menu is already opened (default: `False`)
         """

+ 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

+ 4 - 1
nicegui/standalone_mode.py

@@ -3,9 +3,12 @@ import os
 import signal
 import tempfile
 import time
+import warnings
 from threading import Thread
 
-import webview
+with warnings.catch_warnings():  # webview depends on bottle which uses the deprecated cgi function (see https://github.com/bottlepy/bottle/issues/1403)
+    warnings.filterwarnings("ignore", category=DeprecationWarning)
+    import webview
 
 shutdown = multiprocessing.Event()
 

+ 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

+ 18 - 77
poetry.lock

@@ -436,7 +436,6 @@ files = [
     {file = "debugpy-1.6.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b5d1b13d7c7bf5d7cf700e33c0b8ddb7baf030fcf502f76fc061ddd9405d16c"},
     {file = "debugpy-1.6.6-cp38-cp38-win32.whl", hash = "sha256:70ab53918fd907a3ade01909b3ed783287ede362c80c75f41e79596d5ccacd32"},
     {file = "debugpy-1.6.6-cp38-cp38-win_amd64.whl", hash = "sha256:c05349890804d846eca32ce0623ab66c06f8800db881af7a876dc073ac1c2225"},
-    {file = "debugpy-1.6.6-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:11a0f3a106f69901e4a9a5683ce943a7a5605696024134b522aa1bfda25b5fec"},
     {file = "debugpy-1.6.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a771739902b1ae22a120dbbb6bd91b2cae6696c0e318b5007c5348519a4211c6"},
     {file = "debugpy-1.6.6-cp39-cp39-win32.whl", hash = "sha256:549ae0cb2d34fc09d1675f9b01942499751d174381b6082279cf19cdb3c47cbe"},
     {file = "debugpy-1.6.6-cp39-cp39-win_amd64.whl", hash = "sha256:de4a045fbf388e120bb6ec66501458d3134f4729faed26ff95de52a754abddb1"},
@@ -458,14 +457,14 @@ files = [
 
 [[package]]
 name = "exceptiongroup"
-version = "1.1.0"
+version = "1.1.1"
 description = "Backport of PEP 654 (exception groups)"
 category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "exceptiongroup-1.1.0-py3-none-any.whl", hash = "sha256:327cbda3da756e2de031a3107b81ab7b3770a602c4d16ca618298c526f4bec1e"},
-    {file = "exceptiongroup-1.1.0.tar.gz", hash = "sha256:bcb67d800a4497e1b404c2dd44fca47d3b7a5e5433dbab67f96c1a685cdfdf23"},
+    {file = "exceptiongroup-1.1.1-py3-none-any.whl", hash = "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e"},
+    {file = "exceptiongroup-1.1.1.tar.gz", hash = "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"},
 ]
 
 [package.extras]
@@ -555,14 +554,14 @@ woff = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "zopfli (>=0.1.4)"]
 
 [[package]]
 name = "fonttools"
-version = "4.39.0"
+version = "4.39.2"
 description = "Tools to manipulate font files"
 category = "main"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "fonttools-4.39.0-py3-none-any.whl", hash = "sha256:f5e764e1fd6ad54dfc201ff32af0ba111bcfbe0d05b24540af74c63db4ed6390"},
-    {file = "fonttools-4.39.0.zip", hash = "sha256:909c104558835eac27faeb56be5a4c32694192dca123d073bf746ce9254054af"},
+    {file = "fonttools-4.39.2-py3-none-any.whl", hash = "sha256:85245aa2fd4cf502a643c9a9a2b5a393703e150a6eaacc3e0e84bb448053f061"},
+    {file = "fonttools-4.39.2.zip", hash = "sha256:e2d9f10337c9e3b17f9bce17a60a16a885a7d23b59b7f45ce07ea643e5580439"},
 ]
 
 [package.extras]
@@ -971,7 +970,6 @@ packaging = ">=20.0"
 pillow = ">=6.2.0"
 pyparsing = ">=2.2.1"
 python-dateutil = ">=2.7"
-setuptools_scm = ">=4,<7"
 
 [[package]]
 name = "matplotlib"
@@ -1207,13 +1205,6 @@ files = [
     {file = "Pillow-9.4.0-1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:b8c2f6eb0df979ee99433d8b3f6d193d9590f735cf12274c108bd954e30ca858"},
     {file = "Pillow-9.4.0-1-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:b70756ec9417c34e097f987b4d8c510975216ad26ba6e57ccb53bc758f490dab"},
     {file = "Pillow-9.4.0-1-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:43521ce2c4b865d385e78579a082b6ad1166ebed2b1a2293c3be1d68dd7ca3b9"},
-    {file = "Pillow-9.4.0-2-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:9d9a62576b68cd90f7075876f4e8444487db5eeea0e4df3ba298ee38a8d067b0"},
-    {file = "Pillow-9.4.0-2-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:87708d78a14d56a990fbf4f9cb350b7d89ee8988705e58e39bdf4d82c149210f"},
-    {file = "Pillow-9.4.0-2-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:8a2b5874d17e72dfb80d917213abd55d7e1ed2479f38f001f264f7ce7bae757c"},
-    {file = "Pillow-9.4.0-2-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:83125753a60cfc8c412de5896d10a0a405e0bd88d0470ad82e0869ddf0cb3848"},
-    {file = "Pillow-9.4.0-2-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:9e5f94742033898bfe84c93c831a6f552bb629448d4072dd312306bab3bd96f1"},
-    {file = "Pillow-9.4.0-2-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:013016af6b3a12a2f40b704677f8b51f72cb007dac785a9933d5c86a72a7fe33"},
-    {file = "Pillow-9.4.0-2-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:99d92d148dd03fd19d16175b6d355cc1b01faf80dae93c6c3eb4163709edc0a9"},
     {file = "Pillow-9.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:2968c58feca624bb6c8502f9564dd187d0e1389964898f5e9e1fbc8533169157"},
     {file = "Pillow-9.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c5c1362c14aee73f50143d74389b2c158707b4abce2cb055b7ad37ce60738d47"},
     {file = "Pillow-9.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd752c5ff1b4a870b7661234694f24b1d2b9076b8bf337321a814c612665f343"},
@@ -1696,14 +1687,14 @@ cli = ["click (>=5.0)"]
 
 [[package]]
 name = "python-engineio"
-version = "4.3.4"
+version = "4.4.0"
 description = "Engine.IO server and client for Python"
 category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
-    {file = "python-engineio-4.3.4.tar.gz", hash = "sha256:d8d8b072799c36cadcdcc2b40d2a560ce09797ab3d2d596b2ad519a5e4df19ae"},
-    {file = "python_engineio-4.3.4-py3-none-any.whl", hash = "sha256:7454314a529bba20e745928601ffeaf101c1b5aca9a6c4e48ad397803d10ea0c"},
+    {file = "python-engineio-4.4.0.tar.gz", hash = "sha256:bcc035c70ecc30acc3cfd49ef19aca6c51fa6caaadd0fa58c2d7480f50d04cf2"},
+    {file = "python_engineio-4.4.0-py3-none-any.whl", hash = "sha256:11f9c35b775fe70e0a25f67b16d5b69fbfafc368cdd87eeb6f4135a475c88e50"},
 ]
 
 [package.extras]
@@ -1727,14 +1718,14 @@ dev = ["atomicwrites (==1.2.1)", "attrs (==19.2.0)", "coverage (==6.5.0)", "hatc
 
 [[package]]
 name = "python-socketio"
-version = "5.7.2"
+version = "5.8.0"
 description = "Socket.IO server and client for Python"
 category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
-    {file = "python-socketio-5.7.2.tar.gz", hash = "sha256:92395062d9db3c13d30e7cdedaa0e1330bba78505645db695415f9a3c628d097"},
-    {file = "python_socketio-5.7.2-py3-none-any.whl", hash = "sha256:d9a9f047e6fdd306c852fbac36516f4b495c2096f8ad9ceb8803b8e5ff5622e3"},
+    {file = "python-socketio-5.8.0.tar.gz", hash = "sha256:e714f4dddfaaa0cb0e37a1e2deef2bb60590a5b9fea9c343dd8ca5e688416fd9"},
+    {file = "python_socketio-5.8.0-py3-none-any.whl", hash = "sha256:7adb8867aac1c2929b9c1429f1c02e12ca4c36b67c807967393e367dfbb01441"},
 ]
 
 [package.dependencies]
@@ -1905,44 +1896,6 @@ trio = ">=0.17,<1.0"
 trio-websocket = ">=0.9,<1.0"
 urllib3 = {version = ">=1.26,<2.0", extras = ["socks"]}
 
-[[package]]
-name = "setuptools"
-version = "67.6.0"
-description = "Easily download, build, install, upgrade, and uninstall Python packages"
-category = "main"
-optional = false
-python-versions = ">=3.7"
-files = [
-    {file = "setuptools-67.6.0-py3-none-any.whl", hash = "sha256:b78aaa36f6b90a074c1fa651168723acbf45d14cb1196b6f02c0fd07f17623b2"},
-    {file = "setuptools-67.6.0.tar.gz", hash = "sha256:2ee892cd5f29f3373097f5a814697e397cf3ce313616df0af11231e2ad118077"},
-]
-
-[package.extras]
-docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
-testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
-testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
-
-[[package]]
-name = "setuptools-scm"
-version = "6.4.2"
-description = "the blessed package to manage your versions by scm tags"
-category = "main"
-optional = false
-python-versions = ">=3.6"
-files = [
-    {file = "setuptools_scm-6.4.2-py3-none-any.whl", hash = "sha256:acea13255093849de7ccb11af9e1fb8bde7067783450cee9ef7a93139bddf6d4"},
-    {file = "setuptools_scm-6.4.2.tar.gz", hash = "sha256:6833ac65c6ed9711a4d5d2266f8024cfa07c533a0e55f4c12f6eff280a5a9e30"},
-]
-
-[package.dependencies]
-packaging = ">=20.0"
-setuptools = "*"
-tomli = ">=1.0.0"
-
-[package.extras]
-test = ["pytest (>=6.2)", "virtualenv (>20)"]
-toml = ["setuptools (>=42)"]
-
 [[package]]
 name = "six"
 version = "1.16.0"
@@ -2028,18 +1981,6 @@ files = [
     {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
 ]
 
-[[package]]
-name = "tomli"
-version = "2.0.1"
-description = "A lil' TOML parser"
-category = "main"
-optional = false
-python-versions = ">=3.7"
-files = [
-    {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
-    {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
-]
-
 [[package]]
 name = "trio"
 version = "0.22.0"
@@ -2064,18 +2005,18 @@ sortedcontainers = "*"
 
 [[package]]
 name = "trio-websocket"
-version = "0.9.2"
+version = "0.10.0"
 description = "WebSocket library for Trio"
 category = "dev"
 optional = false
-python-versions = ">=3.5"
+python-versions = ">=3.7"
 files = [
-    {file = "trio-websocket-0.9.2.tar.gz", hash = "sha256:a3d34de8fac26023eee701ed1e7bf4da9a8326b61a62934ec9e53b64970fd8fe"},
-    {file = "trio_websocket-0.9.2-py3-none-any.whl", hash = "sha256:5b558f6e83cc20a37c3b61202476c5295d1addf57bd65543364e0337e37ed2bc"},
+    {file = "trio-websocket-0.10.0.tar.gz", hash = "sha256:5a7a256cf45532a0e876b55c173f96228e95445869b6dfdb1556015de89796fa"},
+    {file = "trio_websocket-0.10.0-py3-none-any.whl", hash = "sha256:ae0a8bab4b0014510aca37fb67a6eaaa77e64aba372a7333845d2eb991989ae2"},
 ]
 
 [package.dependencies]
-async-generator = ">=1.10"
+exceptiongroup = "*"
 trio = ">=0.11"
 wsproto = ">=0.14"
 
@@ -2342,4 +2283,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.7"
-content-hash = "efe873607cc4175ced7c8eaf44072fd9cd5e48b8be1d736277834bbdd5b41c31"
+content-hash = "f22fee7230873254f3c83253605bfa53ae4274e367886aae70a8c5c65849e40b"

+ 1 - 0
pyproject.toml

@@ -27,6 +27,7 @@ python-multipart = "^0.0.6"
 plotly = "^5.13.0"
 orjson = {version = "^3.8.6", markers = "platform_machine != 'i386' and platform_machine != 'i686'"} # orjson does not support 32bit
 pywebview = "^4.0.2"
+importlib_metadata = { version = "^6.0.0", markers = "python_version ~= '3.7'" } # Python 3.7 has no importlib.metadata
 
 [tool.poetry.group.dev.dependencies]
 icecream = "^2.1.0"

+ 7 - 5
test_startup.sh

@@ -1,10 +1,11 @@
 #!/usr/bin/env bash
 
 run() {
-    output=`{ timeout 10 python3 $1; } 2>&1`
+    pwd
+    output=$({ timeout 10 ./$1 $2; } 2>&1)
     exitcode=$?
     test $exitcode -eq 124 && exitcode=0 # exitcode 124 is comming from "timeout command above"
-    echo $output | grep -e "NiceGUI ready to go" -e "Uvicorn running on http://0.0.0.0:8000" > /dev/null || exitcode=1
+    echo $output | grep -e "NiceGUI ready to go" -e "Uvicorn running on http://127.0.0.1:8000" > /dev/null || exitcode=1
     echo $output | grep "Traceback" > /dev/null && exitcode=1
     echo $output | grep "Error" > /dev/null && exitcode=1
     if test $exitcode -ne 0; then
@@ -17,7 +18,7 @@ run() {
 check() {
     echo checking $1 ----------
     pushd $(dirname "$1") >/dev/null
-    if run $(basename "$1"); then
+    if run $(basename "$1") $2; then
         echo "ok --------"
         popd > /dev/null
     else
@@ -31,8 +32,9 @@ error=0
 check main.py || error=1
 for path in examples/*
 do
-    if test -f $path/main.py
-    then
+    if test -f $path/start.sh; then
+       check $path/start.sh dev || error=1 
+    elif test -f $path/main.py; then
         check $path/main.py || error=1
     fi
 done

+ 89 - 0
tests/test_table.py

@@ -0,0 +1,89 @@
+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.wait(0.5)
+    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')

+ 26 - 11
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
@@ -483,15 +496,16 @@ and [tab panels](https://quasar.dev/vue-components/tab-panels) API.
 
     @example(ui.menu, menu)
     def menu_example():
-        choice = ui.label('Try the menu.')
-        with ui.row():
-            with ui.menu() as menu:
-                ui.menu_item('Menu item 1', lambda: choice.set_text('Selected item 1.'))
-                ui.menu_item('Menu item 2', lambda: choice.set_text('Selected item 2.'))
-                ui.menu_item('Menu item 3 (keep open)', lambda: choice.set_text('Selected item 3.'), auto_close=False)
-                ui.separator()
-                ui.menu_item('Close', on_click=menu.close)
-            ui.button('Open menu', on_click=menu.open)
+        with ui.row().classes('w-full items-center'):
+            result = ui.label().classes('mr-auto')
+            with ui.button(on_click=lambda: menu.open()).props('icon=menu'):
+                with ui.menu() as menu:
+                    ui.menu_item('Menu item 1', lambda: result.set_text('Selected item 1'))
+                    ui.menu_item('Menu item 2', lambda: result.set_text('Selected item 2'))
+                    ui.menu_item('Menu item 3 (keep open)',
+                                 lambda: result.set_text('Selected item 3'), auto_close=False)
+                    ui.separator()
+                    ui.menu_item('Close', on_click=menu.close)
 
     @example('''#### Tooltips
 
@@ -1014,8 +1028,9 @@ You can provide SSL certificates directly using [FastAPI](https://fastapi.tiango
 In production we also like using reverse proxies like [Traefik](https://doc.traefik.io/traefik/) or [NGINX](https://www.nginx.com/) to handle these details for us.
 See our [docker-compose.yml](https://github.com/zauberzeug/nicegui/blob/main/docker-compose.yml) as an example.
 
-You may also have a look at [our example for using a custom FastAPI app](https://github.com/zauberzeug/nicegui/tree/main/examples/fastapi).
+You may also have a look at [our demo for using a custom FastAPI app](https://github.com/zauberzeug/nicegui/tree/main/examples/fastapi).
 This will allow you to do very flexible deployments as described in the [FastAPI documentation](https://fastapi.tiangolo.com/deployment/).
+Note that there are additional steps required to allow multiple workers.
 ''')
 
         with ui.column().classes('w-full mt-8 arrow-links'):