Переглянути джерело

Merge branch 'main' into trello-demo

Rodja Trappe 2 роки тому
батько
коміт
c2fb319a82

+ 1 - 0
examples/fastapi/frontend.py

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

+ 1 - 2
examples/fastapi/main.py

@@ -1,6 +1,5 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
 import frontend
 import frontend
-import uvicorn
 from fastapi import FastAPI
 from fastapi import FastAPI
 
 
 app = FastAPI()
 app = FastAPI()
@@ -14,4 +13,4 @@ def read_root():
 frontend.init(app)
 frontend.init(app)
 
 
 if __name__ == '__main__':
 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
 #!/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
 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:
 services:
   app:
   app:
     image: zauberzeug/nicegui:latest
     image: zauberzeug/nicegui:latest
-    ports:
-      - "3000:8080"
-
+    volumes:
+      - ./app:/app # mount local app directory
   proxy:
   proxy:
     image: nginx:1.16.0-alpine
     image: nginx:1.16.0-alpine
     ports:
     ports:
-      - "80:80"
+      - "80:80" # map internal port 80 to external port 80
     volumes:
     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 {
     server {
         listen 80 default_server;
         listen 80 default_server;
         server_name _;
         server_name _;
+        resolver 127.0.0.11; # see https://github.com/docker/compose/issues/3412
 
 
         location ~ ^/nicegui/(.*)$ {
         location ~ ^/nicegui/(.*)$ {
             proxy_http_version 1.1;
             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('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')

+ 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 . import elements, globals, ui
 from .client import Client
 from .client import Client
 from .nicegui import app
 from .nicegui import app

+ 1 - 0
nicegui/elements/menu.py

@@ -12,6 +12,7 @@ class Menu(ValueElement):
         """Menu
         """Menu
 
 
         Creates a 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`)
         :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
 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

+ 4 - 1
nicegui/standalone_mode.py

@@ -3,9 +3,12 @@ import os
 import signal
 import signal
 import tempfile
 import tempfile
 import time
 import time
+import warnings
 from threading import Thread
 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()
 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.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

+ 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-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-win32.whl", hash = "sha256:70ab53918fd907a3ade01909b3ed783287ede362c80c75f41e79596d5ccacd32"},
     {file = "debugpy-1.6.6-cp38-cp38-win_amd64.whl", hash = "sha256:c05349890804d846eca32ce0623ab66c06f8800db881af7a876dc073ac1c2225"},
     {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-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-win32.whl", hash = "sha256:549ae0cb2d34fc09d1675f9b01942499751d174381b6082279cf19cdb3c47cbe"},
     {file = "debugpy-1.6.6-cp39-cp39-win_amd64.whl", hash = "sha256:de4a045fbf388e120bb6ec66501458d3134f4729faed26ff95de52a754abddb1"},
     {file = "debugpy-1.6.6-cp39-cp39-win_amd64.whl", hash = "sha256:de4a045fbf388e120bb6ec66501458d3134f4729faed26ff95de52a754abddb1"},
@@ -458,14 +457,14 @@ files = [
 
 
 [[package]]
 [[package]]
 name = "exceptiongroup"
 name = "exceptiongroup"
-version = "1.1.0"
+version = "1.1.1"
 description = "Backport of PEP 654 (exception groups)"
 description = "Backport of PEP 654 (exception groups)"
 category = "dev"
 category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 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]
 [package.extras]
@@ -555,14 +554,14 @@ woff = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "zopfli (>=0.1.4)"]
 
 
 [[package]]
 [[package]]
 name = "fonttools"
 name = "fonttools"
-version = "4.39.0"
+version = "4.39.2"
 description = "Tools to manipulate font files"
 description = "Tools to manipulate font files"
 category = "main"
 category = "main"
 optional = false
 optional = false
 python-versions = ">=3.8"
 python-versions = ">=3.8"
 files = [
 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]
 [package.extras]
@@ -971,7 +970,6 @@ packaging = ">=20.0"
 pillow = ">=6.2.0"
 pillow = ">=6.2.0"
 pyparsing = ">=2.2.1"
 pyparsing = ">=2.2.1"
 python-dateutil = ">=2.7"
 python-dateutil = ">=2.7"
-setuptools_scm = ">=4,<7"
 
 
 [[package]]
 [[package]]
 name = "matplotlib"
 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-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-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-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_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-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"},
     {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]]
 [[package]]
 name = "python-engineio"
 name = "python-engineio"
-version = "4.3.4"
+version = "4.4.0"
 description = "Engine.IO server and client for Python"
 description = "Engine.IO server and client for Python"
 category = "main"
 category = "main"
 optional = false
 optional = false
 python-versions = ">=3.6"
 python-versions = ">=3.6"
 files = [
 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]
 [package.extras]
@@ -1727,14 +1718,14 @@ dev = ["atomicwrites (==1.2.1)", "attrs (==19.2.0)", "coverage (==6.5.0)", "hatc
 
 
 [[package]]
 [[package]]
 name = "python-socketio"
 name = "python-socketio"
-version = "5.7.2"
+version = "5.8.0"
 description = "Socket.IO server and client for Python"
 description = "Socket.IO server and client for Python"
 category = "main"
 category = "main"
 optional = false
 optional = false
 python-versions = ">=3.6"
 python-versions = ">=3.6"
 files = [
 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]
 [package.dependencies]
@@ -1905,44 +1896,6 @@ trio = ">=0.17,<1.0"
 trio-websocket = ">=0.9,<1.0"
 trio-websocket = ">=0.9,<1.0"
 urllib3 = {version = ">=1.26,<2.0", extras = ["socks"]}
 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]]
 [[package]]
 name = "six"
 name = "six"
 version = "1.16.0"
 version = "1.16.0"
@@ -2028,18 +1981,6 @@ files = [
     {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
     {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]]
 [[package]]
 name = "trio"
 name = "trio"
 version = "0.22.0"
 version = "0.22.0"
@@ -2064,18 +2005,18 @@ sortedcontainers = "*"
 
 
 [[package]]
 [[package]]
 name = "trio-websocket"
 name = "trio-websocket"
-version = "0.9.2"
+version = "0.10.0"
 description = "WebSocket library for Trio"
 description = "WebSocket library for Trio"
 category = "dev"
 category = "dev"
 optional = false
 optional = false
-python-versions = ">=3.5"
+python-versions = ">=3.7"
 files = [
 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]
 [package.dependencies]
-async-generator = ">=1.10"
+exceptiongroup = "*"
 trio = ">=0.11"
 trio = ">=0.11"
 wsproto = ">=0.14"
 wsproto = ">=0.14"
 
 
@@ -2342,4 +2283,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more
 [metadata]
 [metadata]
 lock-version = "2.0"
 lock-version = "2.0"
 python-versions = "^3.7"
 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"
 plotly = "^5.13.0"
 orjson = {version = "^3.8.6", markers = "platform_machine != 'i386' and platform_machine != 'i686'"} # orjson does not support 32bit
 orjson = {version = "^3.8.6", markers = "platform_machine != 'i386' and platform_machine != 'i686'"} # orjson does not support 32bit
 pywebview = "^4.0.2"
 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]
 [tool.poetry.group.dev.dependencies]
 icecream = "^2.1.0"
 icecream = "^2.1.0"

+ 7 - 5
test_startup.sh

@@ -1,10 +1,11 @@
 #!/usr/bin/env bash
 #!/usr/bin/env bash
 
 
 run() {
 run() {
-    output=`{ timeout 10 python3 $1; } 2>&1`
+    pwd
+    output=$({ timeout 10 ./$1 $2; } 2>&1)
     exitcode=$?
     exitcode=$?
     test $exitcode -eq 124 && exitcode=0 # exitcode 124 is comming from "timeout command above"
     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 "Traceback" > /dev/null && exitcode=1
     echo $output | grep "Error" > /dev/null && exitcode=1
     echo $output | grep "Error" > /dev/null && exitcode=1
     if test $exitcode -ne 0; then
     if test $exitcode -ne 0; then
@@ -17,7 +18,7 @@ run() {
 check() {
 check() {
     echo checking $1 ----------
     echo checking $1 ----------
     pushd $(dirname "$1") >/dev/null
     pushd $(dirname "$1") >/dev/null
-    if run $(basename "$1"); then
+    if run $(basename "$1") $2; then
         echo "ok --------"
         echo "ok --------"
         popd > /dev/null
         popd > /dev/null
     else
     else
@@ -31,8 +32,9 @@ error=0
 check main.py || error=1
 check main.py || error=1
 for path in examples/*
 for path in examples/*
 do
 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
         check $path/main.py || error=1
     fi
     fi
 done
 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')
     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
@@ -483,15 +496,16 @@ and [tab panels](https://quasar.dev/vue-components/tab-panels) API.
 
 
     @example(ui.menu, menu)
     @example(ui.menu, menu)
     def menu_example():
     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
     @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.
 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.
 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/).
 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'):
         with ui.column().classes('w-full mt-8 arrow-links'):