Repository: zauberzeug/nicegui
Pull request #2832 (2024-04-06 02:24:45)

Add demos for floating action buttons (fab)
This PR adds two demos on how to use Floating Action Buttons (FABs).


Patch:
diff --git a/nicegui/client.py b/nicegui/client.py
index d3b642b6..74c266ed 100644
--- a/nicegui/client.py
+++ b/nicegui/client.py
@@ -58,6 +58,7 @@ class Client:
         self.shared = shared
         self.on_air = False
         self._disconnect_task: Optional[asyncio.Task] = None
+        self.tab_id: Optional[str] = None
 
         self.outbox = Outbox(self)
 
diff --git a/nicegui/elements/echart.js b/nicegui/elements/echart.js
index c89e90b7..65e95877 100644
--- a/nicegui/elements/echart.js
+++ b/nicegui/elements/echart.js
@@ -64,7 +64,7 @@ export default {
     run_chart_method(name, ...args) {
       if (name.startsWith(":")) {
         name = name.slice(1);
-        args = args.map((arg) => new Function("return " + arg)());
+        args = args.map((arg) => new Function(`return (${arg})`)());
       }
       return runMethod(this.chart, name, args);
     },
diff --git a/nicegui/elements/json_editor.js b/nicegui/elements/json_editor.js
index 9f33681b..7186997e 100644
--- a/nicegui/elements/json_editor.js
+++ b/nicegui/elements/json_editor.js
@@ -35,7 +35,7 @@ export default {
       if (this.editor) {
         if (name.startsWith(":")) {
           name = name.slice(1);
-          args = args.map((arg) => new Function("return " + arg)());
+          args = args.map((arg) => new Function(`return (${arg})`)());
         }
         return runMethod(this.editor, name, args);
       }
diff --git a/nicegui/elements/leaflet.js b/nicegui/elements/leaflet.js
index 2a81d653..a112401a 100644
--- a/nicegui/elements/leaflet.js
+++ b/nicegui/elements/leaflet.js
@@ -125,7 +125,7 @@ export default {
     run_map_method(name, ...args) {
       if (name.startsWith(":")) {
         name = name.slice(1);
-        args = args.map((arg) => new Function("return " + arg)());
+        args = args.map((arg) => new Function(`return (${arg})`)());
       }
       return runMethod(this.map, name, args);
     },
@@ -135,7 +135,7 @@ export default {
         if (layer.id !== id) return;
         if (name.startsWith(":")) {
           name = name.slice(1);
-          args = args.map((arg) => new Function("return " + arg)());
+          args = args.map((arg) => new Function(`return (${arg})`)());
         }
         result = runMethod(layer, name, args);
       });
diff --git a/nicegui/native/native.py b/nicegui/native/native.py
index 858ed9de..931ecf38 100644
--- a/nicegui/native/native.py
+++ b/nicegui/native/native.py
@@ -70,6 +70,9 @@ try:
         def resize(self, width: int, height: int, fix_point: FixPoint = FixPoint.NORTH | FixPoint.WEST) -> None:
             self._send(width, height, fix_point)
 
+        def maximize(self) -> None:
+            self._send()
+
         def minimize(self) -> None:
             self._send()
 
diff --git a/nicegui/nicegui.py b/nicegui/nicegui.py
index b3f9bad5..f93c77dd 100644
--- a/nicegui/nicegui.py
+++ b/nicegui/nicegui.py
@@ -129,6 +129,7 @@ async def _startup() -> None:
     background_tasks.create(binding.refresh_loop(), name='refresh bindings')
     background_tasks.create(Client.prune_instances(), name='prune clients')
     background_tasks.create(Slot.prune_stacks(), name='prune slot stacks')
+    background_tasks.create(core.app.storage.prune_tab_storage(), name='prune tab storage')
     air.connect()
 
 
@@ -158,11 +159,12 @@ async def _exception_handler_500(request: Request, exception: Exception) -> Resp
 
 
 @sio.on('handshake')
-async def _on_handshake(sid: str, client_id: str) -> bool:
-    client = Client.instances.get(client_id)
+async def _on_handshake(sid: str, data: Dict[str, str]) -> bool:
+    client = Client.instances.get(data['client_id'])
     if not client:
         return False
     client.environ = sio.get_environ(sid)
+    client.tab_id = data['tab_id']
     await sio.enter_room(sid, client.id)
     client.handle_handshake()
     return True
diff --git a/nicegui/observables.py b/nicegui/observables.py
index 9b84ca09..bd3516f6 100644
--- a/nicegui/observables.py
+++ b/nicegui/observables.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import abc
+import time
 from typing import Any, Callable, Collection, Dict, Iterable, List, Optional, SupportsIndex, Union
 
 from . import events
@@ -16,6 +17,7 @@ class ObservableCollection(abc.ABC):
                  ) -> None:
         super().__init__(factory() if data is None else data)  # type: ignore
         self._parent = _parent
+        self.last_modified = time.time()
         self._change_handlers: List[Callable] = [on_change] if on_change else []
 
     @property
@@ -27,6 +29,7 @@ class ObservableCollection(abc.ABC):
         return change_handlers
 
     def _handle_change(self) -> None:
+        self.last_modified = time.time()
         for handler in self.change_handlers:
             events.handle_event(handler, events.ObservableChangeEventArguments(sender=self))
 
diff --git a/nicegui/static/nicegui.js b/nicegui/static/nicegui.js
index df797ffb..03ad3e3d 100644
--- a/nicegui/static/nicegui.js
+++ b/nicegui/static/nicegui.js
@@ -153,7 +153,7 @@ function renderRecursively(elements, id) {
       handler = (...args) => {
         const data = {
           id: id,
-          client_id: window.client_id,
+          client_id: window.clientId,
           listener_id: event.listener_id,
           args: stringifyEventArgs(args, event.args),
         };
@@ -212,7 +212,7 @@ function runJavascript(code, request_id) {
     })
     .then((result) => {
       if (request_id) {
-        window.socket.emit("javascript_response", { request_id, client_id: window.client_id, result });
+        window.socket.emit("javascript_response", { request_id, client_id: window.clientId, result });
       }
     });
 }
@@ -264,7 +264,7 @@ function createApp(elements, options) {
     },
     mounted() {
       mounted_app = this;
-      window.client_id = options.query.client_id;
+      window.clientId = options.query.client_id;
       const url = window.location.protocol === "https:" ? "wss://" : "ws://" + window.location.host;
       window.path_prefix = options.prefix;
       window.socket = io(url, {
@@ -275,9 +275,14 @@ function createApp(elements, options) {
       });
       const messageHandlers = {
         connect: () => {
-          window.socket.emit("handshake", window.client_id, (ok) => {
+          let tabId = sessionStorage.getItem("__nicegui_tab_id");
+          if (!tabId) {
+            tabId = crypto.randomUUID();
+            sessionStorage.setItem("__nicegui_tab_id", tabId);
+          }
+          window.socket.emit("handshake", { client_id: window.clientId, tab_id: tabId }, (ok) => {
             if (!ok) {
-              console.log("reloading because handshake failed for client_id " + window.client_id);
+              console.log("reloading because handshake failed for clientId " + window.clientId);
               window.location.reload();
             }
             document.getElementById("popup").style.opacity = 0;
diff --git a/nicegui/static/utils/dynamic_properties.js b/nicegui/static/utils/dynamic_properties.js
index 60ac4fc3..d8ab0a23 100644
--- a/nicegui/static/utils/dynamic_properties.js
+++ b/nicegui/static/utils/dynamic_properties.js
@@ -11,7 +11,7 @@ export function convertDynamicProperties(obj, recursive) {
   for (const [attr, value] of Object.entries(obj)) {
     if (attr.startsWith(":")) {
       try {
-        obj[attr.slice(1)] = new Function("return " + value)();
+        obj[attr.slice(1)] = new Function(`return (${value})`)();
         delete obj[attr];
       } catch (e) {
         console.error(`Error while converting ${attr} attribute to function:`, e);
diff --git a/nicegui/storage.py b/nicegui/storage.py
index 657f8650..64086869 100644
--- a/nicegui/storage.py
+++ b/nicegui/storage.py
@@ -1,7 +1,10 @@
+import asyncio
 import contextvars
 import os
+import time
 import uuid
 from collections.abc import MutableMapping
+from datetime import timedelta
 from pathlib import Path
 from typing import Any, Dict, Iterator, Optional, Union
 
@@ -17,6 +20,8 @@ from .logging import log
 
 request_contextvar: contextvars.ContextVar[Optional[Request]] = contextvars.ContextVar('request_var', default=None)
 
+PURGE_INTERVAL = timedelta(minutes=5).total_seconds()
+
 
 class ReadOnlyDict(MutableMapping):
 
@@ -94,9 +99,11 @@ class Storage:
 
     def __init__(self) -> None:
         self.path = Path(os.environ.get('NICEGUI_STORAGE_PATH', '.nicegui')).resolve()
-        self.migrate_to_utf8()
+        self.migrate_to_utf8()  # DEPRECATED: remove this in 2.0 release
+        self.max_tab_storage_age = timedelta(days=30).total_seconds()
         self._general = PersistentDict(self.path / 'storage-general.json', encoding='utf-8')
         self._users: Dict[str, PersistentDict] = {}
+        self._tabs: Dict[str, observables.ObservableDict] = {}
 
     @property
     def browser(self) -> Union[ReadOnlyDict, Dict]:
@@ -120,7 +127,7 @@ class Storage:
         return request.session
 
     @property
-    def user(self) -> Dict:
+    def user(self) -> PersistentDict:
         """Individual user storage that is persisted on the server (where NiceGUI is executed).
 
         The data is stored in a file on the server.
@@ -145,14 +152,38 @@ class Storage:
             return False  # no client
 
     @property
-    def general(self) -> Dict:
+    def general(self) -> PersistentDict:
         """General storage shared between all users that is persisted on the server (where NiceGUI is executed)."""
         return self._general
 
+    @property
+    def tab(self) -> observables.ObservableDict:
+        """A volatile storage that is only kept during the current tab session."""
+        if self._is_in_auto_index_context():
+            raise RuntimeError('app.storage.tab can only be used with page builder functions '
+                               '(https://nicegui.io/documentation/page)')
+        client = context.get_client()
+        if not client.has_socket_connection:
+            raise RuntimeError('app.storage.tab can only be used with a client connection; '
+                               'see https://nicegui.io/documentation/page#wait_for_client_connection to await it')
+        assert client.tab_id is not None
+        if client.tab_id not in self._tabs:
+            self._tabs[client.tab_id] = observables.ObservableDict()
+        return self._tabs[client.tab_id]
+
+    async def prune_tab_storage(self) -> None:
+        """Regularly prune tab storage that is older than the configured `max_tab_storage_age`."""
+        while True:
+            for tab_id, tab in list(self._tabs.items()):
+                if time.time() > tab.last_modified + self.max_tab_storage_age:
+                    del self._tabs[tab_id]
+            await asyncio.sleep(PURGE_INTERVAL)
+
     def clear(self) -> None:
         """Clears all storage."""
         self._general.clear()
         self._users.clear()
+        self._tabs.clear()
         for filepath in self.path.glob('storage-*.json'):
             filepath.unlink()
 
diff --git a/website/documentation/content/button_documentation.py b/website/documentation/content/button_documentation.py
index 876e425b..510f3b46 100644
--- a/website/documentation/content/button_documentation.py
+++ b/website/documentation/content/button_documentation.py
@@ -27,13 +27,14 @@ def icons() -> None:
 async def await_button_click() -> None:
     # @ui.page('/')
     # async def index():
-    b = ui.button('Step')
-    await b.clicked()
-    ui.label('One')
-    await b.clicked()
-    ui.label('Two')
-    await b.clicked()
-    ui.label('Three')
+    with ui.column():  # HIDE
+        b = ui.button('Step')
+        await b.clicked()
+        ui.label('One')
+        await b.clicked()
+        ui.label('Two')
+        await b.clicked()
+        ui.label('Three')
 
 
 @doc.demo('Disable button with a context manager', '''
@@ -85,4 +86,33 @@ def toggle_button() -> None:
     ToggleButton('Toggle me')
 
 
+@doc.demo('Floating Action Button', '''
+    As described in the [Quasar documentation](https://quasar.dev/vue-components/floating-action-button),
+    a Floating Action Button (FAB) is simply a "page-sticky" with a button inside.
+    With the "fab" prop, the button will be rounded and gets a shadow.
+    Color can be freely chosen, but most often it is an accent color.
+''')
+def fab() -> None:
+    ui.colors(accent='#6AD4DD')
+    # with ui.page_sticky(x_offset=18, y_offset=18):
+    with ui.row().classes('w-full h-full justify-end items-end'):  # HIDE
+        ui.button(icon='home', on_click=lambda: ui.notify('home')) \
+            .props('fab color=accent')
+
+
+@doc.demo('Expandable Floating Action Button', '''
+    The [Quasar FAB (q-fab)](https://quasar.dev/vue-components/floating-action-button),
+    is a button that reveals multiple actions when clicked.
+    While it is not a separate element in NiceGUI, it can be easily created using the generic `ui.element`.
+''')
+def expandable_fab() -> None:
+    with ui.element('q-fab').props('icon=navigation color=green'):
+        ui.element('q-fab-action').props('icon=train color=green-5') \
+            .on('click', lambda: ui.notify('train'))
+        ui.element('q-fab-action').props('icon=sailing color=green-5') \
+            .on('click', lambda: ui.notify('boat'))
+        ui.element('q-fab-action').props('icon=rocket color=green-5') \
+            .on('click', lambda: ui.notify('rocket'))
+
+
 doc.reference(ui.button)
diff --git a/website/documentation/content/storage_documentation.py b/website/documentation/content/storage_documentation.py
index 79277691..ccd045b5 100644
--- a/website/documentation/content/storage_documentation.py
+++ b/website/documentation/content/storage_documentation.py
@@ -13,9 +13,14 @@ doc.title('Storage')
 
 
 @doc.demo('Storage', '''
-    NiceGUI offers a straightforward method for data persistence within your application. 
-    It features three built-in storage types:
-
+    NiceGUI offers a straightforward mechanism for data persistence within your application. 
+    It features four built-in storage types:
+
+    - `app.storage.tab`:
+        Stored server-side in memory, this dictionary is unique to each tab session and can hold arbitrary objects.
+        Data will be lost when restarting the server until <https://github.com/zauberzeug/nicegui/discussions/2841> is implemented.
+        This storage is only available within [page builder functions](/documentation/page) 
+        and requires an established connection, obtainable via [`await client.connected()`](/documentation/page#wait_for_client_connection).
     - `app.storage.user`:
         Stored server-side, each dictionary is associated with a unique identifier held in a browser session cookie.
         Unique to each user, this storage is accessible across all their browser tabs.
@@ -86,3 +91,19 @@ def ui_state():
     #         .classes('w-full').bind_value(app.storage.user, 'note')
     # END OF DEMO
     ui.textarea('This note is kept between visits').classes('w-full').bind_value(app.storage.user, 'note')
+
+
+@doc.demo('Storing data per browser tab', '''
+    When storing data in `app.storage.tab`, a single user can open multiple tabs of the same app, each with its own storage data.
+    This may be beneficial in certain scenarios like search or when performing data analysis.
+    It is also more secure to use such a volatile storage for scenarios like logging into a bank account or accessing a password manager.
+''')
+def tab_storage():
+    from nicegui import app
+
+    # @ui.page('/')
+    # async def index(client):
+    #     await client.connected()
+    with ui.column():  # HIDE
+        app.storage.tab['count'] = app.storage.tab.get('count', 0) + 1
+        ui.label(f'Tab reloaded {app.storage.tab["count"]} times')


Test patch:
diff --git a/tests/test_storage.py b/tests/test_storage.py
index d26257e3..8db90f2a 100644
--- a/tests/test_storage.py
+++ b/tests/test_storage.py
@@ -3,7 +3,9 @@ from pathlib import Path
 
 import httpx
 
-from nicegui import Client, app, background_tasks, ui
+from nicegui import Client, app, background_tasks, context
+from nicegui import storage as storage_module
+from nicegui import ui
 from nicegui.testing import Screen
 
 
@@ -163,3 +165,65 @@ def test_rapid_storage(screen: Screen):
     screen.click('test')
     screen.wait(0.5)
     assert Path('.nicegui', 'storage-general.json').read_text('utf-8') == '{"one":1,"two":2,"three":3}'
+
+
+def test_tab_storage_is_local(screen: Screen):
+    @ui.page('/')
+    async def page():
+        await context.get_client().connected()
+        app.storage.tab['count'] = app.storage.tab.get('count', 0) + 1
+        ui.label().bind_text_from(app.storage.tab, 'count')
+
+    screen.open('/')
+    screen.should_contain('1')
+    screen.open('/')
+    screen.should_contain('2')
+
+    screen.switch_to(1)
+    screen.open('/')
+    screen.should_contain('1')
+
+    screen.switch_to(0)
+    screen.open('/')
+    screen.should_contain('3')
+
+
+def test_tab_storage_is_auto_removed(screen: Screen):
+    storage_module.PURGE_INTERVAL = 0.1
+    app.storage.max_tab_storage_age = 0.5
+
+    @ui.page('/')
+    async def page():
+        await context.get_client().connected()
+        app.storage.tab['count'] = app.storage.tab.get('count', 0) + 1
+        ui.label().bind_text_from(app.storage.tab, 'count')
+
+    screen.open('/')
+    screen.should_contain('1')
+    screen.open('/')
+    screen.should_contain('2')
+
+    screen.wait(1)
+    screen.open('/')
+    screen.should_contain('1')
+
+
+def test_clear_tab_storage(screen: Screen):
+    storage_module.PURGE_INTERVAL = 60
+
+    @ui.page('/')
+    async def page():
+        await context.get_client().connected()
+        app.storage.tab['test'] = '123'
+        ui.button('clear', on_click=app.storage.clear)
+
+    screen.open('/')
+    screen.should_contain('clear')
+
+    tab_storages = app.storage._tabs  # pylint: disable=protected-access
+    assert len(tab_storages) == 1
+    assert list(tab_storages.values())[0] == {'test': '123'}
+
+    screen.click('clear')
+    screen.wait(0.5)
+    assert not tab_storages