From d3b35aea19432f8861d0cfd9bf744555032b0af7 Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Mon, 1 Apr 2024 09:07:23 +0200 Subject: [PATCH 01/79] Per client data --- examples/client_data/main.py | 33 ++++++++++++++++++++++++++ nicegui/client.py | 7 ++++++ nicegui/storage.py | 45 +++++++++++++++++++++++++++--------- 3 files changed, 74 insertions(+), 11 deletions(-) create mode 100644 examples/client_data/main.py diff --git a/examples/client_data/main.py b/examples/client_data/main.py new file mode 100644 index 000000000..246305dcb --- /dev/null +++ b/examples/client_data/main.py @@ -0,0 +1,33 @@ +from nicegui import ui +from nicegui.page import page +from nicegui import app + + +@page('/') +def index(): + def increment(): + data = app.storage.user + counter = data['counter'] = data.get('counter', 0) + 1 + button.text = f'Hello {counter} times!' + + ui.label("This data is unique per user") + ui.html('
') + button = ui.button('Hello, World!').on_click(increment) + ui.link('Go to perClient', '/perClient') + + +@page('/perClient') +def index(): + def increment(): + data = app.storage.client + counter = data['counter'] = data.get('counter', 0) + 1 + button.text = f'Hello {counter} times!' + + ui.label("This data is unique per browser tab - like in Streamlit") + ui.html('
') + button = ui.button('Hello, World!').on_click(increment) + ui.link('Go to per user', '/') + ui.link('Open in new tab', '/perClient', new_tab=True) + + +ui.run(storage_secret='my_secret') diff --git a/nicegui/client.py b/nicegui/client.py index 88fd0a2bc..79305d155 100644 --- a/nicegui/client.py +++ b/nicegui/client.py @@ -73,12 +73,19 @@ def __init__(self, page: page, *, shared: bool = False) -> None: self._body_html = '' self.page = page + self.state = {} self.connect_handlers: List[Union[Callable[..., Any], Awaitable]] = [] self.disconnect_handlers: List[Union[Callable[..., Any], Awaitable]] = [] self._temporary_socket_id: Optional[str] = None + @staticmethod + def current_client() -> Optional[Client]: + """Returns the current client if obtainable from the current context.""" + from .context import get_client + return get_client() + @property def is_auto_index_client(self) -> bool: """Return True if this client is the auto-index client.""" diff --git a/nicegui/storage.py b/nicegui/storage.py index 657f8650b..254079a7f 100644 --- a/nicegui/storage.py +++ b/nicegui/storage.py @@ -13,14 +13,17 @@ from starlette.responses import Response from . import background_tasks, context, core, json, observables +from .context import get_slot_stack from .logging import log -request_contextvar: contextvars.ContextVar[Optional[Request]] = contextvars.ContextVar('request_var', default=None) +request_contextvar: contextvars.ContextVar[Optional[Request]] = contextvars.ContextVar( + 'request_var', default=None) class ReadOnlyDict(MutableMapping): - def __init__(self, data: Dict[Any, Any], write_error_message: str = 'Read-only dict') -> None: + def __init__(self, data: Dict[Any, Any], + write_error_message: str = 'Read-only dict') -> None: self._data: Dict[Any, Any] = data self._write_error_message: str = write_error_message @@ -62,6 +65,7 @@ def backup(self) -> None: async def backup() -> None: async with aiofiles.open(self.filepath, 'w', encoding=self.encoding) as f: await f.write(json.dumps(self)) + if core.loop: background_tasks.create_lazy(backup(), name=self.filepath.stem) else: @@ -70,7 +74,8 @@ async def backup() -> None: class RequestTrackingMiddleware(BaseHTTPMiddleware): - async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + async def dispatch(self, request: Request, + call_next: RequestResponseEndpoint) -> Response: request_contextvar.set(request) if 'id' not in request.session: request.session['id'] = str(uuid.uuid4()) @@ -95,7 +100,8 @@ class Storage: def __init__(self) -> None: self.path = Path(os.environ.get('NICEGUI_STORAGE_PATH', '.nicegui')).resolve() self.migrate_to_utf8() - self._general = PersistentDict(self.path / 'storage-general.json', encoding='utf-8') + self._general = PersistentDict(self.path / 'storage-general.json', + encoding='utf-8') self._users: Dict[str, PersistentDict] = {} @property @@ -109,9 +115,11 @@ def browser(self) -> Union[ReadOnlyDict, Dict]: request: Optional[Request] = request_contextvar.get() if request is None: if self._is_in_auto_index_context(): - raise RuntimeError('app.storage.browser can only be used with page builder functions ' - '(https://nicegui.io/documentation/page)') - raise RuntimeError('app.storage.browser needs a storage_secret passed in ui.run()') + raise RuntimeError( + 'app.storage.browser can only be used with page builder functions ' + '(https://nicegui.io/documentation/page)') + raise RuntimeError( + 'app.storage.browser needs a storage_secret passed in ui.run()') if request.state.responded: return ReadOnlyDict( request.session, @@ -129,14 +137,27 @@ def user(self) -> Dict: request: Optional[Request] = request_contextvar.get() if request is None: if self._is_in_auto_index_context(): - raise RuntimeError('app.storage.user can only be used with page builder functions ' - '(https://nicegui.io/documentation/page)') - raise RuntimeError('app.storage.user needs a storage_secret passed in ui.run()') + raise RuntimeError( + 'app.storage.user can only be used with page builder functions ' + '(https://nicegui.io/documentation/page)') + raise RuntimeError( + 'app.storage.user needs a storage_secret passed in ui.run()') session_id = request.session['id'] if session_id not in self._users: - self._users[session_id] = PersistentDict(self.path / f'storage-user-{session_id}.json', encoding='utf-8') + self._users[session_id] = PersistentDict( + self.path / f'storage-user-{session_id}.json', encoding='utf-8') return self._users[session_id] + @property + def client(self) -> Dict: + """Volatile client storage that is persisted on the server (where NiceGUI is + executed) on a per client/per connection basis. + + Note that this kind of storage can only be used in single page applications + where the client connection is preserved between page changes.""" + client = context.get_client() + return client.state + @staticmethod def _is_in_auto_index_context() -> bool: try: @@ -153,6 +174,8 @@ def clear(self) -> None: """Clears all storage.""" self._general.clear() self._users.clear() + if get_slot_stack(): + self.client.clear() for filepath in self.path.glob('storage-*.json'): filepath.unlink() From ef97d2e04f5911cf0bbda829771525179084f53e Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Mon, 1 Apr 2024 09:47:31 +0200 Subject: [PATCH 02/79] Per client data --- examples/client_data/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/client_data/main.py b/examples/client_data/main.py index 246305dcb..1c74980d8 100644 --- a/examples/client_data/main.py +++ b/examples/client_data/main.py @@ -17,7 +17,7 @@ def increment(): @page('/perClient') -def index(): +def per_client(): def increment(): data = app.storage.client counter = data['counter'] = data.get('counter', 0) + 1 From 91887a1c5141e5ed9f7b6d63c488e4462e0cde46 Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Mon, 1 Apr 2024 13:01:47 +0200 Subject: [PATCH 03/79] Backup single page routing --- examples/page_builder/main.py | 20 ++++++++++ examples/single_page_app/main.py | 25 ++++++++---- examples/single_page_app/router_frame.js | 40 ++++++++++++------- nicegui/page_builder.py | 51 ++++++++++++++++++++++++ nicegui/single_page.js | 28 +++++++++++++ nicegui/single_page.py | 47 ++++++++++++++++++++++ 6 files changed, 189 insertions(+), 22 deletions(-) create mode 100644 examples/page_builder/main.py create mode 100644 nicegui/page_builder.py create mode 100644 nicegui/single_page.js create mode 100644 nicegui/single_page.py diff --git a/examples/page_builder/main.py b/examples/page_builder/main.py new file mode 100644 index 000000000..64094ac20 --- /dev/null +++ b/examples/page_builder/main.py @@ -0,0 +1,20 @@ +from nicegui import ui +from nicegui.page import page +from nicegui.page_builder import PageBuilder, PageRouter + + +class DemoPage(PageBuilder): + def __init__(self, **kwargs): + super().__init__(**kwargs) + + def build(self): + ui.link('Go to /about', '/about') + + +@page('/') +def index(): + router = PageRouter() + router.add('/', DemoPage, default=True) + + +ui.run(show=False) diff --git a/examples/single_page_app/main.py b/examples/single_page_app/main.py index 66872f94a..1e57061ce 100755 --- a/examples/single_page_app/main.py +++ b/examples/single_page_app/main.py @@ -4,31 +4,40 @@ from nicegui import ui +def add_links(): + ui.link('One', '/') + ui.link('Two', '/two') + ui.link('Three', '/three') + ui.link('Four', '/four') + + +@ui.page('/four') # normal index page (e.g. the entry point of the app) +def show_four(): + ui.label('Content Four').classes('text-2xl') + add_links() + + @ui.page('/') # normal index page (e.g. the entry point of the app) -@ui.page('/{_:path}') # all other pages will be handled by the router but must be registered to also show the SPA index page def main(): router = Router() @router.add('/') def show_one(): ui.label('Content One').classes('text-2xl') + add_links() @router.add('/two') def show_two(): ui.label('Content Two').classes('text-2xl') + add_links() @router.add('/three') def show_three(): ui.label('Content Three').classes('text-2xl') - - # adding some navigation buttons to switch between the different pages - with ui.row(): - ui.button('One', on_click=lambda: router.open(show_one)).classes('w-32') - ui.button('Two', on_click=lambda: router.open(show_two)).classes('w-32') - ui.button('Three', on_click=lambda: router.open(show_three)).classes('w-32') + add_links() # this places the content which should be displayed router.frame().classes('w-full p-4 bg-gray-100') -ui.run() +ui.run(show=False) diff --git a/examples/single_page_app/router_frame.js b/examples/single_page_app/router_frame.js index 1f8d5b880..f0e2998c8 100644 --- a/examples/single_page_app/router_frame.js +++ b/examples/single_page_app/router_frame.js @@ -1,16 +1,28 @@ export default { - template: "
", - mounted() { - window.addEventListener("popstate", (event) => { - if (event.state?.page) { - this.$emit("open", event.state.page); - } - }); - const connectInterval = setInterval(async () => { - if (window.socket.id === undefined) return; - this.$emit("open", window.location.pathname); - clearInterval(connectInterval); - }, 10); - }, - props: {}, + template: "
", + mounted() { + let router = this; + document.addEventListener('click', function (e) { + // Check if the clicked element is a link + if (e.target.tagName === 'A') { + const href = e.target.getAttribute('href'); // Get the link's href value + if (href.startsWith('/')) { + e.preventDefault(); // Prevent the default link behavior + window.history.pushState({page: href}, "", href); + router.$emit("open", href); + } + } + }); + window.addEventListener("popstate", (event) => { + if (event.state?.page) { + this.$emit("open", event.state.page); + } + }); + const connectInterval = setInterval(async () => { + if (window.socket.id === undefined) return; + this.$emit("open", window.location.pathname); + clearInterval(connectInterval); + }, 10); + }, + props: {}, }; diff --git a/nicegui/page_builder.py b/nicegui/page_builder.py new file mode 100644 index 000000000..80af4f371 --- /dev/null +++ b/nicegui/page_builder.py @@ -0,0 +1,51 @@ +from typing import Union, Callable +from nicegui import ui + + +class PageBuilder: + def __init__(self, router: Union["PageRouter", None] = None): + self._router = router + + def build(self): + pass + + +class PageRouter: + def __init__(self): + self.routes = {} + self.element = ui.column() + self._page_name: Union[str, None] = None + self._page_builder: Union[PageBuilder, None] = None + + def add(self, name: str, page: Union[PageBuilder, Callable, type], + default: bool = True) -> None: + self.routes[name] = page + if default: + self.page = name + + @property + def page(self): + return self._page_name + + @page.setter + def page(self, name: str): + if self._page_name == name: + return + if name not in self.routes: + raise ValueError(f'Page "{name}" not found') + self._page_name = name + self.element.clear() + self._page_builder = None + with self.element: + new_page = self.routes[name] + if isinstance(new_page, PageBuilder): # an already configured page + self._page_builder = new_page + elif issubclass(new_page, + PageBuilder): # a class of which an instance is created + self._page_builder = new_page(router=self) + elif callable(new_page): # a call which builds the ui dynamically + new_page() + else: + raise ValueError(f'Invalid page type: {new_page}') + if self._page_builder: + self._page_builder.build() diff --git a/nicegui/single_page.js b/nicegui/single_page.js new file mode 100644 index 000000000..f0e2998c8 --- /dev/null +++ b/nicegui/single_page.js @@ -0,0 +1,28 @@ +export default { + template: "
", + mounted() { + let router = this; + document.addEventListener('click', function (e) { + // Check if the clicked element is a link + if (e.target.tagName === 'A') { + const href = e.target.getAttribute('href'); // Get the link's href value + if (href.startsWith('/')) { + e.preventDefault(); // Prevent the default link behavior + window.history.pushState({page: href}, "", href); + router.$emit("open", href); + } + } + }); + window.addEventListener("popstate", (event) => { + if (event.state?.page) { + this.$emit("open", event.state.page); + } + }); + const connectInterval = setInterval(async () => { + if (window.socket.id === undefined) return; + this.$emit("open", window.location.pathname); + clearInterval(connectInterval); + }, 10); + }, + props: {}, +}; diff --git a/nicegui/single_page.py b/nicegui/single_page.py new file mode 100644 index 000000000..91b812e76 --- /dev/null +++ b/nicegui/single_page.py @@ -0,0 +1,47 @@ +from typing import Callable, Dict, Union + +from nicegui import background_tasks, helpers, ui + + +class RouterFrame(ui.element, component='single_page.js'): + pass + + +class SinglePageRouter: + + def __init__(self) -> None: + self.routes: Dict[str, Callable] = {} + self.content: ui.element = None + + def add(self, path: str): + def decorator(func: Callable): + self.routes[path] = func + return func + + return decorator + + def open(self, target: Union[Callable, str]) -> None: + if isinstance(target, str): + path = target + builder = self.routes[target] + else: + path = {v: k for k, v in self.routes.items()}[target] + builder = target + + async def build() -> None: + with self.content: + ui.run_javascript(f''' + if (window.location.pathname !== "{path}") {{ + history.pushState({{page: "{path}"}}, "", "{path}"); + }} + ''') + result = builder() + if helpers.is_coroutine_function(builder): + await result + + self.content.clear() + background_tasks.create(build()) + + def frame(self) -> ui.element: + self.content = RouterFrame().on('open', lambda e: self.open(e.args)) + return self.content From 3307281d0df1ab051259793fb6462b262155e8c0 Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Mon, 1 Apr 2024 13:14:59 +0200 Subject: [PATCH 04/79] Removed
nesting from sample app --- examples/single_page_app/main.py | 11 ++++++----- examples/single_page_app/router_frame.js | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/examples/single_page_app/main.py b/examples/single_page_app/main.py index 1e57061ce..8ec0744d7 100755 --- a/examples/single_page_app/main.py +++ b/examples/single_page_app/main.py @@ -11,13 +11,9 @@ def add_links(): ui.link('Four', '/four') -@ui.page('/four') # normal index page (e.g. the entry point of the app) -def show_four(): - ui.label('Content Four').classes('text-2xl') - add_links() - @ui.page('/') # normal index page (e.g. the entry point of the app) +@ui.page('/{_:path}') # all other pages will be handled by the router but must be registered to also show the SPA index page def main(): router = Router() @@ -36,6 +32,11 @@ def show_three(): ui.label('Content Three').classes('text-2xl') add_links() + @router.add('/four') + def show_four(): + ui.label('Content Four').classes('text-2xl') + add_links() + # this places the content which should be displayed router.frame().classes('w-full p-4 bg-gray-100') diff --git a/examples/single_page_app/router_frame.js b/examples/single_page_app/router_frame.js index f0e2998c8..65c70411e 100644 --- a/examples/single_page_app/router_frame.js +++ b/examples/single_page_app/router_frame.js @@ -1,5 +1,5 @@ export default { - template: "
", + template: "", mounted() { let router = this; document.addEventListener('click', function (e) { From 2c7f6807d7f90cc4ea37909ec767a3d90bfccc69 Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Tue, 2 Apr 2024 09:32:22 +0200 Subject: [PATCH 05/79] Intermediate backup, per-session user-auth --- examples/client_data/main.py | 33 ------------------ examples/page_builder/main.py | 24 ++++++------- examples/page_builder/sub_page.py | 6 ++++ examples/session_storage/main.py | 41 ++++++++++++++++++++++ nicegui/single_page.js | 2 +- nicegui/single_page.py | 56 +++++++++++++++++++++++++++++-- nicegui/storage.py | 4 +-- 7 files changed, 115 insertions(+), 51 deletions(-) delete mode 100644 examples/client_data/main.py create mode 100644 examples/page_builder/sub_page.py create mode 100644 examples/session_storage/main.py diff --git a/examples/client_data/main.py b/examples/client_data/main.py deleted file mode 100644 index 1c74980d8..000000000 --- a/examples/client_data/main.py +++ /dev/null @@ -1,33 +0,0 @@ -from nicegui import ui -from nicegui.page import page -from nicegui import app - - -@page('/') -def index(): - def increment(): - data = app.storage.user - counter = data['counter'] = data.get('counter', 0) + 1 - button.text = f'Hello {counter} times!' - - ui.label("This data is unique per user") - ui.html('
') - button = ui.button('Hello, World!').on_click(increment) - ui.link('Go to perClient', '/perClient') - - -@page('/perClient') -def per_client(): - def increment(): - data = app.storage.client - counter = data['counter'] = data.get('counter', 0) + 1 - button.text = f'Hello {counter} times!' - - ui.label("This data is unique per browser tab - like in Streamlit") - ui.html('
') - button = ui.button('Hello, World!').on_click(increment) - ui.link('Go to per user', '/') - ui.link('Open in new tab', '/perClient', new_tab=True) - - -ui.run(storage_secret='my_secret') diff --git a/examples/page_builder/main.py b/examples/page_builder/main.py index 64094ac20..269021089 100644 --- a/examples/page_builder/main.py +++ b/examples/page_builder/main.py @@ -1,20 +1,20 @@ -from nicegui import ui -from nicegui.page import page -from nicegui.page_builder import PageBuilder, PageRouter +from typing import Any, Callable +from fastapi.routing import APIRoute +from starlette.routing import Route -class DemoPage(PageBuilder): - def __init__(self, **kwargs): - super().__init__(**kwargs) +from nicegui import ui +from nicegui.page_builder import PageBuilder, PageRouter +from nicegui.single_page import SinglePageRouter +from nicegui import core - def build(self): - ui.link('Go to /about', '/about') +from sub_page import sub_page -@page('/') -def index(): - router = PageRouter() - router.add('/', DemoPage, default=True) +@ui.page('/some_page') +def some_page(): + ui.label('Some Page').classes('text-2xl') +sp = SinglePageRouter("/") ui.run(show=False) diff --git a/examples/page_builder/sub_page.py b/examples/page_builder/sub_page.py new file mode 100644 index 000000000..422f0f1d6 --- /dev/null +++ b/examples/page_builder/sub_page.py @@ -0,0 +1,6 @@ +from nicegui import ui + + +@ui.page('/sub_page') +def sub_page(): + ui.label('Sub Page').classes('text-2xl') diff --git a/examples/session_storage/main.py b/examples/session_storage/main.py new file mode 100644 index 000000000..6b7efa990 --- /dev/null +++ b/examples/session_storage/main.py @@ -0,0 +1,41 @@ +# This is a demo showing per-session (effectively per browser tab) user authentication w/o the need for cookies +# and with "safe logout" when the browser tab is closed. +# TODO Note that due to loosing the WS connection between page changes and thus the "session" this demo does not work +# yet. + +from nicegui import ui +from nicegui.page import page +from nicegui import app + + +def login(): + fake_pw_dict = {'user1': 'password1', + 'user2': 'password2', + 'user3': 'password3'} + + if app.storage.session['username'] in fake_pw_dict and app.storage.session['password'] == fake_pw_dict[ + app.storage.session['username']]: + ui.navigate.to("/secret_content") + return True + return False + + +@page('/') +def index(): + def handle_login(): + feedback.set_text('Login successful' if login() else 'Login failed') + + user = ui.input('Username').on('keydown.enter', handle_login) + user.bind_value(app.storage.session, "username") + password = ui.input('Password', password=True).on('keydown.enter', handle_login) + password.bind_value(app.storage.session, "password") + feedback = ui.label('') + ui.button('Login', on_click=handle_login) + + +@page('/secret_content') +def secret_content(): + ui.label(f'This is secret content, welcome {app.storage.session["username"]}') + + +ui.run() diff --git a/nicegui/single_page.js b/nicegui/single_page.js index f0e2998c8..65c70411e 100644 --- a/nicegui/single_page.js +++ b/nicegui/single_page.js @@ -1,5 +1,5 @@ export default { - template: "
", + template: "", mounted() { let router = this; document.addEventListener('click', function (e) { diff --git a/nicegui/single_page.py b/nicegui/single_page.py index 91b812e76..3ba0b15ec 100644 --- a/nicegui/single_page.py +++ b/nicegui/single_page.py @@ -1,17 +1,67 @@ +import inspect from typing import Callable, Dict, Union -from nicegui import background_tasks, helpers, ui +from fastapi.routing import APIRoute + +from nicegui import background_tasks, helpers, ui, core class RouterFrame(ui.element, component='single_page.js'): pass -class SinglePageRouter: - +class PageBuilder: def __init__(self) -> None: + pass + + def header(self): + ui.label("Head") + + def footer(self): + ui.label("Foot") + + def build(self): + self.header() + self.footer() + + +class SinglePageRouter(PageBuilder): + + def __init__(self, base_path: str = "/") -> None: + super().__init__() + self.routes: Dict[str, Callable] = {} self.content: ui.element = None + self.base_path = base_path + + candidates = self.find_api_routes() + self.find_method_candidates(candidates) + + @ui.page(base_path) + @ui.page(f'{base_path}' + '{_:path}') # all other pages + def root_page(): + self.build() + + def find_api_routes(self) -> list[str]: + page_routes = [] + removed_routes = [] + for route in core.app.routes: + if isinstance(route, APIRoute): + if (route.path.startswith(self.base_path) and + route.path != self.base_path and + not route.path[len(self.base_path):].startswith("_")): + removed_routes.append(route.path) + page_routes.append(route) + for route in page_routes: + core.app.routes.remove(route) + return removed_routes + + def find_method_candidates(self, candidates: list[str]): + src_globals = inspect.stack()[2].frame.f_globals + for obj in src_globals: + if isinstance(obj, Callable): # and name == 'some_page': + # obj() + print(obj) def add(self, path: str): def decorator(func: Callable): diff --git a/nicegui/storage.py b/nicegui/storage.py index 254079a7f..fbe5ed513 100644 --- a/nicegui/storage.py +++ b/nicegui/storage.py @@ -149,7 +149,7 @@ def user(self) -> Dict: return self._users[session_id] @property - def client(self) -> Dict: + def session(self) -> Dict: """Volatile client storage that is persisted on the server (where NiceGUI is executed) on a per client/per connection basis. @@ -175,7 +175,7 @@ def clear(self) -> None: self._general.clear() self._users.clear() if get_slot_stack(): - self.client.clear() + self.session.clear() for filepath in self.path.glob('storage-*.json'): filepath.unlink() From 7ff4728f7e0385cf8e38acbae9ba4892b13a1f8c Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Tue, 2 Apr 2024 22:49:21 +0200 Subject: [PATCH 06/79] Functional single page app login screen. Integrated single page app into Client.open so navigation to SPA pages is redirected. Fixed bug with forward and backwards navigation between SPA pages. Collecting data from original pages to able to apply the original page title. --- examples/session_storage/main.py | 16 ++-- examples/single_page_app/main.py | 22 ++---- examples/single_page_app/router.py | 2 +- examples/single_page_app/router_frame.js | 42 ++++------- nicegui/client.py | 24 ++++-- nicegui/page.py | 2 + nicegui/single_page.js | 13 ++-- nicegui/single_page.py | 95 ++++++++++-------------- 8 files changed, 98 insertions(+), 118 deletions(-) diff --git a/examples/session_storage/main.py b/examples/session_storage/main.py index 6b7efa990..301775942 100644 --- a/examples/session_storage/main.py +++ b/examples/session_storage/main.py @@ -6,12 +6,13 @@ from nicegui import ui from nicegui.page import page from nicegui import app +from nicegui.single_page import SinglePageRouter def login(): - fake_pw_dict = {'user1': 'password1', - 'user2': 'password2', - 'user3': 'password3'} + fake_pw_dict = {'user1': 'pw1', + 'user2': 'pw2', + 'user3': 'pw3'} if app.storage.session['username'] in fake_pw_dict and app.storage.session['password'] == fake_pw_dict[ app.storage.session['username']]: @@ -31,11 +32,14 @@ def handle_login(): password.bind_value(app.storage.session, "password") feedback = ui.label('') ui.button('Login', on_click=handle_login) + ui.link('Logout', '/secret_content') # TODO remove me -@page('/secret_content') +@page('/secret_content', title='Secret Content') def secret_content(): - ui.label(f'This is secret content, welcome {app.storage.session["username"]}') + ui.label(f'This is secret content, welcome {app.storage.session.get("username", "unknown user")}!') -ui.run() +if __name__ in {"__main__", "__mp_main__"}: + sp = SinglePageRouter("/") + ui.run(show=False) diff --git a/examples/single_page_app/main.py b/examples/single_page_app/main.py index 8ec0744d7..b5c5cacd8 100755 --- a/examples/single_page_app/main.py +++ b/examples/single_page_app/main.py @@ -4,14 +4,6 @@ from nicegui import ui -def add_links(): - ui.link('One', '/') - ui.link('Two', '/two') - ui.link('Three', '/three') - ui.link('Four', '/four') - - - @ui.page('/') # normal index page (e.g. the entry point of the app) @ui.page('/{_:path}') # all other pages will be handled by the router but must be registered to also show the SPA index page def main(): @@ -20,25 +12,23 @@ def main(): @router.add('/') def show_one(): ui.label('Content One').classes('text-2xl') - add_links() @router.add('/two') def show_two(): ui.label('Content Two').classes('text-2xl') - add_links() @router.add('/three') def show_three(): ui.label('Content Three').classes('text-2xl') - add_links() - @router.add('/four') - def show_four(): - ui.label('Content Four').classes('text-2xl') - add_links() + # adding some navigation buttons to switch between the different pages + with ui.row(): + ui.button('One', on_click=lambda: router.open(show_one)).classes('w-32') + ui.button('Two', on_click=lambda: router.open(show_two)).classes('w-32') + ui.button('Three', on_click=lambda: router.open(show_three)).classes('w-32') # this places the content which should be displayed router.frame().classes('w-full p-4 bg-gray-100') -ui.run(show=False) +ui.run() \ No newline at end of file diff --git a/examples/single_page_app/router.py b/examples/single_page_app/router.py index 59999761b..d08a42aaa 100644 --- a/examples/single_page_app/router.py +++ b/examples/single_page_app/router.py @@ -42,4 +42,4 @@ async def build() -> None: def frame(self) -> ui.element: self.content = RouterFrame().on('open', lambda e: self.open(e.args)) - return self.content + return self.content \ No newline at end of file diff --git a/examples/single_page_app/router_frame.js b/examples/single_page_app/router_frame.js index 65c70411e..b15de8782 100644 --- a/examples/single_page_app/router_frame.js +++ b/examples/single_page_app/router_frame.js @@ -1,28 +1,16 @@ export default { - template: "", - mounted() { - let router = this; - document.addEventListener('click', function (e) { - // Check if the clicked element is a link - if (e.target.tagName === 'A') { - const href = e.target.getAttribute('href'); // Get the link's href value - if (href.startsWith('/')) { - e.preventDefault(); // Prevent the default link behavior - window.history.pushState({page: href}, "", href); - router.$emit("open", href); - } - } - }); - window.addEventListener("popstate", (event) => { - if (event.state?.page) { - this.$emit("open", event.state.page); - } - }); - const connectInterval = setInterval(async () => { - if (window.socket.id === undefined) return; - this.$emit("open", window.location.pathname); - clearInterval(connectInterval); - }, 10); - }, - props: {}, -}; + template: "
", + mounted() { + window.addEventListener("popstate", (event) => { + if (event.state?.page) { + this.$emit("open", event.state.page); + } + }); + const connectInterval = setInterval(async () => { + if (window.socket.id === undefined) return; + this.$emit("open", window.location.pathname); + clearInterval(connectInterval); + }, 10); + }, + props: {}, +}; \ No newline at end of file diff --git a/nicegui/client.py b/nicegui/client.py index 79305d155..27b864ed3 100644 --- a/nicegui/client.py +++ b/nicegui/client.py @@ -32,6 +32,9 @@ class Client: page_routes: Dict[Callable[..., Any], str] = {} """Maps page builders to their routes.""" + single_page_routes: Dict[str, Any] = {} + """Maps paths to the associated single page routers.""" + instances: Dict[str, Client] = {} """Maps client IDs to clients.""" @@ -94,7 +97,8 @@ def is_auto_index_client(self) -> bool: @property def ip(self) -> Optional[str]: """Return the IP address of the client, or None if the client is not connected.""" - return self.environ['asgi.scope']['client'][0] if self.environ else None # pylint: disable=unsubscriptable-object + return self.environ['asgi.scope']['client'][ + 0] if self.environ else None # pylint: disable=unsubscriptable-object @property def has_socket_connection(self) -> bool: @@ -133,12 +137,13 @@ def build_response(self, request: Request, status_code: int = 200) -> Response: 'request': request, 'version': __version__, 'elements': elements.replace('&', '&') - .replace('<', '<') - .replace('>', '>') - .replace('`', '`') - .replace('$', '$'), + .replace('<', '<') + .replace('>', '>') + .replace('`', '`') + .replace('$', '$'), 'head_html': self.head_html, - 'body_html': '\n' + self.body_html + '\n' + '\n'.join(vue_html), + 'body_html': '\n' + self.body_html + '\n' + '\n'.join( + vue_html), 'vue_scripts': '\n'.join(vue_scripts), 'imports': json.dumps(imports), 'js_imports': '\n'.join(js_imports), @@ -224,6 +229,9 @@ async def send_and_wait(): def open(self, target: Union[Callable[..., Any], str], new_tab: bool = False) -> None: """Open a new page in the client.""" path = target if isinstance(target, str) else self.page_routes[target] + if path in self.single_page_routes: + self.single_page_routes[path].open(target, server_side=True) + return self.outbox.enqueue_message('open', {'path': path, 'new_tab': new_tab}, self.id) def download(self, src: Union[str, bytes], filename: Optional[str] = None, media_type: str = '') -> None: @@ -250,6 +258,7 @@ def handle_handshake(self) -> None: def handle_disconnect(self) -> None: """Wait for the browser to reconnect; invoke disconnect handlers if it doesn't.""" + async def handle_disconnect() -> None: if self.page.reconnect_timeout is not None: delay = self.page.reconnect_timeout @@ -262,6 +271,7 @@ async def handle_disconnect() -> None: self.safe_invoke(t) if not self.shared: self.delete() + self._disconnect_task = background_tasks.create(handle_disconnect()) def handle_event(self, msg: Dict) -> None: @@ -285,6 +295,7 @@ def safe_invoke(self, func: Union[Callable[..., Any], Awaitable]) -> None: async def func_with_client(): with self: await func + background_tasks.create(func_with_client()) else: with self: @@ -293,6 +304,7 @@ async def func_with_client(): async def result_with_client(): with self: await result + background_tasks.create(result_with_client()) except Exception as e: core.app.handle_exception(e) diff --git a/nicegui/page.py b/nicegui/page.py index 0673c7a16..3137a8f1b 100644 --- a/nicegui/page.py +++ b/nicegui/page.py @@ -106,6 +106,7 @@ async def decorated(*dec_args, **dec_kwargs) -> Response: async def wait_for_result() -> None: with client: return await result + task = background_tasks.create(wait_for_result()) deadline = time.time() + self.response_timeout while task and not client.is_waiting_for_connection and not task.done(): @@ -134,4 +135,5 @@ async def wait_for_result() -> None: self.api_router.get(self._path, **self.kwargs)(decorated) Client.page_routes[func] = self.path + func.__setattr__("__ng_page", self) return func diff --git a/nicegui/single_page.js b/nicegui/single_page.js index 65c70411e..1939d9512 100644 --- a/nicegui/single_page.js +++ b/nicegui/single_page.js @@ -6,17 +6,16 @@ export default { // Check if the clicked element is a link if (e.target.tagName === 'A') { const href = e.target.getAttribute('href'); // Get the link's href value - if (href.startsWith('/')) { + if (href.startsWith(router.base_path)) { // internal links only e.preventDefault(); // Prevent the default link behavior - window.history.pushState({page: href}, "", href); + window.history.pushState({page: href}, '', href); router.$emit("open", href); } } }); window.addEventListener("popstate", (event) => { - if (event.state?.page) { - this.$emit("open", event.state.page); - } + let new_page = window.location.pathname; + this.$emit("open", new_page); }); const connectInterval = setInterval(async () => { if (window.socket.id === undefined) return; @@ -24,5 +23,7 @@ export default { clearInterval(connectInterval); }, 10); }, - props: {}, + props: { + base_path: String + }, }; diff --git a/nicegui/single_page.py b/nicegui/single_page.py index 3ba0b15ec..4ee112b9c 100644 --- a/nicegui/single_page.py +++ b/nicegui/single_page.py @@ -3,65 +3,46 @@ from fastapi.routing import APIRoute -from nicegui import background_tasks, helpers, ui, core +from nicegui import background_tasks, helpers, ui, core, Client +from nicegui.app import AppConfig class RouterFrame(ui.element, component='single_page.js'): - pass - - -class PageBuilder: - def __init__(self) -> None: - pass - - def header(self): - ui.label("Head") - - def footer(self): - ui.label("Foot") - - def build(self): - self.header() - self.footer() + def __init__(self, base_path: str): + super().__init__() + self._props["base_path"] = base_path -class SinglePageRouter(PageBuilder): +class SinglePageRouter: - def __init__(self, base_path: str = "/") -> None: + def __init__(self, path: str, **kwargs) -> None: super().__init__() self.routes: Dict[str, Callable] = {} - self.content: ui.element = None - self.base_path = base_path + self.content: Union[ui.element, None] = None + self.base_path = path + self.find_api_routes() - candidates = self.find_api_routes() - self.find_method_candidates(candidates) + print("Configuring SinglePageRouter with path:", path) - @ui.page(base_path) - @ui.page(f'{base_path}' + '{_:path}') # all other pages + @ui.page(path, **kwargs) + @ui.page(f'{path}' + '{_:path}', **kwargs) # all other pages def root_page(): - self.build() + self.frame() + + def find_api_routes(self): + page_routes = set() + for key, route in Client.page_routes.items(): + if (route.startswith(self.base_path) and + not route[len(self.base_path):].startswith("_")): + page_routes.add(route) + Client.single_page_routes[route] = self + self.routes[route] = key - def find_api_routes(self) -> list[str]: - page_routes = [] - removed_routes = [] for route in core.app.routes: if isinstance(route, APIRoute): - if (route.path.startswith(self.base_path) and - route.path != self.base_path and - not route.path[len(self.base_path):].startswith("_")): - removed_routes.append(route.path) - page_routes.append(route) - for route in page_routes: - core.app.routes.remove(route) - return removed_routes - - def find_method_candidates(self, candidates: list[str]): - src_globals = inspect.stack()[2].frame.f_globals - for obj in src_globals: - if isinstance(obj, Callable): # and name == 'some_page': - # obj() - print(obj) + if route.path in page_routes: + core.app.routes.remove(route) def add(self, path: str): def decorator(func: Callable): @@ -70,21 +51,23 @@ def decorator(func: Callable): return decorator - def open(self, target: Union[Callable, str]) -> None: - if isinstance(target, str): - path = target - builder = self.routes[target] - else: - path = {v: k for k, v in self.routes.items()}[target] + def open(self, target: Union[Callable, str], server_side=False) -> None: + if isinstance(target, Callable): + target = {v: k for k, v in self.routes.items()}[target] builder = target + else: + builder = self.routes[target] + + if "__ng_page" in builder.__dict__: + new_page = builder.__dict__["__ng_page"] + title = new_page.title + ui.run_javascript(f"document.title = '{title if title is not None else core.app.config.title}'") + + if server_side: + ui.run_javascript(f'window.history.pushState({{page: "{target}"}}, "", "{target}");') async def build() -> None: with self.content: - ui.run_javascript(f''' - if (window.location.pathname !== "{path}") {{ - history.pushState({{page: "{path}"}}, "", "{path}"); - }} - ''') result = builder() if helpers.is_coroutine_function(builder): await result @@ -93,5 +76,5 @@ async def build() -> None: background_tasks.create(build()) def frame(self) -> ui.element: - self.content = RouterFrame().on('open', lambda e: self.open(e.args)) + self.content = RouterFrame(self.base_path).on('open', lambda e: self.open(e.args)) return self.content From 969a2d2fffc1248a02c452a9b1c96e6fdb2a0e3f Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Tue, 2 Apr 2024 22:57:40 +0200 Subject: [PATCH 07/79] Functional single page app login screen. Integrated single page app into Client.open so navigation to SPA pages is redirected. Fixed bug with forward and backwards navigation between SPA pages. Collecting data from original pages to able to apply the original page title. --- examples/session_storage/main.py | 2 +- examples/single_page_app/main.py | 2 +- examples/single_page_app/router_frame.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/session_storage/main.py b/examples/session_storage/main.py index 301775942..66cc2a1b7 100644 --- a/examples/session_storage/main.py +++ b/examples/session_storage/main.py @@ -15,7 +15,7 @@ def login(): 'user3': 'pw3'} if app.storage.session['username'] in fake_pw_dict and app.storage.session['password'] == fake_pw_dict[ - app.storage.session['username']]: + app.storage.session['username']]: ui.navigate.to("/secret_content") return True return False diff --git a/examples/single_page_app/main.py b/examples/single_page_app/main.py index b5c5cacd8..66872f94a 100755 --- a/examples/single_page_app/main.py +++ b/examples/single_page_app/main.py @@ -31,4 +31,4 @@ def show_three(): router.frame().classes('w-full p-4 bg-gray-100') -ui.run() \ No newline at end of file +ui.run() diff --git a/examples/single_page_app/router_frame.js b/examples/single_page_app/router_frame.js index b15de8782..1f8d5b880 100644 --- a/examples/single_page_app/router_frame.js +++ b/examples/single_page_app/router_frame.js @@ -13,4 +13,4 @@ export default { }, 10); }, props: {}, -}; \ No newline at end of file +}; From b9005eeb1aef9557fda046fd798e0c11fc6cad43 Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Tue, 2 Apr 2024 23:28:30 +0200 Subject: [PATCH 08/79] Added additional pages to the session_storage demo Fixed a bug which could occur when open was called before the UI was set up --- examples/page_builder/main.py | 20 ---------- examples/page_builder/sub_page.py | 6 --- examples/session_storage/main.py | 59 +++++++++++++++++++----------- examples/single_page_app/router.py | 2 +- nicegui/page_builder.py | 51 -------------------------- nicegui/single_page.py | 5 +++ 6 files changed, 44 insertions(+), 99 deletions(-) delete mode 100644 examples/page_builder/main.py delete mode 100644 examples/page_builder/sub_page.py delete mode 100644 nicegui/page_builder.py diff --git a/examples/page_builder/main.py b/examples/page_builder/main.py deleted file mode 100644 index 269021089..000000000 --- a/examples/page_builder/main.py +++ /dev/null @@ -1,20 +0,0 @@ -from typing import Any, Callable - -from fastapi.routing import APIRoute -from starlette.routing import Route - -from nicegui import ui -from nicegui.page_builder import PageBuilder, PageRouter -from nicegui.single_page import SinglePageRouter -from nicegui import core - -from sub_page import sub_page - - -@ui.page('/some_page') -def some_page(): - ui.label('Some Page').classes('text-2xl') - - -sp = SinglePageRouter("/") -ui.run(show=False) diff --git a/examples/page_builder/sub_page.py b/examples/page_builder/sub_page.py deleted file mode 100644 index 422f0f1d6..000000000 --- a/examples/page_builder/sub_page.py +++ /dev/null @@ -1,6 +0,0 @@ -from nicegui import ui - - -@ui.page('/sub_page') -def sub_page(): - ui.label('Sub Page').classes('text-2xl') diff --git a/examples/session_storage/main.py b/examples/session_storage/main.py index 66cc2a1b7..7f033da63 100644 --- a/examples/session_storage/main.py +++ b/examples/session_storage/main.py @@ -1,7 +1,5 @@ # This is a demo showing per-session (effectively per browser tab) user authentication w/o the need for cookies # and with "safe logout" when the browser tab is closed. -# TODO Note that due to loosing the WS connection between page changes and thus the "session" this demo does not work -# yet. from nicegui import ui from nicegui.page import page @@ -9,20 +7,31 @@ from nicegui.single_page import SinglePageRouter -def login(): - fake_pw_dict = {'user1': 'pw1', - 'user2': 'pw2', - 'user3': 'pw3'} - - if app.storage.session['username'] in fake_pw_dict and app.storage.session['password'] == fake_pw_dict[ - app.storage.session['username']]: - ui.navigate.to("/secret_content") - return True - return False - - @page('/') def index(): + username = app.storage.session.get('username', '') + if username == '': # redirect to login page + ui.navigate.to('/login') + return + ui.label(f'Welcome back {username}!').classes('text-2xl') + ui.label('Dolor sit amet, consectetur adipiscing elit.').classes('text-lg') + ui.link('About', '/about') + ui.link('Logout', '/logout') + + +@page('/login') +def login_page(): + def login(): + fake_pw_dict = {'user1': 'pw1', + 'user2': 'pw2', + 'user3': 'pw3'} + + if app.storage.session['username'] in fake_pw_dict and app.storage.session['password'] == fake_pw_dict[ + app.storage.session['username']]: + ui.navigate.to("/") + return True + return False + def handle_login(): feedback.set_text('Login successful' if login() else 'Login failed') @@ -32,14 +41,22 @@ def handle_login(): password.bind_value(app.storage.session, "password") feedback = ui.label('') ui.button('Login', on_click=handle_login) - ui.link('Logout', '/secret_content') # TODO remove me + ui.link('About', '/about') + ui.html("Psst... try user1/pw1, user2/pw2, user3/pw3") + + +@page('/logout') +def logout(): + app.storage.session['username'] = '' + app.storage.session['password'] = '' + ui.label('You have been logged out').classes('text-2xl') + ui.navigate.to('/login') -@page('/secret_content', title='Secret Content') -def secret_content(): - ui.label(f'This is secret content, welcome {app.storage.session.get("username", "unknown user")}!') +@page('/about', title="About") +def about(): + ui.label("A basic authentication with a persistent session connection") -if __name__ in {"__main__", "__mp_main__"}: - sp = SinglePageRouter("/") - ui.run(show=False) +sp = SinglePageRouter("/") # setup a single page router at / (and all sub-paths) +ui.run(show=False) diff --git a/examples/single_page_app/router.py b/examples/single_page_app/router.py index d08a42aaa..59999761b 100644 --- a/examples/single_page_app/router.py +++ b/examples/single_page_app/router.py @@ -42,4 +42,4 @@ async def build() -> None: def frame(self) -> ui.element: self.content = RouterFrame().on('open', lambda e: self.open(e.args)) - return self.content \ No newline at end of file + return self.content diff --git a/nicegui/page_builder.py b/nicegui/page_builder.py deleted file mode 100644 index 80af4f371..000000000 --- a/nicegui/page_builder.py +++ /dev/null @@ -1,51 +0,0 @@ -from typing import Union, Callable -from nicegui import ui - - -class PageBuilder: - def __init__(self, router: Union["PageRouter", None] = None): - self._router = router - - def build(self): - pass - - -class PageRouter: - def __init__(self): - self.routes = {} - self.element = ui.column() - self._page_name: Union[str, None] = None - self._page_builder: Union[PageBuilder, None] = None - - def add(self, name: str, page: Union[PageBuilder, Callable, type], - default: bool = True) -> None: - self.routes[name] = page - if default: - self.page = name - - @property - def page(self): - return self._page_name - - @page.setter - def page(self, name: str): - if self._page_name == name: - return - if name not in self.routes: - raise ValueError(f'Page "{name}" not found') - self._page_name = name - self.element.clear() - self._page_builder = None - with self.element: - new_page = self.routes[name] - if isinstance(new_page, PageBuilder): # an already configured page - self._page_builder = new_page - elif issubclass(new_page, - PageBuilder): # a class of which an instance is created - self._page_builder = new_page(router=self) - elif callable(new_page): # a call which builds the ui dynamically - new_page() - else: - raise ValueError(f'Invalid page type: {new_page}') - if self._page_builder: - self._page_builder.build() diff --git a/nicegui/single_page.py b/nicegui/single_page.py index 4ee112b9c..3889f17af 100644 --- a/nicegui/single_page.py +++ b/nicegui/single_page.py @@ -56,6 +56,8 @@ def open(self, target: Union[Callable, str], server_side=False) -> None: target = {v: k for k, v in self.routes.items()}[target] builder = target else: + if target not in self.routes: + return builder = self.routes[target] if "__ng_page" in builder.__dict__: @@ -66,6 +68,9 @@ def open(self, target: Union[Callable, str], server_side=False) -> None: if server_side: ui.run_javascript(f'window.history.pushState({{page: "{target}"}}, "", "{target}");') + if self.content is None: + return + async def build() -> None: with self.content: result = builder() From b3d73f1de361eff52823fc2f8eaa29a1fd5b7b67 Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Tue, 2 Apr 2024 23:52:20 +0200 Subject: [PATCH 09/79] Reverting PyCharm formatting --- nicegui/client.py | 20 ++++++----------- nicegui/page.py | 1 - nicegui/storage.py | 53 ++++++++++++++++++---------------------------- 3 files changed, 28 insertions(+), 46 deletions(-) diff --git a/nicegui/client.py b/nicegui/client.py index 27b864ed3..5641f55eb 100644 --- a/nicegui/client.py +++ b/nicegui/client.py @@ -97,8 +97,7 @@ def is_auto_index_client(self) -> bool: @property def ip(self) -> Optional[str]: """Return the IP address of the client, or None if the client is not connected.""" - return self.environ['asgi.scope']['client'][ - 0] if self.environ else None # pylint: disable=unsubscriptable-object + return self.environ['asgi.scope']['client'][0] if self.environ else None # pylint: disable=unsubscriptable-object @property def has_socket_connection(self) -> bool: @@ -137,13 +136,12 @@ def build_response(self, request: Request, status_code: int = 200) -> Response: 'request': request, 'version': __version__, 'elements': elements.replace('&', '&') - .replace('<', '<') - .replace('>', '>') - .replace('`', '`') - .replace('$', '$'), + .replace('<', '<') + .replace('>', '>') + .replace('`', '`') + .replace('$', '$'), 'head_html': self.head_html, - 'body_html': '\n' + self.body_html + '\n' + '\n'.join( - vue_html), + 'body_html': '\n' + self.body_html + '\n' + '\n'.join(vue_html), 'vue_scripts': '\n'.join(vue_scripts), 'imports': json.dumps(imports), 'js_imports': '\n'.join(js_imports), @@ -258,7 +256,6 @@ def handle_handshake(self) -> None: def handle_disconnect(self) -> None: """Wait for the browser to reconnect; invoke disconnect handlers if it doesn't.""" - async def handle_disconnect() -> None: if self.page.reconnect_timeout is not None: delay = self.page.reconnect_timeout @@ -271,7 +268,6 @@ async def handle_disconnect() -> None: self.safe_invoke(t) if not self.shared: self.delete() - self._disconnect_task = background_tasks.create(handle_disconnect()) def handle_event(self, msg: Dict) -> None: @@ -295,7 +291,6 @@ def safe_invoke(self, func: Union[Callable[..., Any], Awaitable]) -> None: async def func_with_client(): with self: await func - background_tasks.create(func_with_client()) else: with self: @@ -304,7 +299,6 @@ async def func_with_client(): async def result_with_client(): with self: await result - background_tasks.create(result_with_client()) except Exception as e: core.app.handle_exception(e) @@ -359,4 +353,4 @@ async def prune_instances(cls) -> None: except Exception: # NOTE: make sure the loop doesn't crash log.exception('Error while pruning clients') - await asyncio.sleep(10) + await asyncio.sleep(10) \ No newline at end of file diff --git a/nicegui/page.py b/nicegui/page.py index 3137a8f1b..08a089df0 100644 --- a/nicegui/page.py +++ b/nicegui/page.py @@ -106,7 +106,6 @@ async def decorated(*dec_args, **dec_kwargs) -> Response: async def wait_for_result() -> None: with client: return await result - task = background_tasks.create(wait_for_result()) deadline = time.time() + self.response_timeout while task and not client.is_waiting_for_connection and not task.done(): diff --git a/nicegui/storage.py b/nicegui/storage.py index fbe5ed513..a0b686998 100644 --- a/nicegui/storage.py +++ b/nicegui/storage.py @@ -16,14 +16,12 @@ from .context import get_slot_stack from .logging import log -request_contextvar: contextvars.ContextVar[Optional[Request]] = contextvars.ContextVar( - 'request_var', default=None) +request_contextvar: contextvars.ContextVar[Optional[Request]] = contextvars.ContextVar('request_var', default=None) class ReadOnlyDict(MutableMapping): - def __init__(self, data: Dict[Any, Any], - write_error_message: str = 'Read-only dict') -> None: + def __init__(self, data: Dict[Any, Any], write_error_message: str = 'Read-only dict') -> None: self._data: Dict[Any, Any] = data self._write_error_message: str = write_error_message @@ -65,7 +63,6 @@ def backup(self) -> None: async def backup() -> None: async with aiofiles.open(self.filepath, 'w', encoding=self.encoding) as f: await f.write(json.dumps(self)) - if core.loop: background_tasks.create_lazy(backup(), name=self.filepath.stem) else: @@ -74,8 +71,7 @@ async def backup() -> None: class RequestTrackingMiddleware(BaseHTTPMiddleware): - async def dispatch(self, request: Request, - call_next: RequestResponseEndpoint) -> Response: + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: request_contextvar.set(request) if 'id' not in request.session: request.session['id'] = str(uuid.uuid4()) @@ -100,8 +96,7 @@ class Storage: def __init__(self) -> None: self.path = Path(os.environ.get('NICEGUI_STORAGE_PATH', '.nicegui')).resolve() self.migrate_to_utf8() - self._general = PersistentDict(self.path / 'storage-general.json', - encoding='utf-8') + self._general = PersistentDict(self.path / 'storage-general.json', encoding='utf-8') self._users: Dict[str, PersistentDict] = {} @property @@ -115,11 +110,9 @@ def browser(self) -> Union[ReadOnlyDict, Dict]: request: Optional[Request] = request_contextvar.get() if request is None: if self._is_in_auto_index_context(): - raise RuntimeError( - 'app.storage.browser can only be used with page builder functions ' - '(https://nicegui.io/documentation/page)') - raise RuntimeError( - 'app.storage.browser needs a storage_secret passed in ui.run()') + raise RuntimeError('app.storage.browser can only be used with page builder functions ' + '(https://nicegui.io/documentation/page)') + raise RuntimeError('app.storage.browser needs a storage_secret passed in ui.run()') if request.state.responded: return ReadOnlyDict( request.session, @@ -137,27 +130,14 @@ def user(self) -> Dict: request: Optional[Request] = request_contextvar.get() if request is None: if self._is_in_auto_index_context(): - raise RuntimeError( - 'app.storage.user can only be used with page builder functions ' - '(https://nicegui.io/documentation/page)') - raise RuntimeError( - 'app.storage.user needs a storage_secret passed in ui.run()') + raise RuntimeError('app.storage.user can only be used with page builder functions ' + '(https://nicegui.io/documentation/page)') + raise RuntimeError('app.storage.user needs a storage_secret passed in ui.run()') session_id = request.session['id'] if session_id not in self._users: - self._users[session_id] = PersistentDict( - self.path / f'storage-user-{session_id}.json', encoding='utf-8') + self._users[session_id] = PersistentDict(self.path / f'storage-user-{session_id}.json', encoding='utf-8') return self._users[session_id] - @property - def session(self) -> Dict: - """Volatile client storage that is persisted on the server (where NiceGUI is - executed) on a per client/per connection basis. - - Note that this kind of storage can only be used in single page applications - where the client connection is preserved between page changes.""" - client = context.get_client() - return client.state - @staticmethod def _is_in_auto_index_context() -> bool: try: @@ -170,6 +150,15 @@ def general(self) -> Dict: """General storage shared between all users that is persisted on the server (where NiceGUI is executed).""" return self._general + @property + def session(self) -> Dict: + """Volatile client storage that is persisted on the server (where NiceGUI is + executed) on a per client/per connection basis. + Note that this kind of storage can only be used in single page applications + where the client connection is preserved between page changes.""" + client = context.get_client() + return client.state + def clear(self) -> None: """Clears all storage.""" self._general.clear() @@ -192,4 +181,4 @@ def migrate_to_utf8(self) -> None: log.warning(f'Could not load storage file {filepath}') data = {} filepath.rename(new_filepath) - new_filepath.write_text(json.dumps(data), encoding='utf-8') + new_filepath.write_text(json.dumps(data), encoding='utf-8') \ No newline at end of file From 241215ae84b3a7be205bedfa109ea678833a27ac Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Wed, 3 Apr 2024 08:47:09 +0200 Subject: [PATCH 10/79] Made single-page multi-user rdy --- nicegui/single_page.py | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/nicegui/single_page.py b/nicegui/single_page.py index 3889f17af..ac834fe78 100644 --- a/nicegui/single_page.py +++ b/nicegui/single_page.py @@ -1,9 +1,10 @@ +import asyncio import inspect from typing import Callable, Dict, Union from fastapi.routing import APIRoute -from nicegui import background_tasks, helpers, ui, core, Client +from nicegui import background_tasks, helpers, ui, core, Client, app from nicegui.app import AppConfig @@ -19,16 +20,17 @@ def __init__(self, path: str, **kwargs) -> None: super().__init__() self.routes: Dict[str, Callable] = {} - self.content: Union[ui.element, None] = None + # async access lock self.base_path = path self.find_api_routes() - print("Configuring SinglePageRouter with path:", path) - @ui.page(path, **kwargs) @ui.page(f'{path}' + '{_:path}', **kwargs) # all other pages - def root_page(): - self.frame() + async def root_page(client: Client): + await client.connected() + if app.storage.session.get('__pageContent', None) is None: + content: Union[ui.element, None] = RouterFrame(self.base_path).on('open', lambda e: self.open(e.args)) + app.storage.session['__pageContent'] = content def find_api_routes(self): page_routes = set() @@ -39,7 +41,7 @@ def find_api_routes(self): Client.single_page_routes[route] = self self.routes[route] = key - for route in core.app.routes: + for route in core.app.routes.copy(): if isinstance(route, APIRoute): if route.path in page_routes: core.app.routes.remove(route) @@ -68,18 +70,13 @@ def open(self, target: Union[Callable, str], server_side=False) -> None: if server_side: ui.run_javascript(f'window.history.pushState({{page: "{target}"}}, "", "{target}");') - if self.content is None: - return - - async def build() -> None: - with self.content: + async def build(content_element) -> None: + with content_element: result = builder() if helpers.is_coroutine_function(builder): await result - self.content.clear() - background_tasks.create(build()) + content = app.storage.session['__pageContent'] + content.clear() - def frame(self) -> ui.element: - self.content = RouterFrame(self.base_path).on('open', lambda e: self.open(e.args)) - return self.content + background_tasks.create(build(content)) From 13f29ac0e50b6771bd02afffe1a72c974d69c35a Mon Sep 17 00:00:00 2001 From: Author Name Date: Wed, 3 Apr 2024 10:10:25 +0200 Subject: [PATCH 11/79] Removed method decoration and replaced it with additional page_configs registry in Client. General clean-up Added titles to sample app Added docu to SPA --- examples/session_storage/main.py | 6 ++-- nicegui/client.py | 3 ++ nicegui/page.py | 2 +- nicegui/single_page.py | 47 ++++++++++++++++++++++---------- 4 files changed, 39 insertions(+), 19 deletions(-) diff --git a/examples/session_storage/main.py b/examples/session_storage/main.py index 7f033da63..fa8d0e026 100644 --- a/examples/session_storage/main.py +++ b/examples/session_storage/main.py @@ -7,7 +7,7 @@ from nicegui.single_page import SinglePageRouter -@page('/') +@page('/', title="Welcome!") def index(): username = app.storage.session.get('username', '') if username == '': # redirect to login page @@ -19,7 +19,7 @@ def index(): ui.link('Logout', '/logout') -@page('/login') +@page('/login', title="Login") def login_page(): def login(): fake_pw_dict = {'user1': 'pw1', @@ -45,7 +45,7 @@ def handle_login(): ui.html("Psst... try user1/pw1, user2/pw2, user3/pw3") -@page('/logout') +@page('/logout', title="Logout") def logout(): app.storage.session['username'] = '' app.storage.session['password'] = '' diff --git a/nicegui/client.py b/nicegui/client.py index 5641f55eb..e25214be1 100644 --- a/nicegui/client.py +++ b/nicegui/client.py @@ -32,6 +32,9 @@ class Client: page_routes: Dict[Callable[..., Any], str] = {} """Maps page builders to their routes.""" + page_configs: Dict[Callable[..., Any], "page"] = {} + """Maps page builders to their page configuration.""" + single_page_routes: Dict[str, Any] = {} """Maps paths to the associated single page routers.""" diff --git a/nicegui/page.py b/nicegui/page.py index 08a089df0..49da96fa6 100644 --- a/nicegui/page.py +++ b/nicegui/page.py @@ -134,5 +134,5 @@ async def wait_for_result() -> None: self.api_router.get(self._path, **self.kwargs)(decorated) Client.page_routes[func] = self.path - func.__setattr__("__ng_page", self) + Client.page_configs[func] = self return func diff --git a/nicegui/single_page.py b/nicegui/single_page.py index ac834fe78..261046549 100644 --- a/nicegui/single_page.py +++ b/nicegui/single_page.py @@ -1,38 +1,50 @@ -import asyncio -import inspect from typing import Callable, Dict, Union from fastapi.routing import APIRoute from nicegui import background_tasks, helpers, ui, core, Client, app -from nicegui.app import AppConfig class RouterFrame(ui.element, component='single_page.js'): + """The RouterFrame is a special element which is used by the SinglePageRouter to exchange the content of the + current page with the content of the new page. It serves as container and overrides the browser's history + management to prevent the browser from reloading the whole page.""" + def __init__(self, base_path: str): + """ + :param base_path: The base path of the single page router which shall be tracked (e.g. when clicking on links) + """ super().__init__() self._props["base_path"] = base_path class SinglePageRouter: + """The SinglePageRouter allows the development of a Single Page Application (SPA) which maintains a + persistent connection to the server and only updates the content of the page instead of reloading the whole page. + + This enables the development of complex web applications with large amounts of per-user (per browser tab) data + which is kept alive for the duration of the connection.""" def __init__(self, path: str, **kwargs) -> None: + """ + :param path: the base path of the single page router. + """ super().__init__() self.routes: Dict[str, Callable] = {} - # async access lock self.base_path = path - self.find_api_routes() + self._find_api_routes() @ui.page(path, **kwargs) @ui.page(f'{path}' + '{_:path}', **kwargs) # all other pages async def root_page(client: Client): - await client.connected() if app.storage.session.get('__pageContent', None) is None: content: Union[ui.element, None] = RouterFrame(self.base_path).on('open', lambda e: self.open(e.args)) app.storage.session['__pageContent'] = content - def find_api_routes(self): + def _find_api_routes(self): + """Find all API routes already defined via the @page decorator, remove them and redirect them to the + single page router""" page_routes = set() for key, route in Client.page_routes.items(): if (route.startswith(self.base_path) and @@ -46,14 +58,19 @@ def find_api_routes(self): if route.path in page_routes: core.app.routes.remove(route) - def add(self, path: str): - def decorator(func: Callable): - self.routes[path] = func - return func + def add(self, path: str, builder: Callable) -> None: + """Add a new route to the single page router - return decorator + :param path: the path of the route + :param builder: the builder function""" + self.routes[path] = builder def open(self, target: Union[Callable, str], server_side=False) -> None: + """Open a new page in the browser by exchanging the content of the root page's slot element + + :param target: the target route or builder function + :param server_side: Defines if the call is made from the server side and should be pushed to the browser + history""" if isinstance(target, Callable): target = {v: k for k, v in self.routes.items()}[target] builder = target @@ -62,9 +79,9 @@ def open(self, target: Union[Callable, str], server_side=False) -> None: return builder = self.routes[target] - if "__ng_page" in builder.__dict__: - new_page = builder.__dict__["__ng_page"] - title = new_page.title + page_config = Client.page_configs.get(builder, None) + if page_config is not None: # if page was decorated w/ title, favicon etc. + title = page_config.title ui.run_javascript(f"document.title = '{title if title is not None else core.app.config.title}'") if server_side: From 21005e10f0686f665d2859cdff7037aa9dc8294c Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Wed, 3 Apr 2024 23:55:39 +0200 Subject: [PATCH 12/79] * Refactored the SinglePageRouter to give the user more control over the structure of the root page, the possibility to override the class and implement custom routing and to react to the creation of sessions. * Added samples for the single page router * Refactored the Login sample with the new possibilities and making use of Pydantic as an example for a cleaner code base --- examples/session_storage/main.py | 79 ++++++---- examples/single_page_router/advanced.py | 32 ++++ examples/single_page_router/main.py | 21 +++ nicegui/client.py | 2 +- nicegui/single_page.js | 6 +- nicegui/single_page.py | 192 ++++++++++++++++++------ 6 files changed, 252 insertions(+), 80 deletions(-) create mode 100644 examples/single_page_router/advanced.py create mode 100644 examples/single_page_router/main.py diff --git a/examples/session_storage/main.py b/examples/session_storage/main.py index fa8d0e026..44dc0fdc0 100644 --- a/examples/session_storage/main.py +++ b/examples/session_storage/main.py @@ -1,5 +1,6 @@ # This is a demo showing per-session (effectively per browser tab) user authentication w/o the need for cookies # and with "safe logout" when the browser tab is closed. +from pydantic import BaseModel, Field from nicegui import ui from nicegui.page import page @@ -7,56 +8,80 @@ from nicegui.single_page import SinglePageRouter +class UserData(BaseModel): # Example for per-session user data + username: str = Field('', description='The username') + password: str = Field('', description='The password') + logged_in: bool = Field(False, description='Whether the user is logged in') + + def log_out(self): # Clears the user data, e.g. after logout + self.username = '' + self.password = '' + self.logged_in: bool = False + + @staticmethod + def get_current() -> "UserData": # Returns the UserData instance for the current session ("browser tab") + return app.storage.session["userData"] + + +def login() -> bool: # Fake login function. Evaluate the user data updates the logged_in flag on success + fake_pw_dict = {'user1': 'pw1', + 'user2': 'pw2', + 'user3': 'pw3'} + user_data = UserData.get_current() + if user_data.username in fake_pw_dict and user_data.password == fake_pw_dict[user_data.username]: + user_data.logged_in = True + user_data.password = '' # Clear the password + ui.navigate.to(index_page) + return True + return False + + @page('/', title="Welcome!") -def index(): - username = app.storage.session.get('username', '') - if username == '': # redirect to login page +def index_page(): + user_data = UserData.get_current() + if not user_data.logged_in: # redirect to login page ui.navigate.to('/login') return - ui.label(f'Welcome back {username}!').classes('text-2xl') + ui.label(f'Welcome back {user_data.username}!').classes('text-2xl') ui.label('Dolor sit amet, consectetur adipiscing elit.').classes('text-lg') - ui.link('About', '/about') - ui.link('Logout', '/logout') + ui.link('About', about_page) + ui.link('Logout', logout_page) @page('/login', title="Login") def login_page(): - def login(): - fake_pw_dict = {'user1': 'pw1', - 'user2': 'pw2', - 'user3': 'pw3'} - - if app.storage.session['username'] in fake_pw_dict and app.storage.session['password'] == fake_pw_dict[ - app.storage.session['username']]: - ui.navigate.to("/") - return True - return False - def handle_login(): feedback.set_text('Login successful' if login() else 'Login failed') + user_data = UserData.get_current() user = ui.input('Username').on('keydown.enter', handle_login) - user.bind_value(app.storage.session, "username") + user.bind_value(user_data, 'username') password = ui.input('Password', password=True).on('keydown.enter', handle_login) - password.bind_value(app.storage.session, "password") + password.bind_value(user_data, 'password') feedback = ui.label('') ui.button('Login', on_click=handle_login) - ui.link('About', '/about') + ui.link('About', about_page) ui.html("Psst... try user1/pw1, user2/pw2, user3/pw3") @page('/logout', title="Logout") -def logout(): - app.storage.session['username'] = '' - app.storage.session['password'] = '' +def logout_page(): + UserData.get_current().log_out() ui.label('You have been logged out').classes('text-2xl') - ui.navigate.to('/login') + ui.navigate.to(login_page) @page('/about', title="About") -def about(): +def about_page(): ui.label("A basic authentication with a persistent session connection") -sp = SinglePageRouter("/") # setup a single page router at / (and all sub-paths) -ui.run(show=False) +def setup_new_session(): # Initialize the user data for a new session + app.storage.session["userData"] = UserData() + + +# setups a single page router at / (and all sub-paths) +sp = SinglePageRouter("/", on_session_created=setup_new_session) +sp.setup_page_routes() + +ui.run() diff --git a/examples/single_page_router/advanced.py b/examples/single_page_router/advanced.py new file mode 100644 index 000000000..1f2e97f30 --- /dev/null +++ b/examples/single_page_router/advanced.py @@ -0,0 +1,32 @@ +# Advanced example of a single page router which includes a custom router class and a custom root page setup +# with static footer, header and menu elements. + +from nicegui import ui +from nicegui.page import page +from nicegui.single_page import SinglePageRouter + + +class CustomRouter(SinglePageRouter): + def setup_root_page(self): + with ui.header(): + ui.label("My Company").classes("text-2xl") + with ui.left_drawer(): + ui.button("Home", on_click=lambda: ui.navigate.to("/")) + ui.button("About", on_click=lambda: ui.navigate.to("/about")) + self.setup_content_area() # <-- The individual pages will be rendered here + with ui.footer() as footer: + ui.label("Copyright 2023 by My Company") + + +@page('/', title="Welcome!") +def index(): + ui.label("Welcome to the single page router example!").classes("text-2xl") + + +@page('/about', title="About") +def about(): + ui.label("This is the about page").classes("text-2xl") + + +router = CustomRouter("/").setup_page_routes() +ui.run() diff --git a/examples/single_page_router/main.py b/examples/single_page_router/main.py new file mode 100644 index 000000000..6c3d820aa --- /dev/null +++ b/examples/single_page_router/main.py @@ -0,0 +1,21 @@ +# Minimal example of a single page router with two pages + +from nicegui import ui +from nicegui.page import page +from nicegui.single_page import SinglePageRouter + + +@page('/', title="Welcome!") +def index(): + ui.label("Welcome to the single page router example!") + ui.link("About", "/about") + + +@page('/about', title="About") +def about(): + ui.label("This is the about page") + ui.link("Index", "/") + + +router = SinglePageRouter("/").setup_page_routes() +ui.run() diff --git a/nicegui/client.py b/nicegui/client.py index e25214be1..45d585a91 100644 --- a/nicegui/client.py +++ b/nicegui/client.py @@ -231,7 +231,7 @@ def open(self, target: Union[Callable[..., Any], str], new_tab: bool = False) -> """Open a new page in the client.""" path = target if isinstance(target, str) else self.page_routes[target] if path in self.single_page_routes: - self.single_page_routes[path].open(target, server_side=True) + self.single_page_routes[path].open(target) return self.outbox.enqueue_message('open', {'path': path, 'new_tab': new_tab}, self.id) diff --git a/nicegui/single_page.js b/nicegui/single_page.js index 1939d9512..fc68912bf 100644 --- a/nicegui/single_page.js +++ b/nicegui/single_page.js @@ -9,17 +9,17 @@ export default { if (href.startsWith(router.base_path)) { // internal links only e.preventDefault(); // Prevent the default link behavior window.history.pushState({page: href}, '', href); - router.$emit("open", href); + router.$emit("open", href, false); } } }); window.addEventListener("popstate", (event) => { let new_page = window.location.pathname; - this.$emit("open", new_page); + this.$emit("open", new_page, false); }); const connectInterval = setInterval(async () => { if (window.socket.id === undefined) return; - this.$emit("open", window.location.pathname); + this.$emit("open", window.location.pathname, false); clearInterval(connectInterval); }, 10); }, diff --git a/nicegui/single_page.py b/nicegui/single_page.py index 261046549..83658cc5a 100644 --- a/nicegui/single_page.py +++ b/nicegui/single_page.py @@ -1,11 +1,13 @@ -from typing import Callable, Dict, Union +from typing import Callable, Dict, Union, Optional, Tuple from fastapi.routing import APIRoute from nicegui import background_tasks, helpers, ui, core, Client, app +SPR_PAGE_BODY = '__pageContent' -class RouterFrame(ui.element, component='single_page.js'): + +class SinglePageRouterFrame(ui.element, component='single_page.js'): """The RouterFrame is a special element which is used by the SinglePageRouter to exchange the content of the current page with the content of the new page. It serves as container and overrides the browser's history management to prevent the browser from reloading the whole page.""" @@ -18,82 +20,174 @@ def __init__(self, base_path: str): self._props["base_path"] = base_path +class SinglePageRouterEntry: + """The SinglePageRouterEntry is a data class which holds the configuration of a single page router route""" + + def __init__(self, path: str, builder: Callable, title: Union[str, None] = None): + """ + :param path: The path of the route + :param builder: The builder function which is called when the route is opened + :param title: Optional title of the page + """ + self.path = path + self.builder = builder + self.title = title + + class SinglePageRouter: """The SinglePageRouter allows the development of a Single Page Application (SPA) which maintains a persistent connection to the server and only updates the content of the page instead of reloading the whole page. - This enables the development of complex web applications with large amounts of per-user (per browser tab) data - which is kept alive for the duration of the connection.""" + This enables the development of complex web applications with dynamic per-user data (all types of Python classes) + which are kept alive for the duration of the connection. + + Example: + ``` + from nicegui import ui + from nicegui.page import page + from nicegui.single_page import SinglePageRouter - def __init__(self, path: str, **kwargs) -> None: + @page('/', title="Welcome!") + def index(): + ui.label("Welcome to the single page router example!") + ui.link("About", "/about") + + @page('/about', title="About") + def about(): + ui.label("This is the about page") + ui.link("Index", "/") + + router = SinglePageRouter("/").setup_page_routes() + ui.run() + ``` + """ + + def __init__(self, path: str, on_session_created: Optional[Callable] = None) -> None: """ :param path: the base path of the single page router. + :param on_session_created: Optional callback which is called when a new session is created. """ super().__init__() - - self.routes: Dict[str, Callable] = {} + self.routes: Dict[str, SinglePageRouterEntry] = {} self.base_path = path self._find_api_routes() + self.content_area_class = SinglePageRouterFrame + self.on_session_created: Optional[Callable] = on_session_created - @ui.page(path, **kwargs) - @ui.page(f'{path}' + '{_:path}', **kwargs) # all other pages - async def root_page(client: Client): - if app.storage.session.get('__pageContent', None) is None: - content: Union[ui.element, None] = RouterFrame(self.base_path).on('open', lambda e: self.open(e.args)) - app.storage.session['__pageContent'] = content + def setup_page_routes(self, **kwargs): + """Registers the SinglePageRouter with the @page decorator to handle all routes defined by the router - def _find_api_routes(self): - """Find all API routes already defined via the @page decorator, remove them and redirect them to the - single page router""" - page_routes = set() - for key, route in Client.page_routes.items(): - if (route.startswith(self.base_path) and - not route[len(self.base_path):].startswith("_")): - page_routes.add(route) - Client.single_page_routes[route] = self - self.routes[route] = key + :param kwargs: Additional arguments for the @page decorators + """ - for route in core.app.routes.copy(): - if isinstance(route, APIRoute): - if route.path in page_routes: - core.app.routes.remove(route) + @ui.page(self.base_path, **kwargs) + @ui.page(f'{self.base_path}' + '{_:path}', **kwargs) # all other pages + async def root_page(): + self.handle_session_created() + self.setup_root_page() + + def handle_session_created(self): + """Is called when ever a new session is created such as when the user opens the page for the first time or + in a new tab. Can be used to initialize session data""" + if self.on_session_created is not None: + self.on_session_created() + + def setup_root_page(self): + """Builds the root page of the single page router and initializes the content area. + + Is only calling the setup_content_area method by default but can be overridden to customize the root page + for example with a navigation bar, footer or embedding the content area within a container element. + + Example: + ``` + def setup_root_page(self): + app.storage.session["menu"] = ui.left_drawer() + with app.storage.session["menu"] : + ... setup navigation + with ui.column(): + self.setup_content_area() + ... footer + ``` + """ + self.setup_content_area() - def add(self, path: str, builder: Callable) -> None: + def setup_content_area(self) -> SinglePageRouterFrame: + """Setups the content area for the single page router + + :return: The content area element + """ + content = self.content_area_class(self.base_path).on('open', lambda e: self.open(e.args)) + app.storage.session[SPR_PAGE_BODY] = content + return content + + def add_page(self, path: str, builder: Callable, title: Optional[str] = None) -> None: """Add a new route to the single page router - :param path: the path of the route - :param builder: the builder function""" - self.routes[path] = builder + :param path: The path of the route + :param builder: The builder function + :param title: Optional title of the page + """ + self.routes[path] = SinglePageRouterEntry(path, builder, title) - def open(self, target: Union[Callable, str], server_side=False) -> None: - """Open a new page in the browser by exchanging the content of the root page's slot element + def add_router_entry(self, entry: SinglePageRouterEntry) -> None: + """Adds a fully configured SinglePageRouterEntry to the router + + :param entry: The SinglePageRouterEntry to add + """ + self.routes[entry.path] = entry - :param target: the target route or builder function - :param server_side: Defines if the call is made from the server side and should be pushed to the browser - history""" + def get_router_entry(self, target: Union[Callable, str]) -> Union[SinglePageRouterEntry, None]: + """Returns the SinglePageRouterEntry for the given target URL or builder function + + :param target: The target URL or builder function + :return: The SinglePageRouterEntry or None if not found + """ if isinstance(target, Callable): - target = {v: k for k, v in self.routes.items()}[target] - builder = target + for path, entry in self.routes.items(): + if entry.builder == target: + return entry else: - if target not in self.routes: - return - builder = self.routes[target] + return self.routes.get(target, None) - page_config = Client.page_configs.get(builder, None) - if page_config is not None: # if page was decorated w/ title, favicon etc. - title = page_config.title - ui.run_javascript(f"document.title = '{title if title is not None else core.app.config.title}'") + def open(self, target: Union[Callable, str, Tuple[str, bool]]) -> None: + """Open a new page in the browser by exchanging the content of the root page's slot element + :param target: the target route or builder function. If a list is passed, the second element is a boolean + indicating whether the navigation should be server side only and not update the browser.""" + if isinstance(target, list): + target, server_side = target # unpack the list + else: + server_side = True + entry = self.get_router_entry(target) + title = entry.title if entry.title is not None else core.app.config.title + ui.run_javascript(f'document.title = "{title}"') if server_side: ui.run_javascript(f'window.history.pushState({{page: "{target}"}}, "", "{target}");') async def build(content_element) -> None: with content_element: - result = builder() - if helpers.is_coroutine_function(builder): + result = entry.builder() + if helpers.is_coroutine_function(entry.builder): await result - content = app.storage.session['__pageContent'] + content = app.storage.session[SPR_PAGE_BODY] content.clear() - background_tasks.create(build(content)) + + def _find_api_routes(self): + """Find all API routes already defined via the @page decorator, remove them and redirect them to the + single page router""" + page_routes = set() + for key, route in Client.page_routes.items(): + if (route.startswith(self.base_path) and + not route[len(self.base_path):].startswith("_")): + page_routes.add(route) + Client.single_page_routes[route] = self + title = None + if key in Client.page_configs: + title = Client.page_configs[key].title + self.routes[route] = SinglePageRouterEntry(route, builder=key, title=title) + for route in core.app.routes.copy(): + if isinstance(route, APIRoute): + if route.path in page_routes: + core.app.routes.remove(route) From db6b065ff4d6f94365d900d89d262c56fd9ac464 Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Thu, 4 Apr 2024 10:12:55 +0200 Subject: [PATCH 13/79] Implemented app.storage.session which enables the user to store data in the current Client instance - which in practice means "per browser tab". --- nicegui/client.py | 7 +++++++ nicegui/storage.py | 12 ++++++++++++ tests/test_session_state.py | 17 +++++++++++++++++ 3 files changed, 36 insertions(+) create mode 100644 tests/test_session_state.py diff --git a/nicegui/client.py b/nicegui/client.py index 88fd0a2bc..883b8ac73 100644 --- a/nicegui/client.py +++ b/nicegui/client.py @@ -73,6 +73,7 @@ def __init__(self, page: page, *, shared: bool = False) -> None: self._body_html = '' self.page = page + self.state = {} self.connect_handlers: List[Union[Callable[..., Any], Awaitable]] = [] self.disconnect_handlers: List[Union[Callable[..., Any], Awaitable]] = [] @@ -84,6 +85,12 @@ def is_auto_index_client(self) -> bool: """Return True if this client is the auto-index client.""" return self is self.auto_index_client + @staticmethod + def current_client() -> Optional[Client]: + """Returns the current client if obtainable from the current context.""" + from .context import get_client + return get_client() + @property def ip(self) -> Optional[str]: """Return the IP address of the client, or None if the client is not connected.""" diff --git a/nicegui/storage.py b/nicegui/storage.py index 657f8650b..5929c1a2c 100644 --- a/nicegui/storage.py +++ b/nicegui/storage.py @@ -13,6 +13,7 @@ from starlette.responses import Response from . import background_tasks, context, core, json, observables +from .context import get_slot_stack from .logging import log request_contextvar: contextvars.ContextVar[Optional[Request]] = contextvars.ContextVar('request_var', default=None) @@ -149,10 +150,21 @@ def general(self) -> Dict: """General storage shared between all users that is persisted on the server (where NiceGUI is executed).""" return self._general + @property + def session(self) -> Dict: + """Volatile client storage that is persisted on the server (where NiceGUI is + executed) on a per client/per connection basis. + Note that this kind of storage can only be used in single page applications + where the client connection is preserved between page changes.""" + client = context.get_client() + return client.state + def clear(self) -> None: """Clears all storage.""" self._general.clear() self._users.clear() + if get_slot_stack(): + self.session.clear() for filepath in self.path.glob('storage-*.json'): filepath.unlink() diff --git a/tests/test_session_state.py b/tests/test_session_state.py new file mode 100644 index 000000000..d470f603a --- /dev/null +++ b/tests/test_session_state.py @@ -0,0 +1,17 @@ +from nicegui import ui, app +from nicegui.testing import Screen + + +def test_session_state(screen: Screen): + app.storage.session["counter"] = 123 + + def increment(): + app.storage.session["counter"] = app.storage.session["counter"] + 1 + + ui.button("Increment").on_click(increment) + ui.label().bind_text(app.storage.session, "counter") + + screen.open('/') + screen.should_contain('123') + screen.click('Increment') + screen.wait_for('124') From 3fedd36799f5cbbb5afb2606ba20eeec39dd11a7 Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Fri, 5 Apr 2024 09:46:03 +0200 Subject: [PATCH 14/79] Replaced Client.state by ObservableDict Moved context import to top of the file --- nicegui/client.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/nicegui/client.py b/nicegui/client.py index 883b8ac73..98d1dcc96 100644 --- a/nicegui/client.py +++ b/nicegui/client.py @@ -15,10 +15,12 @@ from . import background_tasks, binding, core, helpers, json from .awaitable_response import AwaitableResponse +from .context import get_client from .dependencies import generate_resources from .element import Element from .favicon import get_favicon_url from .logging import log +from .observables import ObservableDict from .outbox import Outbox from .version import __version__ @@ -73,7 +75,7 @@ def __init__(self, page: page, *, shared: bool = False) -> None: self._body_html = '' self.page = page - self.state = {} + self.state = ObservableDict() self.connect_handlers: List[Union[Callable[..., Any], Awaitable]] = [] self.disconnect_handlers: List[Union[Callable[..., Any], Awaitable]] = [] @@ -88,7 +90,6 @@ def is_auto_index_client(self) -> bool: @staticmethod def current_client() -> Optional[Client]: """Returns the current client if obtainable from the current context.""" - from .context import get_client return get_client() @property From 8ecb6f5ff45773229bcc571b76813a08137393ac Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Fri, 5 Apr 2024 13:46:43 +0200 Subject: [PATCH 15/79] Added support for URL route parameters and query parameters --- examples/modularization/main.py | 2 + nicegui/single_page.py | 86 ++++++++++++++++++++++++++++++--- 2 files changed, 80 insertions(+), 8 deletions(-) diff --git a/examples/modularization/main.py b/examples/modularization/main.py index 064abdd72..e44d76553 100755 --- a/examples/modularization/main.py +++ b/examples/modularization/main.py @@ -5,6 +5,7 @@ import theme from nicegui import app, ui +from nicegui.single_page import SinglePageRouter # here we use our custom page decorator directly and just put the content creation into a separate function @@ -20,4 +21,5 @@ def index_page() -> None: # we can also use the APIRouter as described in https://nicegui.io/documentation/page#modularize_with_apirouter app.include_router(example_c.router) +spr = SinglePageRouter("/").setup_page_routes() # TODO Experimental, for performance comparison ui.run(title='Modularization Example') diff --git a/nicegui/single_page.py b/nicegui/single_page.py index 83658cc5a..1aaf066b2 100644 --- a/nicegui/single_page.py +++ b/nicegui/single_page.py @@ -1,3 +1,5 @@ +import inspect +import urllib.parse from typing import Callable, Dict, Union, Optional, Tuple from fastapi.routing import APIRoute @@ -34,6 +36,67 @@ def __init__(self, path: str, builder: Callable, title: Union[str, None] = None) self.title = title +class UrlParameterResolver: + """The UrlParameterResolver is a helper class which is used to resolve the path and query parameters of an URL to + find the matching SinglePageRouterEntry and convert the parameters to the expected types of the builder function""" + + def __init__(self, routes: Dict[str, SinglePageRouterEntry], path: str): + """ + :param routes: The routes of the single page router + :param path: The path of the URL + """ + components = path.split("?") + path = components[0].rstrip("/") + self.routes = routes + self.query_string = components[1] if len(components) > 1 else "" + self.query_args = {} + self.path = path + self.path_args = {} + self.parse_query() + self.entry = self.resolve_path() + if self.entry is not None: + self.convert_arguments() + + def resolve_path(self) -> Optional[SinglePageRouterEntry]: + """Splits the path into its components, tries to match it with the routes and extracts the path arguments + into their corresponding variables. + + :param path: The path to resolve + """ + for route, entry in self.routes.items(): + route_elements = route.lstrip('/').split("/") + path_elements = self.path.lstrip('/').split("/") + if len(route_elements) != len(path_elements): # can't match + continue + match = True + for i, route_element_path in enumerate(route_elements): + if route_element_path.startswith("{") and route_element_path.endswith("}") and len( + route_element_path) > 2: + self.path_args[route_element_path[1:-1]] = path_elements[i] + elif path_elements[i] != route_element_path: + match = False + break + if match: + return entry + return None + + def parse_query(self): + """Parses the query string of the URL into a dictionary of key-value pairs""" + self.query_args = urllib.parse.parse_qs(self.query_string) + + def convert_arguments(self): + """Converts the path and query arguments to the expected types of the builder function""" + sig = inspect.signature(self.entry.builder) + for name, param in sig.parameters.items(): + for params in [self.path_args, self.query_args]: + if name in params: + # Convert parameter to the expected type + try: + params[name] = param.annotation(params[name]) + except ValueError as e: + raise ValueError(f"Could not convert parameter {name}: {e}") + + class SinglePageRouter: """The SinglePageRouter allows the development of a Single Page Application (SPA) which maintains a persistent connection to the server and only updates the content of the page instead of reloading the whole page. @@ -127,7 +190,7 @@ def add_page(self, path: str, builder: Callable, title: Optional[str] = None) -> :param builder: The builder function :param title: Optional title of the page """ - self.routes[path] = SinglePageRouterEntry(path, builder, title) + self.routes[path] = SinglePageRouterEntry(path.rstrip("/"), builder, title) def add_router_entry(self, entry: SinglePageRouterEntry) -> None: """Adds a fully configured SinglePageRouterEntry to the router @@ -136,7 +199,7 @@ def add_router_entry(self, entry: SinglePageRouterEntry) -> None: """ self.routes[entry.path] = entry - def get_router_entry(self, target: Union[Callable, str]) -> Union[SinglePageRouterEntry, None]: + def get_router_entry(self, target: Union[Callable, str]) -> Tuple[Optional[SinglePageRouterEntry], dict, dict]: """Returns the SinglePageRouterEntry for the given target URL or builder function :param target: The target URL or builder function @@ -145,9 +208,14 @@ def get_router_entry(self, target: Union[Callable, str]) -> Union[SinglePageRout if isinstance(target, Callable): for path, entry in self.routes.items(): if entry.builder == target: - return entry + return entry, {}, {} else: - return self.routes.get(target, None) + target = target.rstrip("/") + entry = self.routes.get(target, None) + if entry is None: + parser = UrlParameterResolver(self.routes, target) + return parser.entry, parser.path_args, parser.query_args + return entry, {}, {} def open(self, target: Union[Callable, str, Tuple[str, bool]]) -> None: """Open a new page in the browser by exchanging the content of the root page's slot element @@ -158,21 +226,22 @@ def open(self, target: Union[Callable, str, Tuple[str, bool]]) -> None: target, server_side = target # unpack the list else: server_side = True - entry = self.get_router_entry(target) + entry, route_args, query_args = self.get_router_entry(target) title = entry.title if entry.title is not None else core.app.config.title ui.run_javascript(f'document.title = "{title}"') if server_side: ui.run_javascript(f'window.history.pushState({{page: "{target}"}}, "", "{target}");') - async def build(content_element) -> None: + async def build(content_element, kwargs) -> None: with content_element: - result = entry.builder() + result = entry.builder(**kwargs) if helpers.is_coroutine_function(entry.builder): await result content = app.storage.session[SPR_PAGE_BODY] content.clear() - background_tasks.create(build(content)) + combined_dict = {**route_args, **query_args} + background_tasks.create(build(content, combined_dict)) def _find_api_routes(self): """Find all API routes already defined via the @page decorator, remove them and redirect them to the @@ -186,6 +255,7 @@ def _find_api_routes(self): title = None if key in Client.page_configs: title = Client.page_configs[key].title + route = route.rstrip("/") self.routes[route] = SinglePageRouterEntry(route, builder=key, title=title) for route in core.app.routes.copy(): if isinstance(route, APIRoute): From ab67606ca74e653e05b70f8c0596444c85c1d3de Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Fri, 5 Apr 2024 13:59:20 +0200 Subject: [PATCH 16/79] Fixed doc. Allowed the Single Page App content root as parent for top level elements such as header, footer etc. --- nicegui/page_layout.py | 2 +- nicegui/single_page.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/nicegui/page_layout.py b/nicegui/page_layout.py index 5e30dca46..e2bbd5021 100644 --- a/nicegui/page_layout.py +++ b/nicegui/page_layout.py @@ -272,7 +272,7 @@ def __init__(self, position: PageStickyPositions = 'bottom-right', x_offset: flo def _check_current_slot(element: Element) -> None: parent = context.get_slot().parent - if parent != parent.client.content: + if parent != parent.client.content and parent != parent.client.state.get("__singlePageContent", None): log.warning(f'Found top level layout element "{element.__class__.__name__}" inside element "{parent.__class__.__name__}". ' 'Top level layout elements should not be nested but must be direct children of the page content. ' 'This will be raising an exception in NiceGUI 1.5') # DEPRECATED diff --git a/nicegui/single_page.py b/nicegui/single_page.py index 1aaf066b2..7170ff0a9 100644 --- a/nicegui/single_page.py +++ b/nicegui/single_page.py @@ -6,7 +6,7 @@ from nicegui import background_tasks, helpers, ui, core, Client, app -SPR_PAGE_BODY = '__pageContent' +SPR_PAGE_BODY = '__singlePageContent' class SinglePageRouterFrame(ui.element, component='single_page.js'): @@ -60,8 +60,6 @@ def __init__(self, routes: Dict[str, SinglePageRouterEntry], path: str): def resolve_path(self) -> Optional[SinglePageRouterEntry]: """Splits the path into its components, tries to match it with the routes and extracts the path arguments into their corresponding variables. - - :param path: The path to resolve """ for route, entry in self.routes.items(): route_elements = route.lstrip('/').split("/") From fac035686ce1ce6ae38eac7261a12c2e3b4c6a79 Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Fri, 5 Apr 2024 14:16:15 +0200 Subject: [PATCH 17/79] Added page not found handling, still needs a bit more love though to show the real 404 page. --- nicegui/single_page.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nicegui/single_page.py b/nicegui/single_page.py index 7170ff0a9..85b63b6bb 100644 --- a/nicegui/single_page.py +++ b/nicegui/single_page.py @@ -2,6 +2,7 @@ import urllib.parse from typing import Callable, Dict, Union, Optional, Tuple +from fastapi import HTTPException from fastapi.routing import APIRoute from nicegui import background_tasks, helpers, ui, core, Client, app @@ -225,6 +226,8 @@ def open(self, target: Union[Callable, str, Tuple[str, bool]]) -> None: else: server_side = True entry, route_args, query_args = self.get_router_entry(target) + if entry is None: + entry = ui.label(f"Page not found: {target}").classes("text-red-500") # Could be beautified title = entry.title if entry.title is not None else core.app.config.title ui.run_javascript(f'document.title = "{title}"') if server_side: From 0c72d16980f433c95ea5704432a7481b6f9829e7 Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Fri, 5 Apr 2024 23:34:15 +0200 Subject: [PATCH 18/79] Added more fine granular definition of which pages are included in the SPA with inclusion and exclusion masks. Added the possibility to disable the browser history completely for the SPA if desired. Added a sample to the advanced spa demo which excludes onw page from the SPA but shares the same layout. --- examples/single_page_router/advanced.py | 37 +++-- nicegui/single_page.js | 16 ++- nicegui/single_page.py | 184 ++++++++++++------------ nicegui/single_page_url_parser.py | 59 ++++++++ 4 files changed, 186 insertions(+), 110 deletions(-) create mode 100644 nicegui/single_page_url_parser.py diff --git a/examples/single_page_router/advanced.py b/examples/single_page_router/advanced.py index 1f2e97f30..590a1b499 100644 --- a/examples/single_page_router/advanced.py +++ b/examples/single_page_router/advanced.py @@ -1,21 +1,22 @@ # Advanced example of a single page router which includes a custom router class and a custom root page setup # with static footer, header and menu elements. +from typing import Callable from nicegui import ui from nicegui.page import page from nicegui.single_page import SinglePageRouter -class CustomRouter(SinglePageRouter): - def setup_root_page(self): - with ui.header(): - ui.label("My Company").classes("text-2xl") - with ui.left_drawer(): - ui.button("Home", on_click=lambda: ui.navigate.to("/")) - ui.button("About", on_click=lambda: ui.navigate.to("/about")) - self.setup_content_area() # <-- The individual pages will be rendered here - with ui.footer() as footer: - ui.label("Copyright 2023 by My Company") +def setup_page_layout(content: Callable): + with ui.header(): + ui.label("My Company").classes("text-2xl") + with ui.left_drawer(): + ui.button("Home", on_click=lambda: ui.navigate.to("/")) + ui.button("About", on_click=lambda: ui.navigate.to("/about")) + ui.button("Contact", on_click=lambda: ui.navigate.to("/contact")) + content() # <-- The individual pages will be rendered here + with ui.footer() as footer: + ui.label("Copyright 2023 by My Company") @page('/', title="Welcome!") @@ -28,5 +29,19 @@ def about(): ui.label("This is the about page").classes("text-2xl") -router = CustomRouter("/").setup_page_routes() +@page('/contact', title="Contact") # this page will not be hosted as SPA +def contact(): + def custom_content_area(): + ui.label("This is the contact page").classes("text-2xl") + + setup_page_layout(content=custom_content_area) + + +class CustomRouter(SinglePageRouter): + def setup_root_page(self, **kwargs): + setup_page_layout(content=self.setup_content_area) + + +router = CustomRouter("/", included=[index, about], excluded=[contact]) +router.setup_page_routes() ui.run() diff --git a/nicegui/single_page.js b/nicegui/single_page.js index fc68912bf..ca2ee5358 100644 --- a/nicegui/single_page.js +++ b/nicegui/single_page.js @@ -5,11 +5,18 @@ export default { document.addEventListener('click', function (e) { // Check if the clicked element is a link if (e.target.tagName === 'A') { - const href = e.target.getAttribute('href'); // Get the link's href value - if (href.startsWith(router.base_path)) { // internal links only + let href = e.target.getAttribute('href'); // Get the link's href value + // check if the link ends with / and remove it + if (href.endsWith("/")) href = href.slice(0, -1); + // for all valid path masks + for (let mask of router.valid_path_masks) { + // apply filename matching with * and ? wildcards + let regex = new RegExp(mask.replace(/\?/g, ".").replace(/\*/g, ".*")); + if (!regex.test(href)) continue; e.preventDefault(); // Prevent the default link behavior - window.history.pushState({page: href}, '', href); + if (router.use_browser_history) window.history.pushState({page: href}, '', href); router.$emit("open", href, false); + return } } }); @@ -24,6 +31,7 @@ export default { }, 10); }, props: { - base_path: String + valid_path_masks: [], + use_browser_history: {type: Boolean, default: true} }, }; diff --git a/nicegui/single_page.py b/nicegui/single_page.py index 85b63b6bb..2c503aa0f 100644 --- a/nicegui/single_page.py +++ b/nicegui/single_page.py @@ -1,11 +1,11 @@ -import inspect -import urllib.parse -from typing import Callable, Dict, Union, Optional, Tuple +import fnmatch +import re +from typing import Callable, Dict, Union, Optional, Tuple, Self, List, Set -from fastapi import HTTPException from fastapi.routing import APIRoute from nicegui import background_tasks, helpers, ui, core, Client, app +from nicegui.single_page_url_parser import UrlParser SPR_PAGE_BODY = '__singlePageContent' @@ -15,12 +15,14 @@ class SinglePageRouterFrame(ui.element, component='single_page.js'): current page with the content of the new page. It serves as container and overrides the browser's history management to prevent the browser from reloading the whole page.""" - def __init__(self, base_path: str): + def __init__(self, valid_path_masks: list[str], use_browser_history: bool = True): """ - :param base_path: The base path of the single page router which shall be tracked (e.g. when clicking on links) + :param valid_path_masks: A list of valid path masks which shall be allowed to be opened by the router + :param use_browser_history: Optional flag to enable or disable the browser history management. Default is True. """ super().__init__() - self._props["base_path"] = base_path + self._props["valid_path_masks"] = valid_path_masks + self._props["browser_history"] = use_browser_history class SinglePageRouterEntry: @@ -36,64 +38,20 @@ def __init__(self, path: str, builder: Callable, title: Union[str, None] = None) self.builder = builder self.title = title - -class UrlParameterResolver: - """The UrlParameterResolver is a helper class which is used to resolve the path and query parameters of an URL to - find the matching SinglePageRouterEntry and convert the parameters to the expected types of the builder function""" - - def __init__(self, routes: Dict[str, SinglePageRouterEntry], path: str): - """ - :param routes: The routes of the single page router - :param path: The path of the URL - """ - components = path.split("?") - path = components[0].rstrip("/") - self.routes = routes - self.query_string = components[1] if len(components) > 1 else "" - self.query_args = {} - self.path = path - self.path_args = {} - self.parse_query() - self.entry = self.resolve_path() - if self.entry is not None: - self.convert_arguments() - - def resolve_path(self) -> Optional[SinglePageRouterEntry]: - """Splits the path into its components, tries to match it with the routes and extracts the path arguments - into their corresponding variables. + def verify(self) -> Self: + """Verifies a SinglePageRouterEntry for correctness. Raises a ValueError if the entry is invalid. """ - for route, entry in self.routes.items(): - route_elements = route.lstrip('/').split("/") - path_elements = self.path.lstrip('/').split("/") - if len(route_elements) != len(path_elements): # can't match - continue - match = True - for i, route_element_path in enumerate(route_elements): - if route_element_path.startswith("{") and route_element_path.endswith("}") and len( - route_element_path) > 2: - self.path_args[route_element_path[1:-1]] = path_elements[i] - elif path_elements[i] != route_element_path: - match = False - break - if match: - return entry - return None - - def parse_query(self): - """Parses the query string of the URL into a dictionary of key-value pairs""" - self.query_args = urllib.parse.parse_qs(self.query_string) - - def convert_arguments(self): - """Converts the path and query arguments to the expected types of the builder function""" - sig = inspect.signature(self.entry.builder) - for name, param in sig.parameters.items(): - for params in [self.path_args, self.query_args]: - if name in params: - # Convert parameter to the expected type - try: - params[name] = param.annotation(params[name]) - except ValueError as e: - raise ValueError(f"Could not convert parameter {name}: {e}") + path = self.path + if "{" in path: + # verify only a single open and close curly bracket is present + elements = path.split("/") + for cur_element in elements: + if "{" in cur_element: + if cur_element.count("{") != 1 or cur_element.count("}") != 1 or len(cur_element) < 3 or \ + not (cur_element.startswith("{") and cur_element.endswith("}")): + raise ValueError("Only simple path parameters are supported. /path/{value}/{another_value}\n" + f"failed for path: {path}") + return self class SinglePageRouter: @@ -103,44 +61,51 @@ class SinglePageRouter: This enables the development of complex web applications with dynamic per-user data (all types of Python classes) which are kept alive for the duration of the connection. - Example: - ``` - from nicegui import ui - from nicegui.page import page - from nicegui.single_page import SinglePageRouter + For examples see examples/single_page_router""" - @page('/', title="Welcome!") - def index(): - ui.label("Welcome to the single page router example!") - ui.link("About", "/about") - - @page('/about', title="About") - def about(): - ui.label("This is the about page") - ui.link("Index", "/") - - router = SinglePageRouter("/").setup_page_routes() - ui.run() - ``` - """ - - def __init__(self, path: str, on_session_created: Optional[Callable] = None) -> None: + def __init__(self, + path: str, + browser_history: bool = True, + included: Union[List[Union[Callable, str]], str, Callable] = "/*", + excluded: Union[List[Union[Callable, str]], str, Callable] = "", + on_session_created: Optional[Callable] = None) -> None: """ :param path: the base path of the single page router. + :param browser_history: Optional flag to enable or disable the browser history management. Default is True. + :param included: Optional list of masks and callables of paths to include. Default is "/*" which includes all. + If you do not want to include all relative paths, you can specify a list of masks or callables to refine the + included paths. If a callable is passed, it must be decorated with a page. + :param excluded: Optional list of masks and callables of paths to exclude. Default is "" which excludes none. + Explicitly included paths (without wildcards) and Callables are always included, even if they match an + exclusion mask. :param on_session_created: Optional callback which is called when a new session is created. """ super().__init__() self.routes: Dict[str, SinglePageRouterEntry] = {} self.base_path = path - self._find_api_routes() + # list of masks and callables of paths to include + self.included: List[Union[Callable, str]] = [included] if not isinstance(included, list) else included + # list of masks and callables of paths to exclude + self.excluded: List[Union[Callable, str]] = [excluded] if not isinstance(excluded, list) else excluded + # low level system paths which are excluded by default + self.system_excluded = ["/docs", "/redoc", "/openapi.json", "_*"] + # set of all registered paths which were finally included for verification w/ mask matching in the browser + self.included_paths: Set[str] = set() self.content_area_class = SinglePageRouterFrame self.on_session_created: Optional[Callable] = on_session_created + self.use_browser_history = browser_history + self._setup_configured = False def setup_page_routes(self, **kwargs): """Registers the SinglePageRouter with the @page decorator to handle all routes defined by the router :param kwargs: Additional arguments for the @page decorators """ + if self._setup_configured: + raise ValueError("The SinglePageRouter is already configured") + self._setup_configured = True + self._update_masks() + self._find_api_routes() @ui.page(self.base_path, **kwargs) @ui.page(f'{self.base_path}' + '{_:path}', **kwargs) # all other pages @@ -178,7 +143,8 @@ def setup_content_area(self) -> SinglePageRouterFrame: :return: The content area element """ - content = self.content_area_class(self.base_path).on('open', lambda e: self.open(e.args)) + content = self.content_area_class( + list(self.included_paths), self.use_browser_history).on('open', lambda e: self.open(e.args)) app.storage.session[SPR_PAGE_BODY] = content return content @@ -189,14 +155,14 @@ def add_page(self, path: str, builder: Callable, title: Optional[str] = None) -> :param builder: The builder function :param title: Optional title of the page """ - self.routes[path] = SinglePageRouterEntry(path.rstrip("/"), builder, title) + self.routes[path] = SinglePageRouterEntry(path.rstrip("/"), builder, title).verify() def add_router_entry(self, entry: SinglePageRouterEntry) -> None: """Adds a fully configured SinglePageRouterEntry to the router :param entry: The SinglePageRouterEntry to add """ - self.routes[entry.path] = entry + self.routes[entry.path] = entry.verify() def get_router_entry(self, target: Union[Callable, str]) -> Tuple[Optional[SinglePageRouterEntry], dict, dict]: """Returns the SinglePageRouterEntry for the given target URL or builder function @@ -212,7 +178,7 @@ def get_router_entry(self, target: Union[Callable, str]) -> Tuple[Optional[Singl target = target.rstrip("/") entry = self.routes.get(target, None) if entry is None: - parser = UrlParameterResolver(self.routes, target) + parser = UrlParser(self.routes, target) return parser.entry, parser.path_args, parser.query_args return entry, {}, {} @@ -230,7 +196,7 @@ def open(self, target: Union[Callable, str, Tuple[str, bool]]) -> None: entry = ui.label(f"Page not found: {target}").classes("text-red-500") # Could be beautified title = entry.title if entry.title is not None else core.app.config.title ui.run_javascript(f'document.title = "{title}"') - if server_side: + if server_side and self.use_browser_history: ui.run_javascript(f'window.history.pushState({{page: "{target}"}}, "", "{target}");') async def build(content_element, kwargs) -> None: @@ -244,20 +210,48 @@ async def build(content_element, kwargs) -> None: combined_dict = {**route_args, **query_args} background_tasks.create(build(content, combined_dict)) - def _find_api_routes(self): + def _is_excluded(self, path: str) -> bool: + """Checks if a path is excluded by the exclusion masks + + :param path: The path to check + :return: True if the path is excluded, False otherwise""" + for element in self.included: + if path == element: # if it is a perfect, explicit match: allow + return False + if fnmatch.fnmatch(path, element): # if it is just a mask match: verify it is not excluded + for ex_element in self.excluded: + if fnmatch.fnmatch(path, ex_element): + return True # inclusion mask matched but also exclusion mask + return False # inclusion mask matched + return True # no inclusion mask matched + + def _update_masks(self) -> None: + """Updates the inclusion and exclusion masks and resolves Callables to the actual paths""" + for cur_list in [self.included, self.excluded]: + for index, element in enumerate(cur_list): + if isinstance(element, Callable): + if element in Client.page_routes: + cur_list[index] = Client.page_routes[element] + else: + raise ValueError( + f"Invalid target page in inclusion/exclusion list, no @page assigned to element") + + def _find_api_routes(self) -> None: """Find all API routes already defined via the @page decorator, remove them and redirect them to the single page router""" page_routes = set() for key, route in Client.page_routes.items(): - if (route.startswith(self.base_path) and - not route[len(self.base_path):].startswith("_")): + if route.startswith(self.base_path) and not self._is_excluded(route): page_routes.add(route) Client.single_page_routes[route] = self title = None if key in Client.page_configs: title = Client.page_configs[key].title route = route.rstrip("/") - self.routes[route] = SinglePageRouterEntry(route, builder=key, title=title) + self.add_router_entry(SinglePageRouterEntry(route, builder=key, title=title)) + # /site/{value}/{other_value} --> /site/*/* for easy matching in JavaScript + route_mask = re.sub(r'{[^}]+}', '*', route) + self.included_paths.add(route_mask) for route in core.app.routes.copy(): if isinstance(route, APIRoute): if route.path in page_routes: diff --git a/nicegui/single_page_url_parser.py b/nicegui/single_page_url_parser.py new file mode 100644 index 000000000..e585183f2 --- /dev/null +++ b/nicegui/single_page_url_parser.py @@ -0,0 +1,59 @@ +import inspect +import urllib.parse +from typing import Dict, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from nicegui.single_page import SinglePageRouterEntry + + +class UrlParser: + """Aa helper class which is used to parse the path and query parameters of an URL to find the matching + SinglePageRouterEntry and convert the parameters to the expected types of the builder function""" + + def __init__(self, routes: Dict[str, "SinglePageRouterEntry"], path: str): + """ + :param routes: The routes of the single page router + :param path: The path of the URL + """ + parsed_url = urllib.parse.urlparse(urllib.parse.unquote(path)) + self.routes = routes # all valid routes + self.path = parsed_url.path # url path w/o query + self.query_string = parsed_url.query + self.path_args = {} + self.query_args = urllib.parse.parse_qs(self.query_string) + self.entry = self.parse_path() + if self.entry is not None: + self.convert_arguments() + + def parse_path(self) -> Optional["SinglePageRouterEntry"]: + """Splits the path into its components, tries to match it with the routes and extracts the path arguments + into their corresponding variables. + """ + for route, entry in self.routes.items(): + route_elements = route.lstrip('/').split("/") + path_elements = self.path.lstrip('/').split("/") + if len(route_elements) != len(path_elements): # can't match + continue + match = True + for i, route_element_path in enumerate(route_elements): + if route_element_path.startswith("{") and route_element_path.endswith("}") and len( + route_element_path) > 2: + self.path_args[route_element_path[1:-1]] = path_elements[i] + elif path_elements[i] != route_element_path: + match = False + break + if match: + return entry + return None + + def convert_arguments(self): + """Converts the path and query arguments to the expected types of the builder function""" + sig = inspect.signature(self.entry.builder) + for func_param_name, func_param_info in sig.parameters.items(): + for params in [self.path_args, self.query_args]: + if func_param_name in params: + try: + params[func_param_name] = func_param_info.annotation( + params[func_param_name]) # Convert parameter to the expected type + except ValueError as e: + raise ValueError(f"Could not convert parameter {func_param_name}: {e}") From ea8dad54b5a404894afe984bcda412aa02fbb6ac Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Sat, 6 Apr 2024 08:41:17 +0200 Subject: [PATCH 19/79] Renamed app.storage.session to app.storage.client. Adjusted documentation of app.storage.client. --- nicegui/storage.py | 18 ++++++++++++------ ...t_session_state.py => test_client_state.py} | 6 +++--- 2 files changed, 15 insertions(+), 9 deletions(-) rename tests/{test_session_state.py => test_client_state.py} (62%) diff --git a/nicegui/storage.py b/nicegui/storage.py index 5929c1a2c..67ae594f2 100644 --- a/nicegui/storage.py +++ b/nicegui/storage.py @@ -15,6 +15,7 @@ from . import background_tasks, context, core, json, observables from .context import get_slot_stack from .logging import log +from .observables import ObservableDict request_contextvar: contextvars.ContextVar[Optional[Request]] = contextvars.ContextVar('request_var', default=None) @@ -151,11 +152,16 @@ def general(self) -> Dict: return self._general @property - def session(self) -> Dict: - """Volatile client storage that is persisted on the server (where NiceGUI is - executed) on a per client/per connection basis. - Note that this kind of storage can only be used in single page applications - where the client connection is preserved between page changes.""" + def client(self) -> ObservableDict: + """Client storage that is persisted on the server (where NiceGUI is executed) on a per client + connection basis. + + The data is lost when the client disconnects through reloading the page, closing the tab or + navigating away from the page. It can be used to store data that is only relevant for the current view such + as filter settings on a dashboard or in-page navigation. As the data is not persisted it also allows the + storage of data structures such as database connections, pandas tables, numpy arrays, user specific ML models + or other living objects that are not serializable to JSON. + """ client = context.get_client() return client.state @@ -164,7 +170,7 @@ def clear(self) -> None: self._general.clear() self._users.clear() if get_slot_stack(): - self.session.clear() + self.client.clear() for filepath in self.path.glob('storage-*.json'): filepath.unlink() diff --git a/tests/test_session_state.py b/tests/test_client_state.py similarity index 62% rename from tests/test_session_state.py rename to tests/test_client_state.py index d470f603a..61eee7ffc 100644 --- a/tests/test_session_state.py +++ b/tests/test_client_state.py @@ -3,13 +3,13 @@ def test_session_state(screen: Screen): - app.storage.session["counter"] = 123 + app.storage.client["counter"] = 123 def increment(): - app.storage.session["counter"] = app.storage.session["counter"] + 1 + app.storage.client["counter"] = app.storage.client["counter"] + 1 ui.button("Increment").on_click(increment) - ui.label().bind_text(app.storage.session, "counter") + ui.label().bind_text(app.storage.client, "counter") screen.open('/') screen.should_contain('123') From 84bc6ce8b5047e7e85924ff76e578b2dfec6d2b4 Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Sat, 6 Apr 2024 10:21:45 +0200 Subject: [PATCH 20/79] Added support for fragments / hashes. Further refinements * Added support for "jump marks" / fragments / hashes in single page application. You can now follow references within a single page and also open a SPA page directly at a passed hash. * Refactored the URL parsing to make it more flexible for real URL and callable targets * Added a dedicated single_page_content property to the Client class * Fixed a bug which was triggered when moving from a non SPA page to an SPA page --- examples/session_storage/main.py | 4 +- examples/single_page_router/advanced.py | 15 +++++++- nicegui/client.py | 9 +---- nicegui/single_page.js | 11 ++++-- nicegui/single_page.py | 38 ++++++++++--------- ..._page_url_parser.py => single_page_url.py} | 31 ++++++++++++--- 6 files changed, 71 insertions(+), 37 deletions(-) rename nicegui/{single_page_url_parser.py => single_page_url.py} (66%) diff --git a/examples/session_storage/main.py b/examples/session_storage/main.py index 44dc0fdc0..d73ca9297 100644 --- a/examples/session_storage/main.py +++ b/examples/session_storage/main.py @@ -20,7 +20,7 @@ def log_out(self): # Clears the user data, e.g. after logout @staticmethod def get_current() -> "UserData": # Returns the UserData instance for the current session ("browser tab") - return app.storage.session["userData"] + return app.storage.client["userData"] def login() -> bool: # Fake login function. Evaluate the user data updates the logged_in flag on success @@ -77,7 +77,7 @@ def about_page(): def setup_new_session(): # Initialize the user data for a new session - app.storage.session["userData"] = UserData() + app.storage.client["userData"] = UserData() # setups a single page router at / (and all sub-paths) diff --git a/examples/single_page_router/advanced.py b/examples/single_page_router/advanced.py index 590a1b499..5a447c561 100644 --- a/examples/single_page_router/advanced.py +++ b/examples/single_page_router/advanced.py @@ -26,7 +26,20 @@ def index(): @page('/about', title="About") def about(): - ui.label("This is the about page").classes("text-2xl") + ui.label("This is the about page testing local references").classes("text-2xl") + ui.label("Top").classes("text-lg").props("id=ltop") + ui.link("Bottom", "#lbottom") + ui.link("Center", "#lcenter") + for i in range(30): + ui.label(f"Lorem ipsum dolor sit amet, consectetur adipiscing elit. {i}") + ui.label("Center").classes("text-lg").props("id=lcenter") + ui.link("Top", "#ltop") + ui.link("Bottom", "#lbottom") + for i in range(30): + ui.label(f"Lorem ipsum dolor sit amet, consectetur adipiscing elit. {i}") + ui.label("Bottom").classes("text-lg").props("id=lbottom") + ui.link("Top", "#ltop") + ui.link("Center", "#lcenter") @page('/contact', title="Contact") # this page will not be hosted as SPA diff --git a/nicegui/client.py b/nicegui/client.py index 21ef94c99..bfaf115a1 100644 --- a/nicegui/client.py +++ b/nicegui/client.py @@ -81,6 +81,7 @@ def __init__(self, page: page, *, shared: bool = False) -> None: self._body_html = '' self.page = page + self.single_page_content = None self.state = ObservableDict() self.connect_handlers: List[Union[Callable[..., Any], Awaitable]] = [] @@ -88,12 +89,6 @@ def __init__(self, page: page, *, shared: bool = False) -> None: self._temporary_socket_id: Optional[str] = None - @staticmethod - def current_client() -> Optional[Client]: - """Returns the current client if obtainable from the current context.""" - from .context import get_client - return get_client() - @property def is_auto_index_client(self) -> bool: """Return True if this client is the auto-index client.""" @@ -237,7 +232,7 @@ async def send_and_wait(): def open(self, target: Union[Callable[..., Any], str], new_tab: bool = False) -> None: """Open a new page in the client.""" path = target if isinstance(target, str) else self.page_routes[target] - if path in self.single_page_routes: + if path in self.single_page_routes and self.single_page_content is not None: # moving from SPR to SPR? self.single_page_routes[path].open(target) return self.outbox.enqueue_message('open', {'path': path, 'new_tab': new_tab}, self.id) diff --git a/nicegui/single_page.js b/nicegui/single_page.js index ca2ee5358..4882d68c3 100644 --- a/nicegui/single_page.js +++ b/nicegui/single_page.js @@ -6,6 +6,9 @@ export default { // Check if the clicked element is a link if (e.target.tagName === 'A') { let href = e.target.getAttribute('href'); // Get the link's href value + // remove query and anchor + const org_href = href; + href = href.split("?")[0].split("#")[0] // check if the link ends with / and remove it if (href.endsWith("/")) href = href.slice(0, -1); // for all valid path masks @@ -14,8 +17,8 @@ export default { let regex = new RegExp(mask.replace(/\?/g, ".").replace(/\*/g, ".*")); if (!regex.test(href)) continue; e.preventDefault(); // Prevent the default link behavior - if (router.use_browser_history) window.history.pushState({page: href}, '', href); - router.$emit("open", href, false); + if (router.use_browser_history) window.history.pushState({page: org_href}, '', org_href); + router.$emit("open", org_href, false); return } } @@ -26,7 +29,9 @@ export default { }); const connectInterval = setInterval(async () => { if (window.socket.id === undefined) return; - this.$emit("open", window.location.pathname, false); + let target = window.location.pathname; + if (window.location.hash !== "") target += window.location.hash; + this.$emit("open", target, false); clearInterval(connectInterval); }, 10); }, diff --git a/nicegui/single_page.py b/nicegui/single_page.py index 2c503aa0f..1f8ff18f0 100644 --- a/nicegui/single_page.py +++ b/nicegui/single_page.py @@ -5,7 +5,7 @@ from fastapi.routing import APIRoute from nicegui import background_tasks, helpers, ui, core, Client, app -from nicegui.single_page_url_parser import UrlParser +from nicegui.single_page_url import SinglePageUrl SPR_PAGE_BODY = '__singlePageContent' @@ -128,8 +128,8 @@ def setup_root_page(self): Example: ``` def setup_root_page(self): - app.storage.session["menu"] = ui.left_drawer() - with app.storage.session["menu"] : + app.storage.client["menu"] = ui.left_drawer() + with app.storage.client["menu"] : ... setup navigation with ui.column(): self.setup_content_area() @@ -145,7 +145,7 @@ def setup_content_area(self) -> SinglePageRouterFrame: """ content = self.content_area_class( list(self.included_paths), self.use_browser_history).on('open', lambda e: self.open(e.args)) - app.storage.session[SPR_PAGE_BODY] = content + Client.current_client().single_page_content = content return content def add_page(self, path: str, builder: Callable, title: Optional[str] = None) -> None: @@ -164,23 +164,19 @@ def add_router_entry(self, entry: SinglePageRouterEntry) -> None: """ self.routes[entry.path] = entry.verify() - def get_router_entry(self, target: Union[Callable, str]) -> Tuple[Optional[SinglePageRouterEntry], dict, dict]: + def get_target_url(self, target: Union[Callable, str]) -> SinglePageUrl: """Returns the SinglePageRouterEntry for the given target URL or builder function :param target: The target URL or builder function - :return: The SinglePageRouterEntry or None if not found + :return: The SinglePageUrl object with the parsed route and query arguments """ if isinstance(target, Callable): for path, entry in self.routes.items(): if entry.builder == target: - return entry, {}, {} + return SinglePageUrl(entry=entry) else: - target = target.rstrip("/") - entry = self.routes.get(target, None) - if entry is None: - parser = UrlParser(self.routes, target) - return parser.entry, parser.path_args, parser.query_args - return entry, {}, {} + parser = SinglePageUrl(target) + return parser.parse_single_page_route(self.routes, target) def open(self, target: Union[Callable, str, Tuple[str, bool]]) -> None: """Open a new page in the browser by exchanging the content of the root page's slot element @@ -191,24 +187,30 @@ def open(self, target: Union[Callable, str, Tuple[str, bool]]) -> None: target, server_side = target # unpack the list else: server_side = True - entry, route_args, query_args = self.get_router_entry(target) + target_url = self.get_target_url(target) + entry = target_url.entry if entry is None: + if target_url.fragment is not None: + ui.run_javascript(f'window.location.href = "#{target_url.fragment}";') # go to fragment + return entry = ui.label(f"Page not found: {target}").classes("text-red-500") # Could be beautified title = entry.title if entry.title is not None else core.app.config.title ui.run_javascript(f'document.title = "{title}"') if server_side and self.use_browser_history: ui.run_javascript(f'window.history.pushState({{page: "{target}"}}, "", "{target}");') - async def build(content_element, kwargs) -> None: + async def build(content_element, fragment, kwargs) -> None: with content_element: result = entry.builder(**kwargs) if helpers.is_coroutine_function(entry.builder): await result + if fragment is not None: + await ui.run_javascript(f'window.location.href = "#{fragment}";') - content = app.storage.session[SPR_PAGE_BODY] + content = Client.current_client().single_page_content content.clear() - combined_dict = {**route_args, **query_args} - background_tasks.create(build(content, combined_dict)) + combined_dict = {**target_url.path_args, **target_url.query_args} + background_tasks.create(build(content, target_url.fragment, combined_dict)) def _is_excluded(self, path: str) -> bool: """Checks if a path is excluded by the exclusion masks diff --git a/nicegui/single_page_url_parser.py b/nicegui/single_page_url.py similarity index 66% rename from nicegui/single_page_url_parser.py rename to nicegui/single_page_url.py index e585183f2..5309a6a0a 100644 --- a/nicegui/single_page_url_parser.py +++ b/nicegui/single_page_url.py @@ -1,29 +1,48 @@ import inspect import urllib.parse -from typing import Dict, Optional, TYPE_CHECKING +from typing import Dict, Optional, TYPE_CHECKING, Self if TYPE_CHECKING: from nicegui.single_page import SinglePageRouterEntry -class UrlParser: +class SinglePageUrl: """Aa helper class which is used to parse the path and query parameters of an URL to find the matching SinglePageRouterEntry and convert the parameters to the expected types of the builder function""" - def __init__(self, routes: Dict[str, "SinglePageRouterEntry"], path: str): + def __init__(self, path: Optional[str] = None, entry: Optional["SinglePageRouterEntry"] = None, + fragment: Optional[str] = None, query_string: Optional[str] = None): """ - :param routes: The routes of the single page router + :param path: The path of the URL + :param entry: Predefined entry, e.g. targeting a Callable + :param fragment: The fragment of the URL + """ + self.routes = {} # all valid routes + self.path = path # url path w/o query + self.fragment = fragment + self.query_string = query_string + self.path_args = {} + self.query_args = urllib.parse.parse_qs(self.query_string) + self.entry = entry + + def parse_single_page_route(self, routes: Dict[str, "SinglePageRouterEntry"], path: str) -> Self: + """ + :param routes: All routes of the single page router :param path: The path of the URL """ parsed_url = urllib.parse.urlparse(urllib.parse.unquote(path)) self.routes = routes # all valid routes self.path = parsed_url.path # url path w/o query - self.query_string = parsed_url.query + self.fragment = parsed_url.fragment if len(parsed_url.fragment) > 0 else None + self.query_string = parsed_url.query if len(parsed_url.query) > 0 else None self.path_args = {} self.query_args = urllib.parse.parse_qs(self.query_string) + if self.fragment is not None and len(self.path) == 0: + return self self.entry = self.parse_path() if self.entry is not None: self.convert_arguments() + return self def parse_path(self) -> Optional["SinglePageRouterEntry"]: """Splits the path into its components, tries to match it with the routes and extracts the path arguments @@ -31,7 +50,7 @@ def parse_path(self) -> Optional["SinglePageRouterEntry"]: """ for route, entry in self.routes.items(): route_elements = route.lstrip('/').split("/") - path_elements = self.path.lstrip('/').split("/") + path_elements = self.path.lstrip('/').rstrip("/").split("/") if len(route_elements) != len(path_elements): # can't match continue match = True From 30a87e3cf27f4cce60af7877792bfd0d5ae09903 Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Sat, 6 Apr 2024 10:24:40 +0200 Subject: [PATCH 21/79] Refinement of imports. Removed now obsolete SPR_PAGE_BODY. --- nicegui/single_page.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/nicegui/single_page.py b/nicegui/single_page.py index 1f8ff18f0..e7ce6a6a1 100644 --- a/nicegui/single_page.py +++ b/nicegui/single_page.py @@ -4,11 +4,9 @@ from fastapi.routing import APIRoute -from nicegui import background_tasks, helpers, ui, core, Client, app +from nicegui import background_tasks, helpers, ui, core, Client from nicegui.single_page_url import SinglePageUrl -SPR_PAGE_BODY = '__singlePageContent' - class SinglePageRouterFrame(ui.element, component='single_page.js'): """The RouterFrame is a special element which is used by the SinglePageRouter to exchange the content of the From a923f344e19d1bbddb76304286e9a640138d04bc Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Sat, 6 Apr 2024 10:52:37 +0200 Subject: [PATCH 22/79] Bug fix: Reference to __singlePageContent in page_layout --- nicegui/page_layout.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nicegui/page_layout.py b/nicegui/page_layout.py index e2bbd5021..dab4519f3 100644 --- a/nicegui/page_layout.py +++ b/nicegui/page_layout.py @@ -272,7 +272,7 @@ def __init__(self, position: PageStickyPositions = 'bottom-right', x_offset: flo def _check_current_slot(element: Element) -> None: parent = context.get_slot().parent - if parent != parent.client.content and parent != parent.client.state.get("__singlePageContent", None): + if parent != parent.client.content and parent != parent.client.single_page_content: log.warning(f'Found top level layout element "{element.__class__.__name__}" inside element "{parent.__class__.__name__}". ' 'Top level layout elements should not be nested but must be direct children of the page content. ' 'This will be raising an exception in NiceGUI 1.5') # DEPRECATED From efcee633dab78c14b6d4154bcc40465e3f46e4ba Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Sat, 6 Apr 2024 10:54:51 +0200 Subject: [PATCH 23/79] Bug fix: Handling invalid SPA sub routes --- nicegui/single_page.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nicegui/single_page.py b/nicegui/single_page.py index e7ce6a6a1..e97ad390f 100644 --- a/nicegui/single_page.py +++ b/nicegui/single_page.py @@ -191,7 +191,7 @@ def open(self, target: Union[Callable, str, Tuple[str, bool]]) -> None: if target_url.fragment is not None: ui.run_javascript(f'window.location.href = "#{target_url.fragment}";') # go to fragment return - entry = ui.label(f"Page not found: {target}").classes("text-red-500") # Could be beautified + return title = entry.title if entry.title is not None else core.app.config.title ui.run_javascript(f'document.title = "{title}"') if server_side and self.use_browser_history: From f50f4690068ef78f101c42074d40a403af8fad19 Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Sat, 6 Apr 2024 11:59:48 +0200 Subject: [PATCH 24/79] Removed per-client storage specific code --- examples/session_storage/main.py | 87 -------------------------------- nicegui/client.py | 6 --- nicegui/single_page.py | 9 ++-- nicegui/storage.py | 14 ----- tests/test_client_state.py | 17 ------- 5 files changed, 4 insertions(+), 129 deletions(-) delete mode 100644 examples/session_storage/main.py delete mode 100644 tests/test_client_state.py diff --git a/examples/session_storage/main.py b/examples/session_storage/main.py deleted file mode 100644 index d73ca9297..000000000 --- a/examples/session_storage/main.py +++ /dev/null @@ -1,87 +0,0 @@ -# This is a demo showing per-session (effectively per browser tab) user authentication w/o the need for cookies -# and with "safe logout" when the browser tab is closed. -from pydantic import BaseModel, Field - -from nicegui import ui -from nicegui.page import page -from nicegui import app -from nicegui.single_page import SinglePageRouter - - -class UserData(BaseModel): # Example for per-session user data - username: str = Field('', description='The username') - password: str = Field('', description='The password') - logged_in: bool = Field(False, description='Whether the user is logged in') - - def log_out(self): # Clears the user data, e.g. after logout - self.username = '' - self.password = '' - self.logged_in: bool = False - - @staticmethod - def get_current() -> "UserData": # Returns the UserData instance for the current session ("browser tab") - return app.storage.client["userData"] - - -def login() -> bool: # Fake login function. Evaluate the user data updates the logged_in flag on success - fake_pw_dict = {'user1': 'pw1', - 'user2': 'pw2', - 'user3': 'pw3'} - user_data = UserData.get_current() - if user_data.username in fake_pw_dict and user_data.password == fake_pw_dict[user_data.username]: - user_data.logged_in = True - user_data.password = '' # Clear the password - ui.navigate.to(index_page) - return True - return False - - -@page('/', title="Welcome!") -def index_page(): - user_data = UserData.get_current() - if not user_data.logged_in: # redirect to login page - ui.navigate.to('/login') - return - ui.label(f'Welcome back {user_data.username}!').classes('text-2xl') - ui.label('Dolor sit amet, consectetur adipiscing elit.').classes('text-lg') - ui.link('About', about_page) - ui.link('Logout', logout_page) - - -@page('/login', title="Login") -def login_page(): - def handle_login(): - feedback.set_text('Login successful' if login() else 'Login failed') - - user_data = UserData.get_current() - user = ui.input('Username').on('keydown.enter', handle_login) - user.bind_value(user_data, 'username') - password = ui.input('Password', password=True).on('keydown.enter', handle_login) - password.bind_value(user_data, 'password') - feedback = ui.label('') - ui.button('Login', on_click=handle_login) - ui.link('About', about_page) - ui.html("Psst... try user1/pw1, user2/pw2, user3/pw3") - - -@page('/logout', title="Logout") -def logout_page(): - UserData.get_current().log_out() - ui.label('You have been logged out').classes('text-2xl') - ui.navigate.to(login_page) - - -@page('/about', title="About") -def about_page(): - ui.label("A basic authentication with a persistent session connection") - - -def setup_new_session(): # Initialize the user data for a new session - app.storage.client["userData"] = UserData() - - -# setups a single page router at / (and all sub-paths) -sp = SinglePageRouter("/", on_session_created=setup_new_session) -sp.setup_page_routes() - -ui.run() diff --git a/nicegui/client.py b/nicegui/client.py index e609b32aa..dfd891fbd 100644 --- a/nicegui/client.py +++ b/nicegui/client.py @@ -81,7 +81,6 @@ def __init__(self, page: page, *, shared: bool = False) -> None: self.page = page self.single_page_content = None - self.state = ObservableDict() self.connect_handlers: List[Union[Callable[..., Any], Awaitable]] = [] self.disconnect_handlers: List[Union[Callable[..., Any], Awaitable]] = [] @@ -93,11 +92,6 @@ def is_auto_index_client(self) -> bool: """Return True if this client is the auto-index client.""" return self is self.auto_index_client - @staticmethod - def current_client() -> Optional[Client]: - """Returns the current client if obtainable from the current context.""" - return get_client() - @property def ip(self) -> Optional[str]: """Return the IP address of the client, or None if the client is not connected.""" diff --git a/nicegui/single_page.py b/nicegui/single_page.py index e97ad390f..46929be3f 100644 --- a/nicegui/single_page.py +++ b/nicegui/single_page.py @@ -4,7 +4,7 @@ from fastapi.routing import APIRoute -from nicegui import background_tasks, helpers, ui, core, Client +from nicegui import background_tasks, helpers, ui, core, context, Client from nicegui.single_page_url import SinglePageUrl @@ -126,8 +126,7 @@ def setup_root_page(self): Example: ``` def setup_root_page(self): - app.storage.client["menu"] = ui.left_drawer() - with app.storage.client["menu"] : + with ui.left_drawer(): ... setup navigation with ui.column(): self.setup_content_area() @@ -143,7 +142,7 @@ def setup_content_area(self) -> SinglePageRouterFrame: """ content = self.content_area_class( list(self.included_paths), self.use_browser_history).on('open', lambda e: self.open(e.args)) - Client.current_client().single_page_content = content + context.get_client().single_page_content = content return content def add_page(self, path: str, builder: Callable, title: Optional[str] = None) -> None: @@ -205,7 +204,7 @@ async def build(content_element, fragment, kwargs) -> None: if fragment is not None: await ui.run_javascript(f'window.location.href = "#{fragment}";') - content = Client.current_client().single_page_content + content = context.get_client().single_page_content content.clear() combined_dict = {**target_url.path_args, **target_url.query_args} background_tasks.create(build(content, target_url.fragment, combined_dict)) diff --git a/nicegui/storage.py b/nicegui/storage.py index 36a186d6c..55ed873a7 100644 --- a/nicegui/storage.py +++ b/nicegui/storage.py @@ -151,20 +151,6 @@ def general(self) -> Dict: """General storage shared between all users that is persisted on the server (where NiceGUI is executed).""" return self._general - @property - def client(self) -> ObservableDict: - """Client storage that is persisted on the server (where NiceGUI is executed) on a per client - connection basis. - - The data is lost when the client disconnects through reloading the page, closing the tab or - navigating away from the page. It can be used to store data that is only relevant for the current view such - as filter settings on a dashboard or in-page navigation. As the data is not persisted it also allows the - storage of data structures such as database connections, pandas tables, numpy arrays, user specific ML models - or other living objects that are not serializable to JSON. - """ - client = context.get_client() - return client.state - def clear(self) -> None: """Clears all storage.""" self._general.clear() diff --git a/tests/test_client_state.py b/tests/test_client_state.py deleted file mode 100644 index 61eee7ffc..000000000 --- a/tests/test_client_state.py +++ /dev/null @@ -1,17 +0,0 @@ -from nicegui import ui, app -from nicegui.testing import Screen - - -def test_session_state(screen: Screen): - app.storage.client["counter"] = 123 - - def increment(): - app.storage.client["counter"] = app.storage.client["counter"] + 1 - - ui.button("Increment").on_click(increment) - ui.label().bind_text(app.storage.client, "counter") - - screen.open('/') - screen.should_contain('123') - screen.click('Increment') - screen.wait_for('124') From 87e83b5b8c66291a1bd3f05dbe94ef3f1139975c Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Sat, 6 Apr 2024 12:50:18 +0200 Subject: [PATCH 25/79] Cleaned client and storage class Refactored naming and doc in single_page.py --- nicegui/client.py | 2 -- nicegui/single_page.py | 47 +++++++++++++++++++++--------------------- nicegui/storage.py | 6 +----- 3 files changed, 25 insertions(+), 30 deletions(-) diff --git a/nicegui/client.py b/nicegui/client.py index dfd891fbd..ebe17a6de 100644 --- a/nicegui/client.py +++ b/nicegui/client.py @@ -15,13 +15,11 @@ from . import background_tasks, binding, core, helpers, json from .awaitable_response import AwaitableResponse -from .context import get_client from .dependencies import generate_resources from .element import Element from .favicon import get_favicon_url from .javascript_request import JavaScriptRequest from .logging import log -from .observables import ObservableDict from .outbox import Outbox from .version import __version__ diff --git a/nicegui/single_page.py b/nicegui/single_page.py index 46929be3f..16f59a089 100644 --- a/nicegui/single_page.py +++ b/nicegui/single_page.py @@ -9,8 +9,8 @@ class SinglePageRouterFrame(ui.element, component='single_page.js'): - """The RouterFrame is a special element which is used by the SinglePageRouter to exchange the content of the - current page with the content of the new page. It serves as container and overrides the browser's history + """The SinglePageRouterFrame is a special element which is used by the SinglePageRouter to exchange the content of + the current page with the content of the new page. It serves as container and overrides the browser's history management to prevent the browser from reloading the whole page.""" def __init__(self, valid_path_masks: list[str], use_browser_history: bool = True): @@ -37,8 +37,7 @@ def __init__(self, path: str, builder: Callable, title: Union[str, None] = None) self.title = title def verify(self) -> Self: - """Verifies a SinglePageRouterEntry for correctness. Raises a ValueError if the entry is invalid. - """ + """Verifies a SinglePageRouterEntry for correctness. Raises a ValueError if the entry is invalid.""" path = self.path if "{" in path: # verify only a single open and close curly bracket is present @@ -56,8 +55,9 @@ class SinglePageRouter: """The SinglePageRouter allows the development of a Single Page Application (SPA) which maintains a persistent connection to the server and only updates the content of the page instead of reloading the whole page. - This enables the development of complex web applications with dynamic per-user data (all types of Python classes) - which are kept alive for the duration of the connection. + This allows faster page switches and a more dynamic user experience because instead of reloading the whole page, + only the content area is updated. The SinglePageRouter is a high-level abstraction which manages the routing + and content area of the SPA. For examples see examples/single_page_router""" @@ -66,7 +66,7 @@ def __init__(self, browser_history: bool = True, included: Union[List[Union[Callable, str]], str, Callable] = "/*", excluded: Union[List[Union[Callable, str]], str, Callable] = "", - on_session_created: Optional[Callable] = None) -> None: + on_instance_created: Optional[Callable] = None) -> None: """ :param path: the base path of the single page router. :param browser_history: Optional flag to enable or disable the browser history management. Default is True. @@ -76,7 +76,8 @@ def __init__(self, :param excluded: Optional list of masks and callables of paths to exclude. Default is "" which excludes none. Explicitly included paths (without wildcards) and Callables are always included, even if they match an exclusion mask. - :param on_session_created: Optional callback which is called when a new session is created. + :param on_instance_created: Optional callback which is called when a new instance is created. Each browser tab + or window is a new instance. This can be used to initialize the state of the application. """ super().__init__() self.routes: Dict[str, SinglePageRouterEntry] = {} @@ -90,7 +91,7 @@ def __init__(self, # set of all registered paths which were finally included for verification w/ mask matching in the browser self.included_paths: Set[str] = set() self.content_area_class = SinglePageRouterFrame - self.on_session_created: Optional[Callable] = on_session_created + self.on_instance_created: Optional[Callable] = on_instance_created self.use_browser_history = browser_history self._setup_configured = False @@ -108,14 +109,14 @@ def setup_page_routes(self, **kwargs): @ui.page(self.base_path, **kwargs) @ui.page(f'{self.base_path}' + '{_:path}', **kwargs) # all other pages async def root_page(): - self.handle_session_created() + self.handle_instance_created() self.setup_root_page() - def handle_session_created(self): - """Is called when ever a new session is created such as when the user opens the page for the first time or - in a new tab. Can be used to initialize session data""" - if self.on_session_created is not None: - self.on_session_created() + def handle_instance_created(self): + """Is called when ever a new instance is created such as when the user opens the page for the first time or + in a new tab""" + if self.on_instance_created is not None: + self.on_instance_created() def setup_root_page(self): """Builds the root page of the single page router and initializes the content area. @@ -161,19 +162,19 @@ def add_router_entry(self, entry: SinglePageRouterEntry) -> None: """ self.routes[entry.path] = entry.verify() - def get_target_url(self, target: Union[Callable, str]) -> SinglePageUrl: - """Returns the SinglePageRouterEntry for the given target URL or builder function + def get_target_url(self, path: Union[Callable, str]) -> SinglePageUrl: + """Returns the SinglePageRouterEntry for the given URL path or builder function - :param target: The target URL or builder function - :return: The SinglePageUrl object with the parsed route and query arguments + :param path: The URL path to open or a builder function + :return: The SinglePageUrl object which contains the parsed route, query arguments and fragment """ - if isinstance(target, Callable): + if isinstance(path, Callable): for path, entry in self.routes.items(): - if entry.builder == target: + if entry.builder == path: return SinglePageUrl(entry=entry) else: - parser = SinglePageUrl(target) - return parser.parse_single_page_route(self.routes, target) + parser = SinglePageUrl(path) + return parser.parse_single_page_route(self.routes, path) def open(self, target: Union[Callable, str, Tuple[str, bool]]) -> None: """Open a new page in the browser by exchanging the content of the root page's slot element diff --git a/nicegui/storage.py b/nicegui/storage.py index 55ed873a7..657f8650b 100644 --- a/nicegui/storage.py +++ b/nicegui/storage.py @@ -13,9 +13,7 @@ from starlette.responses import Response from . import background_tasks, context, core, json, observables -from .context import get_slot_stack from .logging import log -from .observables import ObservableDict request_contextvar: contextvars.ContextVar[Optional[Request]] = contextvars.ContextVar('request_var', default=None) @@ -155,8 +153,6 @@ def clear(self) -> None: """Clears all storage.""" self._general.clear() self._users.clear() - if get_slot_stack(): - self.client.clear() for filepath in self.path.glob('storage-*.json'): filepath.unlink() @@ -173,4 +169,4 @@ def migrate_to_utf8(self) -> None: log.warning(f'Could not load storage file {filepath}') data = {} filepath.rename(new_filepath) - new_filepath.write_text(json.dumps(data), encoding='utf-8') \ No newline at end of file + new_filepath.write_text(json.dumps(data), encoding='utf-8') From 2cf505852cca48a38fd621d42854f442cbe7ca63 Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Sat, 6 Apr 2024 14:24:10 +0200 Subject: [PATCH 26/79] Fixed double quotes --- examples/single_page_router/advanced.py | 46 ++++++++++++------------- examples/single_page_router/main.py | 14 ++++---- nicegui/single_page.js | 18 +++++----- nicegui/single_page.py | 32 ++++++++--------- nicegui/single_page_url.py | 14 ++++---- 5 files changed, 62 insertions(+), 62 deletions(-) diff --git a/examples/single_page_router/advanced.py b/examples/single_page_router/advanced.py index 5a447c561..71ccaec3b 100644 --- a/examples/single_page_router/advanced.py +++ b/examples/single_page_router/advanced.py @@ -9,43 +9,43 @@ def setup_page_layout(content: Callable): with ui.header(): - ui.label("My Company").classes("text-2xl") + ui.label('My Company').classes('text-2xl') with ui.left_drawer(): - ui.button("Home", on_click=lambda: ui.navigate.to("/")) - ui.button("About", on_click=lambda: ui.navigate.to("/about")) - ui.button("Contact", on_click=lambda: ui.navigate.to("/contact")) + ui.button('Home', on_click=lambda: ui.navigate.to('/')) + ui.button('About', on_click=lambda: ui.navigate.to('/about')) + ui.button('Contact', on_click=lambda: ui.navigate.to('/contact')) content() # <-- The individual pages will be rendered here with ui.footer() as footer: - ui.label("Copyright 2023 by My Company") + ui.label('Copyright 2023 by My Company') -@page('/', title="Welcome!") +@page('/', title='Welcome!') def index(): - ui.label("Welcome to the single page router example!").classes("text-2xl") + ui.label('Welcome to the single page router example!').classes('text-2xl') -@page('/about', title="About") +@page('/about', title='About') def about(): - ui.label("This is the about page testing local references").classes("text-2xl") - ui.label("Top").classes("text-lg").props("id=ltop") - ui.link("Bottom", "#lbottom") - ui.link("Center", "#lcenter") + ui.label('This is the about page testing local references').classes('text-2xl') + ui.label('Top').classes('text-lg').props('id=ltop') + ui.link('Bottom', '#lbottom') + ui.link('Center', '#lcenter') for i in range(30): - ui.label(f"Lorem ipsum dolor sit amet, consectetur adipiscing elit. {i}") - ui.label("Center").classes("text-lg").props("id=lcenter") - ui.link("Top", "#ltop") - ui.link("Bottom", "#lbottom") + ui.label(f'Lorem ipsum dolor sit amet, consectetur adipiscing elit. {i}') + ui.label('Center').classes('text-lg').props('id=lcenter') + ui.link('Top', '#ltop') + ui.link('Bottom', '#lbottom') for i in range(30): - ui.label(f"Lorem ipsum dolor sit amet, consectetur adipiscing elit. {i}") - ui.label("Bottom").classes("text-lg").props("id=lbottom") - ui.link("Top", "#ltop") - ui.link("Center", "#lcenter") + ui.label(f'Lorem ipsum dolor sit amet, consectetur adipiscing elit. {i}') + ui.label('Bottom').classes('text-lg').props('id=lbottom') + ui.link('Top', '#ltop') + ui.link('Center', '#lcenter') -@page('/contact', title="Contact") # this page will not be hosted as SPA +@page('/contact', title='Contact') # this page will not be hosted as SPA def contact(): def custom_content_area(): - ui.label("This is the contact page").classes("text-2xl") + ui.label('This is the contact page').classes('text-2xl') setup_page_layout(content=custom_content_area) @@ -55,6 +55,6 @@ def setup_root_page(self, **kwargs): setup_page_layout(content=self.setup_content_area) -router = CustomRouter("/", included=[index, about], excluded=[contact]) +router = CustomRouter('/', included=[index, about], excluded=[contact]) router.setup_page_routes() ui.run() diff --git a/examples/single_page_router/main.py b/examples/single_page_router/main.py index 6c3d820aa..34e758992 100644 --- a/examples/single_page_router/main.py +++ b/examples/single_page_router/main.py @@ -5,17 +5,17 @@ from nicegui.single_page import SinglePageRouter -@page('/', title="Welcome!") +@page('/', title='Welcome!') def index(): - ui.label("Welcome to the single page router example!") - ui.link("About", "/about") + ui.label('Welcome to the single page router example!') + ui.link('About', '/about') -@page('/about', title="About") +@page('/about', title='About') def about(): - ui.label("This is the about page") - ui.link("Index", "/") + ui.label('This is the about page') + ui.link('Index', '/') -router = SinglePageRouter("/").setup_page_routes() +router = SinglePageRouter('/').setup_page_routes() ui.run() diff --git a/nicegui/single_page.js b/nicegui/single_page.js index 4882d68c3..55623134d 100644 --- a/nicegui/single_page.js +++ b/nicegui/single_page.js @@ -1,5 +1,5 @@ export default { - template: "", + template: '', mounted() { let router = this; document.addEventListener('click', function (e) { @@ -8,30 +8,30 @@ export default { let href = e.target.getAttribute('href'); // Get the link's href value // remove query and anchor const org_href = href; - href = href.split("?")[0].split("#")[0] + href = href.split('?')[0].split('#')[0] // check if the link ends with / and remove it - if (href.endsWith("/")) href = href.slice(0, -1); + if (href.endsWith('/')) href = href.slice(0, -1); // for all valid path masks for (let mask of router.valid_path_masks) { // apply filename matching with * and ? wildcards - let regex = new RegExp(mask.replace(/\?/g, ".").replace(/\*/g, ".*")); + let regex = new RegExp(mask.replace(/\?/g, '.').replace(/\*/g, '.*')); if (!regex.test(href)) continue; e.preventDefault(); // Prevent the default link behavior if (router.use_browser_history) window.history.pushState({page: org_href}, '', org_href); - router.$emit("open", org_href, false); + router.$emit('open', org_href, false); return } } }); - window.addEventListener("popstate", (event) => { + window.addEventListener('popstate', (event) => { let new_page = window.location.pathname; - this.$emit("open", new_page, false); + this.$emit('open', new_page, false); }); const connectInterval = setInterval(async () => { if (window.socket.id === undefined) return; let target = window.location.pathname; - if (window.location.hash !== "") target += window.location.hash; - this.$emit("open", target, false); + if (window.location.hash !== '') target += window.location.hash; + this.$emit('open', target, false); clearInterval(connectInterval); }, 10); }, diff --git a/nicegui/single_page.py b/nicegui/single_page.py index 16f59a089..30c9095ed 100644 --- a/nicegui/single_page.py +++ b/nicegui/single_page.py @@ -19,8 +19,8 @@ def __init__(self, valid_path_masks: list[str], use_browser_history: bool = True :param use_browser_history: Optional flag to enable or disable the browser history management. Default is True. """ super().__init__() - self._props["valid_path_masks"] = valid_path_masks - self._props["browser_history"] = use_browser_history + self._props['valid_path_masks'] = valid_path_masks + self._props['browser_history'] = use_browser_history class SinglePageRouterEntry: @@ -39,15 +39,15 @@ def __init__(self, path: str, builder: Callable, title: Union[str, None] = None) def verify(self) -> Self: """Verifies a SinglePageRouterEntry for correctness. Raises a ValueError if the entry is invalid.""" path = self.path - if "{" in path: + if '{' in path: # verify only a single open and close curly bracket is present - elements = path.split("/") + elements = path.split('/') for cur_element in elements: - if "{" in cur_element: - if cur_element.count("{") != 1 or cur_element.count("}") != 1 or len(cur_element) < 3 or \ - not (cur_element.startswith("{") and cur_element.endswith("}")): - raise ValueError("Only simple path parameters are supported. /path/{value}/{another_value}\n" - f"failed for path: {path}") + if '{' in cur_element: + if cur_element.count('{') != 1 or cur_element.count('}') != 1 or len(cur_element) < 3 or \ + not (cur_element.startswith('{') and cur_element.endswith('}')): + raise ValueError('Only simple path parameters are supported. /path/{value}/{another_value}\n' + f'failed for path: {path}') return self @@ -64,8 +64,8 @@ class SinglePageRouter: def __init__(self, path: str, browser_history: bool = True, - included: Union[List[Union[Callable, str]], str, Callable] = "/*", - excluded: Union[List[Union[Callable, str]], str, Callable] = "", + included: Union[List[Union[Callable, str]], str, Callable] = '/*', + excluded: Union[List[Union[Callable, str]], str, Callable] = '', on_instance_created: Optional[Callable] = None) -> None: """ :param path: the base path of the single page router. @@ -87,7 +87,7 @@ def __init__(self, # list of masks and callables of paths to exclude self.excluded: List[Union[Callable, str]] = [excluded] if not isinstance(excluded, list) else excluded # low level system paths which are excluded by default - self.system_excluded = ["/docs", "/redoc", "/openapi.json", "_*"] + self.system_excluded = ['/docs', '/redoc', '/openapi.json', '_*'] # set of all registered paths which were finally included for verification w/ mask matching in the browser self.included_paths: Set[str] = set() self.content_area_class = SinglePageRouterFrame @@ -101,7 +101,7 @@ def setup_page_routes(self, **kwargs): :param kwargs: Additional arguments for the @page decorators """ if self._setup_configured: - raise ValueError("The SinglePageRouter is already configured") + raise ValueError('The SinglePageRouter is already configured') self._setup_configured = True self._update_masks() self._find_api_routes() @@ -153,7 +153,7 @@ def add_page(self, path: str, builder: Callable, title: Optional[str] = None) -> :param builder: The builder function :param title: Optional title of the page """ - self.routes[path] = SinglePageRouterEntry(path.rstrip("/"), builder, title).verify() + self.routes[path] = SinglePageRouterEntry(path.rstrip('/'), builder, title).verify() def add_router_entry(self, entry: SinglePageRouterEntry) -> None: """Adds a fully configured SinglePageRouterEntry to the router @@ -234,7 +234,7 @@ def _update_masks(self) -> None: cur_list[index] = Client.page_routes[element] else: raise ValueError( - f"Invalid target page in inclusion/exclusion list, no @page assigned to element") + f'Invalid target page in inclusion/exclusion list, no @page assigned to element') def _find_api_routes(self) -> None: """Find all API routes already defined via the @page decorator, remove them and redirect them to the @@ -247,7 +247,7 @@ def _find_api_routes(self) -> None: title = None if key in Client.page_configs: title = Client.page_configs[key].title - route = route.rstrip("/") + route = route.rstrip('/') self.add_router_entry(SinglePageRouterEntry(route, builder=key, title=title)) # /site/{value}/{other_value} --> /site/*/* for easy matching in JavaScript route_mask = re.sub(r'{[^}]+}', '*', route) diff --git a/nicegui/single_page_url.py b/nicegui/single_page_url.py index 5309a6a0a..39c86749b 100644 --- a/nicegui/single_page_url.py +++ b/nicegui/single_page_url.py @@ -10,7 +10,7 @@ class SinglePageUrl: """Aa helper class which is used to parse the path and query parameters of an URL to find the matching SinglePageRouterEntry and convert the parameters to the expected types of the builder function""" - def __init__(self, path: Optional[str] = None, entry: Optional["SinglePageRouterEntry"] = None, + def __init__(self, path: Optional[str] = None, entry: Optional['SinglePageRouterEntry'] = None, fragment: Optional[str] = None, query_string: Optional[str] = None): """ :param path: The path of the URL @@ -25,7 +25,7 @@ def __init__(self, path: Optional[str] = None, entry: Optional["SinglePageRouter self.query_args = urllib.parse.parse_qs(self.query_string) self.entry = entry - def parse_single_page_route(self, routes: Dict[str, "SinglePageRouterEntry"], path: str) -> Self: + def parse_single_page_route(self, routes: Dict[str, 'SinglePageRouterEntry'], path: str) -> Self: """ :param routes: All routes of the single page router :param path: The path of the URL @@ -44,18 +44,18 @@ def parse_single_page_route(self, routes: Dict[str, "SinglePageRouterEntry"], pa self.convert_arguments() return self - def parse_path(self) -> Optional["SinglePageRouterEntry"]: + def parse_path(self) -> Optional['SinglePageRouterEntry']: """Splits the path into its components, tries to match it with the routes and extracts the path arguments into their corresponding variables. """ for route, entry in self.routes.items(): - route_elements = route.lstrip('/').split("/") - path_elements = self.path.lstrip('/').rstrip("/").split("/") + route_elements = route.lstrip('/').split('/') + path_elements = self.path.lstrip('/').rstrip('/').split('/') if len(route_elements) != len(path_elements): # can't match continue match = True for i, route_element_path in enumerate(route_elements): - if route_element_path.startswith("{") and route_element_path.endswith("}") and len( + if route_element_path.startswith('{') and route_element_path.endswith('}') and len( route_element_path) > 2: self.path_args[route_element_path[1:-1]] = path_elements[i] elif path_elements[i] != route_element_path: @@ -75,4 +75,4 @@ def convert_arguments(self): params[func_param_name] = func_param_info.annotation( params[func_param_name]) # Convert parameter to the expected type except ValueError as e: - raise ValueError(f"Could not convert parameter {func_param_name}: {e}") + raise ValueError(f'Could not convert parameter {func_param_name}: {e}') From 215e49304b0041efe0d2f4ec9ddb35ffb03ca286 Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Sun, 14 Apr 2024 09:26:10 +0200 Subject: [PATCH 27/79] Splitting reload functionality into separate RouterFrame class responsible for async building Added outlet and outlet view class --- examples/modularization/main.py | 4 +- examples/outlet/main.py | 64 ++++++++ examples/single_page_router/advanced.py | 2 +- examples/single_page_router/main.py | 2 +- nicegui/client.py | 2 +- .../router_frame.js} | 6 +- nicegui/elements/router_frame.py | 70 ++++++++ nicegui/{single_page.py => outlet.py} | 153 ++++++++---------- ...single_page_url.py => router_frame_url.py} | 2 +- nicegui/ui.py | 6 +- 10 files changed, 211 insertions(+), 100 deletions(-) create mode 100644 examples/outlet/main.py rename nicegui/{single_page.js => elements/router_frame.js} (91%) create mode 100644 nicegui/elements/router_frame.py rename nicegui/{single_page.py => outlet.py} (70%) rename nicegui/{single_page_url.py => router_frame_url.py} (98%) diff --git a/examples/modularization/main.py b/examples/modularization/main.py index e44d76553..2a535f4f1 100755 --- a/examples/modularization/main.py +++ b/examples/modularization/main.py @@ -5,7 +5,6 @@ import theme from nicegui import app, ui -from nicegui.single_page import SinglePageRouter # here we use our custom page decorator directly and just put the content creation into a separate function @@ -21,5 +20,4 @@ def index_page() -> None: # we can also use the APIRouter as described in https://nicegui.io/documentation/page#modularize_with_apirouter app.include_router(example_c.router) -spr = SinglePageRouter("/").setup_page_routes() # TODO Experimental, for performance comparison -ui.run(title='Modularization Example') +ui.run(title='Modularization Example') \ No newline at end of file diff --git a/examples/outlet/main.py b/examples/outlet/main.py new file mode 100644 index 000000000..07aa0fab9 --- /dev/null +++ b/examples/outlet/main.py @@ -0,0 +1,64 @@ +from nicegui import ui + + +@ui.outlet('/') +def spa1(): + ui.label("spa1 header") + yield + ui.label("spa1 footer") + + +# SPA outlet routers can be defined side by side +@ui.outlet('/spa2') +def spa2(): + ui.label('spa2') + yield + + +# views are defined with relative path to their outlet +@spa1.view('/') +def spa1_index(): + ui.label('content of spa1') + ui.link('more', '/more') + +@spa1.view('/more') +def spa1_more(): + ui.label('more content of spa1') + ui.link('main', '/') + +''' +# the view is a function upon the decorated function of the outlet (same technique as "refreshable.refresh") +@spa2.view('/') +def spa2_index(): + ui.label('content of spa2') + ui.link('more', '/more') + + +@spa2.view('/more') +def spa2_more(): + ui.label('more content of spa2') + ui.link('main', '/') + + +# spa outlets can also be nested (by calling outlet function upon the decorated function of the outlet) +@spa2.outlet('/nested') +def nested(): + ui.label('nested outled') + yield + + +@nested.view('/') +def nested_index(): + ui.label('content of nested') + ui.link('main', '/') + + +# normal pages are still available +@ui.page('/') +def index(): + ui.link('spa1', '/spa1') + ui.link('spa2', '/spa2') + ui.link('nested', '/spa2/nested') +''' + +ui.run(show=False) diff --git a/examples/single_page_router/advanced.py b/examples/single_page_router/advanced.py index 71ccaec3b..33364dcf8 100644 --- a/examples/single_page_router/advanced.py +++ b/examples/single_page_router/advanced.py @@ -4,7 +4,7 @@ from nicegui import ui from nicegui.page import page -from nicegui.single_page import SinglePageRouter +from nicegui.outlet import SinglePageRouter def setup_page_layout(content: Callable): diff --git a/examples/single_page_router/main.py b/examples/single_page_router/main.py index 34e758992..233433a9a 100644 --- a/examples/single_page_router/main.py +++ b/examples/single_page_router/main.py @@ -2,7 +2,7 @@ from nicegui import ui from nicegui.page import page -from nicegui.single_page import SinglePageRouter +from nicegui.outlet import SinglePageRouter @page('/', title='Welcome!') diff --git a/nicegui/client.py b/nicegui/client.py index 1c65e969c..6ef6cdcc3 100644 --- a/nicegui/client.py +++ b/nicegui/client.py @@ -227,7 +227,7 @@ def open(self, target: Union[Callable[..., Any], str], new_tab: bool = False) -> """Open a new page in the client.""" path = target if isinstance(target, str) else self.page_routes[target] if path in self.single_page_routes and self.single_page_content is not None: # moving from SPR to SPR? - self.single_page_routes[path].open(target) + self.single_page_routes[path].navigate_to(target) return self.outbox.enqueue_message('open', {'path': path, 'new_tab': new_tab}, self.id) diff --git a/nicegui/single_page.js b/nicegui/elements/router_frame.js similarity index 91% rename from nicegui/single_page.js rename to nicegui/elements/router_frame.js index 55623134d..092fd8d32 100644 --- a/nicegui/single_page.js +++ b/nicegui/elements/router_frame.js @@ -18,20 +18,20 @@ export default { if (!regex.test(href)) continue; e.preventDefault(); // Prevent the default link behavior if (router.use_browser_history) window.history.pushState({page: org_href}, '', org_href); - router.$emit('open', org_href, false); + router.$emit('open', org_href); return } } }); window.addEventListener('popstate', (event) => { let new_page = window.location.pathname; - this.$emit('open', new_page, false); + this.$emit('open', new_page); }); const connectInterval = setInterval(async () => { if (window.socket.id === undefined) return; let target = window.location.pathname; if (window.location.hash !== '') target += window.location.hash; - this.$emit('open', target, false); + this.$emit('open', target); clearInterval(connectInterval); }, 10); }, diff --git a/nicegui/elements/router_frame.py b/nicegui/elements/router_frame.py new file mode 100644 index 000000000..2ede1fcbd --- /dev/null +++ b/nicegui/elements/router_frame.py @@ -0,0 +1,70 @@ +from typing import Union, Callable, Tuple, Any, Optional, Self + +from nicegui import ui, helpers, context, background_tasks, core +from nicegui.router_frame_url import SinglePageUrl + + +class RouterFrame(ui.element, component='router_frame.js'): + """The RouterFrame is a special element which is used by the SinglePageRouter to exchange the content of + the current page with the content of the new page. It serves as container and overrides the browser's history + management to prevent the browser from reloading the whole page.""" + + def __init__(self, valid_path_masks: list[str], + use_browser_history: bool = True, + change_title: bool = False): + """ + :param valid_path_masks: A list of valid path masks which shall be allowed to be opened by the router + :param use_browser_history: Optional flag to enable or disable the browser history management. Default is True. + :param change_title: Optional flag to enable or disable the title change. Default is False. + """ + super().__init__() + self._props['valid_path_masks'] = valid_path_masks + self._props['browser_history'] = use_browser_history + self.use_browser_history = use_browser_history + self.change_title = False + self._on_resolve: Optional[Callable[[Any], SinglePageUrl]] = None + self.on('open', lambda e: self.navigate_to(e.args)) + + def on_resolve(self, on_resolve: Callable[[Any], SinglePageUrl]) -> Self: + """Set the on_resolve function which is used to resolve the target to a SinglePageUrl + :param on_resolve: The on_resolve function which receives a target object such as an URL or Callable and + returns a SinglePageUrl object.""" + self._on_resolve = on_resolve + return self + + def get_target_url(self, target: Any) -> SinglePageUrl: + if self._on_resolve is not None: + return self._on_resolve(target) + raise NotImplementedError + + def navigate_to(self, target: Any, _server_side=False) -> None: + """Open a new page in the browser by exchanging the content of the router frame + :param target: the target route or builder function. If a list is passed, the second element is a boolean + indicating whether the navigation should be server side only and not update the browser. + :param _server_side: Optional flag which defines if the call is originated on the server side and thus + the browser history should be updated. Default is False.""" + target_url = self.get_target_url(target) + entry = target_url.entry + if entry is None: + if target_url.fragment is not None: + ui.run_javascript(f'window.location.href = "#{target_url.fragment}";') # go to fragment + return + return + if self.change_title: + title = entry.title if entry.title is not None else core.app.config.title + ui.page_title(title) + if _server_side and self.use_browser_history: + ui.run_javascript(f'window.history.pushState({{page: "{target}"}}, "", "{target}");') + + async def build(content_element, fragment, kwargs) -> None: + with content_element: + result = entry.builder(**kwargs) + if helpers.is_coroutine_function(entry.builder): + await result + if fragment is not None: + await ui.run_javascript(f'window.location.href = "#{fragment}";') + + content = context.get_client().single_page_content + content.clear() + combined_dict = {**target_url.path_args, **target_url.query_args} + background_tasks.create(build(content, target_url.fragment, combined_dict)) diff --git a/nicegui/single_page.py b/nicegui/outlet.py similarity index 70% rename from nicegui/single_page.py rename to nicegui/outlet.py index 30c9095ed..37581eea8 100644 --- a/nicegui/single_page.py +++ b/nicegui/outlet.py @@ -1,26 +1,13 @@ import fnmatch import re -from typing import Callable, Dict, Union, Optional, Tuple, Self, List, Set +from functools import wraps +from typing import Callable, Dict, Union, Optional, Tuple, Self, List, Set, Any, Generator from fastapi.routing import APIRoute -from nicegui import background_tasks, helpers, ui, core, context, Client -from nicegui.single_page_url import SinglePageUrl - - -class SinglePageRouterFrame(ui.element, component='single_page.js'): - """The SinglePageRouterFrame is a special element which is used by the SinglePageRouter to exchange the content of - the current page with the content of the new page. It serves as container and overrides the browser's history - management to prevent the browser from reloading the whole page.""" - - def __init__(self, valid_path_masks: list[str], use_browser_history: bool = True): - """ - :param valid_path_masks: A list of valid path masks which shall be allowed to be opened by the router - :param use_browser_history: Optional flag to enable or disable the browser history management. Default is True. - """ - super().__init__() - self._props['valid_path_masks'] = valid_path_masks - self._props['browser_history'] = use_browser_history +from nicegui import background_tasks, helpers, ui, core, context +from nicegui.elements.router_frame import RouterFrame +from nicegui.router_frame_url import SinglePageUrl class SinglePageRouterEntry: @@ -51,7 +38,17 @@ def verify(self) -> Self: return self -class SinglePageRouter: +class SinglePageRouteFrame: + def open(self, target: Union[Callable, str, Tuple[str, bool]]) -> None: + """Open a new page in the browser by exchanging the content of the root page's slot element + + :param target: the target route or builder function. If a list is passed, the second element is a boolean + indicating whether the navigation should be server side only and not update the browser.""" + if isinstance(target, list): + target, server_side = target + + +class outlet: """The SinglePageRouter allows the development of a Single Page Application (SPA) which maintains a persistent connection to the server and only updates the content of the page instead of reloading the whole page. @@ -66,7 +63,8 @@ def __init__(self, browser_history: bool = True, included: Union[List[Union[Callable, str]], str, Callable] = '/*', excluded: Union[List[Union[Callable, str]], str, Callable] = '', - on_instance_created: Optional[Callable] = None) -> None: + on_instance_created: Optional[Callable] = None, + **kwargs) -> None: """ :param path: the base path of the single page router. :param browser_history: Optional flag to enable or disable the browser history management. Default is True. @@ -78,6 +76,7 @@ def __init__(self, exclusion mask. :param on_instance_created: Optional callback which is called when a new instance is created. Each browser tab or window is a new instance. This can be used to initialize the state of the application. + :param kwargs: Additional arguments for the @page decorators """ super().__init__() self.routes: Dict[str, SinglePageRouterEntry] = {} @@ -90,10 +89,35 @@ def __init__(self, self.system_excluded = ['/docs', '/redoc', '/openapi.json', '_*'] # set of all registered paths which were finally included for verification w/ mask matching in the browser self.included_paths: Set[str] = set() - self.content_area_class = SinglePageRouterFrame self.on_instance_created: Optional[Callable] = on_instance_created self.use_browser_history = browser_history self._setup_configured = False + self.outlet_builder: Optional[Callable] = None + self.page_kwargs = kwargs + + def __call__(self, func: Callable[..., Any]) -> Self: + """Decorator for the outlet function""" + self.outlet_builder = func + self._setup_routing_pages() + return self + + def _setup_routing_pages(self): + @ui.page(self.base_path, **self.page_kwargs) + @ui.page(f'{self.base_path}' + '{_:path}', **self.page_kwargs) # all other pages + async def root_page(): + content = self.outlet_builder() + client = context.get_client() + while True: + try: + next(content) + if client.single_page_content is None: + client.single_page_content = self._setup_content_area() + except StopIteration: + break + + def view(self, path: str) -> "OutletView": + """Decorator for the view function""" + return OutletView(path, self) def setup_page_routes(self, **kwargs): """Registers the SinglePageRouter with the @page decorator to handle all routes defined by the router @@ -106,43 +130,13 @@ def setup_page_routes(self, **kwargs): self._update_masks() self._find_api_routes() - @ui.page(self.base_path, **kwargs) - @ui.page(f'{self.base_path}' + '{_:path}', **kwargs) # all other pages - async def root_page(): - self.handle_instance_created() - self.setup_root_page() - - def handle_instance_created(self): - """Is called when ever a new instance is created such as when the user opens the page for the first time or - in a new tab""" - if self.on_instance_created is not None: - self.on_instance_created() - - def setup_root_page(self): - """Builds the root page of the single page router and initializes the content area. - - Is only calling the setup_content_area method by default but can be overridden to customize the root page - for example with a navigation bar, footer or embedding the content area within a container element. - - Example: - ``` - def setup_root_page(self): - with ui.left_drawer(): - ... setup navigation - with ui.column(): - self.setup_content_area() - ... footer - ``` - """ - self.setup_content_area() - - def setup_content_area(self) -> SinglePageRouterFrame: + def _setup_content_area(self) -> RouterFrame: """Setups the content area for the single page router :return: The content area element """ - content = self.content_area_class( - list(self.included_paths), self.use_browser_history).on('open', lambda e: self.open(e.args)) + content = RouterFrame(list(self.included_paths), self.use_browser_history) + content.on_resolve(self.get_target_url) context.get_client().single_page_content = content return content @@ -153,7 +147,8 @@ def add_page(self, path: str, builder: Callable, title: Optional[str] = None) -> :param builder: The builder function :param title: Optional title of the page """ - self.routes[path] = SinglePageRouterEntry(path.rstrip('/'), builder, title).verify() + self.included_paths.add(path.rstrip('/')) + self.routes[path] = SinglePageRouterEntry(path, builder, title).verify() def add_router_entry(self, entry: SinglePageRouterEntry) -> None: """Adds a fully configured SinglePageRouterEntry to the router @@ -176,40 +171,6 @@ def get_target_url(self, path: Union[Callable, str]) -> SinglePageUrl: parser = SinglePageUrl(path) return parser.parse_single_page_route(self.routes, path) - def open(self, target: Union[Callable, str, Tuple[str, bool]]) -> None: - """Open a new page in the browser by exchanging the content of the root page's slot element - - :param target: the target route or builder function. If a list is passed, the second element is a boolean - indicating whether the navigation should be server side only and not update the browser.""" - if isinstance(target, list): - target, server_side = target # unpack the list - else: - server_side = True - target_url = self.get_target_url(target) - entry = target_url.entry - if entry is None: - if target_url.fragment is not None: - ui.run_javascript(f'window.location.href = "#{target_url.fragment}";') # go to fragment - return - return - title = entry.title if entry.title is not None else core.app.config.title - ui.run_javascript(f'document.title = "{title}"') - if server_side and self.use_browser_history: - ui.run_javascript(f'window.history.pushState({{page: "{target}"}}, "", "{target}");') - - async def build(content_element, fragment, kwargs) -> None: - with content_element: - result = entry.builder(**kwargs) - if helpers.is_coroutine_function(entry.builder): - await result - if fragment is not None: - await ui.run_javascript(f'window.location.href = "#{fragment}";') - - content = context.get_client().single_page_content - content.clear() - combined_dict = {**target_url.path_args, **target_url.query_args} - background_tasks.create(build(content, target_url.fragment, combined_dict)) - def _is_excluded(self, path: str) -> bool: """Checks if a path is excluded by the exclusion masks @@ -227,6 +188,7 @@ def _is_excluded(self, path: str) -> bool: def _update_masks(self) -> None: """Updates the inclusion and exclusion masks and resolves Callables to the actual paths""" + from nicegui.page import Client for cur_list in [self.included, self.excluded]: for index, element in enumerate(cur_list): if isinstance(element, Callable): @@ -239,6 +201,7 @@ def _update_masks(self) -> None: def _find_api_routes(self) -> None: """Find all API routes already defined via the @page decorator, remove them and redirect them to the single page router""" + from nicegui.page import Client page_routes = set() for key, route in Client.page_routes.items(): if route.startswith(self.base_path) and not self._is_excluded(route): @@ -256,3 +219,15 @@ def _find_api_routes(self) -> None: if isinstance(route, APIRoute): if route.path in page_routes: core.app.routes.remove(route) + + +class OutletView: + + def __init__(self, path: str, parent_outlet: outlet): + self.path = path + self.parent_outlet = parent_outlet + + def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]: + self.parent_outlet.add_page( + self.parent_outlet.base_path.rstrip('/') + self.path, func) + return func diff --git a/nicegui/single_page_url.py b/nicegui/router_frame_url.py similarity index 98% rename from nicegui/single_page_url.py rename to nicegui/router_frame_url.py index 39c86749b..71f555b7b 100644 --- a/nicegui/single_page_url.py +++ b/nicegui/router_frame_url.py @@ -3,7 +3,7 @@ from typing import Dict, Optional, TYPE_CHECKING, Self if TYPE_CHECKING: - from nicegui.single_page import SinglePageRouterEntry + from nicegui.outlet import SinglePageRouterEntry class SinglePageUrl: diff --git a/nicegui/ui.py b/nicegui/ui.py index 5c5e61a3e..f6927bad2 100644 --- a/nicegui/ui.py +++ b/nicegui/ui.py @@ -110,6 +110,7 @@ 'add_style', 'update', 'page', + 'outlet', 'drawer', 'footer', 'header', @@ -119,6 +120,7 @@ 'right_drawer', 'run', 'run_with', + 'router_frame' ] from .element import Element as element @@ -186,6 +188,7 @@ from .elements.radio import Radio as radio from .elements.range import Range as range # pylint: disable=redefined-builtin from .elements.restructured_text import ReStructuredText as restructured_text +from .elements.router_frame import RouterFrame as router_frame from .elements.row import Row as row from .elements.scene import Scene as scene from .elements.scroll_area import ScrollArea as scroll_area @@ -227,6 +230,7 @@ from .functions.style import add_css, add_sass, add_scss, add_style from .functions.update import update from .page import page +from .outlet import outlet from .page_layout import Drawer as drawer from .page_layout import Footer as footer from .page_layout import Header as header @@ -234,4 +238,4 @@ from .page_layout import PageSticky as page_sticky from .page_layout import RightDrawer as right_drawer from .ui_run import run -from .ui_run_with import run_with +from .ui_run_with import run_with \ No newline at end of file From 3167521d7bf9a4ff473914c47b0b9e063c83788c Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Sun, 14 Apr 2024 18:09:40 +0200 Subject: [PATCH 28/79] Refactoring of outlet structure and URL resolving, backup --- examples/outlet/main.py | 4 +- nicegui/client.py | 11 +++-- nicegui/elements/router_frame.py | 15 +++---- nicegui/outlet.py | 77 ++++++++++++++++++-------------- nicegui/router_frame_url.py | 5 ++- nicegui/ui.py | 2 +- 6 files changed, 64 insertions(+), 50 deletions(-) diff --git a/examples/outlet/main.py b/examples/outlet/main.py index 07aa0fab9..0c591b6d2 100644 --- a/examples/outlet/main.py +++ b/examples/outlet/main.py @@ -1,7 +1,7 @@ from nicegui import ui -@ui.outlet('/') +ui.outlet('/') def spa1(): ui.label("spa1 header") yield @@ -9,7 +9,7 @@ def spa1(): # SPA outlet routers can be defined side by side -@ui.outlet('/spa2') +ui.outlet('/spa2') def spa2(): ui.label('spa2') yield diff --git a/nicegui/client.py b/nicegui/client.py index 6ef6cdcc3..5332551e2 100644 --- a/nicegui/client.py +++ b/nicegui/client.py @@ -26,6 +26,7 @@ if TYPE_CHECKING: from .page import page + from .outlet import Outlet as outlet templates = Jinja2Templates(Path(__file__).parent / 'templates') @@ -81,7 +82,7 @@ def __init__(self, page: page, *, shared: bool = False) -> None: self.page = page self.storage = ObservableDict() - self.single_page_content = None + self.outlets: Dict[str, "outlet"] = {} self.connect_handlers: List[Union[Callable[..., Any], Awaitable]] = [] self.disconnect_handlers: List[Union[Callable[..., Any], Awaitable]] = [] @@ -226,9 +227,11 @@ async def send_and_wait(): def open(self, target: Union[Callable[..., Any], str], new_tab: bool = False) -> None: """Open a new page in the client.""" path = target if isinstance(target, str) else self.page_routes[target] - if path in self.single_page_routes and self.single_page_content is not None: # moving from SPR to SPR? - self.single_page_routes[path].navigate_to(target) - return + for cur_outlet in self.outlets.values(): + target = cur_outlet.resolve_target(target) + if target.valid: + cur_outlet.navigate_to(path) + return self.outbox.enqueue_message('open', {'path': path, 'new_tab': new_tab}, self.id) def download(self, src: Union[str, bytes], filename: Optional[str] = None, media_type: str = '') -> None: diff --git a/nicegui/elements/router_frame.py b/nicegui/elements/router_frame.py index 2ede1fcbd..65d6e73b1 100644 --- a/nicegui/elements/router_frame.py +++ b/nicegui/elements/router_frame.py @@ -1,7 +1,7 @@ from typing import Union, Callable, Tuple, Any, Optional, Self from nicegui import ui, helpers, context, background_tasks, core -from nicegui.router_frame_url import SinglePageUrl +from nicegui.router_frame_url import SinglePageTarget class RouterFrame(ui.element, component='router_frame.js'): @@ -22,17 +22,17 @@ def __init__(self, valid_path_masks: list[str], self._props['browser_history'] = use_browser_history self.use_browser_history = use_browser_history self.change_title = False - self._on_resolve: Optional[Callable[[Any], SinglePageUrl]] = None + self._on_resolve: Optional[Callable[[Any], SinglePageTarget]] = None self.on('open', lambda e: self.navigate_to(e.args)) - def on_resolve(self, on_resolve: Callable[[Any], SinglePageUrl]) -> Self: + def on_resolve(self, on_resolve: Callable[[Any], SinglePageTarget]) -> Self: """Set the on_resolve function which is used to resolve the target to a SinglePageUrl :param on_resolve: The on_resolve function which receives a target object such as an URL or Callable and returns a SinglePageUrl object.""" self._on_resolve = on_resolve return self - def get_target_url(self, target: Any) -> SinglePageUrl: + def resolve_target(self, target: Any) -> SinglePageTarget: if self._on_resolve is not None: return self._on_resolve(target) raise NotImplementedError @@ -43,7 +43,7 @@ def navigate_to(self, target: Any, _server_side=False) -> None: indicating whether the navigation should be server side only and not update the browser. :param _server_side: Optional flag which defines if the call is originated on the server side and thus the browser history should be updated. Default is False.""" - target_url = self.get_target_url(target) + target_url = self.resolve_target(target) entry = target_url.entry if entry is None: if target_url.fragment is not None: @@ -64,7 +64,6 @@ async def build(content_element, fragment, kwargs) -> None: if fragment is not None: await ui.run_javascript(f'window.location.href = "#{fragment}";') - content = context.get_client().single_page_content - content.clear() + self.clear() combined_dict = {**target_url.path_args, **target_url.query_args} - background_tasks.create(build(content, target_url.fragment, combined_dict)) + background_tasks.create(build(self, target_url.fragment, combined_dict)) diff --git a/nicegui/outlet.py b/nicegui/outlet.py index 37581eea8..c74f777a5 100644 --- a/nicegui/outlet.py +++ b/nicegui/outlet.py @@ -7,7 +7,7 @@ from nicegui import background_tasks, helpers, ui, core, context from nicegui.elements.router_frame import RouterFrame -from nicegui.router_frame_url import SinglePageUrl +from nicegui.router_frame_url import SinglePageTarget class SinglePageRouterEntry: @@ -48,7 +48,7 @@ def open(self, target: Union[Callable, str, Tuple[str, bool]]) -> None: target, server_side = target -class outlet: +class Outlet: """The SinglePageRouter allows the development of a Single Page Application (SPA) which maintains a persistent connection to the server and only updates the content of the page instead of reloading the whole page. @@ -105,17 +105,9 @@ def _setup_routing_pages(self): @ui.page(self.base_path, **self.page_kwargs) @ui.page(f'{self.base_path}' + '{_:path}', **self.page_kwargs) # all other pages async def root_page(): - content = self.outlet_builder() - client = context.get_client() - while True: - try: - next(content) - if client.single_page_content is None: - client.single_page_content = self._setup_content_area() - except StopIteration: - break - - def view(self, path: str) -> "OutletView": + self._setup_content_area() + + def view(self, path: str) -> 'OutletView': """Decorator for the view function""" return OutletView(path, self) @@ -130,16 +122,6 @@ def setup_page_routes(self, **kwargs): self._update_masks() self._find_api_routes() - def _setup_content_area(self) -> RouterFrame: - """Setups the content area for the single page router - - :return: The content area element - """ - content = RouterFrame(list(self.included_paths), self.use_browser_history) - content.on_resolve(self.get_target_url) - context.get_client().single_page_content = content - return content - def add_page(self, path: str, builder: Callable, title: Optional[str] = None) -> None: """Add a new route to the single page router @@ -157,19 +139,46 @@ def add_router_entry(self, entry: SinglePageRouterEntry) -> None: """ self.routes[entry.path] = entry.verify() - def get_target_url(self, path: Union[Callable, str]) -> SinglePageUrl: - """Returns the SinglePageRouterEntry for the given URL path or builder function + def resolve_target(self, target: Union[Callable, str]) -> SinglePageTarget: + """Tries to resolve a target such as a builder function or an URL path w/ route and query parameters. - :param path: The URL path to open or a builder function - :return: The SinglePageUrl object which contains the parsed route, query arguments and fragment + :param target: The URL path to open or a builder function + :return: The resolved target. Defines .valid if the target is valid """ - if isinstance(path, Callable): - for path, entry in self.routes.items(): - if entry.builder == path: - return SinglePageUrl(entry=entry) + if isinstance(target, Callable): + for target, entry in self.routes.items(): + if entry.builder == target: + return SinglePageTarget(entry=entry) else: - parser = SinglePageUrl(path) - return parser.parse_single_page_route(self.routes, path) + parser = SinglePageTarget(target) + return parser.parse_single_page_route(self.routes, target) + + def navigate_to(self, target: Union[Callable, str, SinglePageTarget]) -> bool: + """Navigate to a target + + :param target: The target to navigate to + """ + if not isinstance(target, SinglePageTarget): + target = self.resolve_target(target) + if not target.valid: + return False + # TODO find right content area + return True + + def _setup_content_area(self): + """Setups the content area for the single page router + + :return: The content area element + """ + frame = self.outlet_builder() + next(frame) # execute top layout components till first yield + content = RouterFrame(list(self.included_paths), self.use_browser_history) # exchangeable content of the page + content.on_resolve(self.resolve_target) + while True: # execute the rest of the outlets ui setup yield by yield + try: + next(frame) + except StopIteration: + break def _is_excluded(self, path: str) -> bool: """Checks if a path is excluded by the exclusion masks @@ -223,7 +232,7 @@ def _find_api_routes(self) -> None: class OutletView: - def __init__(self, path: str, parent_outlet: outlet): + def __init__(self, path: str, parent_outlet: Outlet): self.path = path self.parent_outlet = parent_outlet diff --git a/nicegui/router_frame_url.py b/nicegui/router_frame_url.py index 71f555b7b..b3617751a 100644 --- a/nicegui/router_frame_url.py +++ b/nicegui/router_frame_url.py @@ -6,7 +6,7 @@ from nicegui.outlet import SinglePageRouterEntry -class SinglePageUrl: +class SinglePageTarget: """Aa helper class which is used to parse the path and query parameters of an URL to find the matching SinglePageRouterEntry and convert the parameters to the expected types of the builder function""" @@ -24,6 +24,7 @@ def __init__(self, path: Optional[str] = None, entry: Optional['SinglePageRouter self.path_args = {} self.query_args = urllib.parse.parse_qs(self.query_string) self.entry = entry + self.valid = entry is not None def parse_single_page_route(self, routes: Dict[str, 'SinglePageRouterEntry'], path: str) -> Self: """ @@ -38,10 +39,12 @@ def parse_single_page_route(self, routes: Dict[str, 'SinglePageRouterEntry'], pa self.path_args = {} self.query_args = urllib.parse.parse_qs(self.query_string) if self.fragment is not None and len(self.path) == 0: + self.valid = True return self self.entry = self.parse_path() if self.entry is not None: self.convert_arguments() + self.valid = True return self def parse_path(self) -> Optional['SinglePageRouterEntry']: diff --git a/nicegui/ui.py b/nicegui/ui.py index f6927bad2..29b5a598f 100644 --- a/nicegui/ui.py +++ b/nicegui/ui.py @@ -230,7 +230,7 @@ from .functions.style import add_css, add_sass, add_scss, add_style from .functions.update import update from .page import page -from .outlet import outlet +from .outlet import Outlet as outlet from .page_layout import Drawer as drawer from .page_layout import Footer as footer from .page_layout import Header as header From 75a5e5343176eae57e482b58276e919bccfea250 Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Sun, 14 Apr 2024 22:26:58 +0200 Subject: [PATCH 29/79] Preparing nested outlets --- examples/outlet/main.py | 26 +- examples/single_page_router/advanced.py | 2 +- examples/single_page_router/main.py | 4 +- nicegui/client.py | 10 +- nicegui/elements/router_frame.py | 2 +- nicegui/outlet.py | 247 +++--------------- nicegui/page_layout.py | 2 +- nicegui/single_page_app.py | 81 ++++++ ...ter_frame_url.py => single_page_target.py} | 2 +- 9 files changed, 156 insertions(+), 220 deletions(-) create mode 100644 nicegui/single_page_app.py rename nicegui/{router_frame_url.py => single_page_target.py} (98%) diff --git a/examples/outlet/main.py b/examples/outlet/main.py index 0c591b6d2..dabfdd7a4 100644 --- a/examples/outlet/main.py +++ b/examples/outlet/main.py @@ -1,7 +1,7 @@ from nicegui import ui -ui.outlet('/') +@ui.outlet('/') def spa1(): ui.label("spa1 header") yield @@ -9,7 +9,7 @@ def spa1(): # SPA outlet routers can be defined side by side -ui.outlet('/spa2') +@ui.outlet('/spa2') def spa2(): ui.label('spa2') yield @@ -20,11 +20,33 @@ def spa2(): def spa1_index(): ui.label('content of spa1') ui.link('more', '/more') + ui.link('nested', '/nested') + @spa1.view('/more') def spa1_more(): ui.label('more content of spa1') ui.link('main', '/') + ui.link('nested', '/nested') + + +@spa1.outlet('/nested') +def nested(): + ui.label('nested outlet') + yield + + +@nested.view('/') +def nested_index(): + ui.label('content of nested') + ui.link('nested other', '/nested/other') + + +@nested.view('/other') +def nested_other(): + ui.label('other nested') + ui.link('nested index', '/nested') + ''' # the view is a function upon the decorated function of the outlet (same technique as "refreshable.refresh") diff --git a/examples/single_page_router/advanced.py b/examples/single_page_router/advanced.py index 33364dcf8..0da674fce 100644 --- a/examples/single_page_router/advanced.py +++ b/examples/single_page_router/advanced.py @@ -4,7 +4,7 @@ from nicegui import ui from nicegui.page import page -from nicegui.outlet import SinglePageRouter +from nicegui.single_page_router import SinglePageRouter def setup_page_layout(content: Callable): diff --git a/examples/single_page_router/main.py b/examples/single_page_router/main.py index 233433a9a..a42df22bf 100644 --- a/examples/single_page_router/main.py +++ b/examples/single_page_router/main.py @@ -2,7 +2,7 @@ from nicegui import ui from nicegui.page import page -from nicegui.outlet import SinglePageRouter +from nicegui.single_page_router import SinglePageRouter @page('/', title='Welcome!') @@ -17,5 +17,5 @@ def about(): ui.link('Index', '/') -router = SinglePageRouter('/').setup_page_routes() +router = SinglePageRouter('/').reroute_pages() ui.run() diff --git a/nicegui/client.py b/nicegui/client.py index 5332551e2..86760b200 100644 --- a/nicegui/client.py +++ b/nicegui/client.py @@ -26,7 +26,7 @@ if TYPE_CHECKING: from .page import page - from .outlet import Outlet as outlet + from .elements.router_frame import RouterFrame templates = Jinja2Templates(Path(__file__).parent / 'templates') @@ -82,7 +82,7 @@ def __init__(self, page: page, *, shared: bool = False) -> None: self.page = page self.storage = ObservableDict() - self.outlets: Dict[str, "outlet"] = {} + self.single_page_router_frame: Optional[RouterFrame] = None self.connect_handlers: List[Union[Callable[..., Any], Awaitable]] = [] self.disconnect_handlers: List[Union[Callable[..., Any], Awaitable]] = [] @@ -227,10 +227,10 @@ async def send_and_wait(): def open(self, target: Union[Callable[..., Any], str], new_tab: bool = False) -> None: """Open a new page in the client.""" path = target if isinstance(target, str) else self.page_routes[target] - for cur_outlet in self.outlets.values(): - target = cur_outlet.resolve_target(target) + for cur_spr in self.single_page_routes.values(): + target = cur_spr.resolve_target(target) if target.valid: - cur_outlet.navigate_to(path) + cur_spr.navigate_to(path) return self.outbox.enqueue_message('open', {'path': path, 'new_tab': new_tab}, self.id) diff --git a/nicegui/elements/router_frame.py b/nicegui/elements/router_frame.py index 65d6e73b1..d63f70855 100644 --- a/nicegui/elements/router_frame.py +++ b/nicegui/elements/router_frame.py @@ -1,7 +1,7 @@ from typing import Union, Callable, Tuple, Any, Optional, Self from nicegui import ui, helpers, context, background_tasks, core -from nicegui.router_frame_url import SinglePageTarget +from nicegui.single_page_target import SinglePageTarget class RouterFrame(ui.element, component='router_frame.js'): diff --git a/nicegui/outlet.py b/nicegui/outlet.py index c74f777a5..eac849dd3 100644 --- a/nicegui/outlet.py +++ b/nicegui/outlet.py @@ -1,242 +1,75 @@ -import fnmatch -import re -from functools import wraps -from typing import Callable, Dict, Union, Optional, Tuple, Self, List, Set, Any, Generator +from typing import Callable, Any, Self, Optional -from fastapi.routing import APIRoute +from nicegui.single_page_router import SinglePageRouter -from nicegui import background_tasks, helpers, ui, core, context -from nicegui.elements.router_frame import RouterFrame -from nicegui.router_frame_url import SinglePageTarget - -class SinglePageRouterEntry: - """The SinglePageRouterEntry is a data class which holds the configuration of a single page router route""" - - def __init__(self, path: str, builder: Callable, title: Union[str, None] = None): - """ - :param path: The path of the route - :param builder: The builder function which is called when the route is opened - :param title: Optional title of the page - """ - self.path = path - self.builder = builder - self.title = title - - def verify(self) -> Self: - """Verifies a SinglePageRouterEntry for correctness. Raises a ValueError if the entry is invalid.""" - path = self.path - if '{' in path: - # verify only a single open and close curly bracket is present - elements = path.split('/') - for cur_element in elements: - if '{' in cur_element: - if cur_element.count('{') != 1 or cur_element.count('}') != 1 or len(cur_element) < 3 or \ - not (cur_element.startswith('{') and cur_element.endswith('}')): - raise ValueError('Only simple path parameters are supported. /path/{value}/{another_value}\n' - f'failed for path: {path}') - return self - - -class SinglePageRouteFrame: - def open(self, target: Union[Callable, str, Tuple[str, bool]]) -> None: - """Open a new page in the browser by exchanging the content of the root page's slot element - - :param target: the target route or builder function. If a list is passed, the second element is a boolean - indicating whether the navigation should be server side only and not update the browser.""" - if isinstance(target, list): - target, server_side = target - - -class Outlet: - """The SinglePageRouter allows the development of a Single Page Application (SPA) which maintains a - persistent connection to the server and only updates the content of the page instead of reloading the whole page. - - This allows faster page switches and a more dynamic user experience because instead of reloading the whole page, - only the content area is updated. The SinglePageRouter is a high-level abstraction which manages the routing - and content area of the SPA. - - For examples see examples/single_page_router""" +class Outlet(SinglePageRouter): + """An outlet function defines the page layout of a single page application into which dynamic content can be + inserted. It is a high-level abstraction which manages the routing and content area of the SPA.""" def __init__(self, path: str, browser_history: bool = True, - included: Union[List[Union[Callable, str]], str, Callable] = '/*', - excluded: Union[List[Union[Callable, str]], str, Callable] = '', on_instance_created: Optional[Callable] = None, **kwargs) -> None: """ :param path: the base path of the single page router. + :param layout_builder: A layout builder function which defines the layout of the page. The layout builder + must be a generator function and contain a yield statement to separate the layout from the content area. :param browser_history: Optional flag to enable or disable the browser history management. Default is True. - :param included: Optional list of masks and callables of paths to include. Default is "/*" which includes all. - If you do not want to include all relative paths, you can specify a list of masks or callables to refine the - included paths. If a callable is passed, it must be decorated with a page. - :param excluded: Optional list of masks and callables of paths to exclude. Default is "" which excludes none. - Explicitly included paths (without wildcards) and Callables are always included, even if they match an - exclusion mask. :param on_instance_created: Optional callback which is called when a new instance is created. Each browser tab or window is a new instance. This can be used to initialize the state of the application. + :param parent: The parent outlet of this outlet. :param kwargs: Additional arguments for the @page decorators """ - super().__init__() - self.routes: Dict[str, SinglePageRouterEntry] = {} - self.base_path = path - # list of masks and callables of paths to include - self.included: List[Union[Callable, str]] = [included] if not isinstance(included, list) else included - # list of masks and callables of paths to exclude - self.excluded: List[Union[Callable, str]] = [excluded] if not isinstance(excluded, list) else excluded - # low level system paths which are excluded by default - self.system_excluded = ['/docs', '/redoc', '/openapi.json', '_*'] - # set of all registered paths which were finally included for verification w/ mask matching in the browser - self.included_paths: Set[str] = set() - self.on_instance_created: Optional[Callable] = on_instance_created - self.use_browser_history = browser_history - self._setup_configured = False - self.outlet_builder: Optional[Callable] = None - self.page_kwargs = kwargs + super().__init__(path, browser_history=browser_history, on_instance_created=on_instance_created, **kwargs) def __call__(self, func: Callable[..., Any]) -> Self: - """Decorator for the outlet function""" + """Decorator for the layout builder / "outlet" function""" + def outlet_view(): + self.setup_content_area() + self.outlet_builder = func - self._setup_routing_pages() + if self.parent_router is None: + self.setup_pages() + else: + relative_path = self.base_path[len(self.parent_router.base_path):] + OutletView(self.parent_router, relative_path)(outlet_view) return self - def _setup_routing_pages(self): - @ui.page(self.base_path, **self.page_kwargs) - @ui.page(f'{self.base_path}' + '{_:path}', **self.page_kwargs) # all other pages - async def root_page(): - self._setup_content_area() - - def view(self, path: str) -> 'OutletView': - """Decorator for the view function""" - return OutletView(path, self) - - def setup_page_routes(self, **kwargs): - """Registers the SinglePageRouter with the @page decorator to handle all routes defined by the router - - :param kwargs: Additional arguments for the @page decorators + def view(self, path: str, title: Optional[str] = None) -> 'OutletView': + """Decorator for the view function + :param path: The path of the view, relative to the base path of the outlet + :param title: Optional title of the view. If a title is set, it will be displayed in the browser tab + when the view is active, otherwise the default title of the application is displayed. """ - if self._setup_configured: - raise ValueError('The SinglePageRouter is already configured') - self._setup_configured = True - self._update_masks() - self._find_api_routes() + return OutletView(self, path, title=title) - def add_page(self, path: str, builder: Callable, title: Optional[str] = None) -> None: - """Add a new route to the single page router + def outlet(self, path: str) -> 'Outlet': + """Defines a nested outlet - :param path: The path of the route - :param builder: The builder function - :param title: Optional title of the page + :param path: The relative path of the outlet """ - self.included_paths.add(path.rstrip('/')) - self.routes[path] = SinglePageRouterEntry(path, builder, title).verify() - - def add_router_entry(self, entry: SinglePageRouterEntry) -> None: - """Adds a fully configured SinglePageRouterEntry to the router - - :param entry: The SinglePageRouterEntry to add - """ - self.routes[entry.path] = entry.verify() - - def resolve_target(self, target: Union[Callable, str]) -> SinglePageTarget: - """Tries to resolve a target such as a builder function or an URL path w/ route and query parameters. + abs_path = self.base_path.rstrip('/') + path + return Outlet(abs_path, parent=self) - :param target: The URL path to open or a builder function - :return: The resolved target. Defines .valid if the target is valid - """ - if isinstance(target, Callable): - for target, entry in self.routes.items(): - if entry.builder == target: - return SinglePageTarget(entry=entry) - else: - parser = SinglePageTarget(target) - return parser.parse_single_page_route(self.routes, target) - def navigate_to(self, target: Union[Callable, str, SinglePageTarget]) -> bool: - """Navigate to a target +class OutletView: + """Defines a single view / "content page" which is displayed in an outlet""" - :param target: The target to navigate to + def __init__(self, parent_outlet: SinglePageRouter, path: str, title: Optional[str] = None): """ - if not isinstance(target, SinglePageTarget): - target = self.resolve_target(target) - if not target.valid: - return False - # TODO find right content area - return True - - def _setup_content_area(self): - """Setups the content area for the single page router - - :return: The content area element + :param parent_outlet: The parent outlet in which this view is displayed + :param path: The path of the view, relative to the base path of the outlet + :param title: Optional title of the view. If a title is set, it will be displayed in the browser tab + when the view is active, otherwise the default title of the application is displayed. """ - frame = self.outlet_builder() - next(frame) # execute top layout components till first yield - content = RouterFrame(list(self.included_paths), self.use_browser_history) # exchangeable content of the page - content.on_resolve(self.resolve_target) - while True: # execute the rest of the outlets ui setup yield by yield - try: - next(frame) - except StopIteration: - break - - def _is_excluded(self, path: str) -> bool: - """Checks if a path is excluded by the exclusion masks - - :param path: The path to check - :return: True if the path is excluded, False otherwise""" - for element in self.included: - if path == element: # if it is a perfect, explicit match: allow - return False - if fnmatch.fnmatch(path, element): # if it is just a mask match: verify it is not excluded - for ex_element in self.excluded: - if fnmatch.fnmatch(path, ex_element): - return True # inclusion mask matched but also exclusion mask - return False # inclusion mask matched - return True # no inclusion mask matched - - def _update_masks(self) -> None: - """Updates the inclusion and exclusion masks and resolves Callables to the actual paths""" - from nicegui.page import Client - for cur_list in [self.included, self.excluded]: - for index, element in enumerate(cur_list): - if isinstance(element, Callable): - if element in Client.page_routes: - cur_list[index] = Client.page_routes[element] - else: - raise ValueError( - f'Invalid target page in inclusion/exclusion list, no @page assigned to element') - - def _find_api_routes(self) -> None: - """Find all API routes already defined via the @page decorator, remove them and redirect them to the - single page router""" - from nicegui.page import Client - page_routes = set() - for key, route in Client.page_routes.items(): - if route.startswith(self.base_path) and not self._is_excluded(route): - page_routes.add(route) - Client.single_page_routes[route] = self - title = None - if key in Client.page_configs: - title = Client.page_configs[key].title - route = route.rstrip('/') - self.add_router_entry(SinglePageRouterEntry(route, builder=key, title=title)) - # /site/{value}/{other_value} --> /site/*/* for easy matching in JavaScript - route_mask = re.sub(r'{[^}]+}', '*', route) - self.included_paths.add(route_mask) - for route in core.app.routes.copy(): - if isinstance(route, APIRoute): - if route.path in page_routes: - core.app.routes.remove(route) - - -class OutletView: - - def __init__(self, path: str, parent_outlet: Outlet): self.path = path + self.title = title self.parent_outlet = parent_outlet def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]: - self.parent_outlet.add_page( - self.parent_outlet.base_path.rstrip('/') + self.path, func) + """Decorator for the view function""" + self.parent_outlet.add_view( + self.parent_outlet.base_path.rstrip('/') + self.path, func, title=self.title) return func diff --git a/nicegui/page_layout.py b/nicegui/page_layout.py index dab4519f3..5e30dca46 100644 --- a/nicegui/page_layout.py +++ b/nicegui/page_layout.py @@ -272,7 +272,7 @@ def __init__(self, position: PageStickyPositions = 'bottom-right', x_offset: flo def _check_current_slot(element: Element) -> None: parent = context.get_slot().parent - if parent != parent.client.content and parent != parent.client.single_page_content: + if parent != parent.client.content: log.warning(f'Found top level layout element "{element.__class__.__name__}" inside element "{parent.__class__.__name__}". ' 'Top level layout elements should not be nested but must be direct children of the page content. ' 'This will be raising an exception in NiceGUI 1.5') # DEPRECATED diff --git a/nicegui/single_page_app.py b/nicegui/single_page_app.py new file mode 100644 index 000000000..f5467c7de --- /dev/null +++ b/nicegui/single_page_app.py @@ -0,0 +1,81 @@ +import fnmatch +from typing import Union, List, Callable, re + +from fastapi.routing import APIRoute + +from nicegui import core +from nicegui.single_page_router import SinglePageRouter, SinglePageRouterEntry + + +class SinglePageApp: + + def __init__(self, + target: SinglePageRouter, + included: Union[List[Union[Callable, str]], str, Callable] = '/*', + excluded: Union[List[Union[Callable, str]], str, Callable] = '') -> None: + """ + :param included: Optional list of masks and callables of paths to include. Default is "/*" which includes all. + If you do not want to include all relative paths, you can specify a list of masks or callables to refine the + included paths. If a callable is passed, it must be decorated with a page. + :param excluded: Optional list of masks and callables of paths to exclude. Default is "" which excludes none. + Explicitly included paths (without wildcards) and Callables are always included, even if they match an + exclusion mask. + """ + self.spr = target + self.included: List[Union[Callable, str]] = [included] if not isinstance(included, list) else included + self.excluded: List[Union[Callable, str]] = [excluded] if not isinstance(excluded, list) else excluded + self.system_excluded = ['/docs', '/redoc', '/openapi.json', '_*'] + + def reroute_pages(self): + """Registers the SinglePageRouter with the @page decorator to handle all routes defined by the router""" + self._update_masks() + self._find_api_routes() + + def is_excluded(self, path: str) -> bool: + """Checks if a path is excluded by the exclusion masks + + :param path: The path to check + :return: True if the path is excluded, False otherwise""" + for inclusion_mask in self.included: + if path == inclusion_mask: # if it is a perfect, explicit match: allow + return False + if fnmatch.fnmatch(path, inclusion_mask): # if it is just a mask match: verify it is not excluded + for ex_element in self.excluded: + if fnmatch.fnmatch(path, ex_element): + return True # inclusion mask matched but also exclusion mask + return False # inclusion mask matched + return True # no inclusion mask matched + + def _update_masks(self) -> None: + """Updates the inclusion and exclusion masks and resolves Callables to the actual paths""" + from nicegui.page import Client + for cur_list in [self.included, self.excluded]: + for index, element in enumerate(cur_list): + if isinstance(element, Callable): + if element in Client.page_routes: + cur_list[index] = Client.page_routes[element] + else: + raise ValueError( + f'Invalid target page in inclusion/exclusion list, no @page assigned to element') + + def _find_api_routes(self) -> None: + """Find all API routes already defined via the @page decorator, remove them and redirect them to the + single page router""" + from nicegui.page import Client + page_routes = set() + base_path = self.spr.base_path + for key, route in Client.page_routes.items(): + if route.startswith(base_path) and not self.is_excluded(route): + page_routes.add(route) + Client.single_page_routes[route] = self + title = None + if key in Client.page_configs: + title = Client.page_configs[key].title + route = route.rstrip('/') + self.spr.add_router_entry(SinglePageRouterEntry(route, builder=key, title=title)) + route_mask = SinglePageRouterEntry.create_path_mask(route) + self.spr.included_paths.add(route_mask) + for route in core.app.routes.copy(): + if isinstance(route, APIRoute): + if route.path in page_routes: + core.app.routes.remove(route) diff --git a/nicegui/router_frame_url.py b/nicegui/single_page_target.py similarity index 98% rename from nicegui/router_frame_url.py rename to nicegui/single_page_target.py index b3617751a..9e2bb5e60 100644 --- a/nicegui/router_frame_url.py +++ b/nicegui/single_page_target.py @@ -3,7 +3,7 @@ from typing import Dict, Optional, TYPE_CHECKING, Self if TYPE_CHECKING: - from nicegui.outlet import SinglePageRouterEntry + from nicegui.single_page_router import SinglePageRouterEntry class SinglePageTarget: From 4b989d97e986a08656e497159241ea3bf8b292df Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Sun, 14 Apr 2024 22:27:04 +0200 Subject: [PATCH 30/79] Preparing nested outlets --- nicegui/single_page_router.py | 154 ++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 nicegui/single_page_router.py diff --git a/nicegui/single_page_router.py b/nicegui/single_page_router.py new file mode 100644 index 000000000..14e96fef5 --- /dev/null +++ b/nicegui/single_page_router.py @@ -0,0 +1,154 @@ +import fnmatch +import re +from functools import wraps +from typing import Callable, Dict, Union, Optional, Tuple, Self, List, Set, Any, Generator + +from fastapi.routing import APIRoute + +from nicegui import background_tasks, helpers, ui, core, context +from nicegui.elements.router_frame import RouterFrame +from nicegui.single_page_target import SinglePageTarget + + +class SinglePageRouterEntry: + """The SinglePageRouterEntry is a data class which holds the configuration of a single page router route""" + + def __init__(self, path: str, builder: Callable, title: Union[str, None] = None): + """ + :param path: The path of the route + :param builder: The builder function which is called when the route is opened + :param title: Optional title of the page + """ + self.path = path + self.builder = builder + self.title = title + + def verify(self) -> Self: + """Verifies a SinglePageRouterEntry for correctness. Raises a ValueError if the entry is invalid.""" + path = self.path + if '{' in path: + # verify only a single open and close curly bracket is present + elements = path.split('/') + for cur_element in elements: + if '{' in cur_element: + if cur_element.count('{') != 1 or cur_element.count('}') != 1 or len(cur_element) < 3 or \ + not (cur_element.startswith('{') and cur_element.endswith('}')): + raise ValueError('Only simple path parameters are supported. /path/{value}/{another_value}\n' + f'failed for path: {path}') + return self + + @staticmethod + def create_path_mask(path: str) -> str: + """Converts a path to a mask which can be used for fnmatch matching + + /site/{value}/{other_value} --> /site/*/* + :param path: The path to convert + :return: The mask with all path parameters replaced by a wildcard + """ + return re.sub(r'{[^}]+}', '*', path) + + +class SinglePageRouter: + """The SinglePageRouter allows the development of a Single Page Application (SPA). + + SPAs are web applications which load a single HTML page and dynamically update the content of the page. + This allows faster page switches and a more dynamic user experience.""" + + def __init__(self, + path: str, + outlet_builder: Optional[Callable] = None, + browser_history: bool = True, + parent: Optional["SinglePageRouter"] = None, + on_instance_created: Optional[Callable] = None, + **kwargs) -> None: + """ + :param path: the base path of the single page router. + :param outlet_builder: A layout definition function which defines the layout of the page. The layout builder + must be a generator function and contain a yield statement to separate the layout from the content area. + :param browser_history: Optional flag to enable or disable the browser history management. Default is True. + :param parent: The parent router of this router if this router is a nested router. + :param on_instance_created: Optional callback which is called when a new instance is created. Each browser tab + or window is a new instance. This can be used to initialize the state of the application. + :param kwargs: Additional arguments for the @page decorators + """ + super().__init__() + self.routes: Dict[str, SinglePageRouterEntry] = {} + self.base_path = path + self.included_paths: Set[str] = set() + self.on_instance_created: Optional[Callable] = on_instance_created + self.use_browser_history = browser_history + self._setup_configured = False + self.outlet_builder: Optional[Callable] = outlet_builder + self.parent_router = parent + self.page_kwargs = kwargs + + def setup_pages(self): + @ui.page(self.base_path, **self.page_kwargs) + @ui.page(f'{self.base_path}' + '{_:path}', **self.page_kwargs) # all other pages + async def root_page(): + self.setup_content_area() + + def add_view(self, path: str, builder: Callable, title: Optional[str] = None) -> None: + """Add a new route to the single page router + + :param path: The path of the route, including FastAPI path parameters + :param builder: The builder function (the view to be displayed) + :param title: Optional title of the page + """ + path_mask = SinglePageRouterEntry.create_path_mask(path.rstrip('/')) + self.included_paths.add(path_mask) + self.routes[path] = SinglePageRouterEntry(path, builder, title).verify() + + def add_router_entry(self, entry: SinglePageRouterEntry) -> None: + """Adds a fully configured SinglePageRouterEntry to the router + + :param entry: The SinglePageRouterEntry to add + """ + self.routes[entry.path] = entry.verify() + + def resolve_target(self, target: Union[Callable, str]) -> SinglePageTarget: + """Tries to resolve a target such as a builder function or an URL path w/ route and query parameters. + + :param target: The URL path to open or a builder function + :return: The resolved target. Defines .valid if the target is valid + """ + if isinstance(target, Callable): + for target, entry in self.routes.items(): + if entry.builder == target: + return SinglePageTarget(entry=entry) + else: + parser = SinglePageTarget(target) + return parser.parse_single_page_route(self.routes, target) + + def navigate_to(self, target: Union[Callable, str, SinglePageTarget]) -> bool: + """Navigate to a target + + :param target: The target to navigate to + """ + if not isinstance(target, SinglePageTarget): + target = self.resolve_target(target) + router_frame = context.get_client().single_page_router_frames.get(self.base_path, None) + if not target.valid or router_frame is None: + return False + router_frame.navigate_to(target) + return True + + def setup_content_area(self): + """Setups the content area for the single page router + """ + if self.outlet_builder is None: + raise ValueError('The outlet builder function is not defined. Use the @outlet decorator to define it or' + ' pass it as an argument to the SinglePageRouter constructor.') + frame = self.outlet_builder() + if not isinstance(frame, Generator): + raise ValueError('The outlet builder must be a generator function and contain a yield statement' + ' to separate the layout from the content area.') + next(frame) # insert ui elements before yield + content = RouterFrame(list(self.included_paths), self.use_browser_history) # exchangeable content of the page + content.on_resolve(self.resolve_target) + if self.parent_router is None: + context.get_client().single_page_router_frame = content + try: + next(frame) # if provided insert ui elements after yield + except StopIteration: + pass From 9f685fa63ef1d444aeb68e60e946ad25777f0388 Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Wed, 1 May 2024 23:14:22 +0200 Subject: [PATCH 31/79] Nested outlets are working in general now. Parent SPRs and parent RouteFrames know now about active children and can respect their sub routes accordingly. Open bug: Both the main and the sub routers react to / routes, only one of both should though. --- nicegui/elements/router_frame.js | 50 +++++++++++++++++++------------- nicegui/elements/router_frame.py | 32 +++++++++++++++++--- nicegui/outlet.py | 10 +++++-- nicegui/single_page_router.py | 27 ++++++++++++++--- 4 files changed, 88 insertions(+), 31 deletions(-) diff --git a/nicegui/elements/router_frame.js b/nicegui/elements/router_frame.js index 092fd8d32..23c685c75 100644 --- a/nicegui/elements/router_frame.js +++ b/nicegui/elements/router_frame.js @@ -2,38 +2,48 @@ export default { template: '', mounted() { let router = this; + + function validate_path(path) { + let href = path.split('?')[0].split('#')[0] + // check if the link ends with / and remove it + if (href.endsWith('/')) href = href.slice(0, -1); + // for all valid path masks + for (let mask of router.valid_path_masks) { + // apply filename matching with * and ? wildcards + let regex = new RegExp(mask.replace(/\?/g, '.').replace(/\*/g, '.*')); + if (!regex.test(href)) continue; + return true; + } + return false; + } + + const connectInterval = setInterval(async () => { + if (window.socket.id === undefined) return; + let target = window.location.pathname; + if (window.location.hash !== '') target += window.location.hash; + this.$emit('open', target); + clearInterval(connectInterval); + }, 10); document.addEventListener('click', function (e) { // Check if the clicked element is a link if (e.target.tagName === 'A') { let href = e.target.getAttribute('href'); // Get the link's href value // remove query and anchor - const org_href = href; - href = href.split('?')[0].split('#')[0] - // check if the link ends with / and remove it - if (href.endsWith('/')) href = href.slice(0, -1); - // for all valid path masks - for (let mask of router.valid_path_masks) { - // apply filename matching with * and ? wildcards - let regex = new RegExp(mask.replace(/\?/g, '.').replace(/\*/g, '.*')); - if (!regex.test(href)) continue; + if (validate_path(href)) { e.preventDefault(); // Prevent the default link behavior - if (router.use_browser_history) window.history.pushState({page: org_href}, '', org_href); - router.$emit('open', org_href); - return + if (router.use_browser_history) window.history.pushState({page: href}, '', href); + // TODO BUG: Path is valid for the root router and the sub router for /. + // Only one of both is allowed to push the state, otherwise the browser history is broken. + router.$emit('open', href); } } }); window.addEventListener('popstate', (event) => { let new_page = window.location.pathname; - this.$emit('open', new_page); + if (validate_path(new_page)) { + this.$emit('open', new_page); + } }); - const connectInterval = setInterval(async () => { - if (window.socket.id === undefined) return; - let target = window.location.pathname; - if (window.location.hash !== '') target += window.location.hash; - this.$emit('open', target); - clearInterval(connectInterval); - }, 10); }, props: { valid_path_masks: [], diff --git a/nicegui/elements/router_frame.py b/nicegui/elements/router_frame.py index d63f70855..c26185ac9 100644 --- a/nicegui/elements/router_frame.py +++ b/nicegui/elements/router_frame.py @@ -9,19 +9,27 @@ class RouterFrame(ui.element, component='router_frame.js'): the current page with the content of the new page. It serves as container and overrides the browser's history management to prevent the browser from reloading the whole page.""" - def __init__(self, valid_path_masks: list[str], + def __init__(self, + base_path: str = "", + valid_path_masks: Optional[list[str]] = None, use_browser_history: bool = True, - change_title: bool = False): + change_title: bool = False, + parent_router_frame: "RouterFrame" = None): """ + :param base_path: The base url path of this router frame :param valid_path_masks: A list of valid path masks which shall be allowed to be opened by the router :param use_browser_history: Optional flag to enable or disable the browser history management. Default is True. :param change_title: Optional flag to enable or disable the title change. Default is False. """ super().__init__() - self._props['valid_path_masks'] = valid_path_masks + self._props['valid_path_masks'] = valid_path_masks if valid_path_masks is not None else [] self._props['browser_history'] = use_browser_history + self.child_frames: dict[str, "RouterFrame"] = {} self.use_browser_history = use_browser_history - self.change_title = False + self.change_title = change_title + self.parent_frame = parent_router_frame + if parent_router_frame is not None: + parent_router_frame._register_sub_frame(valid_path_masks[0], self) self._on_resolve: Optional[Callable[[Any], SinglePageTarget]] = None self.on('open', lambda e: self.navigate_to(e.args)) @@ -43,6 +51,11 @@ def navigate_to(self, target: Any, _server_side=False) -> None: indicating whether the navigation should be server side only and not update the browser. :param _server_side: Optional flag which defines if the call is originated on the server side and thus the browser history should be updated. Default is False.""" + # check if sub router is active and might handle the target + for path_mask, frame in self.child_frames.items(): + if path_mask == target or target.startswith(path_mask + "/"): + frame.navigate_to(target, _server_side) + return target_url = self.resolve_target(target) entry = target_url.entry if entry is None: @@ -67,3 +80,14 @@ async def build(content_element, fragment, kwargs) -> None: self.clear() combined_dict = {**target_url.path_args, **target_url.query_args} background_tasks.create(build(self, target_url.fragment, combined_dict)) + + def clear(self) -> None: + self.child_frames.clear() + super().clear() + + def _register_sub_frame(self, path: str, frame: "RouterFrame") -> None: + """Registers a sub frame to the router frame + + :param path: The path of the sub frame + :param frame: The sub frame""" + self.child_frames[path] = frame diff --git a/nicegui/outlet.py b/nicegui/outlet.py index eac849dd3..520effb88 100644 --- a/nicegui/outlet.py +++ b/nicegui/outlet.py @@ -10,6 +10,7 @@ class Outlet(SinglePageRouter): def __init__(self, path: str, browser_history: bool = True, + parent: Optional["SinglePageRouter"] = None, on_instance_created: Optional[Callable] = None, **kwargs) -> None: """ @@ -20,12 +21,14 @@ def __init__(self, :param on_instance_created: Optional callback which is called when a new instance is created. Each browser tab or window is a new instance. This can be used to initialize the state of the application. :param parent: The parent outlet of this outlet. - :param kwargs: Additional arguments for the @page decorators + :param kwargs: Additional arguments fsetup_pages(or the @page decorators """ - super().__init__(path, browser_history=browser_history, on_instance_created=on_instance_created, **kwargs) + super().__init__(path, browser_history=browser_history, on_instance_created=on_instance_created, + parent=parent, **kwargs) def __call__(self, func: Callable[..., Any]) -> Self: """Decorator for the layout builder / "outlet" function""" + def outlet_view(): self.setup_content_area() @@ -70,6 +73,7 @@ def __init__(self, parent_outlet: SinglePageRouter, path: str, title: Optional[s def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]: """Decorator for the view function""" + abs_path = (self.parent_outlet.base_path + self.path).rstrip('/') self.parent_outlet.add_view( - self.parent_outlet.base_path.rstrip('/') + self.path, func, title=self.title) + abs_path, func, title=self.title) return func diff --git a/nicegui/single_page_router.py b/nicegui/single_page_router.py index 14e96fef5..4167cbd36 100644 --- a/nicegui/single_page_router.py +++ b/nicegui/single_page_router.py @@ -80,6 +80,9 @@ def __init__(self, self._setup_configured = False self.outlet_builder: Optional[Callable] = outlet_builder self.parent_router = parent + if self.parent_router is not None: + self.parent_router._register_child_router(self) + self.child_routers: List["SinglePageRouter"] = [] self.page_kwargs = kwargs def setup_pages(self): @@ -117,6 +120,10 @@ def resolve_target(self, target: Union[Callable, str]) -> SinglePageTarget: if entry.builder == target: return SinglePageTarget(entry=entry) else: + for cur_router in self.child_routers: + if target.startswith((cur_router.base_path.rstrip("/")+"/")) or target == cur_router.base_path: + target = cur_router.base_path + break parser = SinglePageTarget(target) return parser.parse_single_page_route(self.routes, target) @@ -127,7 +134,7 @@ def navigate_to(self, target: Union[Callable, str, SinglePageTarget]) -> bool: """ if not isinstance(target, SinglePageTarget): target = self.resolve_target(target) - router_frame = context.get_client().single_page_router_frames.get(self.base_path, None) + router_frame = context.get_client().single_page_router_frame if not target.valid or router_frame is None: return False router_frame.navigate_to(target) @@ -144,11 +151,23 @@ def setup_content_area(self): raise ValueError('The outlet builder must be a generator function and contain a yield statement' ' to separate the layout from the content area.') next(frame) # insert ui elements before yield - content = RouterFrame(list(self.included_paths), self.use_browser_history) # exchangeable content of the page - content.on_resolve(self.resolve_target) - if self.parent_router is None: + parent_router_frame = None + for slot in reversed(context.get_slot_stack()): # we need to inform the parent router frame abot + if isinstance(slot.parent, RouterFrame): # our existence so it can navigate to our pages + parent_router_frame = slot.parent + break + content = RouterFrame(base_path=self.base_path, + valid_path_masks=list(self.included_paths), + use_browser_history=self.use_browser_history, + parent_router_frame=parent_router_frame) # exchangeable content of the page + if parent_router_frame is None: # register root routers to the client context.get_client().single_page_router_frame = content + content.on_resolve(self.resolve_target) try: next(frame) # if provided insert ui elements after yield except StopIteration: pass + + def _register_child_router(self, router: "SinglePageRouter") -> None: + """Registers a child router to the parent router""" + self.child_routers.append(router) From d5a90095d8e535be21891d59aba46c3c7c6bc689 Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Fri, 3 May 2024 07:23:50 +0200 Subject: [PATCH 32/79] router_Frame.js event handlers are now removed upon unmount - this fixed the bug that the state was pushed to the history twice. TODO: If two outlets share the same root path, e.g. / and /spa2, the rf / will still intercept navigation to /spa2. --- examples/outlet/main.py | 12 ++++---- nicegui/elements/router_frame.js | 48 ++++++++++++++++++++++++-------- nicegui/elements/router_frame.py | 4 +++ nicegui/single_page_router.py | 2 +- 4 files changed, 49 insertions(+), 17 deletions(-) diff --git a/examples/outlet/main.py b/examples/outlet/main.py index dabfdd7a4..3fe74a5e2 100644 --- a/examples/outlet/main.py +++ b/examples/outlet/main.py @@ -1,6 +1,12 @@ from nicegui import ui +@ui.outlet('/spa2') # TODO Can not be opened yet, if / is already intercepting links due to valid pattern match +def spa2(): + ui.label('spa2') + yield + + @ui.outlet('/') def spa1(): ui.label("spa1 header") @@ -9,11 +15,6 @@ def spa1(): # SPA outlet routers can be defined side by side -@ui.outlet('/spa2') -def spa2(): - ui.label('spa2') - yield - # views are defined with relative path to their outlet @spa1.view('/') @@ -21,6 +22,7 @@ def spa1_index(): ui.label('content of spa1') ui.link('more', '/more') ui.link('nested', '/nested') + ui.link('Other outlet', '/spa2') @spa1.view('/more') diff --git a/nicegui/elements/router_frame.js b/nicegui/elements/router_frame.js index 23c685c75..00fbe4257 100644 --- a/nicegui/elements/router_frame.js +++ b/nicegui/elements/router_frame.js @@ -1,6 +1,8 @@ export default { template: '', mounted() { + if(this._debug) console.log('Mounted RouterFrame ' + this.base_path); + let router = this; function validate_path(path) { @@ -17,6 +19,17 @@ export default { return false; } + function is_handled_by_child_frame(path) { + // check child frames + for (let frame of router.child_frame_paths) { + if (path.startsWith(frame + '/') || (path === frame)) { + console.log(path + ' handled by child RouterFrame ' + frame + ', skipping...'); + return true; + } + } + return false; + } + const connectInterval = setInterval(async () => { if (window.socket.id === undefined) return; let target = window.location.pathname; @@ -24,29 +37,42 @@ export default { this.$emit('open', target); clearInterval(connectInterval); }, 10); - document.addEventListener('click', function (e) { + + this.clickEventListener = function (e) { // Check if the clicked element is a link if (e.target.tagName === 'A') { let href = e.target.getAttribute('href'); // Get the link's href value // remove query and anchor if (validate_path(href)) { e.preventDefault(); // Prevent the default link behavior - if (router.use_browser_history) window.history.pushState({page: href}, '', href); - // TODO BUG: Path is valid for the root router and the sub router for /. - // Only one of both is allowed to push the state, otherwise the browser history is broken. + if (!is_handled_by_child_frame(href) && router.use_browser_history) { + window.history.pushState({page: href}, '', href); + if (router._debug) console.log('RouterFrame pushing state ' + href + ' by ' + router.base_path); + } router.$emit('open', href); } } - }); - window.addEventListener('popstate', (event) => { - let new_page = window.location.pathname; - if (validate_path(new_page)) { - this.$emit('open', new_page); + }; + this.popstateEventListener = function (event) { + let href = window.location.pathname; + if (validate_path(href) && !is_handled_by_child_frame(href)) { + router.$emit('open', href); } - }); + }; + + document.addEventListener('click', this.clickEventListener); + window.addEventListener('popstate', this.popstateEventListener); + }, + unmounted() { + document.removeEventListener('click', this.clickEventListener); + window.removeEventListener('popstate', this.popstateEventListener); + if (this._debug) console.log('Unmounted RouterFrame ' + this.base_path); }, props: { + base_path: {type: String}, valid_path_masks: [], - use_browser_history: {type: Boolean, default: true} + use_browser_history: {type: Boolean, default: true}, + child_frame_paths: [], + _debug: {type: Boolean, default: true}, }, }; diff --git a/nicegui/elements/router_frame.py b/nicegui/elements/router_frame.py index c26185ac9..3d653027a 100644 --- a/nicegui/elements/router_frame.py +++ b/nicegui/elements/router_frame.py @@ -23,7 +23,9 @@ def __init__(self, """ super().__init__() self._props['valid_path_masks'] = valid_path_masks if valid_path_masks is not None else [] + self._props['base_path'] = base_path self._props['browser_history'] = use_browser_history + self._props['child_frames'] = [] self.child_frames: dict[str, "RouterFrame"] = {} self.use_browser_history = use_browser_history self.change_title = change_title @@ -83,6 +85,7 @@ async def build(content_element, fragment, kwargs) -> None: def clear(self) -> None: self.child_frames.clear() + self._props['child_frame_paths'] = [] super().clear() def _register_sub_frame(self, path: str, frame: "RouterFrame") -> None: @@ -91,3 +94,4 @@ def _register_sub_frame(self, path: str, frame: "RouterFrame") -> None: :param path: The path of the sub frame :param frame: The sub frame""" self.child_frames[path] = frame + self._props['child_frame_paths'] = list(self.child_frames.keys()) diff --git a/nicegui/single_page_router.py b/nicegui/single_page_router.py index 4167cbd36..fc425b8a2 100644 --- a/nicegui/single_page_router.py +++ b/nicegui/single_page_router.py @@ -157,7 +157,7 @@ def setup_content_area(self): parent_router_frame = slot.parent break content = RouterFrame(base_path=self.base_path, - valid_path_masks=list(self.included_paths), + valid_path_masks=sorted(list(self.included_paths)), use_browser_history=self.use_browser_history, parent_router_frame=parent_router_frame) # exchangeable content of the page if parent_router_frame is None: # register root routers to the client From 07d9a3aa54d293d438a4b316ca8c12c5e8a925c8 Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Fri, 3 May 2024 14:26:59 +0200 Subject: [PATCH 33/79] Fixed SinglePageApp and advanced demo to also allow the upgrade of classical, page based apps to SPAs Added OutletViews as possible targets of the link class RoutingFrame can now also explicitly ignore certain paths Bugfix: Title is now changed again on SPA navigation WIP: Recursive URL target resolving upon first load --- examples/outlet/main.py | 11 ++- examples/single_page_router/advanced.py | 34 +++----- examples/single_page_router/main.py | 3 +- nicegui/client.py | 20 +++-- nicegui/elements/link.py | 9 +- nicegui/elements/router_frame.js | 35 +++++--- nicegui/elements/router_frame.py | 54 ++++++++---- nicegui/outlet.py | 38 ++++++-- nicegui/single_page_app.py | 24 ++++-- nicegui/single_page_router.py | 110 +++++++++++++++--------- nicegui/single_page_target.py | 9 +- 11 files changed, 236 insertions(+), 111 deletions(-) diff --git a/examples/outlet/main.py b/examples/outlet/main.py index 3fe74a5e2..e9cf10fb6 100644 --- a/examples/outlet/main.py +++ b/examples/outlet/main.py @@ -1,7 +1,7 @@ from nicegui import ui -@ui.outlet('/spa2') # TODO Can not be opened yet, if / is already intercepting links due to valid pattern match +@ui.outlet('/spa2') def spa2(): ui.label('spa2') yield @@ -21,8 +21,10 @@ def spa1(): def spa1_index(): ui.label('content of spa1') ui.link('more', '/more') - ui.link('nested', '/nested') + ui.link('nested', nested_index) ui.link('Other outlet', '/spa2') + ui.link("Click me", lambda: ui.notification("Hello!")) + ui.button("Click me", on_click=lambda: ui.navigate.to('/nested/sub_page')) @spa1.view('/more') @@ -44,6 +46,11 @@ def nested_index(): ui.link('nested other', '/nested/other') +@nested.view('/sub_page') +def nested_sub(): + ui.label('content of nested sub page') + + @nested.view('/other') def nested_other(): ui.label('other nested') diff --git a/examples/single_page_router/advanced.py b/examples/single_page_router/advanced.py index 0da674fce..aa6505063 100644 --- a/examples/single_page_router/advanced.py +++ b/examples/single_page_router/advanced.py @@ -4,21 +4,10 @@ from nicegui import ui from nicegui.page import page +from nicegui.single_page_app import SinglePageApp from nicegui.single_page_router import SinglePageRouter -def setup_page_layout(content: Callable): - with ui.header(): - ui.label('My Company').classes('text-2xl') - with ui.left_drawer(): - ui.button('Home', on_click=lambda: ui.navigate.to('/')) - ui.button('About', on_click=lambda: ui.navigate.to('/about')) - ui.button('Contact', on_click=lambda: ui.navigate.to('/contact')) - content() # <-- The individual pages will be rendered here - with ui.footer() as footer: - ui.label('Copyright 2023 by My Company') - - @page('/', title='Welcome!') def index(): ui.label('Welcome to the single page router example!').classes('text-2xl') @@ -44,17 +33,20 @@ def about(): @page('/contact', title='Contact') # this page will not be hosted as SPA def contact(): - def custom_content_area(): - ui.label('This is the contact page').classes('text-2xl') + ui.label('This is the contact page').classes('text-2xl') - setup_page_layout(content=custom_content_area) - -class CustomRouter(SinglePageRouter): - def setup_root_page(self, **kwargs): - setup_page_layout(content=self.setup_content_area) +def page_template(): + with ui.header(): + ui.label('My Company').classes('text-2xl') + with ui.left_drawer(): + ui.button('Home', on_click=lambda: ui.navigate.to('/')) + ui.button('About', on_click=lambda: ui.navigate.to('/about')) + ui.button('Contact', on_click=lambda: ui.navigate.to('/contact')) + yield + with ui.footer() as footer: + ui.label('Copyright 2024 by My Company') -router = CustomRouter('/', included=[index, about], excluded=[contact]) -router.setup_page_routes() +spa = SinglePageApp("/", page_template=page_template).setup() ui.run() diff --git a/examples/single_page_router/main.py b/examples/single_page_router/main.py index a42df22bf..349f3a5c9 100644 --- a/examples/single_page_router/main.py +++ b/examples/single_page_router/main.py @@ -2,6 +2,7 @@ from nicegui import ui from nicegui.page import page +from nicegui.single_page_app import SinglePageApp from nicegui.single_page_router import SinglePageRouter @@ -17,5 +18,5 @@ def about(): ui.link('Index', '/') -router = SinglePageRouter('/').reroute_pages() +router = SinglePageApp('/').reroute_pages() ui.run() diff --git a/nicegui/client.py b/nicegui/client.py index 04c3f1acb..5fc79e667 100644 --- a/nicegui/client.py +++ b/nicegui/client.py @@ -97,7 +97,8 @@ def is_auto_index_client(self) -> bool: @property def ip(self) -> Optional[str]: """Return the IP address of the client, or None if the client is not connected.""" - return self.environ['asgi.scope']['client'][0] if self.environ else None # pylint: disable=unsubscriptable-object + return self.environ['asgi.scope']['client'][ + 0] if self.environ else None # pylint: disable=unsubscriptable-object @property def has_socket_connection(self) -> bool: @@ -137,12 +138,13 @@ def build_response(self, request: Request, status_code: int = 200) -> Response: 'request': request, 'version': __version__, 'elements': elements.replace('&', '&') - .replace('<', '<') - .replace('>', '>') - .replace('`', '`') - .replace('$', '$'), + .replace('<', '<') + .replace('>', '>') + .replace('`', '`') + .replace('$', '$'), 'head_html': self.head_html, - 'body_html': '\n' + self.body_html + '\n' + '\n'.join(vue_html), + 'body_html': '\n' + self.body_html + '\n' + '\n'.join( + vue_html), 'vue_scripts': '\n'.join(vue_scripts), 'imports': json.dumps(imports), 'js_imports': '\n'.join(js_imports), @@ -229,7 +231,7 @@ def open(self, target: Union[Callable[..., Any], str], new_tab: bool = False) -> """Open a new page in the client.""" path = target if isinstance(target, str) else self.page_routes[target] for cur_spr in self.single_page_routes.values(): - target = cur_spr.resolve_target(target) + target = cur_spr.resolve_target(path) if target.valid: cur_spr.navigate_to(path) return @@ -259,6 +261,7 @@ def handle_handshake(self) -> None: def handle_disconnect(self) -> None: """Wait for the browser to reconnect; invoke disconnect handlers if it doesn't.""" + async def handle_disconnect() -> None: if self.page.reconnect_timeout is not None: delay = self.page.reconnect_timeout @@ -271,6 +274,7 @@ async def handle_disconnect() -> None: self.safe_invoke(t) if not self.shared: self.delete() + self._disconnect_task = background_tasks.create(handle_disconnect()) def handle_event(self, msg: Dict) -> None: @@ -294,6 +298,7 @@ def safe_invoke(self, func: Union[Callable[..., Any], Awaitable]) -> None: async def func_with_client(): with self: await func + background_tasks.create(func_with_client()) else: with self: @@ -302,6 +307,7 @@ async def func_with_client(): async def result_with_client(): with self: await result + background_tasks.create(result_with_client()) except Exception as e: core.app.handle_exception(e) diff --git a/nicegui/elements/link.py b/nicegui/elements/link.py index 2c559f036..1dd5be596 100644 --- a/nicegui/elements/link.py +++ b/nicegui/elements/link.py @@ -3,6 +3,7 @@ from ..client import Client from ..element import Element from .mixins.text_element import TextElement +from ..outlet import OutletView class Link(TextElement, component='link.js'): @@ -28,8 +29,14 @@ def __init__(self, self._props['href'] = target elif isinstance(target, Element): self._props['href'] = f'#c{target.id}' + elif isinstance(target, OutletView): + self._props['href'] = target.url elif callable(target): - self._props['href'] = Client.page_routes[target] + if target in Client.page_routes: + self._props['href'] = Client.page_routes[target] + else: + self._props['href'] = "#" + self.on('click', lambda: target()) self._props['target'] = '_blank' if new_tab else '_self' self._classes.append('nicegui-link') diff --git a/nicegui/elements/router_frame.js b/nicegui/elements/router_frame.js index 00fbe4257..cf5708c3b 100644 --- a/nicegui/elements/router_frame.js +++ b/nicegui/elements/router_frame.js @@ -1,7 +1,7 @@ export default { template: '', mounted() { - if(this._debug) console.log('Mounted RouterFrame ' + this.base_path); + if (this._debug) console.log('Mounted RouterFrame ' + this.base_path); let router = this; @@ -9,8 +9,15 @@ export default { let href = path.split('?')[0].split('#')[0] // check if the link ends with / and remove it if (href.endsWith('/')) href = href.slice(0, -1); - // for all valid path masks - for (let mask of router.valid_path_masks) { + // for all excluded path masks + for (let mask of router.excluded_path_masks) { + // apply filename matching with * and ? wildcards + let regex = new RegExp(mask.replace(/\?/g, '.').replace(/\*/g, '.*')); + if (!regex.test(href)) continue; + return false; + } + // for all included path masks + for (let mask of router.included_path_masks) { // apply filename matching with * and ? wildcards let regex = new RegExp(mask.replace(/\?/g, '.').replace(/\*/g, '.*')); if (!regex.test(href)) continue; @@ -32,8 +39,7 @@ export default { const connectInterval = setInterval(async () => { if (window.socket.id === undefined) return; - let target = window.location.pathname; - if (window.location.hash !== '') target += window.location.hash; + let target = router.initial_path this.$emit('open', target); clearInterval(connectInterval); }, 10); @@ -42,14 +48,21 @@ export default { // Check if the clicked element is a link if (e.target.tagName === 'A') { let href = e.target.getAttribute('href'); // Get the link's href value + if (href === "#") { + e.preventDefault(); + return; + } // remove query and anchor if (validate_path(href)) { e.preventDefault(); // Prevent the default link behavior - if (!is_handled_by_child_frame(href) && router.use_browser_history) { - window.history.pushState({page: href}, '', href); - if (router._debug) console.log('RouterFrame pushing state ' + href + ' by ' + router.base_path); + if (!is_handled_by_child_frame(href)) { + if (router.use_browser_history) { + window.history.pushState({page: href}, '', href); + if (router._debug) console.log('RouterFrame pushing state ' + href + ' by ' + router.base_path); + } + router.$emit('open', href); + if (router._debug) console.log('Opening ' + href + ' by ' + router.base_path); } - router.$emit('open', href); } } }; @@ -70,7 +83,9 @@ export default { }, props: { base_path: {type: String}, - valid_path_masks: [], + initial_path: {type: String}, + included_path_masks: [], + excluded_path_masks: [], use_browser_history: {type: Boolean, default: true}, child_frame_paths: [], _debug: {type: Boolean, default: true}, diff --git a/nicegui/elements/router_frame.py b/nicegui/elements/router_frame.py index 3d653027a..37ee3b48d 100644 --- a/nicegui/elements/router_frame.py +++ b/nicegui/elements/router_frame.py @@ -11,18 +11,38 @@ class RouterFrame(ui.element, component='router_frame.js'): def __init__(self, base_path: str = "", - valid_path_masks: Optional[list[str]] = None, + included_paths: Optional[list[str]] = None, + excluded_paths: Optional[list[str]] = None, use_browser_history: bool = True, - change_title: bool = False, - parent_router_frame: "RouterFrame" = None): + change_title: bool = True, + parent_router_frame: "RouterFrame" = None, + initial_path: Optional[str] = None): """ :param base_path: The base url path of this router frame - :param valid_path_masks: A list of valid path masks which shall be allowed to be opened by the router + :param included_paths: A list of valid path masks which shall be allowed to be opened by the router + :param excluded_paths: A list of path masks which shall be excluded from the router :param use_browser_history: Optional flag to enable or disable the browser history management. Default is True. - :param change_title: Optional flag to enable or disable the title change. Default is False. + :param change_title: Optional flag to enable or disable the title change. Default is True. + :param initial_path: The initial path of the router frame """ super().__init__() - self._props['valid_path_masks'] = valid_path_masks if valid_path_masks is not None else [] + included_masks = [] + excluded_masks = [] + if included_paths is not None: + for path in included_paths: + cleaned = path.rstrip('/') + included_masks.append(cleaned) + included_masks.append(cleaned + "/*") + if excluded_paths is not None: + for path in excluded_paths: + cleaned = path.rstrip('/') + excluded_masks.append(cleaned) + excluded_masks.append(cleaned + "/*") + if initial_path is None: + initial_path = base_path + self._props['initial_path'] = initial_path + self._props['included_path_masks'] = included_masks + self._props['excluded_path_masks'] = excluded_masks self._props['base_path'] = base_path self._props['browser_history'] = use_browser_history self._props['child_frames'] = [] @@ -31,9 +51,10 @@ def __init__(self, self.change_title = change_title self.parent_frame = parent_router_frame if parent_router_frame is not None: - parent_router_frame._register_sub_frame(valid_path_masks[0], self) + parent_router_frame._register_sub_frame(included_paths[0], self) self._on_resolve: Optional[Callable[[Any], SinglePageTarget]] = None - self.on('open', lambda e: self.navigate_to(e.args)) + print("Router frame with base path", base_path) + self.on('open', lambda e: self.navigate_to(e.args, server_side=False)) def on_resolve(self, on_resolve: Callable[[Any], SinglePageTarget]) -> Self: """Set the on_resolve function which is used to resolve the target to a SinglePageUrl @@ -43,20 +64,22 @@ def on_resolve(self, on_resolve: Callable[[Any], SinglePageTarget]) -> Self: return self def resolve_target(self, target: Any) -> SinglePageTarget: + if isinstance(target, SinglePageTarget): + return target if self._on_resolve is not None: return self._on_resolve(target) raise NotImplementedError - def navigate_to(self, target: Any, _server_side=False) -> None: + def navigate_to(self, target: [SinglePageTarget, str], server_side=True) -> None: """Open a new page in the browser by exchanging the content of the router frame - :param target: the target route or builder function. If a list is passed, the second element is a boolean - indicating whether the navigation should be server side only and not update the browser. - :param _server_side: Optional flag which defines if the call is originated on the server side and thus + :param target: The target page or url. + :param server_side: Optional flag which defines if the call is originated on the server side and thus the browser history should be updated. Default is False.""" # check if sub router is active and might handle the target + print("Navigation to target", target) for path_mask, frame in self.child_frames.items(): if path_mask == target or target.startswith(path_mask + "/"): - frame.navigate_to(target, _server_side) + frame.navigate_to(target, server_side) return target_url = self.resolve_target(target) entry = target_url.entry @@ -68,8 +91,9 @@ def navigate_to(self, target: Any, _server_side=False) -> None: if self.change_title: title = entry.title if entry.title is not None else core.app.config.title ui.page_title(title) - if _server_side and self.use_browser_history: - ui.run_javascript(f'window.history.pushState({{page: "{target}"}}, "", "{target}");') + if server_side and self.use_browser_history: + ui.run_javascript( + f'window.history.pushState({{page: "{target_url.original_path}"}}, "", "{target_url.original_path}");') async def build(content_element, fragment, kwargs) -> None: with content_element: diff --git a/nicegui/outlet.py b/nicegui/outlet.py index 520effb88..0ee63e7bd 100644 --- a/nicegui/outlet.py +++ b/nicegui/outlet.py @@ -1,5 +1,6 @@ -from typing import Callable, Any, Self, Optional +from typing import Callable, Any, Self, Optional, Generator +from nicegui.client import Client from nicegui.single_page_router import SinglePageRouter @@ -9,28 +10,50 @@ class Outlet(SinglePageRouter): def __init__(self, path: str, + outlet_builder: Optional[Callable] = None, browser_history: bool = True, parent: Optional["SinglePageRouter"] = None, on_instance_created: Optional[Callable] = None, **kwargs) -> None: """ :param path: the base path of the single page router. + :param outlet_builder: A layout definition function which defines the layout of the page. The layout builder + must be a generator function and contain a yield statement to separate the layout from the content area. :param layout_builder: A layout builder function which defines the layout of the page. The layout builder must be a generator function and contain a yield statement to separate the layout from the content area. :param browser_history: Optional flag to enable or disable the browser history management. Default is True. :param on_instance_created: Optional callback which is called when a new instance is created. Each browser tab or window is a new instance. This can be used to initialize the state of the application. :param parent: The parent outlet of this outlet. - :param kwargs: Additional arguments fsetup_pages(or the @page decorators + :param kwargs: Additional arguments """ super().__init__(path, browser_history=browser_history, on_instance_created=on_instance_created, parent=parent, **kwargs) + self.outlet_builder: Optional[Callable] = outlet_builder + if parent is None: + Client.single_page_routes[path] = self + + def build_page_template(self): + """Setups the content area for the single page router""" + if self.outlet_builder is None: + raise ValueError('The outlet builder function is not defined. Use the @outlet decorator to define it or' + ' pass it as an argument to the SinglePageRouter constructor.') + frame = self.outlet_builder() + if not isinstance(frame, Generator): + raise ValueError('The outlet builder must be a generator function and contain a yield statement' + ' to separate the layout from the content area.') + next(frame) # insert ui elements before yield + yield + try: + next(frame) # if provided insert ui elements after yield + except StopIteration: + pass def __call__(self, func: Callable[..., Any]) -> Self: """Decorator for the layout builder / "outlet" function""" def outlet_view(): - self.setup_content_area() + self.build_page() self.outlet_builder = func if self.parent_router is None: @@ -71,9 +94,14 @@ def __init__(self, parent_outlet: SinglePageRouter, path: str, title: Optional[s self.title = title self.parent_outlet = parent_outlet + @property + def url(self) -> str: + """The absolute URL of the view""" + return (self.parent_outlet.base_path.rstrip("/") + "/" + self.path.lstrip("/")).rstrip('/') + def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]: """Decorator for the view function""" - abs_path = (self.parent_outlet.base_path + self.path).rstrip('/') + abs_path = self.url self.parent_outlet.add_view( abs_path, func, title=self.title) - return func + return self diff --git a/nicegui/single_page_app.py b/nicegui/single_page_app.py index f5467c7de..2fe2e45a6 100644 --- a/nicegui/single_page_app.py +++ b/nicegui/single_page_app.py @@ -1,5 +1,5 @@ import fnmatch -from typing import Union, List, Callable, re +from typing import Union, List, Callable, re, Generator from fastapi.routing import APIRoute @@ -10,10 +10,13 @@ class SinglePageApp: def __init__(self, - target: SinglePageRouter, + target: Union[SinglePageRouter, str], + page_template: Callable[[], Generator] = None, included: Union[List[Union[Callable, str]], str, Callable] = '/*', excluded: Union[List[Union[Callable, str]], str, Callable] = '') -> None: """ + :param target: The SinglePageRouter which shall be used as the main router for the single page application. + Alternatively, you can pass the root path of the pages which shall be redirected to the single page router. :param included: Optional list of masks and callables of paths to include. Default is "/*" which includes all. If you do not want to include all relative paths, you can specify a list of masks or callables to refine the included paths. If a callable is passed, it must be decorated with a page. @@ -21,11 +24,18 @@ def __init__(self, Explicitly included paths (without wildcards) and Callables are always included, even if they match an exclusion mask. """ - self.spr = target + if isinstance(target, str): + target = SinglePageRouter(target, page_template=page_template) + self.single_page_router = target self.included: List[Union[Callable, str]] = [included] if not isinstance(included, list) else included self.excluded: List[Union[Callable, str]] = [excluded] if not isinstance(excluded, list) else excluded self.system_excluded = ['/docs', '/redoc', '/openapi.json', '_*'] + def setup(self): + """Registers the SinglePageRouter with the @page decorator to handle all routes defined by the router""" + self.reroute_pages() + self.single_page_router.setup_pages(force=True) + def reroute_pages(self): """Registers the SinglePageRouter with the @page decorator to handle all routes defined by the router""" self._update_masks() @@ -63,18 +73,18 @@ def _find_api_routes(self) -> None: single page router""" from nicegui.page import Client page_routes = set() - base_path = self.spr.base_path + base_path = self.single_page_router.base_path for key, route in Client.page_routes.items(): if route.startswith(base_path) and not self.is_excluded(route): page_routes.add(route) - Client.single_page_routes[route] = self + Client.single_page_routes[route] = self.single_page_router title = None if key in Client.page_configs: title = Client.page_configs[key].title route = route.rstrip('/') - self.spr.add_router_entry(SinglePageRouterEntry(route, builder=key, title=title)) + self.single_page_router.add_router_entry(SinglePageRouterEntry(route, builder=key, title=title)) route_mask = SinglePageRouterEntry.create_path_mask(route) - self.spr.included_paths.add(route_mask) + self.single_page_router.included_paths.add(route_mask) for route in core.app.routes.copy(): if isinstance(route, APIRoute): if route.path in page_routes: diff --git a/nicegui/single_page_router.py b/nicegui/single_page_router.py index fc425b8a2..406ac9557 100644 --- a/nicegui/single_page_router.py +++ b/nicegui/single_page_router.py @@ -1,11 +1,9 @@ -import fnmatch import re -from functools import wraps -from typing import Callable, Dict, Union, Optional, Tuple, Self, List, Set, Any, Generator +from typing import Callable, Dict, Union, Optional, Self, List, Set, Generator -from fastapi.routing import APIRoute - -from nicegui import background_tasks, helpers, ui, core, context +from nicegui import ui +from nicegui.context import context +from nicegui.client import Client from nicegui.elements.router_frame import RouterFrame from nicegui.single_page_target import SinglePageTarget @@ -56,17 +54,17 @@ class SinglePageRouter: def __init__(self, path: str, - outlet_builder: Optional[Callable] = None, browser_history: bool = True, parent: Optional["SinglePageRouter"] = None, + page_template: Optional[Callable[[], Generator]] = None, on_instance_created: Optional[Callable] = None, **kwargs) -> None: """ :param path: the base path of the single page router. - :param outlet_builder: A layout definition function which defines the layout of the page. The layout builder - must be a generator function and contain a yield statement to separate the layout from the content area. :param browser_history: Optional flag to enable or disable the browser history management. Default is True. :param parent: The parent router of this router if this router is a nested router. + :param page_template: Optional page template generator function which defines the layout of the page. It + needs to yield a value to separate the layout from the content area. :param on_instance_created: Optional callback which is called when a new instance is created. Each browser tab or window is a new instance. This can be used to initialize the state of the application. :param kwargs: Additional arguments for the @page decorators @@ -75,21 +73,34 @@ def __init__(self, self.routes: Dict[str, SinglePageRouterEntry] = {} self.base_path = path self.included_paths: Set[str] = set() + self.excluded_paths: Set[str] = set() self.on_instance_created: Optional[Callable] = on_instance_created self.use_browser_history = browser_history + self.page_template = page_template self._setup_configured = False - self.outlet_builder: Optional[Callable] = outlet_builder self.parent_router = parent if self.parent_router is not None: self.parent_router._register_child_router(self) self.child_routers: List["SinglePageRouter"] = [] self.page_kwargs = kwargs - def setup_pages(self): + def setup_pages(self, force=False) -> Self: + for key, route in Client.page_routes.items(): + if route.startswith(self.base_path.rstrip("/") + "/"): # '/' after '/sub_router' - do not intercept links + self.excluded_paths.add(route) + if force: + continue + if self.base_path.startswith(route.rstrip("/") + "/"): # '/sub_router' after '/' - forbidden + raise ValueError(f'Another router with path "{route.rstrip("/")}/*" is already registered which ' + f'includes this router\'s base path "{self.base_path}". You can declare the nested ' + f'router first to prioritize it and avoid this issue.') + @ui.page(self.base_path, **self.page_kwargs) @ui.page(f'{self.base_path}' + '{_:path}', **self.page_kwargs) # all other pages async def root_page(): - self.setup_content_area() + self.build_page() + + return self def add_view(self, path: str, builder: Callable, title: Optional[str] = None) -> None: """Add a new route to the single page router @@ -118,55 +129,74 @@ def resolve_target(self, target: Union[Callable, str]) -> SinglePageTarget: if isinstance(target, Callable): for target, entry in self.routes.items(): if entry.builder == target: - return SinglePageTarget(entry=entry) + return SinglePageTarget(entry=entry, router=self) else: + resolved = None for cur_router in self.child_routers: - if target.startswith((cur_router.base_path.rstrip("/")+"/")) or target == cur_router.base_path: - target = cur_router.base_path - break - parser = SinglePageTarget(target) - return parser.parse_single_page_route(self.routes, target) - - def navigate_to(self, target: Union[Callable, str, SinglePageTarget]) -> bool: + if target.startswith((cur_router.base_path.rstrip("/") + "/")) or target == cur_router.base_path: + resolved = cur_router.resolve_target(target) + if resolved.valid: + target = cur_router.base_path + break + parser = SinglePageTarget(target, router=self) + result = parser.parse_single_page_route(self.routes, target) + if resolved is not None: + result.original_path = resolved.original_path + return result + + def navigate_to(self, target: Union[Callable, str, SinglePageTarget], server_side=True) -> bool: """Navigate to a target :param target: The target to navigate to + :param server_side: Optional flag which defines if the call is originated on the server side """ + org_target = target if not isinstance(target, SinglePageTarget): target = self.resolve_target(target) - router_frame = context.get_client().single_page_router_frame + router_frame = context.client.single_page_router_frame if not target.valid or router_frame is None: return False - router_frame.navigate_to(target) + router_frame.navigate_to(org_target, server_side=server_side) return True - def setup_content_area(self): - """Setups the content area for the single page router - """ - if self.outlet_builder is None: - raise ValueError('The outlet builder function is not defined. Use the @outlet decorator to define it or' - ' pass it as an argument to the SinglePageRouter constructor.') - frame = self.outlet_builder() - if not isinstance(frame, Generator): - raise ValueError('The outlet builder must be a generator function and contain a yield statement' - ' to separate the layout from the content area.') - next(frame) # insert ui elements before yield + def build_page_template(self) -> Generator: + """Builds the page template. Needs to call insert_content_area at some point which defines the exchangeable + content of the page. + + :return: The page template generator function""" + if self.build_page_template is not None: + return self.page_template() + else: + raise ValueError('No page template generator function provided.') + + def build_page(self): + template = self.build_page_template() + if not isinstance(template, Generator): + raise ValueError('The page template method must yield a value to separate the layout from the content ' + 'area.') + next(template) + self.insert_content_area() + try: + next(template) + except StopIteration: + pass + + def insert_content_area(self): + """Setups the content area""" parent_router_frame = None - for slot in reversed(context.get_slot_stack()): # we need to inform the parent router frame abot + for slot in reversed(context.slot_stack): # we need to inform the parent router frame about if isinstance(slot.parent, RouterFrame): # our existence so it can navigate to our pages parent_router_frame = slot.parent break content = RouterFrame(base_path=self.base_path, - valid_path_masks=sorted(list(self.included_paths)), + included_paths=sorted(list(self.included_paths)), + excluded_paths=sorted(list(self.excluded_paths)), use_browser_history=self.use_browser_history, parent_router_frame=parent_router_frame) # exchangeable content of the page + # TODO Correction of initial base path when opening the page programmatically if parent_router_frame is None: # register root routers to the client - context.get_client().single_page_router_frame = content + context.client.single_page_router_frame = content content.on_resolve(self.resolve_target) - try: - next(frame) # if provided insert ui elements after yield - except StopIteration: - pass def _register_child_router(self, router: "SinglePageRouter") -> None: """Registers a child router to the parent router""" diff --git a/nicegui/single_page_target.py b/nicegui/single_page_target.py index 9e2bb5e60..066ad5e2e 100644 --- a/nicegui/single_page_target.py +++ b/nicegui/single_page_target.py @@ -3,7 +3,7 @@ from typing import Dict, Optional, TYPE_CHECKING, Self if TYPE_CHECKING: - from nicegui.single_page_router import SinglePageRouterEntry + from nicegui.single_page_router import SinglePageRouterEntry, SinglePageRouter class SinglePageTarget: @@ -11,13 +11,17 @@ class SinglePageTarget: SinglePageRouterEntry and convert the parameters to the expected types of the builder function""" def __init__(self, path: Optional[str] = None, entry: Optional['SinglePageRouterEntry'] = None, - fragment: Optional[str] = None, query_string: Optional[str] = None): + fragment: Optional[str] = None, query_string: Optional[str] = None, + router: Optional['SinglePageRouter'] = None): """ :param path: The path of the URL :param entry: Predefined entry, e.g. targeting a Callable :param fragment: The fragment of the URL + :param query_string: The query string of the URL + :param router: The SinglePageRouter by which the URL was resolved """ self.routes = {} # all valid routes + self.original_path = path self.path = path # url path w/o query self.fragment = fragment self.query_string = query_string @@ -25,6 +29,7 @@ def __init__(self, path: Optional[str] = None, entry: Optional['SinglePageRouter self.query_args = urllib.parse.parse_qs(self.query_string) self.entry = entry self.valid = entry is not None + self.router = router def parse_single_page_route(self, routes: Dict[str, 'SinglePageRouterEntry'], path: str) -> Self: """ From ab5dad5d70efa6e6c1215bfd717259bb6dad3b91 Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Fri, 3 May 2024 17:30:45 +0200 Subject: [PATCH 34/79] Fixed support for fragment targets Made it possible to directly jump into nested pages Added the possibility to pass the FastAPI request data into the builder function --- nicegui/elements/router_frame.js | 11 +++++++++-- nicegui/elements/router_frame.py | 28 +++++++++++++++++----------- nicegui/page.py | 12 ++++++++++++ nicegui/single_page_router.py | 25 +++++++++++++++++-------- 4 files changed, 55 insertions(+), 21 deletions(-) diff --git a/nicegui/elements/router_frame.js b/nicegui/elements/router_frame.js index cf5708c3b..19414e7ea 100644 --- a/nicegui/elements/router_frame.js +++ b/nicegui/elements/router_frame.js @@ -39,8 +39,9 @@ export default { const connectInterval = setInterval(async () => { if (window.socket.id === undefined) return; - let target = router.initial_path + let target = router.target_url this.$emit('open', target); + if (router._debug) console.log('Initial opening ' + target + ' by ' + router.base_path); clearInterval(connectInterval); }, 10); @@ -67,9 +68,15 @@ export default { } }; this.popstateEventListener = function (event) { + let state = event.state; let href = window.location.pathname; + event.preventDefault(); + if (window.location.hash) { + return; + } if (validate_path(href) && !is_handled_by_child_frame(href)) { router.$emit('open', href); + if (router._debug) console.log('Pop opening ' + href + ' by ' + router.base_path); } }; @@ -83,7 +90,7 @@ export default { }, props: { base_path: {type: String}, - initial_path: {type: String}, + target_url: {type: String}, included_path_masks: [], excluded_path_masks: [], use_browser_history: {type: Boolean, default: true}, diff --git a/nicegui/elements/router_frame.py b/nicegui/elements/router_frame.py index 37ee3b48d..132cb4718 100644 --- a/nicegui/elements/router_frame.py +++ b/nicegui/elements/router_frame.py @@ -1,3 +1,4 @@ +import inspect from typing import Union, Callable, Tuple, Any, Optional, Self from nicegui import ui, helpers, context, background_tasks, core @@ -16,14 +17,14 @@ def __init__(self, use_browser_history: bool = True, change_title: bool = True, parent_router_frame: "RouterFrame" = None, - initial_path: Optional[str] = None): + target_url: Optional[str] = None): """ :param base_path: The base url path of this router frame :param included_paths: A list of valid path masks which shall be allowed to be opened by the router :param excluded_paths: A list of path masks which shall be excluded from the router :param use_browser_history: Optional flag to enable or disable the browser history management. Default is True. :param change_title: Optional flag to enable or disable the title change. Default is True. - :param initial_path: The initial path of the router frame + :param target_url: The initial url of the router frame """ super().__init__() included_masks = [] @@ -38,9 +39,12 @@ def __init__(self, cleaned = path.rstrip('/') excluded_masks.append(cleaned) excluded_masks.append(cleaned + "/*") - if initial_path is None: - initial_path = base_path - self._props['initial_path'] = initial_path + if target_url is None: + if parent_router_frame is not None and parent_router_frame._props['target_url'] is not None: + target_url = parent_router_frame._props['target_url'] + else: + target_url = base_path + self._props['target_url'] = target_url self._props['included_path_masks'] = included_masks self._props['excluded_path_masks'] = excluded_masks self._props['base_path'] = base_path @@ -53,7 +57,6 @@ def __init__(self, if parent_router_frame is not None: parent_router_frame._register_sub_frame(included_paths[0], self) self._on_resolve: Optional[Callable[[Any], SinglePageTarget]] = None - print("Router frame with base path", base_path) self.on('open', lambda e: self.navigate_to(e.args, server_side=False)) def on_resolve(self, on_resolve: Callable[[Any], SinglePageTarget]) -> Self: @@ -76,7 +79,6 @@ def navigate_to(self, target: [SinglePageTarget, str], server_side=True) -> None :param server_side: Optional flag which defines if the call is originated on the server side and thus the browser history should be updated. Default is False.""" # check if sub router is active and might handle the target - print("Navigation to target", target) for path_mask, frame in self.child_frames.items(): if path_mask == target or target.startswith(path_mask + "/"): frame.navigate_to(target, server_side) @@ -88,6 +90,8 @@ def navigate_to(self, target: [SinglePageTarget, str], server_side=True) -> None ui.run_javascript(f'window.location.href = "#{target_url.fragment}";') # go to fragment return return + + self._props["target_url"] = target_url.original_path if self.change_title: title = entry.title if entry.title is not None else core.app.config.title ui.page_title(title) @@ -95,17 +99,19 @@ def navigate_to(self, target: [SinglePageTarget, str], server_side=True) -> None ui.run_javascript( f'window.history.pushState({{page: "{target_url.original_path}"}}, "", "{target_url.original_path}");') - async def build(content_element, fragment, kwargs) -> None: + async def build(content_element, target_url, kwargs) -> None: with content_element: + args = inspect.signature(entry.builder).parameters.keys() + kwargs = {k: v for k, v in kwargs.items() if k in args} result = entry.builder(**kwargs) if helpers.is_coroutine_function(entry.builder): await result - if fragment is not None: - await ui.run_javascript(f'window.location.href = "#{fragment}";') + if target_url.fragment is not None: + await ui.run_javascript(f'window.location.href = "#{target_url.fragment}";') self.clear() combined_dict = {**target_url.path_args, **target_url.query_args} - background_tasks.create(build(self, target_url.fragment, combined_dict)) + background_tasks.create(build(self, target_url, combined_dict)) def clear(self) -> None: self.child_frames.clear() diff --git a/nicegui/page.py b/nicegui/page.py index 93cdf8025..82ea495ff 100644 --- a/nicegui/page.py +++ b/nicegui/page.py @@ -105,11 +105,23 @@ async def decorated(*dec_args, **dec_kwargs) -> Response: with Client(self) as client: if any(p.name == 'client' for p in inspect.signature(func).parameters.values()): dec_kwargs['client'] = client + if any(p.name == 'request_data' for p in inspect.signature(func).parameters.values()): + url = request.url + dec_kwargs['request_data'] = {"client": + {"host": request.client.host, + "port": request.client.port}, + "cookies": request.cookies, + "url": + {"path": url.path, + "query": url.query, + "username": url.username, "password": url.password, + "fragment": url.fragment}} result = func(*dec_args, **dec_kwargs) if helpers.is_coroutine_function(func): async def wait_for_result() -> None: with client: return await result + task = background_tasks.create(wait_for_result()) deadline = time.time() + self.response_timeout while task and not client.is_waiting_for_connection and not task.done(): diff --git a/nicegui/single_page_router.py b/nicegui/single_page_router.py index 406ac9557..cf557a3f3 100644 --- a/nicegui/single_page_router.py +++ b/nicegui/single_page_router.py @@ -86,7 +86,8 @@ def __init__(self, def setup_pages(self, force=False) -> Self: for key, route in Client.page_routes.items(): - if route.startswith(self.base_path.rstrip("/") + "/"): # '/' after '/sub_router' - do not intercept links + if route.startswith( + self.base_path.rstrip("/") + "/") and route.rstrip("/") not in self.included_paths: self.excluded_paths.add(route) if force: continue @@ -97,8 +98,14 @@ def setup_pages(self, force=False) -> Self: @ui.page(self.base_path, **self.page_kwargs) @ui.page(f'{self.base_path}' + '{_:path}', **self.page_kwargs) # all other pages - async def root_page(): - self.build_page() + async def root_page(request_data=None): + initial_url = None + if request_data is not None: + initial_url = request_data["url"]["path"] + query = request_data["url"].get("query", {}) + if query: + initial_url += "?" + query + self.build_page(initial_url=initial_url) return self @@ -132,8 +139,9 @@ def resolve_target(self, target: Union[Callable, str]) -> SinglePageTarget: return SinglePageTarget(entry=entry, router=self) else: resolved = None + path = target.split("#")[0].split("?")[0] for cur_router in self.child_routers: - if target.startswith((cur_router.base_path.rstrip("/") + "/")) or target == cur_router.base_path: + if path.startswith((cur_router.base_path.rstrip("/") + "/")) or path == cur_router.base_path: resolved = cur_router.resolve_target(target) if resolved.valid: target = cur_router.base_path @@ -169,19 +177,19 @@ def build_page_template(self) -> Generator: else: raise ValueError('No page template generator function provided.') - def build_page(self): + def build_page(self, initial_url: Optional[str] = None): template = self.build_page_template() if not isinstance(template, Generator): raise ValueError('The page template method must yield a value to separate the layout from the content ' 'area.') next(template) - self.insert_content_area() + self.insert_content_area(initial_url) try: next(template) except StopIteration: pass - def insert_content_area(self): + def insert_content_area(self, initial_url: Optional[str] = None): """Setups the content area""" parent_router_frame = None for slot in reversed(context.slot_stack): # we need to inform the parent router frame about @@ -192,7 +200,8 @@ def insert_content_area(self): included_paths=sorted(list(self.included_paths)), excluded_paths=sorted(list(self.excluded_paths)), use_browser_history=self.use_browser_history, - parent_router_frame=parent_router_frame) # exchangeable content of the page + parent_router_frame=parent_router_frame, + target_url=initial_url) # TODO Correction of initial base path when opening the page programmatically if parent_router_frame is None: # register root routers to the client context.client.single_page_router_frame = content From 4f54ce114201ab65b4849264d18ef3e262881272 Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Sat, 4 May 2024 08:50:52 +0200 Subject: [PATCH 35/79] Added Page Not Found fallback if an invalid SPA route is taken The initial page build is now synchronous with the integration of the RouterFrame to prevent ugly progressive page updates on the initial site visit. --- nicegui/elements/router_frame.js | 21 +++--- nicegui/elements/router_frame.py | 109 ++++++++++++++++++++++--------- nicegui/single_page_app.py | 2 +- nicegui/single_page_router.py | 10 +-- 4 files changed, 95 insertions(+), 47 deletions(-) diff --git a/nicegui/elements/router_frame.js b/nicegui/elements/router_frame.js index 19414e7ea..6f2404491 100644 --- a/nicegui/elements/router_frame.js +++ b/nicegui/elements/router_frame.js @@ -5,10 +5,14 @@ export default { let router = this; - function validate_path(path) { + function normalize_path(path) { let href = path.split('?')[0].split('#')[0] - // check if the link ends with / and remove it if (href.endsWith('/')) href = href.slice(0, -1); + return href; + } + + function validate_path(path) { + let href = normalize_path(path); // for all excluded path masks for (let mask of router.excluded_path_masks) { // apply filename matching with * and ? wildcards @@ -28,23 +32,16 @@ export default { function is_handled_by_child_frame(path) { // check child frames + let href = normalize_path(path); for (let frame of router.child_frame_paths) { - if (path.startsWith(frame + '/') || (path === frame)) { - console.log(path + ' handled by child RouterFrame ' + frame + ', skipping...'); + if (path.startsWith(frame + '/') || (href === frame)) { + console.log(href + ' handled by child RouterFrame ' + frame + ', skipping...'); return true; } } return false; } - const connectInterval = setInterval(async () => { - if (window.socket.id === undefined) return; - let target = router.target_url - this.$emit('open', target); - if (router._debug) console.log('Initial opening ' + target + ' by ' + router.base_path); - clearInterval(connectInterval); - }, 10); - this.clickEventListener = function (e) { // Check if the clicked element is a link if (e.target.tagName === 'A') { diff --git a/nicegui/elements/router_frame.py b/nicegui/elements/router_frame.py index 132cb4718..0f689911d 100644 --- a/nicegui/elements/router_frame.py +++ b/nicegui/elements/router_frame.py @@ -1,9 +1,13 @@ import inspect -from typing import Union, Callable, Tuple, Any, Optional, Self +import typing +from typing import Callable, Any, Optional, Self -from nicegui import ui, helpers, context, background_tasks, core +from nicegui import ui, helpers, background_tasks, core from nicegui.single_page_target import SinglePageTarget +if typing.TYPE_CHECKING: + from nicegui.single_page_router import SinglePageRouter + class RouterFrame(ui.element, component='router_frame.js'): """The RouterFrame is a special element which is used by the SinglePageRouter to exchange the content of @@ -11,7 +15,7 @@ class RouterFrame(ui.element, component='router_frame.js'): management to prevent the browser from reloading the whole page.""" def __init__(self, - base_path: str = "", + router: "SinglePageRouter", included_paths: Optional[list[str]] = None, excluded_paths: Optional[list[str]] = None, use_browser_history: bool = True, @@ -19,7 +23,7 @@ def __init__(self, parent_router_frame: "RouterFrame" = None, target_url: Optional[str] = None): """ - :param base_path: The base url path of this router frame + :param router: The SinglePageRouter which controls this router frame :param included_paths: A list of valid path masks which shall be allowed to be opened by the router :param excluded_paths: A list of path masks which shall be excluded from the router :param use_browser_history: Optional flag to enable or disable the browser history management. Default is True. @@ -27,27 +31,28 @@ def __init__(self, :param target_url: The initial url of the router frame """ super().__init__() + self.router = router included_masks = [] excluded_masks = [] if included_paths is not None: for path in included_paths: cleaned = path.rstrip('/') included_masks.append(cleaned) - included_masks.append(cleaned + "/*") + included_masks.append(cleaned + '/*') if excluded_paths is not None: for path in excluded_paths: cleaned = path.rstrip('/') excluded_masks.append(cleaned) - excluded_masks.append(cleaned + "/*") + excluded_masks.append(cleaned + '/*') if target_url is None: if parent_router_frame is not None and parent_router_frame._props['target_url'] is not None: target_url = parent_router_frame._props['target_url'] else: - target_url = base_path + target_url = self.router.base_path self._props['target_url'] = target_url self._props['included_path_masks'] = included_masks self._props['excluded_path_masks'] = excluded_masks - self._props['base_path'] = base_path + self._props['base_path'] = self.router.base_path self._props['browser_history'] = use_browser_history self._props['child_frames'] = [] self.child_frames: dict[str, "RouterFrame"] = {} @@ -57,31 +62,43 @@ def __init__(self, if parent_router_frame is not None: parent_router_frame._register_sub_frame(included_paths[0], self) self._on_resolve: Optional[Callable[[Any], SinglePageTarget]] = None - self.on('open', lambda e: self.navigate_to(e.args, server_side=False)) + self.on('open', lambda e: self.navigate_to(e.args, _server_side=False)) + + @property + def target_url(self) -> str: + """The current target url of the router frame""" + return self._props['target_url'] def on_resolve(self, on_resolve: Callable[[Any], SinglePageTarget]) -> Self: """Set the on_resolve function which is used to resolve the target to a SinglePageUrl + :param on_resolve: The on_resolve function which receives a target object such as an URL or Callable and returns a SinglePageUrl object.""" self._on_resolve = on_resolve return self def resolve_target(self, target: Any) -> SinglePageTarget: + """Resolves a URL or SPA target to a SinglePageUrl which contains details about the builder function to + be called and the arguments to pass to the builder function. + + :param target: The target object such as a URL or Callable + :return: The resolved SinglePageUrl object""" if isinstance(target, SinglePageTarget): return target if self._on_resolve is not None: return self._on_resolve(target) raise NotImplementedError - def navigate_to(self, target: [SinglePageTarget, str], server_side=True) -> None: + def navigate_to(self, target: [SinglePageTarget, str], _server_side=True, _sync=False) -> None: """Open a new page in the browser by exchanging the content of the router frame + :param target: The target page or url. - :param server_side: Optional flag which defines if the call is originated on the server side and thus + :param _server_side: Optional flag which defines if the call is originated on the server side and thus the browser history should be updated. Default is False.""" # check if sub router is active and might handle the target for path_mask, frame in self.child_frames.items(): - if path_mask == target or target.startswith(path_mask + "/"): - frame.navigate_to(target, server_side) + if path_mask == target or target.startswith(path_mask + '/'): + frame.navigate_to(target, _server_side) return target_url = self.resolve_target(target) entry = target_url.entry @@ -89,31 +106,56 @@ def navigate_to(self, target: [SinglePageTarget, str], server_side=True) -> None if target_url.fragment is not None: ui.run_javascript(f'window.location.href = "#{target_url.fragment}";') # go to fragment return - return - - self._props["target_url"] = target_url.original_path - if self.change_title: - title = entry.title if entry.title is not None else core.app.config.title - ui.page_title(title) - if server_side and self.use_browser_history: + title = "Page not found" + builder = self._page_not_found + else: + builder = entry.builder + title = entry.title + if _server_side and self.use_browser_history: ui.run_javascript( f'window.history.pushState({{page: "{target_url.original_path}"}}, "", "{target_url.original_path}");') + self._props['target_url'] = target_url.original_path + builder_kwargs = {**target_url.path_args, **target_url.query_args} + if "url_path" not in builder_kwargs: + builder_kwargs["url_path"] = target_url.original_path + target_fragment = target_url.fragment + self.update_content(builder, builder_kwargs, title, target_fragment, _sync=_sync) - async def build(content_element, target_url, kwargs) -> None: - with content_element: - args = inspect.signature(entry.builder).parameters.keys() - kwargs = {k: v for k, v in kwargs.items() if k in args} - result = entry.builder(**kwargs) - if helpers.is_coroutine_function(entry.builder): + def update_content(self, builder, builder_kwargs, title, target_fragment, _sync=False): + """Update the content of the router frame + + :param builder: The builder function which builds the content of the page + :param builder_kwargs: The keyword arguments to pass to the builder function + :param title: The title of the page + :param target_fragment: The fragment to navigate to after the content has been loaded""" + if self.change_title: + ui.page_title(title if title is not None else core.app.config.title) + + def exec_builder(): + """Execute the builder function with the given keyword arguments""" + args = inspect.signature(builder).parameters.keys() + kwargs = {k: v for k, v in builder_kwargs.items() if k in args} + return builder(**kwargs) + + async def build() -> None: + with self: + result = exec_builder() + if helpers.is_coroutine_function(builder): await result - if target_url.fragment is not None: - await ui.run_javascript(f'window.location.href = "#{target_url.fragment}";') + if target_fragment is not None: + await ui.run_javascript(f'window.location.href = "#{target_fragment}";') self.clear() - combined_dict = {**target_url.path_args, **target_url.query_args} - background_tasks.create(build(self, target_url, combined_dict)) + if _sync: + with self: + exec_builder() + if target_fragment is not None: + ui.run_javascript(f'window.location.href = "#{target_fragment}";') + else: + background_tasks.create(build()) def clear(self) -> None: + """Clear the content of the router frame and removes all references to sub frames""" self.child_frames.clear() self._props['child_frame_paths'] = [] super().clear() @@ -125,3 +167,10 @@ def _register_sub_frame(self, path: str, frame: "RouterFrame") -> None: :param frame: The sub frame""" self.child_frames[path] = frame self._props['child_frame_paths'] = list(self.child_frames.keys()) + + def _page_not_found(self, url_path: str): + """ + Default builder function for the page not found error page + """ + ui.label(f'Oops! Page Not Found 🚧').classes('text-3xl') + ui.label(f'Sorry, the page you are looking for could not be found. 😔') diff --git a/nicegui/single_page_app.py b/nicegui/single_page_app.py index 2fe2e45a6..9a0461704 100644 --- a/nicegui/single_page_app.py +++ b/nicegui/single_page_app.py @@ -1,5 +1,5 @@ import fnmatch -from typing import Union, List, Callable, re, Generator +from typing import Union, List, Callable, Generator from fastapi.routing import APIRoute diff --git a/nicegui/single_page_router.py b/nicegui/single_page_router.py index cf557a3f3..fdebb7a39 100644 --- a/nicegui/single_page_router.py +++ b/nicegui/single_page_router.py @@ -164,7 +164,7 @@ def navigate_to(self, target: Union[Callable, str, SinglePageTarget], server_sid router_frame = context.client.single_page_router_frame if not target.valid or router_frame is None: return False - router_frame.navigate_to(org_target, server_side=server_side) + router_frame.navigate_to(org_target, _server_side=server_side) return True def build_page_template(self) -> Generator: @@ -196,16 +196,18 @@ def insert_content_area(self, initial_url: Optional[str] = None): if isinstance(slot.parent, RouterFrame): # our existence so it can navigate to our pages parent_router_frame = slot.parent break - content = RouterFrame(base_path=self.base_path, + content = RouterFrame(router=self, included_paths=sorted(list(self.included_paths)), excluded_paths=sorted(list(self.excluded_paths)), use_browser_history=self.use_browser_history, parent_router_frame=parent_router_frame, target_url=initial_url) - # TODO Correction of initial base path when opening the page programmatically + content.on_resolve(self.resolve_target) if parent_router_frame is None: # register root routers to the client context.client.single_page_router_frame = content - content.on_resolve(self.resolve_target) + initial_url = content.target_url + if initial_url is not None: + content.navigate_to(initial_url, _server_side=False, _sync=True) def _register_child_router(self, router: "SinglePageRouter") -> None: """Registers a child router to the parent router""" From b4dd1863a8d868316261177851fbbf0ea387ab01 Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Fri, 10 May 2024 12:14:27 +0200 Subject: [PATCH 36/79] Parameterizable outlets * Created enhanced outlet demo * Made it possible to yield variables from outlets and to pass them to nested views and outlets * Fixed bug which caused complex hyperlinks affecting a whole div not being catched by the SPA link handler * It is now possible to make use of path variables in outlets and views * Type correctness for variables passed via path is now only enforced if the user defined a type at all --- examples/outlet/main.py | 170 +++++++++++++++--------------- examples/outlet/services.json | 173 +++++++++++++++++++++++++++++++ nicegui/elements/router_frame.js | 35 ++++--- nicegui/elements/router_frame.py | 51 +++++++-- nicegui/outlet.py | 25 +++-- nicegui/page_layout.py | 3 +- nicegui/single_page_router.py | 27 +++-- nicegui/single_page_target.py | 2 + 8 files changed, 363 insertions(+), 123 deletions(-) create mode 100644 examples/outlet/services.json diff --git a/examples/outlet/main.py b/examples/outlet/main.py index e9cf10fb6..13cde82ce 100644 --- a/examples/outlet/main.py +++ b/examples/outlet/main.py @@ -1,95 +1,99 @@ -from nicegui import ui - - -@ui.outlet('/spa2') -def spa2(): - ui.label('spa2') - yield - - -@ui.outlet('/') -def spa1(): - ui.label("spa1 header") - yield - ui.label("spa1 footer") - - -# SPA outlet routers can be defined side by side - -# views are defined with relative path to their outlet -@spa1.view('/') -def spa1_index(): - ui.label('content of spa1') - ui.link('more', '/more') - ui.link('nested', nested_index) - ui.link('Other outlet', '/spa2') - ui.link("Click me", lambda: ui.notification("Hello!")) - ui.button("Click me", on_click=lambda: ui.navigate.to('/nested/sub_page')) +import json +import os.path +from nicegui import ui -@spa1.view('/more') -def spa1_more(): - ui.label('more content of spa1') - ui.link('main', '/') - ui.link('nested', '/nested') +# load service definition file of imaginary cloud services +services = json.load(open(os.path.dirname(__file__) + '/services.json')) -@spa1.outlet('/nested') -def nested(): - ui.label('nested outlet') +@ui.outlet('/other_app') +def other_app(): + ui.label('Other app header').classes('text-h2') + ui.html('
') yield + ui.html('
') + ui.label('Other app footer') -@nested.view('/') -def nested_index(): - ui.label('content of nested') - ui.link('nested other', '/nested/other') - +@other_app.view('/') +def other_app_index(): + ui.label('Welcome to the index page of the other application') -@nested.view('/sub_page') -def nested_sub(): - ui.label('content of nested sub page') - - -@nested.view('/other') -def nested_other(): - ui.label('other nested') - ui.link('nested index', '/nested') - - -''' -# the view is a function upon the decorated function of the outlet (same technique as "refreshable.refresh") -@spa2.view('/') -def spa2_index(): - ui.label('content of spa2') - ui.link('more', '/more') - - -@spa2.view('/more') -def spa2_more(): - ui.label('more content of spa2') - ui.link('main', '/') - - -# spa outlets can also be nested (by calling outlet function upon the decorated function of the outlet) -@spa2.outlet('/nested') -def nested(): - ui.label('nested outled') - yield - - -@nested.view('/') -def nested_index(): - ui.label('content of nested') - ui.link('main', '/') +@ui.outlet('/') +def main_router(): + with ui.header(): + with ui.link("", '/') as lnk: + ui.html('Nice' + 'CLOUD').classes('text-h3') + lnk.style('text-decoration: none; color: inherit;') + with ui.footer(): + ui.label("Copyright 2024 by My Company") + + with ui.element().classes('p-8'): + yield + + +@main_router.view('/') +def main_app_index(): + ui.label("Welcome to NiceCLOUD!").classes('text-3xl') + ui.html("
") + with ui.grid(columns=3) as grid: + grid.classes('gap-8') + for key, info in services.items(): + link = f'/services/{key}' + with ui.element(): + with ui.row(): + ui.label(info['emoji']).classes('text-2xl') + with ui.link("", link) as lnk: + ui.label(info['title']).classes('text-2xl') + lnk.style('text-decoration: none; color: inherit;') + ui.label(info['description']) + + ui.html("

") + # add a link to the other app + ui.link("Other App", '/other_app') + + +@main_router.outlet('/services/{service_name}') +def services_router(service_name: str): + service_config = services[service_name] + with ui.left_drawer(bordered=True) as menu_drawer: + menu_drawer.classes('bg-primary') + title = service_config['title'] + ui.label(title).classes('text-2xl text-white') + # add menu items + menu_items = service_config['sub_services'] + for key, info in menu_items.items(): + title = info['title'] + with ui.button(title) as btn: + btn.classes('text-white bg-secondary').on_click(lambda sn=service_name, k=key: + ui.navigate.to(f'/services/{sn}/{k}')) + + yield {'service': services[service_name]} + + +@services_router.outlet('/{sub_service_name}') +def sub_service(service, sub_service_name: str): + service_title = service['title'] + sub_service = service["sub_services"][sub_service_name] + ui.label(f'{service_title} > {sub_service["title"]}').classes('text-h4') + ui.html("
") + yield {'sub_service': sub_service} + + +@sub_service.view('/') +def sub_service_index(sub_service): + ui.label(sub_service["description"]) + + +@services_router.view('/') +def show_index(service_name, **kwargs): + service_info = services[service_name] + ui.label(service_info["title"]).classes("text-h2") + ui.html("
") + ui.label(service_info["description"]) -# normal pages are still available -@ui.page('/') -def index(): - ui.link('spa1', '/spa1') - ui.link('spa2', '/spa2') - ui.link('nested', '/spa2/nested') -''' ui.run(show=False) diff --git a/examples/outlet/services.json b/examples/outlet/services.json new file mode 100644 index 000000000..79a5fae70 --- /dev/null +++ b/examples/outlet/services.json @@ -0,0 +1,173 @@ +{ + "compute": { + "title": "Compute", + "emoji": "🖥️", + "description": "Powerful virtual machines", + "sub_services": { + "vm_instances": { + "title": "VM Instances", + "description": "Scalable virtual machines available in seconds." + }, + "kubernetes_services": { + "title": "Kubernetes Services", + "description": "Managed Kubernetes clusters to orchestrate containers." + }, + "serverless_functions": { + "title": "Serverless Functions", + "description": "Run code without provisioning servers." + } + } + }, + "storage": { + "title": "Storage", + "emoji": "🗄️", + "description": "Secure and scalable object storage", + "sub_services": { + "blob_storage": { + "title": "Blob Storage", + "description": "Massively scalable object storage for unstructured data." + }, + "file_storage": { + "title": "File Storage", + "description": "Managed file shares for cloud or on-premises deployments." + }, + "queue_storage": { + "title": "Queue Storage", + "description": "A messaging store for reliable messaging between application components." + } + } + }, + "networking": { + "title": "Networking", + "emoji": "🔗", + "description": "Private networks and connectivity", + "sub_services": { + "vnetwork": { + "title": "Virtual Network", + "description": "Provision private networks, optionally connect to on-premises datacenters." + }, + "load_balancer": { + "title": "Load Balancer", + "description": "Deliver high availability and network performance to your applications." + }, + "vpn_gateway": { + "title": "VPN Gateway", + "description": "Establish secure, cross-premises connectivity." + } + } + }, + "database": { + "title": "Database", + "emoji": "💾", + "description": "Managed SQL and NoSQL databases", + "sub_services": { + "sql_database": { + "title": "SQL Database", + "description": "Managed, intelligent SQL in the cloud." + }, + "cosmos_db": { + "title": "Cosmos DB", + "description": "Globally distributed, multi-model database service." + }, + "database_migration": { + "title": "Database Migration", + "description": "Simplify on-premises database migration to the cloud." + } + } + }, + "ai": { + "title": "AI & Machine Learning", + "emoji": "🤖", + "description": "AI services to build machine learning models", + "sub_services": { + "machine_learning": { + "title": "Machine Learning", + "description": "Build, train, and deploy models from the cloud to the edge." + }, + "cognitive_services": { + "title": "Cognitive Services", + "description": "Infuse your apps, websites and bots with intelligent algorithms." + }, + "bot_services": { + "title": "Bot Services", + "description": "Develop intelligent, enterprise-grade bots." + } + } + }, + "analytics": { + "title": "Analytics", + "emoji": "📊", + "description": "Big data processing and analytics", + "sub_services": { + "hdinsight": { + "title": "HDInsight", + "description": "Provision cloud Hadoop, Spark, R Server, HBase, and Storm clusters." + }, + "data_lake_analytics": { + "title": "Data Lake Analytics", + "description": "On-demand analytics job service using a simple developer experience." + }, + "stream_analytics": { + "title": "Stream Analytics", + "description": "Real-time data stream processing from millions of IoT devices." + } + } + }, + "devops": { + "title": "DevOps", + "emoji": "🛠️", + "description": "Tools and services for CI/CD", + "sub_services": { + "devops_pipelines": { + "title": "DevOps Pipelines", + "description": "CI/CD that works with any language, platform, and cloud." + }, + "artifacts": { + "title": "Artifacts", + "description": "Create, host, and share packages with your team." + }, + "source_repositories": { + "title": "Source Repositories", + "description": "Host private Git repositories." + } + } + }, + "security": { + "title": "Security & Identity", + "emoji": "🔒", + "description": "Identity management and threat detection", + "sub_services": { + "identity_services": { + "title": "Identity Services", + "description": "Protect and manage identities as a primary security perimeter." + }, + "threat_protection": { + "title": "Threat Protection", + "description": "Advanced threat detection and protection across cloud services." + }, + "information_protection": { + "title": "Information Protection", + "description": "Secure sensitive data everywhere it resides." + } + } + }, + "iot": { + "title": "Internet of Things", + "emoji": "🌐", + "description": "Internet of Things services", + "sub_services": { + "iot_hub": { + "title": "IoT Hub", + "description": "Connect, monitor and manage billions of IoT assets." + }, + "iot_edge": { + "title": "IoT Edge", + "description": "Deploy cloud intelligence directly on IoT devices to operate in offline mode." + }, + "digital_twins": { + "title": "Digital Twins", + "description": "Create digital representations of connected environments." + } + } + } +} diff --git a/nicegui/elements/router_frame.js b/nicegui/elements/router_frame.js index 6f2404491..baadded6c 100644 --- a/nicegui/elements/router_frame.js +++ b/nicegui/elements/router_frame.js @@ -44,23 +44,26 @@ export default { this.clickEventListener = function (e) { // Check if the clicked element is a link - if (e.target.tagName === 'A') { - let href = e.target.getAttribute('href'); // Get the link's href value - if (href === "#") { - e.preventDefault(); - return; - } - // remove query and anchor - if (validate_path(href)) { - e.preventDefault(); // Prevent the default link behavior - if (!is_handled_by_child_frame(href)) { - if (router.use_browser_history) { - window.history.pushState({page: href}, '', href); - if (router._debug) console.log('RouterFrame pushing state ' + href + ' by ' + router.base_path); - } - router.$emit('open', href); - if (router._debug) console.log('Opening ' + href + ' by ' + router.base_path); + // Use closest to find the nearest parent tag + let link = e.target.closest('a'); + + // If there's no tag, or the tag has no href attribute, do nothing + if (!link || !link.hasAttribute('href')) return; + let href = link.getAttribute('href'); + if (href === "#") { + e.preventDefault(); + return; + } + // remove query and anchor + if (validate_path(href)) { + e.preventDefault(); // Prevent the default link behavior + if (!is_handled_by_child_frame(href)) { + if (router.use_browser_history) { + window.history.pushState({page: href}, '', href); + if (router._debug) console.log('RouterFrame pushing state ' + href + ' by ' + router.base_path); } + router.$emit('open', href); + if (router._debug) console.log('Opening ' + href + ' by ' + router.base_path); } } }; diff --git a/nicegui/elements/router_frame.py b/nicegui/elements/router_frame.py index 0f689911d..38e918b1b 100644 --- a/nicegui/elements/router_frame.py +++ b/nicegui/elements/router_frame.py @@ -1,11 +1,11 @@ import inspect -import typing -from typing import Callable, Any, Optional, Self +from typing import Callable, Any, Optional, Self, Dict, TYPE_CHECKING from nicegui import ui, helpers, background_tasks, core +from nicegui.context import context from nicegui.single_page_target import SinglePageTarget -if typing.TYPE_CHECKING: +if TYPE_CHECKING: from nicegui.single_page_router import SinglePageRouter @@ -55,6 +55,7 @@ def __init__(self, self._props['base_path'] = self.router.base_path self._props['browser_history'] = use_browser_history self._props['child_frames'] = [] + self.user_data = {} self.child_frames: dict[str, "RouterFrame"] = {} self.use_browser_history = use_browser_history self.change_title = change_title @@ -119,6 +120,8 @@ def navigate_to(self, target: [SinglePageTarget, str], _server_side=True, _sync= if "url_path" not in builder_kwargs: builder_kwargs["url_path"] = target_url.original_path target_fragment = target_url.fragment + recursive_user_data = RouterFrame.get_user_data() | self.user_data + builder_kwargs.update(recursive_user_data) self.update_content(builder, builder_kwargs, title, target_fragment, _sync=_sync) def update_content(self, builder, builder_kwargs, title, target_fragment, _sync=False): @@ -133,9 +136,7 @@ def update_content(self, builder, builder_kwargs, title, target_fragment, _sync= def exec_builder(): """Execute the builder function with the given keyword arguments""" - args = inspect.signature(builder).parameters.keys() - kwargs = {k: v for k, v in builder_kwargs.items() if k in args} - return builder(**kwargs) + self.run_safe(builder, **builder_kwargs) async def build() -> None: with self: @@ -160,6 +161,31 @@ def clear(self) -> None: self._props['child_frame_paths'] = [] super().clear() + def update_user_data(self, new_data: dict) -> None: + """Update the user data of the router frame + + :param new_data: The new user data to set""" + self.user_data.update(new_data) + + @staticmethod + def get_user_data() -> Dict: + """Returns a combined dictionary of all user data of the parent router frames""" + result_dict = {} + for slot in context.slot_stack: + if isinstance(slot.parent, RouterFrame): + result_dict.update(slot.parent.user_data) + return result_dict + + @staticmethod + def get_current_frame() -> Optional["RouterFrame"]: + """Get the current router frame from the context stack + + :return: The current router frame or None if no router frame is in the context stack""" + for slot in reversed(context.slot_stack): # we need to inform the parent router frame about + if isinstance(slot.parent, RouterFrame): # our existence so it can navigate to our pages + return slot.parent + return None + def _register_sub_frame(self, path: str, frame: "RouterFrame") -> None: """Registers a sub frame to the router frame @@ -174,3 +200,16 @@ def _page_not_found(self, url_path: str): """ ui.label(f'Oops! Page Not Found 🚧').classes('text-3xl') ui.label(f'Sorry, the page you are looking for could not be found. 😔') + + @staticmethod + def run_safe(builder, **kwargs) -> Any: + """Run a builder function but only pass the keyword arguments which are expected by the builder function + + :param builder: The builder function + :param kwargs: The keyword arguments to pass to the builder function + """ + args = inspect.signature(builder).parameters.keys() + has_kwargs = any([param.kind == inspect.Parameter.VAR_KEYWORD for param in + inspect.signature(builder).parameters.values()]) + filtered = {k: v for k, v in kwargs.items() if k in args} if not has_kwargs else kwargs + return builder(**filtered) diff --git a/nicegui/outlet.py b/nicegui/outlet.py index 0ee63e7bd..eb3be95ae 100644 --- a/nicegui/outlet.py +++ b/nicegui/outlet.py @@ -1,7 +1,9 @@ +import inspect from typing import Callable, Any, Self, Optional, Generator from nicegui.client import Client from nicegui.single_page_router import SinglePageRouter +from nicegui.elements.router_frame import RouterFrame class Outlet(SinglePageRouter): @@ -33,27 +35,36 @@ def __init__(self, if parent is None: Client.single_page_routes[path] = self - def build_page_template(self): + def build_page_template(self, **kwargs): """Setups the content area for the single page router""" if self.outlet_builder is None: raise ValueError('The outlet builder function is not defined. Use the @outlet decorator to define it or' ' pass it as an argument to the SinglePageRouter constructor.') - frame = self.outlet_builder() + frame = RouterFrame.run_safe(self.outlet_builder, **kwargs) if not isinstance(frame, Generator): raise ValueError('The outlet builder must be a generator function and contain a yield statement' ' to separate the layout from the content area.') - next(frame) # insert ui elements before yield - yield + properties = {} + + def add_properties(result): + if isinstance(result, dict): + properties.update(result) + + router_frame = RouterFrame.get_current_frame() + add_properties(next(frame)) # insert ui elements before yield + if router_frame is not None: + router_frame.update_user_data(properties) + yield properties try: - next(frame) # if provided insert ui elements after yield + add_properties(next(frame)) # if provided insert ui elements after yield except StopIteration: pass def __call__(self, func: Callable[..., Any]) -> Self: """Decorator for the layout builder / "outlet" function""" - def outlet_view(): - self.build_page() + def outlet_view(**kwargs): + self.build_page(**kwargs) self.outlet_builder = func if self.parent_router is None: diff --git a/nicegui/page_layout.py b/nicegui/page_layout.py index 87afd09e3..e5ff56f54 100644 --- a/nicegui/page_layout.py +++ b/nicegui/page_layout.py @@ -3,6 +3,7 @@ from .context import context from .element import Element from .elements.mixins.value_element import ValueElement +from .elements.router_frame import RouterFrame from .functions.html import add_body_html from .logging import log @@ -272,7 +273,7 @@ def __init__(self, position: PageStickyPositions = 'bottom-right', x_offset: flo def _check_current_slot(element: Element) -> None: parent = context.slot.parent - if parent != parent.client.content: + if parent != parent.client.content and not isinstance(parent, RouterFrame): log.warning(f'Found top level layout element "{element.__class__.__name__}" inside element "{parent.__class__.__name__}". ' 'Top level layout elements should not be nested but must be direct children of the page content. ' 'This will be raising an exception in NiceGUI 1.5') # DEPRECATED diff --git a/nicegui/single_page_router.py b/nicegui/single_page_router.py index fdebb7a39..5884b98f8 100644 --- a/nicegui/single_page_router.py +++ b/nicegui/single_page_router.py @@ -1,4 +1,5 @@ import re +from fnmatch import fnmatch from typing import Callable, Dict, Union, Optional, Self, List, Set, Generator from nicegui import ui @@ -141,10 +142,15 @@ def resolve_target(self, target: Union[Callable, str]) -> SinglePageTarget: resolved = None path = target.split("#")[0].split("?")[0] for cur_router in self.child_routers: - if path.startswith((cur_router.base_path.rstrip("/") + "/")) or path == cur_router.base_path: + # replace {} placeholders with * to match the fnmatch pattern + mask = SinglePageRouterEntry.create_path_mask(cur_router.base_path.rstrip("/") + "/*") + if fnmatch(path, mask) or path == cur_router.base_path: resolved = cur_router.resolve_target(target) if resolved.valid: target = cur_router.base_path + if "*" in mask: + # isolate the real path elements and update target accordingly + target = "/".join(path.split("/")[:len(cur_router.base_path.split("/"))]) break parser = SinglePageTarget(target, router=self) result = parser.parse_single_page_route(self.routes, target) @@ -177,25 +183,26 @@ def build_page_template(self) -> Generator: else: raise ValueError('No page template generator function provided.') - def build_page(self, initial_url: Optional[str] = None): - template = self.build_page_template() + def build_page(self, initial_url: Optional[str] = None, **kwargs): + template = RouterFrame.run_safe(self.build_page_template, **kwargs) if not isinstance(template, Generator): raise ValueError('The page template method must yield a value to separate the layout from the content ' 'area.') - next(template) + properties = {} + new_properties = next(template) + if isinstance(new_properties, dict): + properties.update(new_properties) self.insert_content_area(initial_url) try: - next(template) + new_properties = next(template) + if isinstance(new_properties, dict): + properties.update(new_properties) except StopIteration: pass def insert_content_area(self, initial_url: Optional[str] = None): """Setups the content area""" - parent_router_frame = None - for slot in reversed(context.slot_stack): # we need to inform the parent router frame about - if isinstance(slot.parent, RouterFrame): # our existence so it can navigate to our pages - parent_router_frame = slot.parent - break + parent_router_frame = RouterFrame.get_current_frame() content = RouterFrame(router=self, included_paths=sorted(list(self.included_paths)), excluded_paths=sorted(list(self.excluded_paths)), diff --git a/nicegui/single_page_target.py b/nicegui/single_page_target.py index 066ad5e2e..cee90926a 100644 --- a/nicegui/single_page_target.py +++ b/nicegui/single_page_target.py @@ -79,6 +79,8 @@ def convert_arguments(self): for func_param_name, func_param_info in sig.parameters.items(): for params in [self.path_args, self.query_args]: if func_param_name in params: + if func_param_info.annotation is inspect.Parameter.empty: + continue try: params[func_param_name] = func_param_info.annotation( params[func_param_name]) # Convert parameter to the expected type From 33193fe28d286ff1d94fb49e0c807c9e7123a54f Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Fri, 10 May 2024 15:19:29 +0200 Subject: [PATCH 37/79] Enhanced Outlet demo Added the possibility to access the current RouterFrame from every view and nested outlet builder method Passed url_path to outlet builder methods --- examples/outlet/main.py | 138 +++++++----- examples/outlet/services.json | 349 +++++++++++++++++-------------- nicegui/elements/router_frame.py | 11 +- nicegui/outlet.py | 16 ++ nicegui/single_page_router.py | 17 +- 5 files changed, 313 insertions(+), 218 deletions(-) diff --git a/examples/outlet/main.py b/examples/outlet/main.py index 13cde82ce..7506c5058 100644 --- a/examples/outlet/main.py +++ b/examples/outlet/main.py @@ -1,14 +1,39 @@ -import json -import os.path +import os +from typing import Dict + +from pydantic import BaseModel, Field from nicegui import ui +from nicegui.page_layout import LeftDrawer + + +# --- Load service data for fake cloud provider portal + +class SubServiceDefinition(BaseModel): + title: str = Field(..., description="The title of the sub-service", examples=["Digital Twin"]) + emoji: str = Field(..., description="An emoji representing the sub-service", examples=["🤖"]) + description: str = Field(..., description="A short description of the sub-service", + examples=["Manage your digital twin"]) + + +class ServiceDefinition(BaseModel): + title: str = Field(..., description="The title of the cloud service", examples=["Virtual Machines"]) + emoji: str = Field(..., description="An emoji representing the cloud service", examples=["💻"]) + description: str + sub_services: Dict[str, SubServiceDefinition] + + +class ServiceDefinitions(BaseModel): + services: Dict[str, ServiceDefinition] + + +services = ServiceDefinitions.parse_file(os.path.join(os.path.dirname(__file__), 'services.json')).services -# load service definition file of imaginary cloud services -services = json.load(open(os.path.dirname(__file__) + '/services.json')) +# --- Other app --- -@ui.outlet('/other_app') -def other_app(): +@ui.outlet('/other_app') # Needs to be defined before the main outlet / to avoid conflicts +def other_app_router(): ui.label('Other app header').classes('text-h2') ui.html('
') yield @@ -16,84 +41,103 @@ def other_app(): ui.label('Other app footer') -@other_app.view('/') +@other_app_router.view('/') def other_app_index(): ui.label('Welcome to the index page of the other application') -@ui.outlet('/') -def main_router(): +# --- Main app --- + +@ui.outlet('/') # main app outlet +def main_router(url_path: str): with ui.header(): with ui.link("", '/') as lnk: ui.html('Nice' 'CLOUD').classes('text-h3') lnk.style('text-decoration: none; color: inherit;') + menu_visible = "/services/" in url_path # the service will make the menu visible anyway - suppresses animation + with ui.left_drawer(bordered=True, value=menu_visible, fixed=True) as menu_drawer: + menu_drawer.classes('bg-primary') with ui.footer(): ui.label("Copyright 2024 by My Company") with ui.element().classes('p-8'): - yield + yield {'menu_drawer': menu_drawer} @main_router.view('/') -def main_app_index(): +def main_app_index(menu_drawer: LeftDrawer): # main app index page + menu_drawer.clear() # clear drawer + menu_drawer.hide() # hide drawer ui.label("Welcome to NiceCLOUD!").classes('text-3xl') ui.html("
") with ui.grid(columns=3) as grid: - grid.classes('gap-8') + grid.classes('gap-16') for key, info in services.items(): link = f'/services/{key}' with ui.element(): with ui.row(): - ui.label(info['emoji']).classes('text-2xl') with ui.link("", link) as lnk: - ui.label(info['title']).classes('text-2xl') + ui.label(info.emoji).classes('text-2xl') + ui.label(info.title).classes('text-2xl') lnk.style('text-decoration: none; color: inherit;') - ui.label(info['description']) + ui.label(info.description) ui.html("

") # add a link to the other app - ui.link("Other App", '/other_app') - - -@main_router.outlet('/services/{service_name}') -def services_router(service_name: str): - service_config = services[service_name] - with ui.left_drawer(bordered=True) as menu_drawer: - menu_drawer.classes('bg-primary') - title = service_config['title'] - ui.label(title).classes('text-2xl text-white') + ui.link("Click here to go to other Single Page App", '/other_app') + # add page url + ui.html(f'

Current page url: {main_router.current_url}') + + +@main_router.outlet('/services/{service_name}') # service outlet +def services_router(service_name: str, menu_drawer: LeftDrawer): + service: ServiceDefinition = services[service_name] + menu_drawer.clear() # clear drawer + with ((((menu_drawer)))): + menu_drawer.show() + with ui.row() as row: + ui.label(service.emoji) + ui.label(service.title) + row.classes('text-h5 text-white').style('text-shadow: 2px 2px #00000070;') + ui.html("
") # add menu items - menu_items = service_config['sub_services'] + menu_items = service.sub_services for key, info in menu_items.items(): - title = info['title'] - with ui.button(title) as btn: - btn.classes('text-white bg-secondary').on_click(lambda sn=service_name, k=key: - ui.navigate.to(f'/services/{sn}/{k}')) - - yield {'service': services[service_name]} + with ui.row() as service_element: + ui.label(info.emoji) + ui.label(info.title) + service_element.classes('text-white text-h6 bg-gray cursor-pointer') + service_element.style('text-shadow: 2px 2px #00000070;') + service_element.on("click", lambda sn=service_name, k=key: ui.navigate.to(f'/services/{sn}/{k}')) + yield {'service': service} + + +@services_router.view('/') # service index page +def show_index(service: ServiceDefinition): + with ui.row() as row: + ui.label(service.emoji).classes('text-h4 vertical-middle') + with ui.column(): + ui.label(service.title).classes('text-h2') + ui.label(service.description) + ui.html("
") -@services_router.outlet('/{sub_service_name}') -def sub_service(service, sub_service_name: str): - service_title = service['title'] - sub_service = service["sub_services"][sub_service_name] - ui.label(f'{service_title} > {sub_service["title"]}').classes('text-h4') +@services_router.outlet('/{sub_service_name}') # sub service outlet +def sub_service_router(service: ServiceDefinition, sub_service_name: str): + sub_service: SubServiceDefinition = service.sub_services[sub_service_name] + ui.label(f'{service.title} > {sub_service.title}').classes('text-h4') ui.html("
") yield {'sub_service': sub_service} + # add page url -@sub_service.view('/') -def sub_service_index(sub_service): - ui.label(sub_service["description"]) - - -@services_router.view('/') -def show_index(service_name, **kwargs): - service_info = services[service_name] - ui.label(service_info["title"]).classes("text-h2") +@sub_service_router.view('/') # sub service index page +def sub_service_index(sub_service: SubServiceDefinition): + ui.label(sub_service.emoji).classes('text-h1') ui.html("
") - ui.label(service_info["description"]) + ui.label(sub_service.description) + ui.html(f'

Current page url: {main_router.current_url}') ui.run(show=False) diff --git a/examples/outlet/services.json b/examples/outlet/services.json index 79a5fae70..4dc764dc0 100644 --- a/examples/outlet/services.json +++ b/examples/outlet/services.json @@ -1,172 +1,201 @@ { - "compute": { - "title": "Compute", - "emoji": "🖥️", - "description": "Powerful virtual machines", - "sub_services": { - "vm_instances": { - "title": "VM Instances", - "description": "Scalable virtual machines available in seconds." - }, - "kubernetes_services": { - "title": "Kubernetes Services", - "description": "Managed Kubernetes clusters to orchestrate containers." - }, - "serverless_functions": { - "title": "Serverless Functions", - "description": "Run code without provisioning servers." + "services": { + "compute": { + "title": "Compute", + "emoji": "🖥", + "description": "Powerful virtual machines", + "sub_services": { + "vm_instances": { + "title": "VM Instances", + "emoji": "💻", + "description": "Scalable virtual machines available in seconds." + }, + "kubernetes_services": { + "title": "Kubernetes Services", + "emoji": "🐳", + "description": "Managed Kubernetes clusters to orchestrate containers." + }, + "serverless_functions": { + "title": "Serverless Functions", + "emoji": "⚡", + "description": "Run code without provisioning servers." + } } - } - }, - "storage": { - "title": "Storage", - "emoji": "🗄️", - "description": "Secure and scalable object storage", - "sub_services": { - "blob_storage": { - "title": "Blob Storage", - "description": "Massively scalable object storage for unstructured data." - }, - "file_storage": { - "title": "File Storage", - "description": "Managed file shares for cloud or on-premises deployments." - }, - "queue_storage": { - "title": "Queue Storage", - "description": "A messaging store for reliable messaging between application components." + }, + "storage": { + "title": "Storage", + "emoji": "🗄️", + "description": "Secure and scalable object storage", + "sub_services": { + "blob_storage": { + "title": "Blob Storage", + "emoji": "📦", + "description": "Massively scalable object storage for unstructured data." + }, + "file_storage": { + "title": "File Storage", + "emoji": "📂", + "description": "Managed file shares for cloud or on-premises deployments." + }, + "queue_storage": { + "title": "Queue Storage", + "emoji": "📬", + "description": "A messaging store for reliable messaging between application components." + } } - } - }, - "networking": { - "title": "Networking", - "emoji": "🔗", - "description": "Private networks and connectivity", - "sub_services": { - "vnetwork": { - "title": "Virtual Network", - "description": "Provision private networks, optionally connect to on-premises datacenters." - }, - "load_balancer": { - "title": "Load Balancer", - "description": "Deliver high availability and network performance to your applications." - }, - "vpn_gateway": { - "title": "VPN Gateway", - "description": "Establish secure, cross-premises connectivity." + }, + "networking": { + "title": "Networking", + "emoji": "🔗", + "description": "Private networks and connectivity", + "sub_services": { + "vnetwork": { + "title": "Virtual Network", + "emoji": "🌐", + "description": "Provision private networks, optionally connect to on-premises datacenters." + }, + "load_balancer": { + "title": "Load Balancer", + "emoji": "⚖️", + "description": "Deliver high availability and network performance to your applications." + }, + "vpn_gateway": { + "title": "VPN Gateway", + "emoji": "🔐", + "description": "Establish secure, cross-premises connectivity." + } } - } - }, - "database": { - "title": "Database", - "emoji": "💾", - "description": "Managed SQL and NoSQL databases", - "sub_services": { - "sql_database": { - "title": "SQL Database", - "description": "Managed, intelligent SQL in the cloud." - }, - "cosmos_db": { - "title": "Cosmos DB", - "description": "Globally distributed, multi-model database service." - }, - "database_migration": { - "title": "Database Migration", - "description": "Simplify on-premises database migration to the cloud." + }, + "database": { + "title": "Database", + "emoji": "💾", + "description": "Managed SQL and NoSQL databases", + "sub_services": { + "sql_database": { + "title": "SQL Database", + "emoji": "🗃️", + "description": "Managed, intelligent SQL in the cloud." + }, + "cosmos_db": { + "title": "Cosmos DB", + "emoji": "🌍", + "description": "Globally distributed, multi-model database service." + }, + "database_migration": { + "title": "Database Migration", + "emoji": "🚚", + "description": "Simplify on-premises database migration to the cloud." + } } - } - }, - "ai": { - "title": "AI & Machine Learning", - "emoji": "🤖", - "description": "AI services to build machine learning models", - "sub_services": { - "machine_learning": { - "title": "Machine Learning", - "description": "Build, train, and deploy models from the cloud to the edge." - }, - "cognitive_services": { - "title": "Cognitive Services", - "description": "Infuse your apps, websites and bots with intelligent algorithms." - }, - "bot_services": { - "title": "Bot Services", - "description": "Develop intelligent, enterprise-grade bots." + }, + "ai": { + "title": "AI & Machine Learning", + "emoji": "🤖", + "description": "AI services to build machine learning models", + "sub_services": { + "machine_learning": { + "title": "Machine Learning", + "emoji": "🧠", + "description": "Build, train, and deploy models from the cloud to the edge." + }, + "cognitive_services": { + "title": "Cognitive Services", + "emoji": "🧩", + "description": "Infuse your apps, websites and bots with intelligent algorithms." + }, + "bot_services": { + "title": "Bot Services", + "emoji": "🤖", + "description": "Develop intelligent, enterprise-grade bots." + } } - } - }, - "analytics": { - "title": "Analytics", - "emoji": "📊", - "description": "Big data processing and analytics", - "sub_services": { - "hdinsight": { - "title": "HDInsight", - "description": "Provision cloud Hadoop, Spark, R Server, HBase, and Storm clusters." - }, - "data_lake_analytics": { - "title": "Data Lake Analytics", - "description": "On-demand analytics job service using a simple developer experience." - }, - "stream_analytics": { - "title": "Stream Analytics", - "description": "Real-time data stream processing from millions of IoT devices." + }, + "analytics": { + "title": "Analytics", + "emoji": "📊", + "description": "Big data processing and analytics", + "sub_services": { + "hdinsight": { + "title": "HDInsight", + "emoji": "🔬", + "description": "Provision cloud Hadoop, Spark, R Server, HBase, and Storm clusters." + }, + "data_lake_analytics": { + "title": "Data Lake Analytics", + "emoji": "🏞️", + "description": "On-demand analytics job service using a simple developer experience." + }, + "stream_analytics": { + "title": "Stream Analytics", + "emoji": "🌊", + "description": "Real-time data stream processing from millions of IoT devices." + } } - } - }, - "devops": { - "title": "DevOps", - "emoji": "🛠️", - "description": "Tools and services for CI/CD", - "sub_services": { - "devops_pipelines": { - "title": "DevOps Pipelines", - "description": "CI/CD that works with any language, platform, and cloud." - }, - "artifacts": { - "title": "Artifacts", - "description": "Create, host, and share packages with your team." - }, - "source_repositories": { - "title": "Source Repositories", - "description": "Host private Git repositories." + }, + "devops": { + "title": "DevOps", + "emoji": "🛠️", + "description": "Tools and services for CI/CD", + "sub_services": { + "devops_pipelines": { + "title": "DevOps Pipelines", + "emoji": "🚀", + "description": "CI/CD that works with any language, platform, and cloud." + }, + "artifacts": { + "title": "Artifacts", + "emoji": "📦", + "description": "Create, host, and share packages with your team." + }, + "source_repositories": { + "title": "Source Repositories", + "emoji": "🗂️", + "description": "Host private Git repositories." + } } - } - }, - "security": { - "title": "Security & Identity", - "emoji": "🔒", - "description": "Identity management and threat detection", - "sub_services": { - "identity_services": { - "title": "Identity Services", - "description": "Protect and manage identities as a primary security perimeter." - }, - "threat_protection": { - "title": "Threat Protection", - "description": "Advanced threat detection and protection across cloud services." - }, - "information_protection": { - "title": "Information Protection", - "description": "Secure sensitive data everywhere it resides." + }, + "security": { + "title": "Security & Identity", + "emoji": "🔒", + "description": "Identity management and threat detection", + "sub_services": { + "identity_services": { + "title": "Identity Services", + "emoji": "🔑", + "description": "Protect and manage identities as a primary security perimeter." + }, + "threat_protection": { + "title": "Threat Protection", + "emoji": "🛡️", + "description": "Advanced threat detection and protection across cloud services." + }, + "information_protection": { + "title": "Information Protection", + "emoji": "📄", + "description": "Secure sensitive data everywhere it resides." + } } - } - }, - "iot": { - "title": "Internet of Things", - "emoji": "🌐", - "description": "Internet of Things services", - "sub_services": { - "iot_hub": { - "title": "IoT Hub", - "description": "Connect, monitor and manage billions of IoT assets." - }, - "iot_edge": { - "title": "IoT Edge", - "description": "Deploy cloud intelligence directly on IoT devices to operate in offline mode." - }, - "digital_twins": { - "title": "Digital Twins", - "description": "Create digital representations of connected environments." + }, + "iot": { + "title": "Internet of Things", + "emoji": "🌐", + "description": "Internet of Things services", + "sub_services": { + "iot_hub": { + "title": "IoT Hub", + "emoji": "🔗", + "description": "Connect, monitor and manage billions of IoT assets." + }, + "iot_edge": { + "title": "IoT Edge", + "emoji": "🛠️", + "description": "Deploy cloud intelligence directly on IoT devices to operate in offline mode." + }, + "digital_twins": { + "title": "Digital Twins", + "emoji": "👥", + "description": "Create digital representations of connected environments." + } } } } diff --git a/nicegui/elements/router_frame.py b/nicegui/elements/router_frame.py index 38e918b1b..65f536b74 100644 --- a/nicegui/elements/router_frame.py +++ b/nicegui/elements/router_frame.py @@ -21,7 +21,9 @@ def __init__(self, use_browser_history: bool = True, change_title: bool = True, parent_router_frame: "RouterFrame" = None, - target_url: Optional[str] = None): + target_url: Optional[str] = None, + user_data: Optional[Dict] = None + ): """ :param router: The SinglePageRouter which controls this router frame :param included_paths: A list of valid path masks which shall be allowed to be opened by the router @@ -29,6 +31,7 @@ def __init__(self, :param use_browser_history: Optional flag to enable or disable the browser history management. Default is True. :param change_title: Optional flag to enable or disable the title change. Default is True. :param target_url: The initial url of the router frame + :param user_data: Optional user data which is passed to the builder functions of the router frame """ super().__init__() self.router = router @@ -55,7 +58,7 @@ def __init__(self, self._props['base_path'] = self.router.base_path self._props['browser_history'] = use_browser_history self._props['child_frames'] = [] - self.user_data = {} + self.user_data = user_data self.child_frames: dict[str, "RouterFrame"] = {} self.use_browser_history = use_browser_history self.change_title = change_title @@ -116,9 +119,7 @@ def navigate_to(self, target: [SinglePageTarget, str], _server_side=True, _sync= ui.run_javascript( f'window.history.pushState({{page: "{target_url.original_path}"}}, "", "{target_url.original_path}");') self._props['target_url'] = target_url.original_path - builder_kwargs = {**target_url.path_args, **target_url.query_args} - if "url_path" not in builder_kwargs: - builder_kwargs["url_path"] = target_url.original_path + builder_kwargs = {**target_url.path_args, **target_url.query_args, "url_path": target_url.original_path} target_fragment = target_url.fragment recursive_user_data = RouterFrame.get_user_data() | self.user_data builder_kwargs.update(recursive_user_data) diff --git a/nicegui/outlet.py b/nicegui/outlet.py index eb3be95ae..49f6e0fa5 100644 --- a/nicegui/outlet.py +++ b/nicegui/outlet.py @@ -55,8 +55,11 @@ def add_properties(result): if router_frame is not None: router_frame.update_user_data(properties) yield properties + router_frame = RouterFrame.get_current_frame() try: add_properties(next(frame)) # if provided insert ui elements after yield + if router_frame is not None: + router_frame.update_user_data(properties) except StopIteration: pass @@ -90,6 +93,19 @@ def outlet(self, path: str) -> 'Outlet': abs_path = self.base_path.rstrip('/') + path return Outlet(abs_path, parent=self) + @property + def current_url(self) -> str: + """Returns the current URL of the outlet. + + Only works when called from within the outlet or view builder function. + + :return: The current URL of the outlet""" + cur_router = RouterFrame.get_current_frame() + if cur_router is None: + raise ValueError('The current URL can only be retrieved from within a nested outlet or view builder ' + 'function.') + return cur_router.target_url + class OutletView: """Defines a single view / "content page" which is displayed in an outlet""" diff --git a/nicegui/single_page_router.py b/nicegui/single_page_router.py index 5884b98f8..b104d6a27 100644 --- a/nicegui/single_page_router.py +++ b/nicegui/single_page_router.py @@ -184,23 +184,26 @@ def build_page_template(self) -> Generator: raise ValueError('No page template generator function provided.') def build_page(self, initial_url: Optional[str] = None, **kwargs): + kwargs['url_path'] = initial_url template = RouterFrame.run_safe(self.build_page_template, **kwargs) if not isinstance(template, Generator): raise ValueError('The page template method must yield a value to separate the layout from the content ' 'area.') - properties = {} + new_user_data = {} new_properties = next(template) if isinstance(new_properties, dict): - properties.update(new_properties) - self.insert_content_area(initial_url) + new_user_data.update(new_properties) + content_area = self.insert_content_area(initial_url, user_data=new_user_data) try: new_properties = next(template) if isinstance(new_properties, dict): - properties.update(new_properties) + new_user_data.update(new_properties) except StopIteration: pass + content_area.update_user_data(new_user_data) - def insert_content_area(self, initial_url: Optional[str] = None): + def insert_content_area(self, initial_url: Optional[str] = None, + user_data: Optional[Dict] = None) -> RouterFrame: """Setups the content area""" parent_router_frame = RouterFrame.get_current_frame() content = RouterFrame(router=self, @@ -208,13 +211,15 @@ def insert_content_area(self, initial_url: Optional[str] = None): excluded_paths=sorted(list(self.excluded_paths)), use_browser_history=self.use_browser_history, parent_router_frame=parent_router_frame, - target_url=initial_url) + target_url=initial_url, + user_data=user_data) content.on_resolve(self.resolve_target) if parent_router_frame is None: # register root routers to the client context.client.single_page_router_frame = content initial_url = content.target_url if initial_url is not None: content.navigate_to(initial_url, _server_side=False, _sync=True) + return content def _register_child_router(self, router: "SinglePageRouter") -> None: """Registers a child router to the parent router""" From e2bccee85aa7f9dbb70cc69c703a9bb486e956af Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Fri, 10 May 2024 19:48:37 +0200 Subject: [PATCH 38/79] Cleaned outlet demo --- examples/outlet/main.py | 66 +++++++++++++++++++---------------------- 1 file changed, 30 insertions(+), 36 deletions(-) diff --git a/examples/outlet/main.py b/examples/outlet/main.py index 7506c5058..5943112ba 100644 --- a/examples/outlet/main.py +++ b/examples/outlet/main.py @@ -10,15 +10,15 @@ # --- Load service data for fake cloud provider portal class SubServiceDefinition(BaseModel): - title: str = Field(..., description="The title of the sub-service", examples=["Digital Twin"]) - emoji: str = Field(..., description="An emoji representing the sub-service", examples=["🤖"]) - description: str = Field(..., description="A short description of the sub-service", - examples=["Manage your digital twin"]) + title: str = Field(..., description='The title of the sub-service', examples=['Digital Twin']) + emoji: str = Field(..., description='An emoji representing the sub-service', examples=['🤖']) + description: str = Field(..., description='A short description of the sub-service', + examples=['Manage your digital twin']) class ServiceDefinition(BaseModel): - title: str = Field(..., description="The title of the cloud service", examples=["Virtual Machines"]) - emoji: str = Field(..., description="An emoji representing the cloud service", examples=["💻"]) + title: str = Field(..., description='The title of the cloud service', examples=['Virtual Machines']) + emoji: str = Field(..., description='An emoji representing the cloud service', examples=['💻']) description: str sub_services: Dict[str, SubServiceDefinition] @@ -51,57 +51,53 @@ def other_app_index(): @ui.outlet('/') # main app outlet def main_router(url_path: str): with ui.header(): - with ui.link("", '/') as lnk: + with ui.link('', '/').style('text-decoration: none; color: inherit;') as lnk: ui.html('Nice' 'CLOUD').classes('text-h3') - lnk.style('text-decoration: none; color: inherit;') - menu_visible = "/services/" in url_path # the service will make the menu visible anyway - suppresses animation - with ui.left_drawer(bordered=True, value=menu_visible, fixed=True) as menu_drawer: - menu_drawer.classes('bg-primary') + menu_visible = '/services/' in url_path # the service will make the menu visible anyway - suppresses animation + menu_drawer = ui.left_drawer(bordered=True, value=menu_visible, fixed=True).classes('bg-primary') with ui.footer(): - ui.label("Copyright 2024 by My Company") + ui.label('Copyright 2024 by My Company') with ui.element().classes('p-8'): - yield {'menu_drawer': menu_drawer} + yield {'menu_drawer': menu_drawer} # pass menu drawer to all sub elements (views and outlets) @main_router.view('/') def main_app_index(menu_drawer: LeftDrawer): # main app index page menu_drawer.clear() # clear drawer menu_drawer.hide() # hide drawer - ui.label("Welcome to NiceCLOUD!").classes('text-3xl') - ui.html("
") + ui.label('Welcome to NiceCLOUD!').classes('text-3xl') + ui.html('
') with ui.grid(columns=3) as grid: grid.classes('gap-16') for key, info in services.items(): link = f'/services/{key}' with ui.element(): - with ui.row(): - with ui.link("", link) as lnk: - ui.label(info.emoji).classes('text-2xl') - ui.label(info.title).classes('text-2xl') - lnk.style('text-decoration: none; color: inherit;') + with ui.link(target=link) as lnk: + with ui.row().classes('text-2xl'): + ui.label(info.emoji) + ui.label(info.title) + lnk.style('text-decoration: none; color: inherit;') ui.label(info.description) - ui.html("

") + ui.html('

') # add a link to the other app - ui.link("Click here to go to other Single Page App", '/other_app') - # add page url - ui.html(f'

Current page url: {main_router.current_url}') + ui.markdown("Click [here](/other_app) to visit the other app.") @main_router.outlet('/services/{service_name}') # service outlet def services_router(service_name: str, menu_drawer: LeftDrawer): service: ServiceDefinition = services[service_name] - menu_drawer.clear() # clear drawer - with ((((menu_drawer)))): + # update menu drawer + menu_drawer.clear() + with menu_drawer: menu_drawer.show() with ui.row() as row: ui.label(service.emoji) ui.label(service.title) row.classes('text-h5 text-white').style('text-shadow: 2px 2px #00000070;') - ui.html("
") - # add menu items + ui.html('
') menu_items = service.sub_services for key, info in menu_items.items(): with ui.row() as service_element: @@ -109,8 +105,8 @@ def services_router(service_name: str, menu_drawer: LeftDrawer): ui.label(info.title) service_element.classes('text-white text-h6 bg-gray cursor-pointer') service_element.style('text-shadow: 2px 2px #00000070;') - service_element.on("click", lambda sn=service_name, k=key: ui.navigate.to(f'/services/{sn}/{k}')) - yield {'service': service} + service_element.on('click', lambda url=f'/services/{service_name}/{key}': ui.navigate.to(url)) + yield {'service': service} # pass service to all sub elements (views and outlets) @services_router.view('/') # service index page @@ -120,24 +116,22 @@ def show_index(service: ServiceDefinition): with ui.column(): ui.label(service.title).classes('text-h2') ui.label(service.description) - ui.html("
") + ui.html('
') @services_router.outlet('/{sub_service_name}') # sub service outlet def sub_service_router(service: ServiceDefinition, sub_service_name: str): sub_service: SubServiceDefinition = service.sub_services[sub_service_name] ui.label(f'{service.title} > {sub_service.title}').classes('text-h4') - ui.html("
") - yield {'sub_service': sub_service} - # add page url + ui.html('
') + yield {'sub_service': sub_service} # pass sub service to all sub elements (views and outlets) @sub_service_router.view('/') # sub service index page def sub_service_index(sub_service: SubServiceDefinition): ui.label(sub_service.emoji).classes('text-h1') - ui.html("
") + ui.html('
') ui.label(sub_service.description) - ui.html(f'

Current page url: {main_router.current_url}') ui.run(show=False) From 04be96abca03caa4e09b61ad4761c48978b68270 Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Fri, 10 May 2024 20:31:43 +0200 Subject: [PATCH 39/79] Clean up. Fixed SinglePageApp demos. --- examples/modularization/main.py | 2 +- examples/single_page_router/main.py | 13 ++++++++++--- nicegui/client.py | 18 ++++++------------ nicegui/elements/router_frame.py | 23 ++++++++++++----------- nicegui/outlet.py | 29 ++++++++++++++++++++++------- nicegui/page_layout.py | 3 +-- nicegui/single_page_router.py | 21 ++++++++++++++------- nicegui/single_page_target.py | 18 +++++++++++------- nicegui/ui.py | 2 +- 9 files changed, 78 insertions(+), 51 deletions(-) diff --git a/examples/modularization/main.py b/examples/modularization/main.py index f388ff9eb..4951b16ee 100755 --- a/examples/modularization/main.py +++ b/examples/modularization/main.py @@ -24,4 +24,4 @@ def index_page() -> None: # Example 4: use APIRouter as described in https://nicegui.io/documentation/page#modularize_with_apirouter app.include_router(api_router_example.router) -ui.run(title='Modularization Example') \ No newline at end of file +ui.run(title='Modularization Example') diff --git a/examples/single_page_router/main.py b/examples/single_page_router/main.py index 349f3a5c9..d38b990e8 100644 --- a/examples/single_page_router/main.py +++ b/examples/single_page_router/main.py @@ -1,9 +1,10 @@ -# Minimal example of a single page router with two pages +# Basic example of the SinglePageApp class which allows the fast conversion of already existing multi-page NiceGUI +# applications into a single page applications. Note that if you want more control over the routing, nested outlets or +# custom page setups,you should use the ui.outlet class instead which allows more flexibility. from nicegui import ui from nicegui.page import page from nicegui.single_page_app import SinglePageApp -from nicegui.single_page_router import SinglePageRouter @page('/', title='Welcome!') @@ -18,5 +19,11 @@ def about(): ui.link('Index', '/') -router = SinglePageApp('/').reroute_pages() +def page_template(): + with ui.header(): + ui.label('My Company').classes('text-2xl') + yield # your content goes here + + +router = SinglePageApp('/', page_template=page_template).setup() ui.run() diff --git a/nicegui/client.py b/nicegui/client.py index 5fc79e667..1b5551b3e 100644 --- a/nicegui/client.py +++ b/nicegui/client.py @@ -97,8 +97,7 @@ def is_auto_index_client(self) -> bool: @property def ip(self) -> Optional[str]: """Return the IP address of the client, or None if the client is not connected.""" - return self.environ['asgi.scope']['client'][ - 0] if self.environ else None # pylint: disable=unsubscriptable-object + return self.environ['asgi.scope']['client'][0] if self.environ else None # pylint: disable=unsubscriptable-object @property def has_socket_connection(self) -> bool: @@ -138,13 +137,12 @@ def build_response(self, request: Request, status_code: int = 200) -> Response: 'request': request, 'version': __version__, 'elements': elements.replace('&', '&') - .replace('<', '<') - .replace('>', '>') - .replace('`', '`') - .replace('$', '$'), + .replace('<', '<') + .replace('>', '>') + .replace('`', '`') + .replace('$', '$'), 'head_html': self.head_html, - 'body_html': '\n' + self.body_html + '\n' + '\n'.join( - vue_html), + 'body_html': '\n' + self.body_html + '\n' + '\n'.join(vue_html), 'vue_scripts': '\n'.join(vue_scripts), 'imports': json.dumps(imports), 'js_imports': '\n'.join(js_imports), @@ -261,7 +259,6 @@ def handle_handshake(self) -> None: def handle_disconnect(self) -> None: """Wait for the browser to reconnect; invoke disconnect handlers if it doesn't.""" - async def handle_disconnect() -> None: if self.page.reconnect_timeout is not None: delay = self.page.reconnect_timeout @@ -274,7 +271,6 @@ async def handle_disconnect() -> None: self.safe_invoke(t) if not self.shared: self.delete() - self._disconnect_task = background_tasks.create(handle_disconnect()) def handle_event(self, msg: Dict) -> None: @@ -298,7 +294,6 @@ def safe_invoke(self, func: Union[Callable[..., Any], Awaitable]) -> None: async def func_with_client(): with self: await func - background_tasks.create(func_with_client()) else: with self: @@ -307,7 +302,6 @@ async def func_with_client(): async def result_with_client(): with self: await result - background_tasks.create(result_with_client()) except Exception as e: core.app.handle_exception(e) diff --git a/nicegui/elements/router_frame.py b/nicegui/elements/router_frame.py index 65f536b74..48de22851 100644 --- a/nicegui/elements/router_frame.py +++ b/nicegui/elements/router_frame.py @@ -93,12 +93,14 @@ def resolve_target(self, target: Any) -> SinglePageTarget: return self._on_resolve(target) raise NotImplementedError - def navigate_to(self, target: [SinglePageTarget, str], _server_side=True, _sync=False) -> None: + def navigate_to(self, target: [SinglePageTarget, str], _server_side=True, sync=False) -> None: """Open a new page in the browser by exchanging the content of the router frame :param target: The target page or url. :param _server_side: Optional flag which defines if the call is originated on the server side and thus - the browser history should be updated. Default is False.""" + the browser history should be updated. Default is False. + :param sync: Optional flag to define if the content should be updated synchronously. Default is False. + """ # check if sub router is active and might handle the target for path_mask, frame in self.child_frames.items(): if path_mask == target or target.startswith(path_mask + '/'): @@ -110,7 +112,7 @@ def navigate_to(self, target: [SinglePageTarget, str], _server_side=True, _sync= if target_url.fragment is not None: ui.run_javascript(f'window.location.href = "#{target_url.fragment}";') # go to fragment return - title = "Page not found" + title = 'Page not found' builder = self._page_not_found else: builder = entry.builder @@ -119,19 +121,20 @@ def navigate_to(self, target: [SinglePageTarget, str], _server_side=True, _sync= ui.run_javascript( f'window.history.pushState({{page: "{target_url.original_path}"}}, "", "{target_url.original_path}");') self._props['target_url'] = target_url.original_path - builder_kwargs = {**target_url.path_args, **target_url.query_args, "url_path": target_url.original_path} + builder_kwargs = {**target_url.path_args, **target_url.query_args, 'url_path': target_url.original_path} target_fragment = target_url.fragment recursive_user_data = RouterFrame.get_user_data() | self.user_data builder_kwargs.update(recursive_user_data) - self.update_content(builder, builder_kwargs, title, target_fragment, _sync=_sync) + self.update_content(builder, builder_kwargs, title, target_fragment, sync=sync) - def update_content(self, builder, builder_kwargs, title, target_fragment, _sync=False): + def update_content(self, builder, builder_kwargs, title, target_fragment, sync=False): """Update the content of the router frame :param builder: The builder function which builds the content of the page :param builder_kwargs: The keyword arguments to pass to the builder function :param title: The title of the page - :param target_fragment: The fragment to navigate to after the content has been loaded""" + :param target_fragment: The fragment to navigate to after the content has been loaded + :param sync: Optional flag to define if the content should be updated synchronously. Default is False.""" if self.change_title: ui.page_title(title if title is not None else core.app.config.title) @@ -141,14 +144,12 @@ def exec_builder(): async def build() -> None: with self: - result = exec_builder() - if helpers.is_coroutine_function(builder): - await result + exec_builder() if target_fragment is not None: await ui.run_javascript(f'window.location.href = "#{target_fragment}";') self.clear() - if _sync: + if sync: with self: exec_builder() if target_fragment is not None: diff --git a/nicegui/outlet.py b/nicegui/outlet.py index 49f6e0fa5..470ab6028 100644 --- a/nicegui/outlet.py +++ b/nicegui/outlet.py @@ -1,4 +1,3 @@ -import inspect from typing import Callable, Any, Self, Optional, Generator from nicegui.client import Client @@ -7,14 +6,23 @@ class Outlet(SinglePageRouter): - """An outlet function defines the page layout of a single page application into which dynamic content can be - inserted. It is a high-level abstraction which manages the routing and content area of the SPA.""" + """An outlet allows the creation of single page applications which do not reload the page when navigating between + different views. The outlet is a container for multiple views and can contain nested outlets. + + To define a new outlet, use the @ui.outlet decorator on a function which defines the layout of the outlet. + The layout function must be a generator function and contain a yield statement to separate the layout from the + content area. The yield can also be used to pass properties to the content are by return a dictionary with the + properties. Each property can be received as function argument in all nested views and outlets. + + Once the outlet is defined, multiple views can be added to the outlet using the @outlet.view decorator on + a function. + """ def __init__(self, path: str, outlet_builder: Optional[Callable] = None, browser_history: bool = True, - parent: Optional["SinglePageRouter"] = None, + parent: Optional['SinglePageRouter'] = None, on_instance_created: Optional[Callable] = None, **kwargs) -> None: """ @@ -78,7 +86,11 @@ def outlet_view(**kwargs): return self def view(self, path: str, title: Optional[str] = None) -> 'OutletView': - """Decorator for the view function + """Decorator for the view function. + + With the view function you define the actual content of the page. The view function is called when the user + navigates to the specified path relative to the outlet's base path. + :param path: The path of the view, relative to the base path of the outlet :param title: Optional title of the view. If a title is set, it will be displayed in the browser tab when the view is active, otherwise the default title of the application is displayed. @@ -123,8 +135,11 @@ def __init__(self, parent_outlet: SinglePageRouter, path: str, title: Optional[s @property def url(self) -> str: - """The absolute URL of the view""" - return (self.parent_outlet.base_path.rstrip("/") + "/" + self.path.lstrip("/")).rstrip('/') + """The absolute URL of the view + + :return: The absolute URL of the view + """ + return (self.parent_outlet.base_path.rstrip('/') + '/' + self.path.lstrip('/')).rstrip('/') def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]: """Decorator for the view function""" diff --git a/nicegui/page_layout.py b/nicegui/page_layout.py index e5ff56f54..87afd09e3 100644 --- a/nicegui/page_layout.py +++ b/nicegui/page_layout.py @@ -3,7 +3,6 @@ from .context import context from .element import Element from .elements.mixins.value_element import ValueElement -from .elements.router_frame import RouterFrame from .functions.html import add_body_html from .logging import log @@ -273,7 +272,7 @@ def __init__(self, position: PageStickyPositions = 'bottom-right', x_offset: flo def _check_current_slot(element: Element) -> None: parent = context.slot.parent - if parent != parent.client.content and not isinstance(parent, RouterFrame): + if parent != parent.client.content: log.warning(f'Found top level layout element "{element.__class__.__name__}" inside element "{parent.__class__.__name__}". ' 'Top level layout elements should not be nested but must be direct children of the page content. ' 'This will be raising an exception in NiceGUI 1.5') # DEPRECATED diff --git a/nicegui/single_page_router.py b/nicegui/single_page_router.py index b104d6a27..7164a439b 100644 --- a/nicegui/single_page_router.py +++ b/nicegui/single_page_router.py @@ -152,8 +152,7 @@ def resolve_target(self, target: Union[Callable, str]) -> SinglePageTarget: # isolate the real path elements and update target accordingly target = "/".join(path.split("/")[:len(cur_router.base_path.split("/"))]) break - parser = SinglePageTarget(target, router=self) - result = parser.parse_single_page_route(self.routes, target) + result = SinglePageTarget(target, router=self).parse_url_path(routes=self.routes) if resolved is not None: result.original_path = resolved.original_path return result @@ -178,10 +177,14 @@ def build_page_template(self) -> Generator: content of the page. :return: The page template generator function""" - if self.build_page_template is not None: + + def default_template(): + yield + + if self.page_template is not None: return self.page_template() else: - raise ValueError('No page template generator function provided.') + return default_template() def build_page(self, initial_url: Optional[str] = None, **kwargs): kwargs['url_path'] = initial_url @@ -202,9 +205,13 @@ def build_page(self, initial_url: Optional[str] = None, **kwargs): pass content_area.update_user_data(new_user_data) - def insert_content_area(self, initial_url: Optional[str] = None, + def insert_content_area(self, + initial_url: Optional[str] = None, user_data: Optional[Dict] = None) -> RouterFrame: - """Setups the content area""" + """Inserts the content area in form of a RouterFrame into the page + + :param initial_url: The initial URL to initialize the router's content with + :param user_data: Optional user data to pass to the content area""" parent_router_frame = RouterFrame.get_current_frame() content = RouterFrame(router=self, included_paths=sorted(list(self.included_paths)), @@ -218,7 +225,7 @@ def insert_content_area(self, initial_url: Optional[str] = None, context.client.single_page_router_frame = content initial_url = content.target_url if initial_url is not None: - content.navigate_to(initial_url, _server_side=False, _sync=True) + content.navigate_to(initial_url, _server_side=False, sync=True) return content def _register_child_router(self, router: "SinglePageRouter") -> None: diff --git a/nicegui/single_page_target.py b/nicegui/single_page_target.py index cee90926a..1204a4a63 100644 --- a/nicegui/single_page_target.py +++ b/nicegui/single_page_target.py @@ -7,11 +7,14 @@ class SinglePageTarget: - """Aa helper class which is used to parse the path and query parameters of an URL to find the matching - SinglePageRouterEntry and convert the parameters to the expected types of the builder function""" + """A helper class which is used to resolve and URL path and it's query and fragment parameters to find the matching + SinglePageRouterEntry and extract path and query parameters.""" - def __init__(self, path: Optional[str] = None, entry: Optional['SinglePageRouterEntry'] = None, - fragment: Optional[str] = None, query_string: Optional[str] = None, + def __init__(self, + path: Optional[str] = None, + entry: Optional['SinglePageRouterEntry'] = None, + fragment: Optional[str] = None, + query_string: Optional[str] = None, router: Optional['SinglePageRouter'] = None): """ :param path: The path of the URL @@ -31,12 +34,13 @@ def __init__(self, path: Optional[str] = None, entry: Optional['SinglePageRouter self.valid = entry is not None self.router = router - def parse_single_page_route(self, routes: Dict[str, 'SinglePageRouterEntry'], path: str) -> Self: + def parse_url_path(self, routes: Dict[str, 'SinglePageRouterEntry']) -> Self: """ + Parses the route using the provided routes dictionary and path. + :param routes: All routes of the single page router - :param path: The path of the URL """ - parsed_url = urllib.parse.urlparse(urllib.parse.unquote(path)) + parsed_url = urllib.parse.urlparse(urllib.parse.unquote(self.path)) self.routes = routes # all valid routes self.path = parsed_url.path # url path w/o query self.fragment = parsed_url.fragment if len(parsed_url.fragment) > 0 else None diff --git a/nicegui/ui.py b/nicegui/ui.py index 198bc7886..f2583afe6 100644 --- a/nicegui/ui.py +++ b/nicegui/ui.py @@ -246,4 +246,4 @@ from .page_layout import PageSticky as page_sticky from .page_layout import RightDrawer as right_drawer from .ui_run import run -from .ui_run_with import run_with \ No newline at end of file +from .ui_run_with import run_with From a112465a5df74df79a6f4208bcf6effa4d1908b1 Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Fri, 10 May 2024 20:39:43 +0200 Subject: [PATCH 40/79] Fixed quotes. --- nicegui/page.py | 19 ++++++++------- nicegui/single_page_router.py | 46 +++++++++++++++-------------------- 2 files changed, 29 insertions(+), 36 deletions(-) diff --git a/nicegui/page.py b/nicegui/page.py index 82ea495ff..a0cf4a761 100644 --- a/nicegui/page.py +++ b/nicegui/page.py @@ -107,15 +107,16 @@ async def decorated(*dec_args, **dec_kwargs) -> Response: dec_kwargs['client'] = client if any(p.name == 'request_data' for p in inspect.signature(func).parameters.values()): url = request.url - dec_kwargs['request_data'] = {"client": - {"host": request.client.host, - "port": request.client.port}, - "cookies": request.cookies, - "url": - {"path": url.path, - "query": url.query, - "username": url.username, "password": url.password, - "fragment": url.fragment}} + dec_kwargs['request_data'] = {'client': + {'host': request.client.host, + 'port': request.client.port}, + 'cookies': request.cookies, + 'url': + {'path': url.path, + 'query': url.query, + 'username': url.username, + 'password': url.password, + 'fragment': url.fragment}} result = func(*dec_args, **dec_kwargs) if helpers.is_coroutine_function(func): async def wait_for_result() -> None: diff --git a/nicegui/single_page_router.py b/nicegui/single_page_router.py index 7164a439b..ff0c69ac6 100644 --- a/nicegui/single_page_router.py +++ b/nicegui/single_page_router.py @@ -16,8 +16,7 @@ def __init__(self, path: str, builder: Callable, title: Union[str, None] = None) """ :param path: The path of the route :param builder: The builder function which is called when the route is opened - :param title: Optional title of the page - """ + :param title: Optional title of the page""" self.path = path self.builder = builder self.title = title @@ -42,8 +41,7 @@ def create_path_mask(path: str) -> str: /site/{value}/{other_value} --> /site/*/* :param path: The path to convert - :return: The mask with all path parameters replaced by a wildcard - """ + :return: The mask with all path parameters replaced by a wildcard""" return re.sub(r'{[^}]+}', '*', path) @@ -60,16 +58,14 @@ def __init__(self, page_template: Optional[Callable[[], Generator]] = None, on_instance_created: Optional[Callable] = None, **kwargs) -> None: - """ - :param path: the base path of the single page router. + """:param path: the base path of the single page router. :param browser_history: Optional flag to enable or disable the browser history management. Default is True. :param parent: The parent router of this router if this router is a nested router. :param page_template: Optional page template generator function which defines the layout of the page. It needs to yield a value to separate the layout from the content area. :param on_instance_created: Optional callback which is called when a new instance is created. Each browser tab or window is a new instance. This can be used to initialize the state of the application. - :param kwargs: Additional arguments for the @page decorators - """ + :param kwargs: Additional arguments for the @page decorators""" super().__init__() self.routes: Dict[str, SinglePageRouterEntry] = {} self.base_path = path @@ -82,17 +78,17 @@ def __init__(self, self.parent_router = parent if self.parent_router is not None: self.parent_router._register_child_router(self) - self.child_routers: List["SinglePageRouter"] = [] + self.child_routers: List['SinglePageRouter'] = [] self.page_kwargs = kwargs def setup_pages(self, force=False) -> Self: for key, route in Client.page_routes.items(): if route.startswith( - self.base_path.rstrip("/") + "/") and route.rstrip("/") not in self.included_paths: + self.base_path.rstrip('/') + '/') and route.rstrip('/') not in self.included_paths: self.excluded_paths.add(route) if force: continue - if self.base_path.startswith(route.rstrip("/") + "/"): # '/sub_router' after '/' - forbidden + if self.base_path.startswith(route.rstrip('/') + '/'): # '/sub_router' after '/' - forbidden raise ValueError(f'Another router with path "{route.rstrip("/")}/*" is already registered which ' f'includes this router\'s base path "{self.base_path}". You can declare the nested ' f'router first to prioritize it and avoid this issue.') @@ -102,10 +98,10 @@ def setup_pages(self, force=False) -> Self: async def root_page(request_data=None): initial_url = None if request_data is not None: - initial_url = request_data["url"]["path"] - query = request_data["url"].get("query", {}) + initial_url = request_data['url']['path'] + query = request_data['url'].get('query', {}) if query: - initial_url += "?" + query + initial_url += '?' + query self.build_page(initial_url=initial_url) return self @@ -115,8 +111,7 @@ def add_view(self, path: str, builder: Callable, title: Optional[str] = None) -> :param path: The path of the route, including FastAPI path parameters :param builder: The builder function (the view to be displayed) - :param title: Optional title of the page - """ + :param title: Optional title of the page""" path_mask = SinglePageRouterEntry.create_path_mask(path.rstrip('/')) self.included_paths.add(path_mask) self.routes[path] = SinglePageRouterEntry(path, builder, title).verify() @@ -124,33 +119,31 @@ def add_view(self, path: str, builder: Callable, title: Optional[str] = None) -> def add_router_entry(self, entry: SinglePageRouterEntry) -> None: """Adds a fully configured SinglePageRouterEntry to the router - :param entry: The SinglePageRouterEntry to add - """ + :param entry: The SinglePageRouterEntry to add""" self.routes[entry.path] = entry.verify() def resolve_target(self, target: Union[Callable, str]) -> SinglePageTarget: """Tries to resolve a target such as a builder function or an URL path w/ route and query parameters. :param target: The URL path to open or a builder function - :return: The resolved target. Defines .valid if the target is valid - """ + :return: The resolved target. Defines .valid if the target is valid""" if isinstance(target, Callable): for target, entry in self.routes.items(): if entry.builder == target: return SinglePageTarget(entry=entry, router=self) else: resolved = None - path = target.split("#")[0].split("?")[0] + path = target.split('#')[0].split('?')[0] for cur_router in self.child_routers: # replace {} placeholders with * to match the fnmatch pattern - mask = SinglePageRouterEntry.create_path_mask(cur_router.base_path.rstrip("/") + "/*") + mask = SinglePageRouterEntry.create_path_mask(cur_router.base_path.rstrip('/') + '/*') if fnmatch(path, mask) or path == cur_router.base_path: resolved = cur_router.resolve_target(target) if resolved.valid: target = cur_router.base_path - if "*" in mask: + if '*' in mask: # isolate the real path elements and update target accordingly - target = "/".join(path.split("/")[:len(cur_router.base_path.split("/"))]) + target = '/'.join(path.split('/')[:len(cur_router.base_path.split('/'))]) break result = SinglePageTarget(target, router=self).parse_url_path(routes=self.routes) if resolved is not None: @@ -161,8 +154,7 @@ def navigate_to(self, target: Union[Callable, str, SinglePageTarget], server_sid """Navigate to a target :param target: The target to navigate to - :param server_side: Optional flag which defines if the call is originated on the server side - """ + :param server_side: Optional flag which defines if the call is originated on the server side""" org_target = target if not isinstance(target, SinglePageTarget): target = self.resolve_target(target) @@ -228,6 +220,6 @@ def insert_content_area(self, content.navigate_to(initial_url, _server_side=False, sync=True) return content - def _register_child_router(self, router: "SinglePageRouter") -> None: + def _register_child_router(self, router: 'SinglePageRouter') -> None: """Registers a child router to the parent router""" self.child_routers.append(router) From 3e5f2c226f49de1707bbf595649960a66edd36c4 Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Sat, 11 May 2024 07:35:26 +0200 Subject: [PATCH 41/79] Renamed the SinglePageRouter to SinglePageRouterConfig to emphasize it is a singleton, static object created and configured once. --- examples/outlet/{ => cloud_ui}/main.py | 9 ++++---- examples/outlet/{ => cloud_ui}/services.json | 0 examples/outlet/login/main.py | 23 +++++++++++++++++++ examples/single_page_router/advanced.py | 2 +- nicegui/elements/router_frame.py | 4 ++-- nicegui/outlet.py | 8 +++---- nicegui/single_page_app.py | 13 ++++++----- ...router.py => single_page_router_config.py} | 10 ++++---- nicegui/single_page_target.py | 4 ++-- 9 files changed, 49 insertions(+), 24 deletions(-) rename examples/outlet/{ => cloud_ui}/main.py (92%) rename examples/outlet/{ => cloud_ui}/services.json (100%) create mode 100644 examples/outlet/login/main.py rename nicegui/{single_page_router.py => single_page_router_config.py} (97%) diff --git a/examples/outlet/main.py b/examples/outlet/cloud_ui/main.py similarity index 92% rename from examples/outlet/main.py rename to examples/outlet/cloud_ui/main.py index 5943112ba..031ca1fcd 100644 --- a/examples/outlet/main.py +++ b/examples/outlet/cloud_ui/main.py @@ -1,3 +1,6 @@ +# Advanced demo showing how to use the ui.outlet and outlet.view decorators to create a nested multi-page app with a +# static header, footer and menu which is shared across all pages and hidden when the user navigates to the root page. + import os from typing import Dict @@ -54,7 +57,7 @@ def main_router(url_path: str): with ui.link('', '/').style('text-decoration: none; color: inherit;') as lnk: ui.html('Nice' 'CLOUD').classes('text-h3') - menu_visible = '/services/' in url_path # the service will make the menu visible anyway - suppresses animation + menu_visible = '/services/' in url_path # make instantly visible if the initial path is a service menu_drawer = ui.left_drawer(bordered=True, value=menu_visible, fixed=True).classes('bg-primary') with ui.footer(): ui.label('Copyright 2024 by My Company') @@ -80,7 +83,6 @@ def main_app_index(menu_drawer: LeftDrawer): # main app index page ui.label(info.title) lnk.style('text-decoration: none; color: inherit;') ui.label(info.description) - ui.html('

') # add a link to the other app ui.markdown("Click [here](/other_app) to visit the other app.") @@ -89,7 +91,6 @@ def main_app_index(menu_drawer: LeftDrawer): # main app index page @main_router.outlet('/services/{service_name}') # service outlet def services_router(service_name: str, menu_drawer: LeftDrawer): service: ServiceDefinition = services[service_name] - # update menu drawer menu_drawer.clear() with menu_drawer: menu_drawer.show() @@ -134,4 +135,4 @@ def sub_service_index(sub_service: SubServiceDefinition): ui.label(sub_service.description) -ui.run(show=False) +ui.run(show=False, title='NiceCLOUD Portal') diff --git a/examples/outlet/services.json b/examples/outlet/cloud_ui/services.json similarity index 100% rename from examples/outlet/services.json rename to examples/outlet/cloud_ui/services.json diff --git a/examples/outlet/login/main.py b/examples/outlet/login/main.py new file mode 100644 index 000000000..0b7419251 --- /dev/null +++ b/examples/outlet/login/main.py @@ -0,0 +1,23 @@ +from nicegui import ui + + +@ui.outlet('/') +def main_router(url_path: str): + with ui.header(): + with ui.link('', '/').style('text-decoration: none; color: inherit;') as lnk: + ui.html('Nice' + 'CLOUD').classes('text-h3') + + +@main_router.view('/') +def main_app_index(): + # login page + ui.label('Welcome to NiceCLOUD!').classes('text-3xl') + ui.html('
') + ui.label('Username:') + ui.textbox('username') + ui.label('Password:') + ui.password('password') + + +ui.run(show=False) diff --git a/examples/single_page_router/advanced.py b/examples/single_page_router/advanced.py index aa6505063..aa52e4276 100644 --- a/examples/single_page_router/advanced.py +++ b/examples/single_page_router/advanced.py @@ -5,7 +5,7 @@ from nicegui import ui from nicegui.page import page from nicegui.single_page_app import SinglePageApp -from nicegui.single_page_router import SinglePageRouter +from nicegui.single_page_router_config import SinglePageRouterConfig @page('/', title='Welcome!') diff --git a/nicegui/elements/router_frame.py b/nicegui/elements/router_frame.py index 48de22851..53736530c 100644 --- a/nicegui/elements/router_frame.py +++ b/nicegui/elements/router_frame.py @@ -6,7 +6,7 @@ from nicegui.single_page_target import SinglePageTarget if TYPE_CHECKING: - from nicegui.single_page_router import SinglePageRouter + from nicegui.single_page_router_config import SinglePageRouterConfig class RouterFrame(ui.element, component='router_frame.js'): @@ -15,7 +15,7 @@ class RouterFrame(ui.element, component='router_frame.js'): management to prevent the browser from reloading the whole page.""" def __init__(self, - router: "SinglePageRouter", + router: "SinglePageRouterConfig", included_paths: Optional[list[str]] = None, excluded_paths: Optional[list[str]] = None, use_browser_history: bool = True, diff --git a/nicegui/outlet.py b/nicegui/outlet.py index 470ab6028..ad6a206c2 100644 --- a/nicegui/outlet.py +++ b/nicegui/outlet.py @@ -1,11 +1,11 @@ from typing import Callable, Any, Self, Optional, Generator from nicegui.client import Client -from nicegui.single_page_router import SinglePageRouter +from nicegui.single_page_router_config import SinglePageRouterConfig from nicegui.elements.router_frame import RouterFrame -class Outlet(SinglePageRouter): +class Outlet(SinglePageRouterConfig): """An outlet allows the creation of single page applications which do not reload the page when navigating between different views. The outlet is a container for multiple views and can contain nested outlets. @@ -22,7 +22,7 @@ def __init__(self, path: str, outlet_builder: Optional[Callable] = None, browser_history: bool = True, - parent: Optional['SinglePageRouter'] = None, + parent: Optional['SinglePageRouterConfig'] = None, on_instance_created: Optional[Callable] = None, **kwargs) -> None: """ @@ -122,7 +122,7 @@ def current_url(self) -> str: class OutletView: """Defines a single view / "content page" which is displayed in an outlet""" - def __init__(self, parent_outlet: SinglePageRouter, path: str, title: Optional[str] = None): + def __init__(self, parent_outlet: SinglePageRouterConfig, path: str, title: Optional[str] = None): """ :param parent_outlet: The parent outlet in which this view is displayed :param path: The path of the view, relative to the base path of the outlet diff --git a/nicegui/single_page_app.py b/nicegui/single_page_app.py index 9a0461704..ecc69b99d 100644 --- a/nicegui/single_page_app.py +++ b/nicegui/single_page_app.py @@ -4,19 +4,20 @@ from fastapi.routing import APIRoute from nicegui import core -from nicegui.single_page_router import SinglePageRouter, SinglePageRouterEntry +from nicegui.single_page_router_config import SinglePageRouterConfig, SinglePageRouterEntry class SinglePageApp: def __init__(self, - target: Union[SinglePageRouter, str], + target: Union[SinglePageRouterConfig, str], page_template: Callable[[], Generator] = None, included: Union[List[Union[Callable, str]], str, Callable] = '/*', excluded: Union[List[Union[Callable, str]], str, Callable] = '') -> None: """ - :param target: The SinglePageRouter which shall be used as the main router for the single page application. - Alternatively, you can pass the root path of the pages which shall be redirected to the single page router. + :param target: The SinglePageRouterConfig which shall be used as the main router for the single page + application. Alternatively, you can pass the root path of the pages which shall be redirected to the + single page router. :param included: Optional list of masks and callables of paths to include. Default is "/*" which includes all. If you do not want to include all relative paths, you can specify a list of masks or callables to refine the included paths. If a callable is passed, it must be decorated with a page. @@ -25,14 +26,14 @@ def __init__(self, exclusion mask. """ if isinstance(target, str): - target = SinglePageRouter(target, page_template=page_template) + target = SinglePageRouterConfig(target, page_template=page_template) self.single_page_router = target self.included: List[Union[Callable, str]] = [included] if not isinstance(included, list) else included self.excluded: List[Union[Callable, str]] = [excluded] if not isinstance(excluded, list) else excluded self.system_excluded = ['/docs', '/redoc', '/openapi.json', '_*'] def setup(self): - """Registers the SinglePageRouter with the @page decorator to handle all routes defined by the router""" + """Registers the SinglePageRouterConfig with the @page decorator to handle all routes defined by the router""" self.reroute_pages() self.single_page_router.setup_pages(force=True) diff --git a/nicegui/single_page_router.py b/nicegui/single_page_router_config.py similarity index 97% rename from nicegui/single_page_router.py rename to nicegui/single_page_router_config.py index ff0c69ac6..2c59cf148 100644 --- a/nicegui/single_page_router.py +++ b/nicegui/single_page_router_config.py @@ -45,8 +45,8 @@ def create_path_mask(path: str) -> str: return re.sub(r'{[^}]+}', '*', path) -class SinglePageRouter: - """The SinglePageRouter allows the development of a Single Page Application (SPA). +class SinglePageRouterConfig: + """The SinglePageRouterConfig allows the development of a Single Page Application (SPA). SPAs are web applications which load a single HTML page and dynamically update the content of the page. This allows faster page switches and a more dynamic user experience.""" @@ -54,7 +54,7 @@ class SinglePageRouter: def __init__(self, path: str, browser_history: bool = True, - parent: Optional["SinglePageRouter"] = None, + parent: Optional["SinglePageRouterConfig"] = None, page_template: Optional[Callable[[], Generator]] = None, on_instance_created: Optional[Callable] = None, **kwargs) -> None: @@ -78,7 +78,7 @@ def __init__(self, self.parent_router = parent if self.parent_router is not None: self.parent_router._register_child_router(self) - self.child_routers: List['SinglePageRouter'] = [] + self.child_routers: List['SinglePageRouterConfig'] = [] self.page_kwargs = kwargs def setup_pages(self, force=False) -> Self: @@ -220,6 +220,6 @@ def insert_content_area(self, content.navigate_to(initial_url, _server_side=False, sync=True) return content - def _register_child_router(self, router: 'SinglePageRouter') -> None: + def _register_child_router(self, router: 'SinglePageRouterConfig') -> None: """Registers a child router to the parent router""" self.child_routers.append(router) diff --git a/nicegui/single_page_target.py b/nicegui/single_page_target.py index 1204a4a63..69b60576b 100644 --- a/nicegui/single_page_target.py +++ b/nicegui/single_page_target.py @@ -3,7 +3,7 @@ from typing import Dict, Optional, TYPE_CHECKING, Self if TYPE_CHECKING: - from nicegui.single_page_router import SinglePageRouterEntry, SinglePageRouter + from nicegui.single_page_router_config import SinglePageRouterEntry, SinglePageRouterConfig class SinglePageTarget: @@ -15,7 +15,7 @@ def __init__(self, entry: Optional['SinglePageRouterEntry'] = None, fragment: Optional[str] = None, query_string: Optional[str] = None, - router: Optional['SinglePageRouter'] = None): + router: Optional['SinglePageRouterConfig'] = None): """ :param path: The path of the URL :param entry: Predefined entry, e.g. targeting a Callable From 36875ac26f99f72db30c11647bad5f35fa49df60 Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Sat, 11 May 2024 09:04:45 +0200 Subject: [PATCH 42/79] Massive refactoring: * Split the functionality of the RouterFrame into RouterFrame (just element related UI update logic) and SinglePageRouter (a per page/per user instance managing the actual routing) * Renamed the old SinglePageRouter to SinglePageRouterConfig * Removed router specific elements from the SinglePageTarget to also make it usable purely with a target builder function and a title (and later favicon etc) --- nicegui/client.py | 4 +- nicegui/elements/router_frame.py | 157 ++++++-------------------- nicegui/outlet.py | 7 +- nicegui/single_page_app.py | 6 +- nicegui/single_page_router.py | 162 +++++++++++++++++++++++++++ nicegui/single_page_router_config.py | 156 +++++++++++++------------- nicegui/single_page_target.py | 54 +++++---- 7 files changed, 320 insertions(+), 226 deletions(-) create mode 100644 nicegui/single_page_router.py diff --git a/nicegui/client.py b/nicegui/client.py index 1b5551b3e..ba5c600ce 100644 --- a/nicegui/client.py +++ b/nicegui/client.py @@ -26,7 +26,7 @@ if TYPE_CHECKING: from .page import page - from .elements.router_frame import RouterFrame + from .single_page_router import SinglePageRouter templates = Jinja2Templates(Path(__file__).parent / 'templates') @@ -82,7 +82,7 @@ def __init__(self, page: page, *, shared: bool = False) -> None: self.page = page self.storage = ObservableDict() - self.single_page_router_frame: Optional[RouterFrame] = None + self.single_page_router: Optional[SinglePageRouter] = None self.connect_handlers: List[Union[Callable[..., Any], Awaitable]] = [] self.disconnect_handlers: List[Union[Callable[..., Any], Awaitable]] = [] diff --git a/nicegui/elements/router_frame.py b/nicegui/elements/router_frame.py index 53736530c..792879c30 100644 --- a/nicegui/elements/router_frame.py +++ b/nicegui/elements/router_frame.py @@ -1,12 +1,7 @@ import inspect -from typing import Callable, Any, Optional, Self, Dict, TYPE_CHECKING +from typing import Optional, Any, Callable -from nicegui import ui, helpers, background_tasks, core -from nicegui.context import context -from nicegui.single_page_target import SinglePageTarget - -if TYPE_CHECKING: - from nicegui.single_page_router_config import SinglePageRouterConfig +from nicegui import ui, background_tasks, core class RouterFrame(ui.element, component='router_frame.js'): @@ -15,26 +10,27 @@ class RouterFrame(ui.element, component='router_frame.js'): management to prevent the browser from reloading the whole page.""" def __init__(self, - router: "SinglePageRouterConfig", + base_path: str, + target_url: Optional[str] = None, included_paths: Optional[list[str]] = None, excluded_paths: Optional[list[str]] = None, use_browser_history: bool = True, change_title: bool = True, - parent_router_frame: "RouterFrame" = None, - target_url: Optional[str] = None, - user_data: Optional[Dict] = None + on_navigate: Optional[Callable[[str], Any]] = None, + user_data: Optional[dict] = None ): """ - :param router: The SinglePageRouter which controls this router frame + :param base_path: The base URL path all relative paths are based on :param included_paths: A list of valid path masks which shall be allowed to be opened by the router :param excluded_paths: A list of path masks which shall be excluded from the router :param use_browser_history: Optional flag to enable or disable the browser history management. Default is True. :param change_title: Optional flag to enable or disable the title change. Default is True. :param target_url: The initial url of the router frame + :param on_navigate: Optional callback which is called when the browser / JavaScript navigates to a new url :param user_data: Optional user data which is passed to the builder functions of the router frame """ super().__init__() - self.router = router + self.change_title = change_title included_masks = [] excluded_masks = [] if included_paths is not None: @@ -47,87 +43,35 @@ def __init__(self, cleaned = path.rstrip('/') excluded_masks.append(cleaned) excluded_masks.append(cleaned + '/*') - if target_url is None: - if parent_router_frame is not None and parent_router_frame._props['target_url'] is not None: - target_url = parent_router_frame._props['target_url'] - else: - target_url = self.router.base_path self._props['target_url'] = target_url self._props['included_path_masks'] = included_masks self._props['excluded_path_masks'] = excluded_masks - self._props['base_path'] = self.router.base_path + self._props['base_path'] = base_path self._props['browser_history'] = use_browser_history self._props['child_frames'] = [] - self.user_data = user_data - self.child_frames: dict[str, "RouterFrame"] = {} - self.use_browser_history = use_browser_history - self.change_title = change_title - self.parent_frame = parent_router_frame - if parent_router_frame is not None: - parent_router_frame._register_sub_frame(included_paths[0], self) - self._on_resolve: Optional[Callable[[Any], SinglePageTarget]] = None - self.on('open', lambda e: self.navigate_to(e.args, _server_side=False)) + self.on('open', lambda e: self.handle_navigate(e.args)) + self.on_navigate = on_navigate + self.user_data = user_data if user_data is not None else {} + + def handle_navigate(self, url: str): + """Navigate to a new url + + :param url: The url to navigate to""" + if self.on_navigate is not None: + self.on_navigate(url) @property def target_url(self) -> str: """The current target url of the router frame""" return self._props['target_url'] - def on_resolve(self, on_resolve: Callable[[Any], SinglePageTarget]) -> Self: - """Set the on_resolve function which is used to resolve the target to a SinglePageUrl - - :param on_resolve: The on_resolve function which receives a target object such as an URL or Callable and - returns a SinglePageUrl object.""" - self._on_resolve = on_resolve - return self - - def resolve_target(self, target: Any) -> SinglePageTarget: - """Resolves a URL or SPA target to a SinglePageUrl which contains details about the builder function to - be called and the arguments to pass to the builder function. - - :param target: The target object such as a URL or Callable - :return: The resolved SinglePageUrl object""" - if isinstance(target, SinglePageTarget): - return target - if self._on_resolve is not None: - return self._on_resolve(target) - raise NotImplementedError - - def navigate_to(self, target: [SinglePageTarget, str], _server_side=True, sync=False) -> None: - """Open a new page in the browser by exchanging the content of the router frame - - :param target: The target page or url. - :param _server_side: Optional flag which defines if the call is originated on the server side and thus - the browser history should be updated. Default is False. - :param sync: Optional flag to define if the content should be updated synchronously. Default is False. - """ - # check if sub router is active and might handle the target - for path_mask, frame in self.child_frames.items(): - if path_mask == target or target.startswith(path_mask + '/'): - frame.navigate_to(target, _server_side) - return - target_url = self.resolve_target(target) - entry = target_url.entry - if entry is None: - if target_url.fragment is not None: - ui.run_javascript(f'window.location.href = "#{target_url.fragment}";') # go to fragment - return - title = 'Page not found' - builder = self._page_not_found - else: - builder = entry.builder - title = entry.title - if _server_side and self.use_browser_history: - ui.run_javascript( - f'window.history.pushState({{page: "{target_url.original_path}"}}, "", "{target_url.original_path}");') - self._props['target_url'] = target_url.original_path - builder_kwargs = {**target_url.path_args, **target_url.query_args, 'url_path': target_url.original_path} - target_fragment = target_url.fragment - recursive_user_data = RouterFrame.get_user_data() | self.user_data - builder_kwargs.update(recursive_user_data) - self.update_content(builder, builder_kwargs, title, target_fragment, sync=sync) - - def update_content(self, builder, builder_kwargs, title, target_fragment, sync=False): + @target_url.setter + def target_url(self, value: str): + """Set the target url of the router frame""" + self._props['target_url'] = value + + def update_content(self, builder, builder_kwargs, title, target_fragment, + sync=False): """Update the content of the router frame :param builder: The builder function which builds the content of the page @@ -159,49 +103,20 @@ async def build() -> None: def clear(self) -> None: """Clear the content of the router frame and removes all references to sub frames""" - self.child_frames.clear() self._props['child_frame_paths'] = [] super().clear() - def update_user_data(self, new_data: dict) -> None: - """Update the user data of the router frame - - :param new_data: The new user data to set""" - self.user_data.update(new_data) - - @staticmethod - def get_user_data() -> Dict: - """Returns a combined dictionary of all user data of the parent router frames""" - result_dict = {} - for slot in context.slot_stack: - if isinstance(slot.parent, RouterFrame): - result_dict.update(slot.parent.user_data) - return result_dict - - @staticmethod - def get_current_frame() -> Optional["RouterFrame"]: - """Get the current router frame from the context stack - - :return: The current router frame or None if no router frame is in the context stack""" - for slot in reversed(context.slot_stack): # we need to inform the parent router frame about - if isinstance(slot.parent, RouterFrame): # our existence so it can navigate to our pages - return slot.parent - return None - - def _register_sub_frame(self, path: str, frame: "RouterFrame") -> None: - """Registers a sub frame to the router frame + @property + def child_frame_paths(self) -> list[str]: + """The child paths of the router frame""" + return self._props['child_frame_paths'] - :param path: The path of the sub frame - :param frame: The sub frame""" - self.child_frames[path] = frame - self._props['child_frame_paths'] = list(self.child_frames.keys()) + @child_frame_paths.setter + def child_frame_paths(self, paths: list[str]) -> None: + """Update the child paths of the router frame - def _page_not_found(self, url_path: str): - """ - Default builder function for the page not found error page - """ - ui.label(f'Oops! Page Not Found 🚧').classes('text-3xl') - ui.label(f'Sorry, the page you are looking for could not be found. 😔') + :param paths: The list of child paths""" + self._props['child_frame_paths'] = paths @staticmethod def run_safe(builder, **kwargs) -> Any: diff --git a/nicegui/outlet.py b/nicegui/outlet.py index ad6a206c2..1323077e9 100644 --- a/nicegui/outlet.py +++ b/nicegui/outlet.py @@ -1,6 +1,7 @@ from typing import Callable, Any, Self, Optional, Generator from nicegui.client import Client +from nicegui.single_page_router import SinglePageRouter from nicegui.single_page_router_config import SinglePageRouterConfig from nicegui.elements.router_frame import RouterFrame @@ -58,12 +59,12 @@ def add_properties(result): if isinstance(result, dict): properties.update(result) - router_frame = RouterFrame.get_current_frame() + router_frame = SinglePageRouter.get_current_frame() add_properties(next(frame)) # insert ui elements before yield if router_frame is not None: router_frame.update_user_data(properties) yield properties - router_frame = RouterFrame.get_current_frame() + router_frame = SinglePageRouter.get_current_frame() try: add_properties(next(frame)) # if provided insert ui elements after yield if router_frame is not None: @@ -112,7 +113,7 @@ def current_url(self) -> str: Only works when called from within the outlet or view builder function. :return: The current URL of the outlet""" - cur_router = RouterFrame.get_current_frame() + cur_router = SinglePageRouter.get_current_frame() if cur_router is None: raise ValueError('The current URL can only be retrieved from within a nested outlet or view builder ' 'function.') diff --git a/nicegui/single_page_app.py b/nicegui/single_page_app.py index ecc69b99d..c9aa36852 100644 --- a/nicegui/single_page_app.py +++ b/nicegui/single_page_app.py @@ -4,7 +4,7 @@ from fastapi.routing import APIRoute from nicegui import core -from nicegui.single_page_router_config import SinglePageRouterConfig, SinglePageRouterEntry +from nicegui.single_page_router_config import SinglePageRouterConfig, SinglePageRouterPath class SinglePageApp: @@ -83,8 +83,8 @@ def _find_api_routes(self) -> None: if key in Client.page_configs: title = Client.page_configs[key].title route = route.rstrip('/') - self.single_page_router.add_router_entry(SinglePageRouterEntry(route, builder=key, title=title)) - route_mask = SinglePageRouterEntry.create_path_mask(route) + self.single_page_router.add_router_entry(SinglePageRouterPath(route, builder=key, title=title)) + route_mask = SinglePageRouterPath.create_path_mask(route) self.single_page_router.included_paths.add(route_mask) for route in core.app.routes.copy(): if isinstance(route, APIRoute): diff --git a/nicegui/single_page_router.py b/nicegui/single_page_router.py new file mode 100644 index 000000000..d2698d35e --- /dev/null +++ b/nicegui/single_page_router.py @@ -0,0 +1,162 @@ +from typing import Callable, Any, Optional, Self, Dict, TYPE_CHECKING + +from nicegui import ui +from nicegui.context import context +from nicegui.elements.router_frame import RouterFrame +from nicegui.single_page_target import SinglePageTarget + +if TYPE_CHECKING: + from nicegui.single_page_router_config import SinglePageRouterConfig + + +class SinglePageRouter: + """The SinglePageRouter manages the SinglePage navigation and content updates for a SinglePageApp instance. + + When ever a new page is opened, the SinglePageRouter exchanges the content of the current page with the content + of the new page. The SinglePageRouter also manages the browser history and title updates. + + Multiple SinglePageRouters can be nested to create complex SinglePageApps with multiple content areas. + + See @ui.outlet or SinglePageApp for more information.""" + + def __init__(self, + config: "SinglePageRouterConfig", + included_paths: Optional[list[str]] = None, + excluded_paths: Optional[list[str]] = None, + use_browser_history: bool = True, + change_title: bool = True, + parent_router: "SinglePageRouter" = None, + target_url: Optional[str] = None, + user_data: Optional[Dict] = None + ): + """ + :param config: The SinglePageRouter which controls this router frame + :param included_paths: A list of valid path masks which shall be allowed to be opened by the router + :param excluded_paths: A list of path masks which shall be excluded from the router + :param use_browser_history: Optional flag to enable or disable the browser history management. Default is True. + :param change_title: Optional flag to enable or disable the title change. Default is True. + :param target_url: The initial url of the router frame + :param user_data: Optional user data which is passed to the builder functions of the router frame + """ + super().__init__() + self.router = config + if target_url is None: + if parent_router is not None and parent_router.target_url is not None: + target_url = parent_router.target_url + else: + target_url = self.router.base_path + self.user_data = user_data + self.child_frames: dict[str, "SinglePageRouter"] = {} + self.use_browser_history = use_browser_history + self.change_title = change_title + self.parent_frame = parent_router + if parent_router is not None: + parent_router._register_sub_frame(included_paths[0], self) + self._on_resolve: Optional[Callable[[Any], SinglePageTarget]] = None + self.router_frame = RouterFrame(base_path=self.router.base_path, + target_url=target_url, + included_paths=included_paths, + excluded_paths=excluded_paths, + use_browser_history=use_browser_history, + on_navigate=lambda url: self.navigate_to(url, _server_side=False), + user_data={"router": self}) + + @property + def target_url(self) -> str: + """The current target url of the router frame""" + return self.router_frame.target_url + + def on_resolve(self, on_resolve: Callable[[Any], SinglePageTarget]) -> Self: + """Set the on_resolve function which is used to resolve the target to a SinglePageUrl + + :param on_resolve: The on_resolve function which receives a target object such as an URL or Callable and + returns a SinglePageUrl object.""" + self._on_resolve = on_resolve + return self + + def resolve_target(self, target: Any) -> SinglePageTarget: + """Resolves a URL or SPA target to a SinglePageUrl which contains details about the builder function to + be called and the arguments to pass to the builder function. + + :param target: The target object such as a URL or Callable + :return: The resolved SinglePageUrl object""" + if isinstance(target, SinglePageTarget): + return target + if self._on_resolve is not None: + return self._on_resolve(target) + raise NotImplementedError + + def navigate_to(self, target: [SinglePageTarget, str], _server_side=True, sync=False) -> None: + """Open a new page in the browser by exchanging the content of the router frame + + :param target: The target page or url. + :param _server_side: Optional flag which defines if the call is originated on the server side and thus + the browser history should be updated. Default is False. + :param sync: Optional flag to define if the content should be updated synchronously. Default is False. + """ + # check if sub router is active and might handle the target + for path_mask, frame in self.child_frames.items(): + if path_mask == target or target.startswith(path_mask + '/'): + frame.navigate_to(target, _server_side) + return + target = self.resolve_target(target) + if target.builder is None: + if target.fragment is not None: + ui.run_javascript(f'window.location.href = "#{target.fragment}";') # go to fragment + return + target = SinglePageTarget(builder=self._page_not_found, title='Page not found') + if _server_side and self.use_browser_history: + ui.run_javascript( + f'window.history.pushState({{page: "{target.original_path}"}}, "", "{target.original_path}");') + self.router_frame.target_url = target.original_path + builder_kwargs = {**target.path_args, **target.query_args, 'url_path': target.original_path} + target_fragment = target.fragment + recursive_user_data = SinglePageRouter.get_user_data() | self.user_data | self.router_frame.user_data + builder_kwargs.update(recursive_user_data) + self.router_frame.update_content(target.builder, builder_kwargs, target.title, target_fragment, sync) + + def clear(self) -> None: + """Clear the content of the router frame and removes all references to sub frames""" + self.child_frames.clear() + self.router_frame.clear() + + def update_user_data(self, new_data: dict) -> None: + """Update the user data of the router frame + + :param new_data: The new user data to set""" + self.user_data.update(new_data) + + @staticmethod + def get_user_data() -> Dict: + """Returns a combined dictionary of all user data of the parent router frames""" + result_dict = {} + for slot in context.slot_stack: + if isinstance(slot.parent, RouterFrame): + result_dict.update(slot.parent.user_data['router'].user_data) + return result_dict + + @staticmethod + def get_current_frame() -> Optional["SinglePageRouter"]: + """Get the current router frame from the context stack + + :return: The current router or None if no router in the context stack""" + for slot in reversed(context.slot_stack): # we need to inform the parent router frame about + if isinstance(slot.parent, RouterFrame): # our existence so it can navigate to our pages + return slot.parent.user_data['router'] + return None + + def _register_sub_frame(self, path: str, frame: "SinglePageRouter") -> None: + """Registers a sub frame to the router frame + + :param path: The path of the sub frame + :param frame: The sub frame""" + self.child_frames[path] = frame + self.router_frame.child_frame_paths = list(self.child_frames.keys()) + + @staticmethod + def _page_not_found(): + """ + Default builder function for the page not found error page + """ + ui.label(f'Oops! Page Not Found 🚧').classes('text-3xl') + ui.label(f'Sorry, the page you are looking for could not be found. 😔') diff --git a/nicegui/single_page_router_config.py b/nicegui/single_page_router_config.py index 2c59cf148..4da30ddbe 100644 --- a/nicegui/single_page_router_config.py +++ b/nicegui/single_page_router_config.py @@ -6,50 +6,15 @@ from nicegui.context import context from nicegui.client import Client from nicegui.elements.router_frame import RouterFrame +from nicegui.single_page_router import SinglePageRouter from nicegui.single_page_target import SinglePageTarget -class SinglePageRouterEntry: - """The SinglePageRouterEntry is a data class which holds the configuration of a single page router route""" - - def __init__(self, path: str, builder: Callable, title: Union[str, None] = None): - """ - :param path: The path of the route - :param builder: The builder function which is called when the route is opened - :param title: Optional title of the page""" - self.path = path - self.builder = builder - self.title = title - - def verify(self) -> Self: - """Verifies a SinglePageRouterEntry for correctness. Raises a ValueError if the entry is invalid.""" - path = self.path - if '{' in path: - # verify only a single open and close curly bracket is present - elements = path.split('/') - for cur_element in elements: - if '{' in cur_element: - if cur_element.count('{') != 1 or cur_element.count('}') != 1 or len(cur_element) < 3 or \ - not (cur_element.startswith('{') and cur_element.endswith('}')): - raise ValueError('Only simple path parameters are supported. /path/{value}/{another_value}\n' - f'failed for path: {path}') - return self - - @staticmethod - def create_path_mask(path: str) -> str: - """Converts a path to a mask which can be used for fnmatch matching - - /site/{value}/{other_value} --> /site/*/* - :param path: The path to convert - :return: The mask with all path parameters replaced by a wildcard""" - return re.sub(r'{[^}]+}', '*', path) - - class SinglePageRouterConfig: - """The SinglePageRouterConfig allows the development of a Single Page Application (SPA). + """The SinglePageRouterConfig allows the development of Single Page Applications (SPAs). - SPAs are web applications which load a single HTML page and dynamically update the content of the page. - This allows faster page switches and a more dynamic user experience.""" + It registers the root page of the SPAs at a given base path as FastAPI endpoint and manages the routing of nested + routers.""" def __init__(self, path: str, @@ -63,11 +28,11 @@ def __init__(self, :param parent: The parent router of this router if this router is a nested router. :param page_template: Optional page template generator function which defines the layout of the page. It needs to yield a value to separate the layout from the content area. - :param on_instance_created: Optional callback which is called when a new instance is created. Each browser tab - or window is a new instance. This can be used to initialize the state of the application. + :param on_instance_created: Optional callback which is called when a new router instance is created. Each + browser tab or window is a new instance. This can be used to initialize the state of the application. :param kwargs: Additional arguments for the @page decorators""" super().__init__() - self.routes: Dict[str, SinglePageRouterEntry] = {} + self.routes: Dict[str, "SinglePageRouterPath"] = {} self.base_path = path self.included_paths: Set[str] = set() self.excluded_paths: Set[str] = set() @@ -77,7 +42,7 @@ def __init__(self, self._setup_configured = False self.parent_router = parent if self.parent_router is not None: - self.parent_router._register_child_router(self) + self.parent_router._register_child_config(self) self.child_routers: List['SinglePageRouterConfig'] = [] self.page_kwargs = kwargs @@ -112,31 +77,31 @@ def add_view(self, path: str, builder: Callable, title: Optional[str] = None) -> :param path: The path of the route, including FastAPI path parameters :param builder: The builder function (the view to be displayed) :param title: Optional title of the page""" - path_mask = SinglePageRouterEntry.create_path_mask(path.rstrip('/')) + path_mask = SinglePageRouterPath.create_path_mask(path.rstrip('/')) self.included_paths.add(path_mask) - self.routes[path] = SinglePageRouterEntry(path, builder, title).verify() + self.routes[path] = SinglePageRouterPath(path, builder, title).verify() - def add_router_entry(self, entry: SinglePageRouterEntry) -> None: - """Adds a fully configured SinglePageRouterEntry to the router + def add_router_entry(self, entry: "SinglePageRouterPath") -> None: + """Adds a fully configured SinglePageRouterPath to the router - :param entry: The SinglePageRouterEntry to add""" + :param entry: The SinglePageRouterPath to add""" self.routes[entry.path] = entry.verify() def resolve_target(self, target: Union[Callable, str]) -> SinglePageTarget: - """Tries to resolve a target such as a builder function or an URL path w/ route and query parameters. + """Tries to resolve a target such as a builder function or a URL path w/ route and query parameters. :param target: The URL path to open or a builder function :return: The resolved target. Defines .valid if the target is valid""" if isinstance(target, Callable): for target, entry in self.routes.items(): if entry.builder == target: - return SinglePageTarget(entry=entry, router=self) + return SinglePageTarget(builder=entry.builder, title=entry.path) else: resolved = None path = target.split('#')[0].split('?')[0] for cur_router in self.child_routers: # replace {} placeholders with * to match the fnmatch pattern - mask = SinglePageRouterEntry.create_path_mask(cur_router.base_path.rstrip('/') + '/*') + mask = SinglePageRouterPath.create_path_mask(cur_router.base_path.rstrip('/') + '/*') if fnmatch(path, mask) or path == cur_router.base_path: resolved = cur_router.resolve_target(target) if resolved.valid: @@ -145,7 +110,7 @@ def resolve_target(self, target: Union[Callable, str]) -> SinglePageTarget: # isolate the real path elements and update target accordingly target = '/'.join(path.split('/')[:len(cur_router.base_path.split('/'))]) break - result = SinglePageTarget(target, router=self).parse_url_path(routes=self.routes) + result = SinglePageTarget(target).parse_url_path(routes=self.routes) if resolved is not None: result.original_path = resolved.original_path return result @@ -158,10 +123,10 @@ def navigate_to(self, target: Union[Callable, str, SinglePageTarget], server_sid org_target = target if not isinstance(target, SinglePageTarget): target = self.resolve_target(target) - router_frame = context.client.single_page_router_frame - if not target.valid or router_frame is None: + router = context.client.single_page_router + if not target.valid or router is None: return False - router_frame.navigate_to(org_target, _server_side=server_side) + router.navigate_to(org_target, _server_side=server_side) return True def build_page_template(self) -> Generator: @@ -178,7 +143,11 @@ def default_template(): else: return default_template() - def build_page(self, initial_url: Optional[str] = None, **kwargs): + def build_page(self, initial_url: Optional[str] = None, **kwargs) -> None: + """Builds the page with the given initial URL + + :param initial_url: The initial URL to initialize the router's content with + :param kwargs: Additional keyword arguments passed to the page template generator function""" kwargs['url_path'] = initial_url template = RouterFrame.run_safe(self.build_page_template, **kwargs) if not isinstance(template, Generator): @@ -188,7 +157,7 @@ def build_page(self, initial_url: Optional[str] = None, **kwargs): new_properties = next(template) if isinstance(new_properties, dict): new_user_data.update(new_properties) - content_area = self.insert_content_area(initial_url, user_data=new_user_data) + content_area = self.create_router_instance(initial_url, user_data=new_user_data) try: new_properties = next(template) if isinstance(new_properties, dict): @@ -197,29 +166,66 @@ def build_page(self, initial_url: Optional[str] = None, **kwargs): pass content_area.update_user_data(new_user_data) - def insert_content_area(self, - initial_url: Optional[str] = None, - user_data: Optional[Dict] = None) -> RouterFrame: - """Inserts the content area in form of a RouterFrame into the page + def create_router_instance(self, + initial_url: Optional[str] = None, + user_data: Optional[Dict] = None) -> SinglePageRouter: + """Creates a new router instance for the current visitor. :param initial_url: The initial URL to initialize the router's content with - :param user_data: Optional user data to pass to the content area""" - parent_router_frame = RouterFrame.get_current_frame() - content = RouterFrame(router=self, - included_paths=sorted(list(self.included_paths)), - excluded_paths=sorted(list(self.excluded_paths)), - use_browser_history=self.use_browser_history, - parent_router_frame=parent_router_frame, - target_url=initial_url, - user_data=user_data) + :param user_data: Optional user data to pass to the content area + :return: The created router instance""" + parent_router = SinglePageRouter.get_current_frame() + content = SinglePageRouter(config=self, + included_paths=sorted(list(self.included_paths)), + excluded_paths=sorted(list(self.excluded_paths)), + use_browser_history=self.use_browser_history, + parent_router=parent_router, + target_url=initial_url, + user_data=user_data) content.on_resolve(self.resolve_target) - if parent_router_frame is None: # register root routers to the client - context.client.single_page_router_frame = content + if parent_router is None: # register root routers to the client + context.client.single_page_router = content initial_url = content.target_url if initial_url is not None: content.navigate_to(initial_url, _server_side=False, sync=True) return content - def _register_child_router(self, router: 'SinglePageRouterConfig') -> None: - """Registers a child router to the parent router""" - self.child_routers.append(router) + def _register_child_config(self, router_config: 'SinglePageRouterConfig') -> None: + """Registers a child router config to the parent router config""" + self.child_routers.append(router_config) + + +class SinglePageRouterPath: + """The SinglePageRouterPath is a data class which holds the configuration of one router path""" + + def __init__(self, path: str, builder: Callable, title: Union[str, None] = None): + """ + :param path: The path of the route + :param builder: The builder function which is called when the route is opened + :param title: Optional title of the page""" + self.path = path + self.builder = builder + self.title = title + + def verify(self) -> Self: + """Verifies a SinglePageRouterPath for correctness. Raises a ValueError if the entry is invalid.""" + path = self.path + if '{' in path: + # verify only a single open and close curly bracket is present + elements = path.split('/') + for cur_element in elements: + if '{' in cur_element: + if cur_element.count('{') != 1 or cur_element.count('}') != 1 or len(cur_element) < 3 or \ + not (cur_element.startswith('{') and cur_element.endswith('}')): + raise ValueError('Only simple path parameters are supported. /path/{value}/{another_value}\n' + f'failed for path: {path}') + return self + + @staticmethod + def create_path_mask(path: str) -> str: + """Converts a path to a mask which can be used for fnmatch matching + + /site/{value}/{other_value} --> /site/*/* + :param path: The path to convert + :return: The mask with all path parameters replaced by a wildcard""" + return re.sub(r'{[^}]+}', '*', path) diff --git a/nicegui/single_page_target.py b/nicegui/single_page_target.py index 69b60576b..8c39697ba 100644 --- a/nicegui/single_page_target.py +++ b/nicegui/single_page_target.py @@ -1,47 +1,46 @@ import inspect import urllib.parse -from typing import Dict, Optional, TYPE_CHECKING, Self +from typing import Dict, Optional, TYPE_CHECKING, Self, Callable if TYPE_CHECKING: - from nicegui.single_page_router_config import SinglePageRouterEntry, SinglePageRouterConfig + from nicegui.single_page_router_config import SinglePageRouterPath class SinglePageTarget: """A helper class which is used to resolve and URL path and it's query and fragment parameters to find the matching - SinglePageRouterEntry and extract path and query parameters.""" + SinglePageRouterPath and extract path and query parameters.""" def __init__(self, path: Optional[str] = None, - entry: Optional['SinglePageRouterEntry'] = None, fragment: Optional[str] = None, query_string: Optional[str] = None, - router: Optional['SinglePageRouterConfig'] = None): + builder: Optional[Callable] = None, + title: Optional[str] = None): """ - :param path: The path of the URL - :param entry: Predefined entry, e.g. targeting a Callable + :param builder: The builder function to be called when the URL is opened + :param path: The path of the URL (to be shown in the browser) :param fragment: The fragment of the URL :param query_string: The query string of the URL - :param router: The SinglePageRouter by which the URL was resolved + :param title: The title of the URL to be displayed in the browser tab """ - self.routes = {} # all valid routes self.original_path = path - self.path = path # url path w/o query + self.path = path + self.path_args = {} + self.path_elements = [] self.fragment = fragment self.query_string = query_string - self.path_args = {} self.query_args = urllib.parse.parse_qs(self.query_string) - self.entry = entry - self.valid = entry is not None - self.router = router + self.title = title + self.builder = builder + self.valid = builder is not None - def parse_url_path(self, routes: Dict[str, 'SinglePageRouterEntry']) -> Self: + def parse_url_path(self, routes: Dict[str, 'SinglePageRouterPath']) -> Self: """ Parses the route using the provided routes dictionary and path. :param routes: All routes of the single page router """ parsed_url = urllib.parse.urlparse(urllib.parse.unquote(self.path)) - self.routes = routes # all valid routes self.path = parsed_url.path # url path w/o query self.fragment = parsed_url.fragment if len(parsed_url.fragment) > 0 else None self.query_string = parsed_url.query if len(parsed_url.query) > 0 else None @@ -50,19 +49,28 @@ def parse_url_path(self, routes: Dict[str, 'SinglePageRouterEntry']) -> Self: if self.fragment is not None and len(self.path) == 0: self.valid = True return self - self.entry = self.parse_path() - if self.entry is not None: + entry = self.parse_path(routes) + if entry is not None: + self.builder = entry.builder + self.title = entry.title self.convert_arguments() self.valid = True + else: + self.builder = None + self.title = None + self.valid = False return self - def parse_path(self) -> Optional['SinglePageRouterEntry']: + def parse_path(self, routes) -> Optional['SinglePageRouterPath']: """Splits the path into its components, tries to match it with the routes and extracts the path arguments into their corresponding variables. + + :param routes: All valid routes of the single page router """ - for route, entry in self.routes.items(): + path_elements = self.path.lstrip('/').rstrip('/').split('/') + self.path_elements = path_elements + for route, entry in routes.items(): route_elements = route.lstrip('/').split('/') - path_elements = self.path.lstrip('/').rstrip('/').split('/') if len(route_elements) != len(path_elements): # can't match continue match = True @@ -79,7 +87,9 @@ def parse_path(self) -> Optional['SinglePageRouterEntry']: def convert_arguments(self): """Converts the path and query arguments to the expected types of the builder function""" - sig = inspect.signature(self.entry.builder) + if not self.builder: + return + sig = inspect.signature(self.builder) for func_param_name, func_param_info in sig.parameters.items(): for params in [self.path_args, self.query_args]: if func_param_name in params: From b49d0c07d45b4bd4d9276bf43d70ced7bb1b597f Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Sat, 11 May 2024 10:17:17 +0200 Subject: [PATCH 43/79] Added on_resolve to outlet.view which is called when ever a view is selected. The on_resolve method allows overriding the selected target parge and changing it's title. --- examples/outlet/cloud_ui/main.py | 13 ++++++++++-- nicegui/elements/router_frame.py | 18 ++++++++++++++-- nicegui/outlet.py | 31 ++++++++++++++++++++++++---- nicegui/single_page_router.py | 12 +++++++++-- nicegui/single_page_router_config.py | 21 ++++++++++++------- nicegui/single_page_target.py | 14 ++++++++++++- 6 files changed, 91 insertions(+), 18 deletions(-) diff --git a/examples/outlet/cloud_ui/main.py b/examples/outlet/cloud_ui/main.py index 031ca1fcd..1038f3cb2 100644 --- a/examples/outlet/cloud_ui/main.py +++ b/examples/outlet/cloud_ui/main.py @@ -8,6 +8,7 @@ from nicegui import ui from nicegui.page_layout import LeftDrawer +from nicegui.single_page_target import SinglePageTarget # --- Load service data for fake cloud provider portal @@ -110,7 +111,15 @@ def services_router(service_name: str, menu_drawer: LeftDrawer): yield {'service': service} # pass service to all sub elements (views and outlets) -@services_router.view('/') # service index page +def update_title(target: SinglePageTarget, + service: ServiceDefinition = None, + sub_service: SubServiceDefinition = None) -> SinglePageTarget: + if target.router is not None: + target.title = 'NiceCLOUD - ' + (f'{sub_service.title}' if sub_service else f'{service.title}') + return target + + +@services_router.view('/', on_resolved=update_title) # service index page def show_index(service: ServiceDefinition): with ui.row() as row: ui.label(service.emoji).classes('text-h4 vertical-middle') @@ -128,7 +137,7 @@ def sub_service_router(service: ServiceDefinition, sub_service_name: str): yield {'sub_service': sub_service} # pass sub service to all sub elements (views and outlets) -@sub_service_router.view('/') # sub service index page +@sub_service_router.view('/', on_resolved=update_title) # sub service index page def sub_service_index(sub_service: SubServiceDefinition): ui.label(sub_service.emoji).classes('text-h1') ui.html('
') diff --git a/nicegui/elements/router_frame.py b/nicegui/elements/router_frame.py index 792879c30..20ccdfd1a 100644 --- a/nicegui/elements/router_frame.py +++ b/nicegui/elements/router_frame.py @@ -119,14 +119,28 @@ def child_frame_paths(self, paths: list[str]) -> None: self._props['child_frame_paths'] = paths @staticmethod - def run_safe(builder, **kwargs) -> Any: + def run_safe(builder, type_check: bool = True, **kwargs) -> Any: """Run a builder function but only pass the keyword arguments which are expected by the builder function :param builder: The builder function + :param type_check: Optional flag to enable or disable the type checking of the keyword arguments. + Default is True. :param kwargs: The keyword arguments to pass to the builder function """ - args = inspect.signature(builder).parameters.keys() + sig = inspect.signature(builder) + args = sig.parameters.keys() has_kwargs = any([param.kind == inspect.Parameter.VAR_KEYWORD for param in inspect.signature(builder).parameters.values()]) + if type_check: + for func_param_name, func_param_info in sig.parameters.items(): + if func_param_name not in kwargs: + continue + if func_param_info.annotation is inspect.Parameter.empty: + continue + # ensure type is correct + if not isinstance(kwargs[func_param_name], func_param_info.annotation): + raise ValueError(f'Invalid type for parameter {func_param_name}, ' + f'expected {func_param_info.annotation}') + filtered = {k: v for k, v in kwargs.items() if k in args} if not has_kwargs else kwargs return builder(**filtered) diff --git a/nicegui/outlet.py b/nicegui/outlet.py index 1323077e9..53525e533 100644 --- a/nicegui/outlet.py +++ b/nicegui/outlet.py @@ -1,9 +1,11 @@ +import inspect from typing import Callable, Any, Self, Optional, Generator from nicegui.client import Client from nicegui.single_page_router import SinglePageRouter from nicegui.single_page_router_config import SinglePageRouterConfig from nicegui.elements.router_frame import RouterFrame +from nicegui.single_page_target import SinglePageTarget class Outlet(SinglePageRouterConfig): @@ -86,7 +88,11 @@ def outlet_view(**kwargs): OutletView(self.parent_router, relative_path)(outlet_view) return self - def view(self, path: str, title: Optional[str] = None) -> 'OutletView': + def view(self, + path: str, + title: Optional[str] = None, + on_resolved: Optional[Callable[[SinglePageTarget, Any], SinglePageTarget]] = None + ) -> 'OutletView': """Decorator for the view function. With the view function you define the actual content of the page. The view function is called when the user @@ -95,8 +101,10 @@ def view(self, path: str, title: Optional[str] = None) -> 'OutletView': :param path: The path of the view, relative to the base path of the outlet :param title: Optional title of the view. If a title is set, it will be displayed in the browser tab when the view is active, otherwise the default title of the application is displayed. + :param on_resolved: Optional callback which is called when the target is resolved to this view. It can be used + to modify the target before the view is displayed. """ - return OutletView(self, path, title=title) + return OutletView(self, path, title=title, on_resolved=on_resolved) def outlet(self, path: str) -> 'Outlet': """Defines a nested outlet @@ -123,16 +131,20 @@ def current_url(self) -> str: class OutletView: """Defines a single view / "content page" which is displayed in an outlet""" - def __init__(self, parent_outlet: SinglePageRouterConfig, path: str, title: Optional[str] = None): + def __init__(self, parent_outlet: SinglePageRouterConfig, path: str, title: Optional[str] = None, + on_resolved: Optional[Callable[[SinglePageTarget, Any], SinglePageTarget]] = None): """ :param parent_outlet: The parent outlet in which this view is displayed :param path: The path of the view, relative to the base path of the outlet :param title: Optional title of the view. If a title is set, it will be displayed in the browser tab when the view is active, otherwise the default title of the application is displayed. + :param on_resolved: Optional callback which is called when the target is resolved to this view. It can be used + to modify the target before the view is displayed. """ self.path = path self.title = title self.parent_outlet = parent_outlet + self.on_resolved = on_resolved @property def url(self) -> str: @@ -142,9 +154,20 @@ def url(self) -> str: """ return (self.parent_outlet.base_path.rstrip('/') + '/' + self.path.lstrip('/')).rstrip('/') + def handle_resolve(self, target: SinglePageTarget, **kwargs) -> SinglePageTarget: + """Is called when the target is resolved to this view + + :param target: The resolved target + :return: The resolved target or a modified target + """ + if self.on_resolved is not None: + RouterFrame.run_safe(self.on_resolved, **kwargs | {'target': target}, + type_check=True) + return target + def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]: """Decorator for the view function""" abs_path = self.url self.parent_outlet.add_view( - abs_path, func, title=self.title) + abs_path, func, title=self.title, on_resolve=self.handle_resolve) return self diff --git a/nicegui/single_page_router.py b/nicegui/single_page_router.py index d2698d35e..8a46171a7 100644 --- a/nicegui/single_page_router.py +++ b/nicegui/single_page_router.py @@ -83,7 +83,10 @@ def resolve_target(self, target: Any) -> SinglePageTarget: if isinstance(target, SinglePageTarget): return target if self._on_resolve is not None: - return self._on_resolve(target) + target = self._on_resolve(target) + if target.valid and target.router is None: + target.router = self + return target raise NotImplementedError def navigate_to(self, target: [SinglePageTarget, str], _server_side=True, sync=False) -> None: @@ -99,7 +102,13 @@ def navigate_to(self, target: [SinglePageTarget, str], _server_side=True, sync=F if path_mask == target or target.startswith(path_mask + '/'): frame.navigate_to(target, _server_side) return + recursive_user_data = SinglePageRouter.get_user_data() | self.user_data | self.router_frame.user_data target = self.resolve_target(target) + if target.router_path is not None: + rp = target.router_path + if rp.on_resolve is not None: + target = rp.on_resolve(target, **recursive_user_data) + if target.builder is None: if target.fragment is not None: ui.run_javascript(f'window.location.href = "#{target.fragment}";') # go to fragment @@ -111,7 +120,6 @@ def navigate_to(self, target: [SinglePageTarget, str], _server_side=True, sync=F self.router_frame.target_url = target.original_path builder_kwargs = {**target.path_args, **target.query_args, 'url_path': target.original_path} target_fragment = target.fragment - recursive_user_data = SinglePageRouter.get_user_data() | self.user_data | self.router_frame.user_data builder_kwargs.update(recursive_user_data) self.router_frame.update_content(target.builder, builder_kwargs, target.title, target_fragment, sync) diff --git a/nicegui/single_page_router_config.py b/nicegui/single_page_router_config.py index 4da30ddbe..b8352e49a 100644 --- a/nicegui/single_page_router_config.py +++ b/nicegui/single_page_router_config.py @@ -1,6 +1,6 @@ import re from fnmatch import fnmatch -from typing import Callable, Dict, Union, Optional, Self, List, Set, Generator +from typing import Callable, Dict, Union, Optional, Self, List, Set, Generator, Any from nicegui import ui from nicegui.context import context @@ -71,15 +71,18 @@ async def root_page(request_data=None): return self - def add_view(self, path: str, builder: Callable, title: Optional[str] = None) -> None: + def add_view(self, path: str, builder: Callable, title: Optional[str] = None, + on_resolve: Optional[Callable[[SinglePageTarget], SinglePageTarget]] = None) -> None: """Add a new route to the single page router :param path: The path of the route, including FastAPI path parameters :param builder: The builder function (the view to be displayed) - :param title: Optional title of the page""" + :param title: Optional title of the page + :param on_resolve: Optional on_resolve function which is called when this path was selected. + """ path_mask = SinglePageRouterPath.create_path_mask(path.rstrip('/')) self.included_paths.add(path_mask) - self.routes[path] = SinglePageRouterPath(path, builder, title).verify() + self.routes[path] = SinglePageRouterPath(path, builder, title, on_resolve=on_resolve).verify() def add_router_entry(self, entry: "SinglePageRouterPath") -> None: """Adds a fully configured SinglePageRouterPath to the router @@ -92,10 +95,11 @@ def resolve_target(self, target: Union[Callable, str]) -> SinglePageTarget: :param target: The URL path to open or a builder function :return: The resolved target. Defines .valid if the target is valid""" + resolve_handler = None if isinstance(target, Callable): for target, entry in self.routes.items(): if entry.builder == target: - return SinglePageTarget(builder=entry.builder, title=entry.path) + return SinglePageTarget(router_path=entry) else: resolved = None path = target.split('#')[0].split('?')[0] @@ -198,14 +202,17 @@ def _register_child_config(self, router_config: 'SinglePageRouterConfig') -> Non class SinglePageRouterPath: """The SinglePageRouterPath is a data class which holds the configuration of one router path""" - def __init__(self, path: str, builder: Callable, title: Union[str, None] = None): + def __init__(self, path: str, builder: Callable, title: Union[str, None] = None, + on_resolve: Optional[Callable[[SinglePageTarget, Any], SinglePageTarget]] = None): """ :param path: The path of the route :param builder: The builder function which is called when the route is opened - :param title: Optional title of the page""" + :param title: Optional title of the page + :param on_resolve: Optional on_resolve function which is called when this path was selected.""" self.path = path self.builder = builder self.title = title + self.on_resolve = on_resolve def verify(self) -> Self: """Verifies a SinglePageRouterPath for correctness. Raises a ValueError if the entry is invalid.""" diff --git a/nicegui/single_page_target.py b/nicegui/single_page_target.py index 8c39697ba..2cb46a5e2 100644 --- a/nicegui/single_page_target.py +++ b/nicegui/single_page_target.py @@ -4,6 +4,7 @@ if TYPE_CHECKING: from nicegui.single_page_router_config import SinglePageRouterPath + from nicegui.single_page_router import SinglePageRouter class SinglePageTarget: @@ -15,13 +16,17 @@ def __init__(self, fragment: Optional[str] = None, query_string: Optional[str] = None, builder: Optional[Callable] = None, - title: Optional[str] = None): + title: Optional[str] = None, + router: Optional["SinglePageRouter"] = None, + router_path: Optional["SinglePageRouterPath"] = None): """ :param builder: The builder function to be called when the URL is opened :param path: The path of the URL (to be shown in the browser) :param fragment: The fragment of the URL :param query_string: The query string of the URL :param title: The title of the URL to be displayed in the browser tab + :param router: The SinglePageRouter which is used to resolve the URL + :param router_path: The SinglePageRouterPath which is matched by the URL """ self.original_path = path self.path = path @@ -33,6 +38,12 @@ def __init__(self, self.title = title self.builder = builder self.valid = builder is not None + self.router = router + self.router_path: Optional["SinglePageRouterPath"] = router_path + if router_path is not None and router_path.builder is not None: + self.builder = router_path.builder + self.title = router_path.title + self.valid = True def parse_url_path(self, routes: Dict[str, 'SinglePageRouterPath']) -> Self: """ @@ -50,6 +61,7 @@ def parse_url_path(self, routes: Dict[str, 'SinglePageRouterPath']) -> Self: self.valid = True return self entry = self.parse_path(routes) + self.router_path = entry if entry is not None: self.builder = entry.builder self.title = entry.title From 1fe8a96ef24289aea784486aeb20e0a5b1232f20 Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Sat, 11 May 2024 19:06:21 +0200 Subject: [PATCH 44/79] * Added completely served side handled browser history * Preparation for more detailed event handling on resolving and navigating to SPA pages --- examples/outlet/cloud_ui/main.py | 6 +- nicegui/elements/router_frame.js | 10 +-- nicegui/elements/router_frame.py | 12 ++-- nicegui/outlet.py | 26 +++---- nicegui/single_page_router.py | 104 +++++++++++++++++---------- nicegui/single_page_router_config.py | 43 +++++++---- 6 files changed, 122 insertions(+), 79 deletions(-) diff --git a/examples/outlet/cloud_ui/main.py b/examples/outlet/cloud_ui/main.py index 1038f3cb2..336a6380c 100644 --- a/examples/outlet/cloud_ui/main.py +++ b/examples/outlet/cloud_ui/main.py @@ -86,7 +86,7 @@ def main_app_index(menu_drawer: LeftDrawer): # main app index page ui.label(info.description) ui.html('

') # add a link to the other app - ui.markdown("Click [here](/other_app) to visit the other app.") + ui.markdown('Click [here](/other_app) to visit the other app.') @main_router.outlet('/services/{service_name}') # service outlet @@ -119,7 +119,7 @@ def update_title(target: SinglePageTarget, return target -@services_router.view('/', on_resolved=update_title) # service index page +@services_router.view('/', on_open=update_title) # service index page def show_index(service: ServiceDefinition): with ui.row() as row: ui.label(service.emoji).classes('text-h4 vertical-middle') @@ -137,7 +137,7 @@ def sub_service_router(service: ServiceDefinition, sub_service_name: str): yield {'sub_service': sub_service} # pass sub service to all sub elements (views and outlets) -@sub_service_router.view('/', on_resolved=update_title) # sub service index page +@sub_service_router.view('/', on_open=update_title) # sub service index page def sub_service_index(sub_service: SubServiceDefinition): ui.label(sub_service.emoji).classes('text-h1') ui.html('
') diff --git a/nicegui/elements/router_frame.js b/nicegui/elements/router_frame.js index baadded6c..13a40844e 100644 --- a/nicegui/elements/router_frame.js +++ b/nicegui/elements/router_frame.js @@ -50,7 +50,7 @@ export default { // If there's no
tag, or the tag has no href attribute, do nothing if (!link || !link.hasAttribute('href')) return; let href = link.getAttribute('href'); - if (href === "#") { + if (href === '#') { e.preventDefault(); return; } @@ -58,11 +58,7 @@ export default { if (validate_path(href)) { e.preventDefault(); // Prevent the default link behavior if (!is_handled_by_child_frame(href)) { - if (router.use_browser_history) { - window.history.pushState({page: href}, '', href); - if (router._debug) console.log('RouterFrame pushing state ' + href + ' by ' + router.base_path); - } - router.$emit('open', href); + router.$emit('open', href, true); if (router._debug) console.log('Opening ' + href + ' by ' + router.base_path); } } @@ -75,7 +71,7 @@ export default { return; } if (validate_path(href) && !is_handled_by_child_frame(href)) { - router.$emit('open', href); + router.$emit('open', href, false); if (router._debug) console.log('Pop opening ' + href + ' by ' + router.base_path); } }; diff --git a/nicegui/elements/router_frame.py b/nicegui/elements/router_frame.py index 20ccdfd1a..67a94e196 100644 --- a/nicegui/elements/router_frame.py +++ b/nicegui/elements/router_frame.py @@ -16,7 +16,7 @@ def __init__(self, excluded_paths: Optional[list[str]] = None, use_browser_history: bool = True, change_title: bool = True, - on_navigate: Optional[Callable[[str], Any]] = None, + on_navigate: Optional[Callable[[str, Optional[bool]], Any]] = None, user_data: Optional[dict] = None ): """ @@ -49,16 +49,18 @@ def __init__(self, self._props['base_path'] = base_path self._props['browser_history'] = use_browser_history self._props['child_frames'] = [] - self.on('open', lambda e: self.handle_navigate(e.args)) + self.on('open', lambda e: self.handle_navigate(e.args[0], e.args[1])) self.on_navigate = on_navigate self.user_data = user_data if user_data is not None else {} - def handle_navigate(self, url: str): + def handle_navigate(self, url: str, history=True): """Navigate to a new url - :param url: The url to navigate to""" + :param url: The url to navigate to + :param history: Optional flag to enable or disable the browser history management. Default is True. + """ if self.on_navigate is not None: - self.on_navigate(url) + self.on_navigate(url, history) @property def target_url(self) -> str: diff --git a/nicegui/outlet.py b/nicegui/outlet.py index 53525e533..168a8812e 100644 --- a/nicegui/outlet.py +++ b/nicegui/outlet.py @@ -81,17 +81,17 @@ def outlet_view(**kwargs): self.build_page(**kwargs) self.outlet_builder = func - if self.parent_router is None: + if self.parent_config is None: self.setup_pages() else: - relative_path = self.base_path[len(self.parent_router.base_path):] - OutletView(self.parent_router, relative_path)(outlet_view) + relative_path = self.base_path[len(self.parent_config.base_path):] + OutletView(self.parent_config, relative_path)(outlet_view) return self def view(self, path: str, title: Optional[str] = None, - on_resolved: Optional[Callable[[SinglePageTarget, Any], SinglePageTarget]] = None + on_open: Optional[Callable[[SinglePageTarget, Any], SinglePageTarget]] = None ) -> 'OutletView': """Decorator for the view function. @@ -101,10 +101,10 @@ def view(self, :param path: The path of the view, relative to the base path of the outlet :param title: Optional title of the view. If a title is set, it will be displayed in the browser tab when the view is active, otherwise the default title of the application is displayed. - :param on_resolved: Optional callback which is called when the target is resolved to this view. It can be used + :param on_open: Optional callback which is called when the target is resolved to this view. It can be used to modify the target before the view is displayed. """ - return OutletView(self, path, title=title, on_resolved=on_resolved) + return OutletView(self, path, title=title, on_open=on_open) def outlet(self, path: str) -> 'Outlet': """Defines a nested outlet @@ -132,19 +132,19 @@ class OutletView: """Defines a single view / "content page" which is displayed in an outlet""" def __init__(self, parent_outlet: SinglePageRouterConfig, path: str, title: Optional[str] = None, - on_resolved: Optional[Callable[[SinglePageTarget, Any], SinglePageTarget]] = None): + on_open: Optional[Callable[[SinglePageTarget, Any], SinglePageTarget]] = None): """ :param parent_outlet: The parent outlet in which this view is displayed :param path: The path of the view, relative to the base path of the outlet :param title: Optional title of the view. If a title is set, it will be displayed in the browser tab when the view is active, otherwise the default title of the application is displayed. - :param on_resolved: Optional callback which is called when the target is resolved to this view. It can be used - to modify the target before the view is displayed. + :param on_open: Optional callback which is called when the target is resolved to this view and going to be + opened. It can be used to modify the target before the view is displayed. """ self.path = path self.title = title self.parent_outlet = parent_outlet - self.on_resolved = on_resolved + self.on_open = on_open @property def url(self) -> str: @@ -160,8 +160,8 @@ def handle_resolve(self, target: SinglePageTarget, **kwargs) -> SinglePageTarget :param target: The resolved target :return: The resolved target or a modified target """ - if self.on_resolved is not None: - RouterFrame.run_safe(self.on_resolved, **kwargs | {'target': target}, + if self.on_open is not None: + RouterFrame.run_safe(self.on_open, **kwargs | {'target': target}, type_check=True) return target @@ -169,5 +169,5 @@ def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]: """Decorator for the view function""" abs_path = self.url self.parent_outlet.add_view( - abs_path, func, title=self.title, on_resolve=self.handle_resolve) + abs_path, func, title=self.title, on_open=self.handle_resolve) return self diff --git a/nicegui/single_page_router.py b/nicegui/single_page_router.py index 8a46171a7..efcf72ef2 100644 --- a/nicegui/single_page_router.py +++ b/nicegui/single_page_router.py @@ -5,6 +5,8 @@ from nicegui.elements.router_frame import RouterFrame from nicegui.single_page_target import SinglePageTarget +PATH_RESOLVING_MAX_RECURSION = 100 + if TYPE_CHECKING: from nicegui.single_page_router_config import SinglePageRouterConfig @@ -20,12 +22,12 @@ class SinglePageRouter: See @ui.outlet or SinglePageApp for more information.""" def __init__(self, - config: "SinglePageRouterConfig", + config: 'SinglePageRouterConfig', included_paths: Optional[list[str]] = None, excluded_paths: Optional[list[str]] = None, use_browser_history: bool = True, change_title: bool = True, - parent_router: "SinglePageRouter" = None, + parent_router: 'SinglePageRouter' = None, target_url: Optional[str] = None, user_data: Optional[Dict] = None ): @@ -39,12 +41,12 @@ def __init__(self, :param user_data: Optional user data which is passed to the builder functions of the router frame """ super().__init__() - self.router = config + self.router_config = config if target_url is None: if parent_router is not None and parent_router.target_url is not None: target_url = parent_router.target_url else: - target_url = self.router.base_path + target_url = self.router_config.base_path self.user_data = user_data self.child_frames: dict[str, "SinglePageRouter"] = {} self.use_browser_history = use_browser_history @@ -52,73 +54,103 @@ def __init__(self, self.parent_frame = parent_router if parent_router is not None: parent_router._register_sub_frame(included_paths[0], self) - self._on_resolve: Optional[Callable[[Any], SinglePageTarget]] = None - self.router_frame = RouterFrame(base_path=self.router.base_path, + self.router_frame = RouterFrame(base_path=self.router_config.base_path, target_url=target_url, included_paths=included_paths, excluded_paths=excluded_paths, use_browser_history=use_browser_history, - on_navigate=lambda url: self.navigate_to(url, _server_side=False), - user_data={"router": self}) + on_navigate=lambda url, history: self.navigate_to(url, history=history), + user_data={'router': self}) + self._on_navigate: Optional[Callable[[str], Optional[str]]] = None + self._on_resolve: Optional[Callable[[str], SinglePageTarget]] = None @property def target_url(self) -> str: - """The current target url of the router frame""" - return self.router_frame.target_url + """The current target url of the router frame - def on_resolve(self, on_resolve: Callable[[Any], SinglePageTarget]) -> Self: - """Set the on_resolve function which is used to resolve the target to a SinglePageUrl - - :param on_resolve: The on_resolve function which receives a target object such as an URL or Callable and - returns a SinglePageUrl object.""" - self._on_resolve = on_resolve - return self + :return: The target url of the router frame""" + return self.router_frame.target_url - def resolve_target(self, target: Any) -> SinglePageTarget: + def resolve_target(self, target: Any, user_data: Optional[Dict] = None) -> SinglePageTarget: """Resolves a URL or SPA target to a SinglePageUrl which contains details about the builder function to be called and the arguments to pass to the builder function. :param target: The target object such as a URL or Callable + :param user_data: Optional user data which is passed to the resolver functions :return: The resolved SinglePageUrl object""" if isinstance(target, SinglePageTarget): return target - if self._on_resolve is not None: - target = self._on_resolve(target) - if target.valid and target.router is None: - target.router = self - return target - raise NotImplementedError + target = self.router_config.resolve_target(target) + if target.valid and target.router is None: + target.router = self + if target is None: + raise NotImplementedError + if target.router_path is not None: + rp = target.router_path + original_path = target.original_path + if rp.on_open is not None: + target = rp.on_open(target, **user_data) + if target.original_path != original_path: + return self.resolve_target(target, user_data) + return target + + def handle_navigate(self, target: str) -> Optional[str]: + """ + Is called when there was a navigation event in the browser or by the navigate_to method. + + By default the original target is returned. The SinglePageRouter and the router config (the outlet) can + manipulate the target before it is resolved. If the target is None, the navigation is suppressed. - def navigate_to(self, target: [SinglePageTarget, str], _server_side=True, sync=False) -> None: + :param target: The target URL + :return: The target URL or None if the navigation is suppressed + """ + cur_config = self.router_config + while cur_config is not None: + if cur_config.on_navigate is not None: + new_target = cur_config.on_navigate(target) + if new_target is None or new_target != target: + return new_target + cur_config = cur_config.parent_config + return target + + def navigate_to(self, target: [SinglePageTarget, str], server_side=True, sync=False, + history: bool = True) -> None: """Open a new page in the browser by exchanging the content of the router frame :param target: The target page or url. - :param _server_side: Optional flag which defines if the call is originated on the server side and thus + :param server_side: Optional flag which defines if the call is originated on the server side and thus the browser history should be updated. Default is False. :param sync: Optional flag to define if the content should be updated synchronously. Default is False. + :param history: Optional flag defining if the history setting shall be respected. Default is True. """ # check if sub router is active and might handle the target for path_mask, frame in self.child_frames.items(): if path_mask == target or target.startswith(path_mask + '/'): - frame.navigate_to(target, _server_side) + frame.navigate_to(target, server_side) + return + if isinstance(target, str): + target = self.handle_navigate(target) + if target is None: return recursive_user_data = SinglePageRouter.get_user_data() | self.user_data | self.router_frame.user_data - target = self.resolve_target(target) - if target.router_path is not None: - rp = target.router_path - if rp.on_resolve is not None: - target = rp.on_resolve(target, **recursive_user_data) - + target = self.resolve_target(target, user_data=recursive_user_data) + if target is None or not target.valid: # navigation suppressed + return + original_path = target.original_path + if history and not server_side and self.use_browser_history: # triggered by the browser + ui.run_javascript(f'window.history.pushState({{page: "{original_path}"}}, "", "{original_path}");') + print("X Navigating to ", original_path) if target.builder is None: if target.fragment is not None: ui.run_javascript(f'window.location.href = "#{target.fragment}";') # go to fragment return target = SinglePageTarget(builder=self._page_not_found, title='Page not found') - if _server_side and self.use_browser_history: + if server_side and self.use_browser_history and history: ui.run_javascript( - f'window.history.pushState({{page: "{target.original_path}"}}, "", "{target.original_path}");') + f'window.history.pushState({{page: "{original_path}"}}, "", "{original_path}");') + print("Y Navigating to ", original_path) self.router_frame.target_url = target.original_path - builder_kwargs = {**target.path_args, **target.query_args, 'url_path': target.original_path} + builder_kwargs = {**target.path_args, **target.query_args, 'url_path': original_path} target_fragment = target.fragment builder_kwargs.update(recursive_user_data) self.router_frame.update_content(target.builder, builder_kwargs, target.title, target_fragment, sync) diff --git a/nicegui/single_page_router_config.py b/nicegui/single_page_router_config.py index b8352e49a..47fa91dfa 100644 --- a/nicegui/single_page_router_config.py +++ b/nicegui/single_page_router_config.py @@ -1,4 +1,5 @@ import re +import typing from fnmatch import fnmatch from typing import Callable, Dict, Union, Optional, Self, List, Set, Generator, Any @@ -9,6 +10,9 @@ from nicegui.single_page_router import SinglePageRouter from nicegui.single_page_target import SinglePageTarget +if typing.TYPE_CHECKING: + from nicegui.single_page_router import SinglePageRouter + class SinglePageRouterConfig: """The SinglePageRouterConfig allows the development of Single Page Applications (SPAs). @@ -19,9 +23,9 @@ class SinglePageRouterConfig: def __init__(self, path: str, browser_history: bool = True, - parent: Optional["SinglePageRouterConfig"] = None, + parent: Optional['SinglePageRouterConfig'] = None, page_template: Optional[Callable[[], Generator]] = None, - on_instance_created: Optional[Callable] = None, + on_instance_created: Optional[Callable[['SinglePageRouter'], None]] = None, **kwargs) -> None: """:param path: the base path of the single page router. :param browser_history: Optional flag to enable or disable the browser history management. Default is True. @@ -37,12 +41,15 @@ def __init__(self, self.included_paths: Set[str] = set() self.excluded_paths: Set[str] = set() self.on_instance_created: Optional[Callable] = on_instance_created + self.on_resolve: Optional[Callable[[str], Optional[SinglePageTarget]]] = None + self.on_open: Optional[Callable[[SinglePageTarget], SinglePageTarget]] = None + self.on_navigate: Optional[Callable[[str], Optional[str]]] = None self.use_browser_history = browser_history self.page_template = page_template self._setup_configured = False - self.parent_router = parent - if self.parent_router is not None: - self.parent_router._register_child_config(self) + self.parent_config = parent + if self.parent_config is not None: + self.parent_config._register_child_config(self) self.child_routers: List['SinglePageRouterConfig'] = [] self.page_kwargs = kwargs @@ -72,17 +79,17 @@ async def root_page(request_data=None): return self def add_view(self, path: str, builder: Callable, title: Optional[str] = None, - on_resolve: Optional[Callable[[SinglePageTarget], SinglePageTarget]] = None) -> None: + on_open: Optional[Callable[[SinglePageTarget], SinglePageTarget]] = None) -> None: """Add a new route to the single page router :param path: The path of the route, including FastAPI path parameters :param builder: The builder function (the view to be displayed) :param title: Optional title of the page - :param on_resolve: Optional on_resolve function which is called when this path was selected. + :param on_open: Optional on_resolve function which is called when this path was selected. """ path_mask = SinglePageRouterPath.create_path_mask(path.rstrip('/')) self.included_paths.add(path_mask) - self.routes[path] = SinglePageRouterPath(path, builder, title, on_resolve=on_resolve).verify() + self.routes[path] = SinglePageRouterPath(path, builder, title, on_open=on_open).verify() def add_router_entry(self, entry: "SinglePageRouterPath") -> None: """Adds a fully configured SinglePageRouterPath to the router @@ -95,12 +102,15 @@ def resolve_target(self, target: Union[Callable, str]) -> SinglePageTarget: :param target: The URL path to open or a builder function :return: The resolved target. Defines .valid if the target is valid""" - resolve_handler = None if isinstance(target, Callable): for target, entry in self.routes.items(): if entry.builder == target: return SinglePageTarget(router_path=entry) else: + if self.on_resolve is not None: + resolved = self.on_resolve(target) + if resolved is not None: + return resolved resolved = None path = target.split('#')[0].split('?')[0] for cur_router in self.child_routers: @@ -117,6 +127,8 @@ def resolve_target(self, target: Union[Callable, str]) -> SinglePageTarget: result = SinglePageTarget(target).parse_url_path(routes=self.routes) if resolved is not None: result.original_path = resolved.original_path + if self.on_open: + result = self.on_open(result) return result def navigate_to(self, target: Union[Callable, str, SinglePageTarget], server_side=True) -> bool: @@ -130,7 +142,7 @@ def navigate_to(self, target: Union[Callable, str, SinglePageTarget], server_sid router = context.client.single_page_router if not target.valid or router is None: return False - router.navigate_to(org_target, _server_side=server_side) + router.navigate_to(org_target, server_side=server_side) return True def build_page_template(self) -> Generator: @@ -186,12 +198,13 @@ def create_router_instance(self, parent_router=parent_router, target_url=initial_url, user_data=user_data) - content.on_resolve(self.resolve_target) if parent_router is None: # register root routers to the client context.client.single_page_router = content initial_url = content.target_url + if self.on_instance_created is not None: + self.on_instance_created(content) if initial_url is not None: - content.navigate_to(initial_url, _server_side=False, sync=True) + content.navigate_to(initial_url, server_side=False, sync=True, history=False) return content def _register_child_config(self, router_config: 'SinglePageRouterConfig') -> None: @@ -203,16 +216,16 @@ class SinglePageRouterPath: """The SinglePageRouterPath is a data class which holds the configuration of one router path""" def __init__(self, path: str, builder: Callable, title: Union[str, None] = None, - on_resolve: Optional[Callable[[SinglePageTarget, Any], SinglePageTarget]] = None): + on_open: Optional[Callable[[SinglePageTarget, Any], SinglePageTarget]] = None): """ :param path: The path of the route :param builder: The builder function which is called when the route is opened :param title: Optional title of the page - :param on_resolve: Optional on_resolve function which is called when this path was selected.""" + :param on_open: Optional on_resolve function which is called when this path was selected.""" self.path = path self.builder = builder self.title = title - self.on_resolve = on_resolve + self.on_open = on_open def verify(self) -> Self: """Verifies a SinglePageRouterPath for correctness. Raises a ValueError if the entry is invalid.""" From 1823110815460d82adb3c6cc3189a84a507173e8 Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Sun, 12 May 2024 13:49:59 +0200 Subject: [PATCH 45/79] * Added post- and pre-update callback in SinglePageTarget to execute specific commands when an SPA content as exchanged * Added on_resolve, on_navigate and on_open events to the Outlet class to enable it to intercept and/or redirect or update certain pages change * Add on_resolve, on_navigate and on_open to SinglePageRouter, allowing the user to define these events just for specific instances * Bugfix: Title updated twice on an SPA page change sometimes. It is ensured now that the title is only update by views changes. * BugFix: For Outlets with path variables always the whole hierarchy was rebuilt on ever page change. This is fixed now. --- examples/outlet/cloud_ui/main.py | 11 +- nicegui/elements/router_frame.js | 4 +- nicegui/elements/router_frame.py | 8 +- nicegui/outlet.py | 19 ++- nicegui/single_page_app.py | 4 +- nicegui/single_page_router.py | 184 +++++++++++++++++++-------- nicegui/single_page_router_config.py | 57 +++++++-- nicegui/single_page_target.py | 21 ++- 8 files changed, 214 insertions(+), 94 deletions(-) diff --git a/examples/outlet/cloud_ui/main.py b/examples/outlet/cloud_ui/main.py index 336a6380c..0257ab8bb 100644 --- a/examples/outlet/cloud_ui/main.py +++ b/examples/outlet/cloud_ui/main.py @@ -23,12 +23,15 @@ class SubServiceDefinition(BaseModel): class ServiceDefinition(BaseModel): title: str = Field(..., description='The title of the cloud service', examples=['Virtual Machines']) emoji: str = Field(..., description='An emoji representing the cloud service', examples=['💻']) - description: str - sub_services: Dict[str, SubServiceDefinition] + description: str = Field(..., description='A short description of the cloud service', + examples=['Create and manage virtual machines']) + sub_services: Dict[str, SubServiceDefinition] = Field(..., + description='The sub-services of the cloud service') class ServiceDefinitions(BaseModel): - services: Dict[str, ServiceDefinition] + services: Dict[str, ServiceDefinition] = Field(..., + description='The cloud services provided by the cloud provider') services = ServiceDefinitions.parse_file(os.path.join(os.path.dirname(__file__), 'services.json')).services @@ -144,4 +147,4 @@ def sub_service_index(sub_service: SubServiceDefinition): ui.label(sub_service.description) -ui.run(show=False, title='NiceCLOUD Portal') +ui.run(title='NiceCLOUD Portal') diff --git a/nicegui/elements/router_frame.js b/nicegui/elements/router_frame.js index 13a40844e..08a49b804 100644 --- a/nicegui/elements/router_frame.js +++ b/nicegui/elements/router_frame.js @@ -35,7 +35,7 @@ export default { let href = normalize_path(path); for (let frame of router.child_frame_paths) { if (path.startsWith(frame + '/') || (href === frame)) { - console.log(href + ' handled by child RouterFrame ' + frame + ', skipping...'); + if (router._debug) console.log(href + ' handled by child RouterFrame ' + frame + ', skipping...'); return true; } } @@ -91,6 +91,6 @@ export default { excluded_path_masks: [], use_browser_history: {type: Boolean, default: true}, child_frame_paths: [], - _debug: {type: Boolean, default: true}, + _debug: {type: Boolean, default: false}, }, }; diff --git a/nicegui/elements/router_frame.py b/nicegui/elements/router_frame.py index 67a94e196..a0f5daa0a 100644 --- a/nicegui/elements/router_frame.py +++ b/nicegui/elements/router_frame.py @@ -1,7 +1,7 @@ import inspect from typing import Optional, Any, Callable -from nicegui import ui, background_tasks, core +from nicegui import ui, background_tasks class RouterFrame(ui.element, component='router_frame.js'): @@ -15,7 +15,6 @@ def __init__(self, included_paths: Optional[list[str]] = None, excluded_paths: Optional[list[str]] = None, use_browser_history: bool = True, - change_title: bool = True, on_navigate: Optional[Callable[[str, Optional[bool]], Any]] = None, user_data: Optional[dict] = None ): @@ -24,13 +23,11 @@ def __init__(self, :param included_paths: A list of valid path masks which shall be allowed to be opened by the router :param excluded_paths: A list of path masks which shall be excluded from the router :param use_browser_history: Optional flag to enable or disable the browser history management. Default is True. - :param change_title: Optional flag to enable or disable the title change. Default is True. :param target_url: The initial url of the router frame :param on_navigate: Optional callback which is called when the browser / JavaScript navigates to a new url :param user_data: Optional user data which is passed to the builder functions of the router frame """ super().__init__() - self.change_title = change_title included_masks = [] excluded_masks = [] if included_paths is not None: @@ -81,8 +78,6 @@ def update_content(self, builder, builder_kwargs, title, target_fragment, :param title: The title of the page :param target_fragment: The fragment to navigate to after the content has been loaded :param sync: Optional flag to define if the content should be updated synchronously. Default is False.""" - if self.change_title: - ui.page_title(title if title is not None else core.app.config.title) def exec_builder(): """Execute the builder function with the given keyword arguments""" @@ -94,7 +89,6 @@ async def build() -> None: if target_fragment is not None: await ui.run_javascript(f'window.location.href = "#{target_fragment}";') - self.clear() if sync: with self: exec_builder() diff --git a/nicegui/outlet.py b/nicegui/outlet.py index 168a8812e..927bd7d0a 100644 --- a/nicegui/outlet.py +++ b/nicegui/outlet.py @@ -26,7 +26,10 @@ def __init__(self, outlet_builder: Optional[Callable] = None, browser_history: bool = True, parent: Optional['SinglePageRouterConfig'] = None, - on_instance_created: Optional[Callable] = None, + on_instance_created: Optional[Callable[['SinglePageRouter'], None]] = None, + on_resolve: Optional[Callable[[str], Optional[SinglePageTarget]]] = None, + on_open: Optional[Callable[[SinglePageTarget], SinglePageTarget]] = None, + on_navigate: Optional[Callable[[str], Optional[str]]] = None, **kwargs) -> None: """ :param path: the base path of the single page router. @@ -37,10 +40,18 @@ def __init__(self, :param browser_history: Optional flag to enable or disable the browser history management. Default is True. :param on_instance_created: Optional callback which is called when a new instance is created. Each browser tab or window is a new instance. This can be used to initialize the state of the application. + :param on_resolve: Optional callback which is called when a URL path is resolved to a target. Can be used + to resolve or redirect a URL path to a target. + :param on_open: Optional callback which is called when a target is opened. Can be used to modify the target + such as title or the actually called builder function. + :param on_navigate: Optional callback which is called when a navigation event is triggered. Can be used to + prevent or modify the navigation. Return the new URL if the navigation should be allowed, modify the URL + or return None to prevent the navigation. :param parent: The parent outlet of this outlet. :param kwargs: Additional arguments """ super().__init__(path, browser_history=browser_history, on_instance_created=on_instance_created, + on_resolve=on_resolve, on_open=on_open, on_navigate=on_navigate, parent=parent, **kwargs) self.outlet_builder: Optional[Callable] = outlet_builder if parent is None: @@ -61,12 +72,12 @@ def add_properties(result): if isinstance(result, dict): properties.update(result) - router_frame = SinglePageRouter.get_current_frame() + router_frame = SinglePageRouter.get_current_router() add_properties(next(frame)) # insert ui elements before yield if router_frame is not None: router_frame.update_user_data(properties) yield properties - router_frame = SinglePageRouter.get_current_frame() + router_frame = SinglePageRouter.get_current_router() try: add_properties(next(frame)) # if provided insert ui elements after yield if router_frame is not None: @@ -121,7 +132,7 @@ def current_url(self) -> str: Only works when called from within the outlet or view builder function. :return: The current URL of the outlet""" - cur_router = SinglePageRouter.get_current_frame() + cur_router = SinglePageRouter.get_current_router() if cur_router is None: raise ValueError('The current URL can only be retrieved from within a nested outlet or view builder ' 'function.') diff --git a/nicegui/single_page_app.py b/nicegui/single_page_app.py index c9aa36852..6891d1804 100644 --- a/nicegui/single_page_app.py +++ b/nicegui/single_page_app.py @@ -10,7 +10,7 @@ class SinglePageApp: def __init__(self, - target: Union[SinglePageRouterConfig, str], + target: Union[SinglePageRouterConfig, str] = '/', page_template: Callable[[], Generator] = None, included: Union[List[Union[Callable, str]], str, Callable] = '/*', excluded: Union[List[Union[Callable, str]], str, Callable] = '') -> None: @@ -35,7 +35,7 @@ def __init__(self, def setup(self): """Registers the SinglePageRouterConfig with the @page decorator to handle all routes defined by the router""" self.reroute_pages() - self.single_page_router.setup_pages(force=True) + self.single_page_router.setup_pages(overwrite=True) def reroute_pages(self): """Registers the SinglePageRouter with the @page decorator to handle all routes defined by the router""" diff --git a/nicegui/single_page_router.py b/nicegui/single_page_router.py index efcf72ef2..845682a74 100644 --- a/nicegui/single_page_router.py +++ b/nicegui/single_page_router.py @@ -1,6 +1,7 @@ -from typing import Callable, Any, Optional, Self, Dict, TYPE_CHECKING +from fnmatch import fnmatch +from typing import Callable, Any, Optional, Dict, TYPE_CHECKING, Self -from nicegui import ui +from nicegui import ui, core from nicegui.context import context from nicegui.elements.router_frame import RouterFrame from nicegui.single_page_target import SinglePageTarget @@ -42,19 +43,38 @@ def __init__(self, """ super().__init__() self.router_config = config + self.base_path = config.base_path if target_url is None: if parent_router is not None and parent_router.target_url is not None: target_url = parent_router.target_url else: target_url = self.router_config.base_path self.user_data = user_data - self.child_frames: dict[str, "SinglePageRouter"] = {} + self.child_routers: dict[str, "SinglePageRouter"] = {} self.use_browser_history = use_browser_history self.change_title = change_title - self.parent_frame = parent_router + self.parent_router = parent_router + # split base path into it's elements + base_path_elements = self.router_config.base_path.split('/') + # replace all asterisks with the actual path elements from target url where possible + target_url_elements = target_url.split('/') + for i, element in enumerate(base_path_elements): + if element.startswith("{") and element.endswith("}"): + if i < len(target_url_elements): + base_path_elements[i] = target_url_elements[i] + # repeat the same for all included paths + if included_paths is not None: + for i, path in enumerate(included_paths): + path_elements = path.split('/') + for j, element in enumerate(path_elements): + if element == '*': + if j < len(base_path_elements): + path_elements[j] = base_path_elements[j] + included_paths[i] = '/'.join(path_elements) + self.base_path = '/'.join(base_path_elements) if parent_router is not None: - parent_router._register_sub_frame(included_paths[0], self) - self.router_frame = RouterFrame(base_path=self.router_config.base_path, + parent_router._register_child_router(included_paths[0], self) + self.router_frame = RouterFrame(base_path=self.base_path, target_url=target_url, included_paths=included_paths, excluded_paths=excluded_paths, @@ -62,7 +82,8 @@ def __init__(self, on_navigate=lambda url, history: self.navigate_to(url, history=history), user_data={'router': self}) self._on_navigate: Optional[Callable[[str], Optional[str]]] = None - self._on_resolve: Optional[Callable[[str], SinglePageTarget]] = None + self._on_resolve: Optional[Callable[[str], Optional[SinglePageTarget]]] = None + self._on_open: Optional[Callable[[SinglePageTarget, Any], SinglePageTarget]] = None @property def target_url(self) -> str: @@ -77,7 +98,11 @@ def resolve_target(self, target: Any, user_data: Optional[Dict] = None) -> Singl :param target: The target object such as a URL or Callable :param user_data: Optional user data which is passed to the resolver functions - :return: The resolved SinglePageUrl object""" + :return: The resolved SinglePageTarget object""" + if self._on_resolve is not None: # try custom handler first if defined + resolved_target = self._on_resolve(target) + if resolved_target is not None: + return resolved_target if isinstance(target, SinglePageTarget): return target target = self.router_config.resolve_target(target) @@ -86,31 +111,40 @@ def resolve_target(self, target: Any, user_data: Optional[Dict] = None) -> Singl if target is None: raise NotImplementedError if target.router_path is not None: - rp = target.router_path original_path = target.original_path - if rp.on_open is not None: + if self._on_open is not None: # try the router instance's custom open handler first + target = RouterFrame.run_safe(self._on_open(**user_data | {'target': target})) + if target.original_path != original_path: + return self.resolve_target(target, user_data) + rp = target.router_path + if rp.on_open is not None: # try the router path's custom open handler target = rp.on_open(target, **user_data) if target.original_path != original_path: return self.resolve_target(target, user_data) + config = self.router_config + while config is not None: + if config.on_open is not None: + target = config.on_open(target) + if target.original_path != original_path: + return self.resolve_target(target, user_data) + config = config.parent_config return target def handle_navigate(self, target: str) -> Optional[str]: - """ - Is called when there was a navigation event in the browser or by the navigate_to method. + """Is called when there was a navigation event in the browser or by the navigate_to method. - By default the original target is returned. The SinglePageRouter and the router config (the outlet) can + By default, the original target is returned. The SinglePageRouter and the router config (the outlet) can manipulate the target before it is resolved. If the target is None, the navigation is suppressed. :param target: The target URL - :return: The target URL or None if the navigation is suppressed - """ - cur_config = self.router_config - while cur_config is not None: - if cur_config.on_navigate is not None: - new_target = cur_config.on_navigate(target) - if new_target is None or new_target != target: - return new_target - cur_config = cur_config.parent_config + :return: The target URL or None if the navigation is suppressed""" + if self._on_navigate is not None: + target = self._on_navigate(target) + if target is None: + return None + new_target = self.router_config.handle_navigate(target) + if new_target is None or new_target != target: + return new_target return target def navigate_to(self, target: [SinglePageTarget, str], server_side=True, sync=False, @@ -121,43 +155,53 @@ def navigate_to(self, target: [SinglePageTarget, str], server_side=True, sync=Fa :param server_side: Optional flag which defines if the call is originated on the server side and thus the browser history should be updated. Default is False. :param sync: Optional flag to define if the content should be updated synchronously. Default is False. - :param history: Optional flag defining if the history setting shall be respected. Default is True. - """ + :param history: Optional flag defining if the history setting shall be respected. Default is True.""" # check if sub router is active and might handle the target - for path_mask, frame in self.child_frames.items(): - if path_mask == target or target.startswith(path_mask + '/'): - frame.navigate_to(target, server_side) - return if isinstance(target, str): + for path_mask, frame in self.child_routers.items(): + if fnmatch(target, path_mask) or fnmatch(target, path_mask.rstrip('/') + '/*'): + frame.navigate_to(target, server_side) + return target = self.handle_navigate(target) if target is None: return - recursive_user_data = SinglePageRouter.get_user_data() | self.user_data | self.router_frame.user_data - target = self.resolve_target(target, user_data=recursive_user_data) + handler_kwargs = SinglePageRouter.get_user_data() | self.user_data | self.router_frame.user_data | \ + {'previous_url_path': self.router_frame.target_url} + handler_kwargs['url_path'] = target if isinstance(target, str) else target.original_path + target = self.resolve_target(target, user_data=handler_kwargs) if target is None or not target.valid: # navigation suppressed return - original_path = target.original_path - if history and not server_side and self.use_browser_history: # triggered by the browser - ui.run_javascript(f'window.history.pushState({{page: "{original_path}"}}, "", "{original_path}");') - print("X Navigating to ", original_path) + target_url = target.original_path + handler_kwargs['target_url'] = target_url + self._update_target_url(target_url) + js_code = '' + if history and self.use_browser_history: # triggered by the browser + js_code += f'window.history.pushState({{page: "{target_url}"}}, "", "{target_url}");' if target.builder is None: if target.fragment is not None: - ui.run_javascript(f'window.location.href = "#{target.fragment}";') # go to fragment + js_code += f'window.location.href = "#{target.fragment}";' # go to fragment + ui.run_javascript(js_code) return target = SinglePageTarget(builder=self._page_not_found, title='Page not found') - if server_side and self.use_browser_history and history: - ui.run_javascript( - f'window.history.pushState({{page: "{original_path}"}}, "", "{original_path}");') - print("Y Navigating to ", original_path) - self.router_frame.target_url = target.original_path - builder_kwargs = {**target.path_args, **target.query_args, 'url_path': original_path} + if len(js_code) > 0: + ui.run_javascript(js_code) + handler_kwargs = {**target.path_args, **target.query_args, 'target': target} | handler_kwargs target_fragment = target.fragment - builder_kwargs.update(recursive_user_data) - self.router_frame.update_content(target.builder, builder_kwargs, target.title, target_fragment, sync) + if target.on_pre_update is not None: + RouterFrame.run_safe(target.on_pre_update, **handler_kwargs) + self.clear() + self.router_frame.update_content(target.builder, handler_kwargs, target.title, target_fragment, sync) + if self.change_title and target.builder and len(self.child_routers) == 0: + # note: If the router is just a container for sub routers, the title is not updated here but + # in the sub router's update_content method + title = target.title if target.title is not None else core.app.config.title + ui.page_title(title) + if target.on_post_update is not None: + RouterFrame.run_safe(target.on_post_update, handler_kwargs) def clear(self) -> None: """Clear the content of the router frame and removes all references to sub frames""" - self.child_frames.clear() + self.child_routers.clear() self.router_frame.clear() def update_user_data(self, new_data: dict) -> None: @@ -166,6 +210,27 @@ def update_user_data(self, new_data: dict) -> None: :param new_data: The new user data to set""" self.user_data.update(new_data) + def on_navigate(self, callback: Callable[[str], Optional[str]]) -> Self: + """Set the on navigate callback which is called when a navigation event is triggered + + :param callback: The callback function""" + self._on_navigate = callback + return self + + def on_resolve(self, callback: Callable[[str], Optional[SinglePageTarget]]) -> Self: + """Set the on resolve callback which is called when a URL path is resolved to a target + + :param callback: The callback function""" + self._on_resolve = callback + return self + + def on_open(self, callback: Callable[[SinglePageTarget, Any], SinglePageTarget]) -> Self: + """Set the on open callback which is called when a target is opened + + :param callback: The callback function""" + self._on_open = callback + return self + @staticmethod def get_user_data() -> Dict: """Returns a combined dictionary of all user data of the parent router frames""" @@ -176,7 +241,7 @@ def get_user_data() -> Dict: return result_dict @staticmethod - def get_current_frame() -> Optional["SinglePageRouter"]: + def get_current_router() -> Optional['SinglePageRouter']: """Get the current router frame from the context stack :return: The current router or None if no router in the context stack""" @@ -185,18 +250,27 @@ def get_current_frame() -> Optional["SinglePageRouter"]: return slot.parent.user_data['router'] return None - def _register_sub_frame(self, path: str, frame: "SinglePageRouter") -> None: - """Registers a sub frame to the router frame + def _register_child_router(self, path: str, frame: 'SinglePageRouter') -> None: + """Registers a child router which handles a certain sub path - :param path: The path of the sub frame - :param frame: The sub frame""" - self.child_frames[path] = frame - self.router_frame.child_frame_paths = list(self.child_frames.keys()) + :param path: The path of the child router + :param frame: The child router""" + self.child_routers[path] = frame + self.router_frame.child_frame_paths = list(self.child_routers.keys()) @staticmethod - def _page_not_found(): - """ - Default builder function for the page not found error page - """ + def _page_not_found() -> None: + """Default builder function for the page not found error page""" ui.label(f'Oops! Page Not Found 🚧').classes('text-3xl') ui.label(f'Sorry, the page you are looking for could not be found. 😔') + + def _update_target_url(self, target_url: str) -> None: + """Updates the target url of the router and all parent routers + + :param target_url: The new target url""" + cur_router = self + for _ in range(PATH_RESOLVING_MAX_RECURSION): + cur_router.router_frame.target_url = target_url + cur_router = cur_router.parent_router + if cur_router is None: + return diff --git a/nicegui/single_page_router_config.py b/nicegui/single_page_router_config.py index 47fa91dfa..07e60caa2 100644 --- a/nicegui/single_page_router_config.py +++ b/nicegui/single_page_router_config.py @@ -26,6 +26,9 @@ def __init__(self, parent: Optional['SinglePageRouterConfig'] = None, page_template: Optional[Callable[[], Generator]] = None, on_instance_created: Optional[Callable[['SinglePageRouter'], None]] = None, + on_resolve: Optional[Callable[[str], Optional[SinglePageTarget]]] = None, + on_open: Optional[Callable[[SinglePageTarget], SinglePageTarget]] = None, + on_navigate: Optional[Callable[[str], Optional[str]]] = None, **kwargs) -> None: """:param path: the base path of the single page router. :param browser_history: Optional flag to enable or disable the browser history management. Default is True. @@ -33,6 +36,13 @@ def __init__(self, :param page_template: Optional page template generator function which defines the layout of the page. It needs to yield a value to separate the layout from the content area. :param on_instance_created: Optional callback which is called when a new router instance is created. Each + :param on_resolve: Optional callback which is called when a URL path is resolved to a target. Can be used + to resolve or redirect a URL path to a target. + :param on_open: Optional callback which is called when a target is opened. Can be used to modify the target + such as title or the actually called builder function. + :param on_navigate: Optional callback which is called when a navigation event is triggered. Can be used to + prevent or modify the navigation. Return the new URL if the navigation should be allowed, modify the URL + or return None to prevent the navigation. browser tab or window is a new instance. This can be used to initialize the state of the application. :param kwargs: Additional arguments for the @page decorators""" super().__init__() @@ -41,9 +51,9 @@ def __init__(self, self.included_paths: Set[str] = set() self.excluded_paths: Set[str] = set() self.on_instance_created: Optional[Callable] = on_instance_created - self.on_resolve: Optional[Callable[[str], Optional[SinglePageTarget]]] = None - self.on_open: Optional[Callable[[SinglePageTarget], SinglePageTarget]] = None - self.on_navigate: Optional[Callable[[str], Optional[str]]] = None + self.on_resolve: Optional[Callable[[str], Optional[SinglePageTarget]]] = on_resolve + self.on_open: Optional[Callable[[SinglePageTarget], SinglePageTarget]] = on_open + self.on_navigate: Optional[Callable[[str], Optional[str]]] = on_navigate self.use_browser_history = browser_history self.page_template = page_template self._setup_configured = False @@ -53,12 +63,17 @@ def __init__(self, self.child_routers: List['SinglePageRouterConfig'] = [] self.page_kwargs = kwargs - def setup_pages(self, force=False) -> Self: + def setup_pages(self, overwrite=False) -> Self: + """Setups the NiceGUI page endpoints and their base UI structure for the root routers + + :param overwrite: Optional flag to force the setup of a given page even if one with a conflicting path is + already existing. Default is False. Classes such as SinglePageApp use this flag to avoid conflicts with + other routers and resolve those conflicts by rerouting the pages.""" for key, route in Client.page_routes.items(): if route.startswith( self.base_path.rstrip('/') + '/') and route.rstrip('/') not in self.included_paths: self.excluded_paths.add(route) - if force: + if overwrite: continue if self.base_path.startswith(route.rstrip('/') + '/'): # '/sub_router' after '/' - forbidden raise ValueError(f'Another router with path "{route.rstrip("/")}/*" is already registered which ' @@ -91,7 +106,7 @@ def add_view(self, path: str, builder: Callable, title: Optional[str] = None, self.included_paths.add(path_mask) self.routes[path] = SinglePageRouterPath(path, builder, title, on_open=on_open).verify() - def add_router_entry(self, entry: "SinglePageRouterPath") -> None: + def add_router_entry(self, entry: 'SinglePageRouterPath') -> None: """Adds a fully configured SinglePageRouterPath to the router :param entry: The SinglePageRouterPath to add""" @@ -107,10 +122,13 @@ def resolve_target(self, target: Union[Callable, str]) -> SinglePageTarget: if entry.builder == target: return SinglePageTarget(router_path=entry) else: - if self.on_resolve is not None: - resolved = self.on_resolve(target) - if resolved is not None: - return resolved + cur_config = self + while cur_config is not None: # try custom on_resolve functions first for manual resolution + if cur_config.on_resolve is not None: + resolved = cur_config.on_resolve(target) + if resolved is not None: + return resolved + cur_config = cur_config.parent_config resolved = None path = target.split('#')[0].split('?')[0] for cur_router in self.child_routers: @@ -127,8 +145,6 @@ def resolve_target(self, target: Union[Callable, str]) -> SinglePageTarget: result = SinglePageTarget(target).parse_url_path(routes=self.routes) if resolved is not None: result.original_path = resolved.original_path - if self.on_open: - result = self.on_open(result) return result def navigate_to(self, target: Union[Callable, str, SinglePageTarget], server_side=True) -> bool: @@ -145,6 +161,19 @@ def navigate_to(self, target: Union[Callable, str, SinglePageTarget], server_sid router.navigate_to(org_target, server_side=server_side) return True + def handle_navigate(self, url: str) -> Optional[str]: + """Handles a navigation event and returns the new URL if the navigation should be allowed + + :param url: The URL to navigate to + :return: The new URL if the navigation should be allowed, None otherwise""" + if self.on_navigate is not None: + new_url = self.on_navigate(url) + if new_url != url: + return new_url + if self.parent_config is not None: + return self.parent_config.handle_navigate(url) + return url + def build_page_template(self) -> Generator: """Builds the page template. Needs to call insert_content_area at some point which defines the exchangeable content of the page. @@ -190,7 +219,7 @@ def create_router_instance(self, :param initial_url: The initial URL to initialize the router's content with :param user_data: Optional user data to pass to the content area :return: The created router instance""" - parent_router = SinglePageRouter.get_current_frame() + parent_router = SinglePageRouter.get_current_router() content = SinglePageRouter(config=self, included_paths=sorted(list(self.included_paths)), excluded_paths=sorted(list(self.excluded_paths)), @@ -204,7 +233,7 @@ def create_router_instance(self, if self.on_instance_created is not None: self.on_instance_created(content) if initial_url is not None: - content.navigate_to(initial_url, server_side=False, sync=True, history=False) + content.navigate_to(initial_url, server_side=True, sync=True, history=False) return content def _register_child_config(self, router_config: 'SinglePageRouterConfig') -> None: diff --git a/nicegui/single_page_target.py b/nicegui/single_page_target.py index 2cb46a5e2..e4ff07402 100644 --- a/nicegui/single_page_target.py +++ b/nicegui/single_page_target.py @@ -1,6 +1,6 @@ import inspect import urllib.parse -from typing import Dict, Optional, TYPE_CHECKING, Self, Callable +from typing import Dict, Optional, TYPE_CHECKING, Self, Callable, Any if TYPE_CHECKING: from nicegui.single_page_router_config import SinglePageRouterPath @@ -8,7 +8,7 @@ class SinglePageTarget: - """A helper class which is used to resolve and URL path and it's query and fragment parameters to find the matching + """A helper class which is used to resolve and URL path, it's query and fragment parameters to find the matching SinglePageRouterPath and extract path and query parameters.""" def __init__(self, @@ -17,8 +17,11 @@ def __init__(self, query_string: Optional[str] = None, builder: Optional[Callable] = None, title: Optional[str] = None, - router: Optional["SinglePageRouter"] = None, - router_path: Optional["SinglePageRouterPath"] = None): + router: Optional['SinglePageRouter'] = None, + router_path: Optional['SinglePageRouterPath'] = None, + on_pre_update: Optional[Callable[[Any], None]] = None, + on_post_update: Optional[Callable[[Any], None]] = None + ): """ :param builder: The builder function to be called when the URL is opened :param path: The path of the URL (to be shown in the browser) @@ -27,6 +30,11 @@ def __init__(self, :param title: The title of the URL to be displayed in the browser tab :param router: The SinglePageRouter which is used to resolve the URL :param router_path: The SinglePageRouterPath which is matched by the URL + :param on_pre_update: Optional callback which is called before content is updated. It can be used to modify the + target or execute JavaScript code before the content is updated. + :param on_post_update: Optional callback which is called after content is updated. It can be used to modify the + target or execute JavaScript code after the content is updated, e.g. showing a message box or redirecting + the user to another page. """ self.original_path = path self.path = path @@ -40,6 +48,8 @@ def __init__(self, self.valid = builder is not None self.router = router self.router_path: Optional["SinglePageRouterPath"] = router_path + self.on_pre_update = on_pre_update + self.on_post_update = on_post_update if router_path is not None and router_path.builder is not None: self.builder = router_path.builder self.title = router_path.title @@ -77,8 +87,7 @@ def parse_path(self, routes) -> Optional['SinglePageRouterPath']: """Splits the path into its components, tries to match it with the routes and extracts the path arguments into their corresponding variables. - :param routes: All valid routes of the single page router - """ + :param routes: All valid routes of the single page router""" path_elements = self.path.lstrip('/').rstrip('/').split('/') self.path_elements = path_elements for route, entry in routes.items(): From 4c8eb017ebb950f84c8dc683726b93d32636a232 Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Sun, 12 May 2024 19:22:52 +0200 Subject: [PATCH 46/79] * Added focus to Input class * Added Login outlet demo --- examples/outlet/cloud_ui/main.py | 6 +- examples/outlet/login/main.py | 102 ++++++++++++++++++++++++--- nicegui/elements/input.py | 4 ++ nicegui/outlet.py | 5 +- nicegui/single_page_router_config.py | 3 +- 5 files changed, 104 insertions(+), 16 deletions(-) diff --git a/examples/outlet/cloud_ui/main.py b/examples/outlet/cloud_ui/main.py index 0257ab8bb..c6f808c7a 100644 --- a/examples/outlet/cloud_ui/main.py +++ b/examples/outlet/cloud_ui/main.py @@ -111,12 +111,14 @@ def services_router(service_name: str, menu_drawer: LeftDrawer): service_element.classes('text-white text-h6 bg-gray cursor-pointer') service_element.style('text-shadow: 2px 2px #00000070;') service_element.on('click', lambda url=f'/services/{service_name}/{key}': ui.navigate.to(url)) - yield {'service': service} # pass service to all sub elements (views and outlets) + yield {'service': service} # pass service object to all sub elements (views and outlets) def update_title(target: SinglePageTarget, service: ServiceDefinition = None, sub_service: SubServiceDefinition = None) -> SinglePageTarget: + # Is called for every page within the service_router and sub_service_router via the on_load callback + # and updates the title of each page if target.router is not None: target.title = 'NiceCLOUD - ' + (f'{sub_service.title}' if sub_service else f'{service.title}') return target @@ -137,7 +139,7 @@ def sub_service_router(service: ServiceDefinition, sub_service_name: str): sub_service: SubServiceDefinition = service.sub_services[sub_service_name] ui.label(f'{service.title} > {sub_service.title}').classes('text-h4') ui.html('
') - yield {'sub_service': sub_service} # pass sub service to all sub elements (views and outlets) + yield {'sub_service': sub_service} # pass sub_service object to all sub elements (views and outlets) @sub_service_router.view('/', on_open=update_title) # sub service index page diff --git a/examples/outlet/login/main.py b/examples/outlet/login/main.py index 0b7419251..057fb832f 100644 --- a/examples/outlet/login/main.py +++ b/examples/outlet/login/main.py @@ -1,23 +1,103 @@ -from nicegui import ui +import html +import uuid +from typing import Optional +from nicegui import ui, app +from nicegui.single_page_target import SinglePageTarget -@ui.outlet('/') -def main_router(url_path: str): +INDEX_URL = '/' +SECRET_AREA_URL = '/secret_area' + +DUMMY_LOGINS = {"admin": "NicePass"} + + +def portal_rerouting(url) -> str: + # automatically redirect to the secret area if the user is already logged in + if '/' == INDEX_URL: + if 'login_token' in app.storage.tab: + return SECRET_AREA_URL + return url + + +@ui.outlet('/', on_navigate=portal_rerouting) +def main_router(): with ui.header(): with ui.link('', '/').style('text-decoration: none; color: inherit;') as lnk: ui.html('Nice' 'CLOUD').classes('text-h3') + with ui.element() as border: + border.style('margin: 20pt;') + yield -@main_router.view('/') +@main_router.view('/', title='🚪 NiceCLOUD Login') def main_app_index(): + def handle_login(): + username = input_field.value + password = pw.value + inv_pw.clear() + if username in DUMMY_LOGINS and DUMMY_LOGINS[username] == password: + app.storage.tab['login_token'] = uuid.uuid4().hex + app.storage.tab['username'] = username + ui.navigate.to(SECRET_AREA_URL) + else: + with inv_pw: + ui.label('Invalid password!').style('color: red;') + # login page - ui.label('Welcome to NiceCLOUD!').classes('text-3xl') - ui.html('
') - ui.label('Username:') - ui.textbox('username') - ui.label('Password:') - ui.password('password') + with ui.column() as col: + ui.label('Login to NiceCLOUD!').classes('text-3xl') + ui.html('
') + input_field = ui.input('Username').style('width: 100%').on('keydown.enter', lambda: pw.focus()) + pw = ui.input('Password', password=True, + password_toggle_button=True).style('width: 100%').on('keydown.enter', handle_login) + col.style('width: 300pt; top: 40%; left: 50%; transform: translate(-50%, -50%); position: absolute;') + # horizontally centered button + with ui.row().style('justify-content: center;'): + ui.button('Login').style('width: 100pt; left: 50%; ' + 'transform: translate(-50%, 0); position: absolute;').on_click(handle_login) + inv_pw = ui.row() + # give user and password hint + ui.html('
') + ui.label('Hint: admin/NicePass').style('color: gray;') + # provide link to try to access secret page w/o login + ui.html('
') + ui.link('Try to access secret area without login', SECRET_AREA_URL).style('color: gray;') + + +def logout(): # logs the user out and redirects to the login page + del app.storage.tab['login_token'] + app.storage.tab.clear() + ui.navigate.to(INDEX_URL) + + +def check_login(url) -> Optional[SinglePageTarget]: + def error_page(): + with ui.column().style('align-items: center; justify-content: center; left: 50%; top: 50%; ' + 'transform: translate(-50%, -50%); position: absolute;'): + ui.label('⚠️').classes('text-6xl') + ui.label('You are not logged in!').classes('text-3xl') + ui.html('
') + ui.button('🚪 Go to login page').on_click(lambda: ui.navigate.to(INDEX_URL)).style('width: 200pt;') + + if 'login_token' not in app.storage.tab: # check if the user is not logged in + return SinglePageTarget(url, builder=error_page, title='Not logged in') + return None # default behavior + + +@main_router.outlet(SECRET_AREA_URL, on_resolve=check_login) +def secret_area_router(): + yield + + +@secret_area_router.view('/', title='🔒 Secret Area') +def secret_area_index(): + with ui.column(): + user_name = app.storage.tab['username'] + esc_name = html.escape(user_name) + ui.html(f'Welcome to the secret area {esc_name}!

').classes('text-3xl') + ui.html('You chose the right place, we have cookies! 🍪

').classes('text-2xl') + ui.button('Logout').on_click(logout) -ui.run(show=False) +ui.run(show=False, storage_secret='secret', title='NiceCLOUD') diff --git a/nicegui/elements/input.py b/nicegui/elements/input.py index 4077dedda..0a2cea2d7 100644 --- a/nicegui/elements/input.py +++ b/nicegui/elements/input.py @@ -70,6 +70,10 @@ def set_autocomplete(self, autocomplete: Optional[List[str]]) -> None: self._props['_autocomplete'] = autocomplete self.update() + def focus(self): + """Focus the element.""" + self.run_method('focus') + def _handle_value_change(self, value: Any) -> None: super()._handle_value_change(value) if self._send_update_on_value_change: diff --git a/nicegui/outlet.py b/nicegui/outlet.py index 927bd7d0a..6b8871e48 100644 --- a/nicegui/outlet.py +++ b/nicegui/outlet.py @@ -117,13 +117,14 @@ def view(self, """ return OutletView(self, path, title=title, on_open=on_open) - def outlet(self, path: str) -> 'Outlet': + def outlet(self, path: str, **kwargs) -> 'Outlet': """Defines a nested outlet :param path: The relative path of the outlet + :param kwargs: Additional arguments for the nested ui.outlet """ abs_path = self.base_path.rstrip('/') + path - return Outlet(abs_path, parent=self) + return Outlet(abs_path, parent=self, **kwargs) @property def current_url(self) -> str: diff --git a/nicegui/single_page_router_config.py b/nicegui/single_page_router_config.py index 07e60caa2..65d69e2d3 100644 --- a/nicegui/single_page_router_config.py +++ b/nicegui/single_page_router_config.py @@ -83,10 +83,11 @@ def setup_pages(self, overwrite=False) -> Self: @ui.page(self.base_path, **self.page_kwargs) @ui.page(f'{self.base_path}' + '{_:path}', **self.page_kwargs) # all other pages async def root_page(request_data=None): + await ui.context.client.connected() # to ensure storage.tab and storage.client availability initial_url = None if request_data is not None: initial_url = request_data['url']['path'] - query = request_data['url'].get('query', {}) + query = request_data['url'].get('query', None) if query: initial_url += '?' + query self.build_page(initial_url=initial_url) From c3a3edbbb36f7da7e7c2e2a461aaab327605bc0f Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Sun, 12 May 2024 21:40:40 +0200 Subject: [PATCH 47/79] Small adjustment to login demo --- examples/outlet/login/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/outlet/login/main.py b/examples/outlet/login/main.py index 057fb832f..02f3d0ba8 100644 --- a/examples/outlet/login/main.py +++ b/examples/outlet/login/main.py @@ -100,4 +100,4 @@ def secret_area_index(): ui.button('Logout').on_click(logout) -ui.run(show=False, storage_secret='secret', title='NiceCLOUD') +ui.run(storage_secret='secret', title='NiceCLOUD') From 94eed81c818786de37a6ea75ae70a0d5d6ed1ad4 Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Wed, 12 Jun 2024 23:13:41 +0200 Subject: [PATCH 48/79] Removed SinglePageApp class and associated examples Renamed Cloud demo to single_page_app_complex --- .../main.py | 0 .../services.json | 0 examples/single_page_router/advanced.py | 52 ----------- examples/single_page_router/main.py | 29 ------ nicegui/single_page_app.py | 92 ------------------- 5 files changed, 173 deletions(-) rename examples/{outlet/cloud_ui => single_page_app_complex}/main.py (100%) rename examples/{outlet/cloud_ui => single_page_app_complex}/services.json (100%) delete mode 100644 examples/single_page_router/advanced.py delete mode 100644 examples/single_page_router/main.py delete mode 100644 nicegui/single_page_app.py diff --git a/examples/outlet/cloud_ui/main.py b/examples/single_page_app_complex/main.py similarity index 100% rename from examples/outlet/cloud_ui/main.py rename to examples/single_page_app_complex/main.py diff --git a/examples/outlet/cloud_ui/services.json b/examples/single_page_app_complex/services.json similarity index 100% rename from examples/outlet/cloud_ui/services.json rename to examples/single_page_app_complex/services.json diff --git a/examples/single_page_router/advanced.py b/examples/single_page_router/advanced.py deleted file mode 100644 index aa52e4276..000000000 --- a/examples/single_page_router/advanced.py +++ /dev/null @@ -1,52 +0,0 @@ -# Advanced example of a single page router which includes a custom router class and a custom root page setup -# with static footer, header and menu elements. -from typing import Callable - -from nicegui import ui -from nicegui.page import page -from nicegui.single_page_app import SinglePageApp -from nicegui.single_page_router_config import SinglePageRouterConfig - - -@page('/', title='Welcome!') -def index(): - ui.label('Welcome to the single page router example!').classes('text-2xl') - - -@page('/about', title='About') -def about(): - ui.label('This is the about page testing local references').classes('text-2xl') - ui.label('Top').classes('text-lg').props('id=ltop') - ui.link('Bottom', '#lbottom') - ui.link('Center', '#lcenter') - for i in range(30): - ui.label(f'Lorem ipsum dolor sit amet, consectetur adipiscing elit. {i}') - ui.label('Center').classes('text-lg').props('id=lcenter') - ui.link('Top', '#ltop') - ui.link('Bottom', '#lbottom') - for i in range(30): - ui.label(f'Lorem ipsum dolor sit amet, consectetur adipiscing elit. {i}') - ui.label('Bottom').classes('text-lg').props('id=lbottom') - ui.link('Top', '#ltop') - ui.link('Center', '#lcenter') - - -@page('/contact', title='Contact') # this page will not be hosted as SPA -def contact(): - ui.label('This is the contact page').classes('text-2xl') - - -def page_template(): - with ui.header(): - ui.label('My Company').classes('text-2xl') - with ui.left_drawer(): - ui.button('Home', on_click=lambda: ui.navigate.to('/')) - ui.button('About', on_click=lambda: ui.navigate.to('/about')) - ui.button('Contact', on_click=lambda: ui.navigate.to('/contact')) - yield - with ui.footer() as footer: - ui.label('Copyright 2024 by My Company') - - -spa = SinglePageApp("/", page_template=page_template).setup() -ui.run() diff --git a/examples/single_page_router/main.py b/examples/single_page_router/main.py deleted file mode 100644 index d38b990e8..000000000 --- a/examples/single_page_router/main.py +++ /dev/null @@ -1,29 +0,0 @@ -# Basic example of the SinglePageApp class which allows the fast conversion of already existing multi-page NiceGUI -# applications into a single page applications. Note that if you want more control over the routing, nested outlets or -# custom page setups,you should use the ui.outlet class instead which allows more flexibility. - -from nicegui import ui -from nicegui.page import page -from nicegui.single_page_app import SinglePageApp - - -@page('/', title='Welcome!') -def index(): - ui.label('Welcome to the single page router example!') - ui.link('About', '/about') - - -@page('/about', title='About') -def about(): - ui.label('This is the about page') - ui.link('Index', '/') - - -def page_template(): - with ui.header(): - ui.label('My Company').classes('text-2xl') - yield # your content goes here - - -router = SinglePageApp('/', page_template=page_template).setup() -ui.run() diff --git a/nicegui/single_page_app.py b/nicegui/single_page_app.py deleted file mode 100644 index 6891d1804..000000000 --- a/nicegui/single_page_app.py +++ /dev/null @@ -1,92 +0,0 @@ -import fnmatch -from typing import Union, List, Callable, Generator - -from fastapi.routing import APIRoute - -from nicegui import core -from nicegui.single_page_router_config import SinglePageRouterConfig, SinglePageRouterPath - - -class SinglePageApp: - - def __init__(self, - target: Union[SinglePageRouterConfig, str] = '/', - page_template: Callable[[], Generator] = None, - included: Union[List[Union[Callable, str]], str, Callable] = '/*', - excluded: Union[List[Union[Callable, str]], str, Callable] = '') -> None: - """ - :param target: The SinglePageRouterConfig which shall be used as the main router for the single page - application. Alternatively, you can pass the root path of the pages which shall be redirected to the - single page router. - :param included: Optional list of masks and callables of paths to include. Default is "/*" which includes all. - If you do not want to include all relative paths, you can specify a list of masks or callables to refine the - included paths. If a callable is passed, it must be decorated with a page. - :param excluded: Optional list of masks and callables of paths to exclude. Default is "" which excludes none. - Explicitly included paths (without wildcards) and Callables are always included, even if they match an - exclusion mask. - """ - if isinstance(target, str): - target = SinglePageRouterConfig(target, page_template=page_template) - self.single_page_router = target - self.included: List[Union[Callable, str]] = [included] if not isinstance(included, list) else included - self.excluded: List[Union[Callable, str]] = [excluded] if not isinstance(excluded, list) else excluded - self.system_excluded = ['/docs', '/redoc', '/openapi.json', '_*'] - - def setup(self): - """Registers the SinglePageRouterConfig with the @page decorator to handle all routes defined by the router""" - self.reroute_pages() - self.single_page_router.setup_pages(overwrite=True) - - def reroute_pages(self): - """Registers the SinglePageRouter with the @page decorator to handle all routes defined by the router""" - self._update_masks() - self._find_api_routes() - - def is_excluded(self, path: str) -> bool: - """Checks if a path is excluded by the exclusion masks - - :param path: The path to check - :return: True if the path is excluded, False otherwise""" - for inclusion_mask in self.included: - if path == inclusion_mask: # if it is a perfect, explicit match: allow - return False - if fnmatch.fnmatch(path, inclusion_mask): # if it is just a mask match: verify it is not excluded - for ex_element in self.excluded: - if fnmatch.fnmatch(path, ex_element): - return True # inclusion mask matched but also exclusion mask - return False # inclusion mask matched - return True # no inclusion mask matched - - def _update_masks(self) -> None: - """Updates the inclusion and exclusion masks and resolves Callables to the actual paths""" - from nicegui.page import Client - for cur_list in [self.included, self.excluded]: - for index, element in enumerate(cur_list): - if isinstance(element, Callable): - if element in Client.page_routes: - cur_list[index] = Client.page_routes[element] - else: - raise ValueError( - f'Invalid target page in inclusion/exclusion list, no @page assigned to element') - - def _find_api_routes(self) -> None: - """Find all API routes already defined via the @page decorator, remove them and redirect them to the - single page router""" - from nicegui.page import Client - page_routes = set() - base_path = self.single_page_router.base_path - for key, route in Client.page_routes.items(): - if route.startswith(base_path) and not self.is_excluded(route): - page_routes.add(route) - Client.single_page_routes[route] = self.single_page_router - title = None - if key in Client.page_configs: - title = Client.page_configs[key].title - route = route.rstrip('/') - self.single_page_router.add_router_entry(SinglePageRouterPath(route, builder=key, title=title)) - route_mask = SinglePageRouterPath.create_path_mask(route) - self.single_page_router.included_paths.add(route_mask) - for route in core.app.routes.copy(): - if isinstance(route, APIRoute): - if route.path in page_routes: - core.app.routes.remove(route) From 8f61006cc4211297870810d6451157c4eb2008b3 Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Wed, 12 Jun 2024 23:51:34 +0200 Subject: [PATCH 49/79] * Removed on_resolve and on_open events from outlet and SinglePageRouterConfig * Extended on_navigate so it can also return SinglePageTargets --- examples/single_page_app_complex/main.py | 10 ++-- .../login => single_page_login}/main.py | 8 +-- nicegui/outlet.py | 29 ++-------- nicegui/single_page_router.py | 57 ++++--------------- nicegui/single_page_router_config.py | 29 +++------- 5 files changed, 36 insertions(+), 97 deletions(-) rename examples/{outlet/login => single_page_login}/main.py (95%) diff --git a/examples/single_page_app_complex/main.py b/examples/single_page_app_complex/main.py index c6f808c7a..b0a80ca63 100644 --- a/examples/single_page_app_complex/main.py +++ b/examples/single_page_app_complex/main.py @@ -124,8 +124,9 @@ def update_title(target: SinglePageTarget, return target -@services_router.view('/', on_open=update_title) # service index page -def show_index(service: ServiceDefinition): +@services_router.view('/') # service index page +def show_index(target: SinglePageTarget, service: ServiceDefinition): + update_title(target, service, None) with ui.row() as row: ui.label(service.emoji).classes('text-h4 vertical-middle') with ui.column(): @@ -142,8 +143,9 @@ def sub_service_router(service: ServiceDefinition, sub_service_name: str): yield {'sub_service': sub_service} # pass sub_service object to all sub elements (views and outlets) -@sub_service_router.view('/', on_open=update_title) # sub service index page -def sub_service_index(sub_service: SubServiceDefinition): +@sub_service_router.view('/') # sub service index page +def sub_service_index(target: SinglePageTarget, service: ServiceDefinition, sub_service: SubServiceDefinition): + update_title(target, service, sub_service) ui.label(sub_service.emoji).classes('text-h1') ui.html('
') ui.label(sub_service.description) diff --git a/examples/outlet/login/main.py b/examples/single_page_login/main.py similarity index 95% rename from examples/outlet/login/main.py rename to examples/single_page_login/main.py index 02f3d0ba8..0f02eccbf 100644 --- a/examples/outlet/login/main.py +++ b/examples/single_page_login/main.py @@ -1,6 +1,6 @@ import html import uuid -from typing import Optional +from typing import Optional, Union from nicegui import ui, app from nicegui.single_page_target import SinglePageTarget @@ -71,7 +71,7 @@ def logout(): # logs the user out and redirects to the login page ui.navigate.to(INDEX_URL) -def check_login(url) -> Optional[SinglePageTarget]: +def check_login(url) -> Optional[Union[str, SinglePageTarget]]: def error_page(): with ui.column().style('align-items: center; justify-content: center; left: 50%; top: 50%; ' 'transform: translate(-50%, -50%); position: absolute;'): @@ -82,10 +82,10 @@ def error_page(): if 'login_token' not in app.storage.tab: # check if the user is not logged in return SinglePageTarget(url, builder=error_page, title='Not logged in') - return None # default behavior + return url # default behavior -@main_router.outlet(SECRET_AREA_URL, on_resolve=check_login) +@main_router.outlet(SECRET_AREA_URL, on_navigate=check_login) def secret_area_router(): yield diff --git a/nicegui/outlet.py b/nicegui/outlet.py index 6b8871e48..a4b012087 100644 --- a/nicegui/outlet.py +++ b/nicegui/outlet.py @@ -1,5 +1,4 @@ -import inspect -from typing import Callable, Any, Self, Optional, Generator +from typing import Callable, Any, Self, Optional, Generator, Union from nicegui.client import Client from nicegui.single_page_router import SinglePageRouter @@ -27,9 +26,7 @@ def __init__(self, browser_history: bool = True, parent: Optional['SinglePageRouterConfig'] = None, on_instance_created: Optional[Callable[['SinglePageRouter'], None]] = None, - on_resolve: Optional[Callable[[str], Optional[SinglePageTarget]]] = None, - on_open: Optional[Callable[[SinglePageTarget], SinglePageTarget]] = None, - on_navigate: Optional[Callable[[str], Optional[str]]] = None, + on_navigate: Optional[Callable[[str], Optional[Union[SinglePageTarget, str]]]] = None, **kwargs) -> None: """ :param path: the base path of the single page router. @@ -40,10 +37,6 @@ def __init__(self, :param browser_history: Optional flag to enable or disable the browser history management. Default is True. :param on_instance_created: Optional callback which is called when a new instance is created. Each browser tab or window is a new instance. This can be used to initialize the state of the application. - :param on_resolve: Optional callback which is called when a URL path is resolved to a target. Can be used - to resolve or redirect a URL path to a target. - :param on_open: Optional callback which is called when a target is opened. Can be used to modify the target - such as title or the actually called builder function. :param on_navigate: Optional callback which is called when a navigation event is triggered. Can be used to prevent or modify the navigation. Return the new URL if the navigation should be allowed, modify the URL or return None to prevent the navigation. @@ -51,7 +44,7 @@ def __init__(self, :param kwargs: Additional arguments """ super().__init__(path, browser_history=browser_history, on_instance_created=on_instance_created, - on_resolve=on_resolve, on_open=on_open, on_navigate=on_navigate, + on_navigate=on_navigate, parent=parent, **kwargs) self.outlet_builder: Optional[Callable] = outlet_builder if parent is None: @@ -101,8 +94,7 @@ def outlet_view(**kwargs): def view(self, path: str, - title: Optional[str] = None, - on_open: Optional[Callable[[SinglePageTarget, Any], SinglePageTarget]] = None + title: Optional[str] = None ) -> 'OutletView': """Decorator for the view function. @@ -112,10 +104,8 @@ def view(self, :param path: The path of the view, relative to the base path of the outlet :param title: Optional title of the view. If a title is set, it will be displayed in the browser tab when the view is active, otherwise the default title of the application is displayed. - :param on_open: Optional callback which is called when the target is resolved to this view. It can be used - to modify the target before the view is displayed. """ - return OutletView(self, path, title=title, on_open=on_open) + return OutletView(self, path, title=title) def outlet(self, path: str, **kwargs) -> 'Outlet': """Defines a nested outlet @@ -143,20 +133,16 @@ def current_url(self) -> str: class OutletView: """Defines a single view / "content page" which is displayed in an outlet""" - def __init__(self, parent_outlet: SinglePageRouterConfig, path: str, title: Optional[str] = None, - on_open: Optional[Callable[[SinglePageTarget, Any], SinglePageTarget]] = None): + def __init__(self, parent_outlet: SinglePageRouterConfig, path: str, title: Optional[str] = None): """ :param parent_outlet: The parent outlet in which this view is displayed :param path: The path of the view, relative to the base path of the outlet :param title: Optional title of the view. If a title is set, it will be displayed in the browser tab when the view is active, otherwise the default title of the application is displayed. - :param on_open: Optional callback which is called when the target is resolved to this view and going to be - opened. It can be used to modify the target before the view is displayed. """ self.path = path self.title = title self.parent_outlet = parent_outlet - self.on_open = on_open @property def url(self) -> str: @@ -172,9 +158,6 @@ def handle_resolve(self, target: SinglePageTarget, **kwargs) -> SinglePageTarget :param target: The resolved target :return: The resolved target or a modified target """ - if self.on_open is not None: - RouterFrame.run_safe(self.on_open, **kwargs | {'target': target}, - type_check=True) return target def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]: diff --git a/nicegui/single_page_router.py b/nicegui/single_page_router.py index 845682a74..acba34230 100644 --- a/nicegui/single_page_router.py +++ b/nicegui/single_page_router.py @@ -1,5 +1,5 @@ from fnmatch import fnmatch -from typing import Callable, Any, Optional, Dict, TYPE_CHECKING, Self +from typing import Callable, Any, Optional, Dict, TYPE_CHECKING, Self, Union from nicegui import ui, core from nicegui.context import context @@ -81,9 +81,7 @@ def __init__(self, use_browser_history=use_browser_history, on_navigate=lambda url, history: self.navigate_to(url, history=history), user_data={'router': self}) - self._on_navigate: Optional[Callable[[str], Optional[str]]] = None - self._on_resolve: Optional[Callable[[str], Optional[SinglePageTarget]]] = None - self._on_open: Optional[Callable[[SinglePageTarget, Any], SinglePageTarget]] = None + self._on_navigate: Optional[Callable[[str], Optional[Union[SinglePageTarget, str]]]] = None @property def target_url(self) -> str: @@ -99,10 +97,6 @@ def resolve_target(self, target: Any, user_data: Optional[Dict] = None) -> Singl :param target: The target object such as a URL or Callable :param user_data: Optional user data which is passed to the resolver functions :return: The resolved SinglePageTarget object""" - if self._on_resolve is not None: # try custom handler first if defined - resolved_target = self._on_resolve(target) - if resolved_target is not None: - return resolved_target if isinstance(target, SinglePageTarget): return target target = self.router_config.resolve_target(target) @@ -110,39 +104,25 @@ def resolve_target(self, target: Any, user_data: Optional[Dict] = None) -> Singl target.router = self if target is None: raise NotImplementedError - if target.router_path is not None: - original_path = target.original_path - if self._on_open is not None: # try the router instance's custom open handler first - target = RouterFrame.run_safe(self._on_open(**user_data | {'target': target})) - if target.original_path != original_path: - return self.resolve_target(target, user_data) - rp = target.router_path - if rp.on_open is not None: # try the router path's custom open handler - target = rp.on_open(target, **user_data) - if target.original_path != original_path: - return self.resolve_target(target, user_data) - config = self.router_config - while config is not None: - if config.on_open is not None: - target = config.on_open(target) - if target.original_path != original_path: - return self.resolve_target(target, user_data) - config = config.parent_config return target - def handle_navigate(self, target: str) -> Optional[str]: + def handle_navigate(self, target: str) -> Optional[Union[SinglePageTarget, str]]: """Is called when there was a navigation event in the browser or by the navigate_to method. By default, the original target is returned. The SinglePageRouter and the router config (the outlet) can manipulate the target before it is resolved. If the target is None, the navigation is suppressed. :param target: The target URL - :return: The target URL or None if the navigation is suppressed""" + :return: The target URL, a completely resolved target or None if the navigation is suppressed""" if self._on_navigate is not None: target = self._on_navigate(target) if target is None: return None + if isinstance(target, SinglePageTarget): + return target new_target = self.router_config.handle_navigate(target) + if isinstance(target, SinglePageTarget): + return target if new_target is None or new_target != target: return new_target return target @@ -166,9 +146,10 @@ def navigate_to(self, target: [SinglePageTarget, str], server_side=True, sync=Fa if target is None: return handler_kwargs = SinglePageRouter.get_user_data() | self.user_data | self.router_frame.user_data | \ - {'previous_url_path': self.router_frame.target_url} + {'previous_url_path': self.router_frame.target_url} handler_kwargs['url_path'] = target if isinstance(target, str) else target.original_path - target = self.resolve_target(target, user_data=handler_kwargs) + if not isinstance(target, SinglePageTarget): + target = self.resolve_target(target, user_data=handler_kwargs) if target is None or not target.valid: # navigation suppressed return target_url = target.original_path @@ -210,27 +191,13 @@ def update_user_data(self, new_data: dict) -> None: :param new_data: The new user data to set""" self.user_data.update(new_data) - def on_navigate(self, callback: Callable[[str], Optional[str]]) -> Self: + def on_navigate(self, callback: Callable[[str], Optional[Union[SinglePageTarget, str]]]) -> Self: """Set the on navigate callback which is called when a navigation event is triggered :param callback: The callback function""" self._on_navigate = callback return self - def on_resolve(self, callback: Callable[[str], Optional[SinglePageTarget]]) -> Self: - """Set the on resolve callback which is called when a URL path is resolved to a target - - :param callback: The callback function""" - self._on_resolve = callback - return self - - def on_open(self, callback: Callable[[SinglePageTarget, Any], SinglePageTarget]) -> Self: - """Set the on open callback which is called when a target is opened - - :param callback: The callback function""" - self._on_open = callback - return self - @staticmethod def get_user_data() -> Dict: """Returns a combined dictionary of all user data of the parent router frames""" diff --git a/nicegui/single_page_router_config.py b/nicegui/single_page_router_config.py index 65d69e2d3..08e3d4904 100644 --- a/nicegui/single_page_router_config.py +++ b/nicegui/single_page_router_config.py @@ -26,9 +26,7 @@ def __init__(self, parent: Optional['SinglePageRouterConfig'] = None, page_template: Optional[Callable[[], Generator]] = None, on_instance_created: Optional[Callable[['SinglePageRouter'], None]] = None, - on_resolve: Optional[Callable[[str], Optional[SinglePageTarget]]] = None, - on_open: Optional[Callable[[SinglePageTarget], SinglePageTarget]] = None, - on_navigate: Optional[Callable[[str], Optional[str]]] = None, + on_navigate: Optional[Callable[[str], Optional[Union[SinglePageTarget, str]]]] = None, **kwargs) -> None: """:param path: the base path of the single page router. :param browser_history: Optional flag to enable or disable the browser history management. Default is True. @@ -36,10 +34,6 @@ def __init__(self, :param page_template: Optional page template generator function which defines the layout of the page. It needs to yield a value to separate the layout from the content area. :param on_instance_created: Optional callback which is called when a new router instance is created. Each - :param on_resolve: Optional callback which is called when a URL path is resolved to a target. Can be used - to resolve or redirect a URL path to a target. - :param on_open: Optional callback which is called when a target is opened. Can be used to modify the target - such as title or the actually called builder function. :param on_navigate: Optional callback which is called when a navigation event is triggered. Can be used to prevent or modify the navigation. Return the new URL if the navigation should be allowed, modify the URL or return None to prevent the navigation. @@ -51,9 +45,7 @@ def __init__(self, self.included_paths: Set[str] = set() self.excluded_paths: Set[str] = set() self.on_instance_created: Optional[Callable] = on_instance_created - self.on_resolve: Optional[Callable[[str], Optional[SinglePageTarget]]] = on_resolve - self.on_open: Optional[Callable[[SinglePageTarget], SinglePageTarget]] = on_open - self.on_navigate: Optional[Callable[[str], Optional[str]]] = on_navigate + self.on_navigate: Optional[Callable[[str], Optional[Union[SinglePageTarget, str]]]] = on_navigate self.use_browser_history = browser_history self.page_template = page_template self._setup_configured = False @@ -123,13 +115,6 @@ def resolve_target(self, target: Union[Callable, str]) -> SinglePageTarget: if entry.builder == target: return SinglePageTarget(router_path=entry) else: - cur_config = self - while cur_config is not None: # try custom on_resolve functions first for manual resolution - if cur_config.on_resolve is not None: - resolved = cur_config.on_resolve(target) - if resolved is not None: - return resolved - cur_config = cur_config.parent_config resolved = None path = target.split('#')[0].split('?')[0] for cur_router in self.child_routers: @@ -162,15 +147,17 @@ def navigate_to(self, target: Union[Callable, str, SinglePageTarget], server_sid router.navigate_to(org_target, server_side=server_side) return True - def handle_navigate(self, url: str) -> Optional[str]: + def handle_navigate(self, url: str) -> Optional[Union[SinglePageTarget, str]]: """Handles a navigation event and returns the new URL if the navigation should be allowed :param url: The URL to navigate to :return: The new URL if the navigation should be allowed, None otherwise""" if self.on_navigate is not None: - new_url = self.on_navigate(url) - if new_url != url: - return new_url + new_target = self.on_navigate(url) + if isinstance(new_target, SinglePageTarget): + return new_target + if new_target != url: + return new_target if self.parent_config is not None: return self.parent_config.handle_navigate(url) return url From bb786f00abfc9c55fc5e8526f29f409f044182c2 Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Thu, 13 Jun 2024 00:15:04 +0200 Subject: [PATCH 50/79] Updated authentication demo --- examples/{single_page_login => authentication_spa}/main.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename examples/{single_page_login => authentication_spa}/main.py (100%) diff --git a/examples/single_page_login/main.py b/examples/authentication_spa/main.py similarity index 100% rename from examples/single_page_login/main.py rename to examples/authentication_spa/main.py From 4a49846e04c092f8497cac9d205f1f490b87683e Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Thu, 13 Jun 2024 17:00:32 +0200 Subject: [PATCH 51/79] Enhanced SinglePageRouter by the possibility to define dynamic views as object methods to enable to user to develop object oriented NiceGUI apps. --- .../single_page_app_complex/cms_config.py | 28 ++++ examples/single_page_app_complex/main.py | 70 +++----- .../main_oop_version.py | 158 ++++++++++++++++++ nicegui/elements/router_frame.py | 4 + nicegui/outlet.py | 24 ++- nicegui/single_page_router.py | 64 +++++-- nicegui/single_page_router_config.py | 35 +++- 7 files changed, 314 insertions(+), 69 deletions(-) create mode 100644 examples/single_page_app_complex/cms_config.py create mode 100644 examples/single_page_app_complex/main_oop_version.py diff --git a/examples/single_page_app_complex/cms_config.py b/examples/single_page_app_complex/cms_config.py new file mode 100644 index 000000000..582302f2b --- /dev/null +++ b/examples/single_page_app_complex/cms_config.py @@ -0,0 +1,28 @@ +import os +from typing import Dict + +from pydantic import BaseModel, Field + + +class SubServiceDefinition(BaseModel): + title: str = Field(..., description='The title of the sub-service', examples=['Digital Twin']) + emoji: str = Field(..., description='An emoji representing the sub-service', examples=['🤖']) + description: str = Field(..., description='A short description of the sub-service', + examples=['Manage your digital twin']) + + +class ServiceDefinition(BaseModel): + title: str = Field(..., description='The title of the cloud service', examples=['Virtual Machines']) + emoji: str = Field(..., description='An emoji representing the cloud service', examples=['💻']) + description: str = Field(..., description='A short description of the cloud service', + examples=['Create and manage virtual machines']) + sub_services: Dict[str, SubServiceDefinition] = Field(..., + description='The sub-services of the cloud service') + + +class ServiceDefinitions(BaseModel): + services: Dict[str, ServiceDefinition] = Field(..., + description='The cloud services provided by the cloud provider') + + +services = ServiceDefinitions.parse_file(os.path.join(os.path.dirname(__file__), 'services.json')).services diff --git a/examples/single_page_app_complex/main.py b/examples/single_page_app_complex/main.py index b0a80ca63..3972b34ae 100644 --- a/examples/single_page_app_complex/main.py +++ b/examples/single_page_app_complex/main.py @@ -1,40 +1,12 @@ # Advanced demo showing how to use the ui.outlet and outlet.view decorators to create a nested multi-page app with a # static header, footer and menu which is shared across all pages and hidden when the user navigates to the root page. -import os -from typing import Dict - -from pydantic import BaseModel, Field - from nicegui import ui from nicegui.page_layout import LeftDrawer +from nicegui.single_page_router import SinglePageRouter from nicegui.single_page_target import SinglePageTarget - -# --- Load service data for fake cloud provider portal - -class SubServiceDefinition(BaseModel): - title: str = Field(..., description='The title of the sub-service', examples=['Digital Twin']) - emoji: str = Field(..., description='An emoji representing the sub-service', examples=['🤖']) - description: str = Field(..., description='A short description of the sub-service', - examples=['Manage your digital twin']) - - -class ServiceDefinition(BaseModel): - title: str = Field(..., description='The title of the cloud service', examples=['Virtual Machines']) - emoji: str = Field(..., description='An emoji representing the cloud service', examples=['💻']) - description: str = Field(..., description='A short description of the cloud service', - examples=['Create and manage virtual machines']) - sub_services: Dict[str, SubServiceDefinition] = Field(..., - description='The sub-services of the cloud service') - - -class ServiceDefinitions(BaseModel): - services: Dict[str, ServiceDefinition] = Field(..., - description='The cloud services provided by the cloud provider') - - -services = ServiceDefinitions.parse_file(os.path.join(os.path.dirname(__file__), 'services.json')).services +from examples.single_page_app_complex.cms_config import SubServiceDefinition, ServiceDefinition, services # --- Other app --- @@ -64,14 +36,14 @@ def main_router(url_path: str): menu_visible = '/services/' in url_path # make instantly visible if the initial path is a service menu_drawer = ui.left_drawer(bordered=True, value=menu_visible, fixed=True).classes('bg-primary') with ui.footer(): - ui.label('Copyright 2024 by My Company') - + with ui.element('a').props('href="/about"'): + ui.label('Copyright 2024 by NiceCLOUD Inc.').classes('text-h7') with ui.element().classes('p-8'): yield {'menu_drawer': menu_drawer} # pass menu drawer to all sub elements (views and outlets) @main_router.view('/') -def main_app_index(menu_drawer: LeftDrawer): # main app index page +def main_index(menu_drawer: LeftDrawer): # main app index page menu_drawer.clear() # clear drawer menu_drawer.hide() # hide drawer ui.label('Welcome to NiceCLOUD!').classes('text-3xl') @@ -92,6 +64,18 @@ def main_app_index(menu_drawer: LeftDrawer): # main app index page ui.markdown('Click [here](/other_app) to visit the other app.') +@main_router.view('/about') +def about_page(menu_drawer: LeftDrawer): + menu_drawer.clear() + menu_drawer.hide() + ui.label('About NiceCLOUD').classes('text-3xl') + ui.html('
') + ui.label('NiceCLOUD Inc.') + ui.label('1234 Nice Street') + ui.label('Nice City') + ui.label('Nice Country') + + @main_router.outlet('/services/{service_name}') # service outlet def services_router(service_name: str, menu_drawer: LeftDrawer): service: ServiceDefinition = services[service_name] @@ -114,19 +98,17 @@ def services_router(service_name: str, menu_drawer: LeftDrawer): yield {'service': service} # pass service object to all sub elements (views and outlets) -def update_title(target: SinglePageTarget, - service: ServiceDefinition = None, - sub_service: SubServiceDefinition = None) -> SinglePageTarget: +def update_title(service: ServiceDefinition = None, + sub_service: SubServiceDefinition = None): # Is called for every page within the service_router and sub_service_router via the on_load callback # and updates the title of each page - if target.router is not None: - target.title = 'NiceCLOUD - ' + (f'{sub_service.title}' if sub_service else f'{service.title}') - return target + SinglePageRouter.current_router().target.title = \ + 'NiceCLOUD - ' + (f'{sub_service.title}' if sub_service else f'{service.title}') @services_router.view('/') # service index page -def show_index(target: SinglePageTarget, service: ServiceDefinition): - update_title(target, service, None) +def show_index(service: ServiceDefinition): + update_title(service, None) with ui.row() as row: ui.label(service.emoji).classes('text-h4 vertical-middle') with ui.column(): @@ -144,11 +126,11 @@ def sub_service_router(service: ServiceDefinition, sub_service_name: str): @sub_service_router.view('/') # sub service index page -def sub_service_index(target: SinglePageTarget, service: ServiceDefinition, sub_service: SubServiceDefinition): - update_title(target, service, sub_service) +def sub_service_index(service: ServiceDefinition, sub_service: SubServiceDefinition): + update_title(service, sub_service) ui.label(sub_service.emoji).classes('text-h1') ui.html('
') ui.label(sub_service.description) -ui.run(title='NiceCLOUD Portal') +ui.run(title='NiceCLOUD Portal', show=False) diff --git a/examples/single_page_app_complex/main_oop_version.py b/examples/single_page_app_complex/main_oop_version.py new file mode 100644 index 000000000..08f033220 --- /dev/null +++ b/examples/single_page_app_complex/main_oop_version.py @@ -0,0 +1,158 @@ +# Advanced demo showing how to use the ui.outlet and outlet.view decorators to create a nested multi-page app with a +# static header, footer and menu which is shared across all pages and hidden when the user navigates to the root page. + +from nicegui import ui +from nicegui.single_page_router import SinglePageRouter +from examples.single_page_app_complex.cms_config import SubServiceDefinition, ServiceDefinition, services + + +# --- Other app --- + +class OtherApp(SinglePageRouter): + def __init__(self, **kwargs): + super().__init__(**kwargs) + + def other_app_index(self): + ui.label('Welcome to the index page of the other application') + + @staticmethod + def page_template(): + ui.label('Other app header').classes('text-h2') + ui.html('
') + yield + ui.html('
') + ui.label('Other app footer') + + +ui.outlet('/other_app', router_class=OtherApp) + + +# --- Main app --- + +class MainAppInstance(SinglePageRouter): + def __init__(self, menu_drawer, **kwargs): + super().__init__(**kwargs) + self.menu_drawer = menu_drawer + self.add_view('/', self.index) + self.add_view('/about', self.about_page, title='About NiceCLOUD') + + def index(self): # main app index page + self.menu_drawer.clear() # clear drawer + self.menu_drawer.hide() # hide drawer + ui.label('Welcome to NiceCLOUD!').classes('text-3xl') + ui.html('
') + with ui.grid(columns=3) as grid: + grid.classes('gap-16') + for key, info in services.items(): + link = f'/services/{key}' + with ui.element(): + with ui.link(target=link) as lnk: + with ui.row().classes('text-2xl'): + ui.label(info.emoji) + ui.label(info.title) + lnk.style('text-decoration: none; color: inherit;') + ui.label(info.description) + ui.html('

') + # add a link to the other app + ui.markdown('Click [here](/other_app) to visit the other app.') + + @staticmethod + def about_page(): + ui.label('About NiceCLOUD').classes('text-3xl') + ui.html('
') + ui.label('NiceCLOUD Inc.') + ui.label('1234 Nice Street') + ui.label('Nice City') + ui.label('Nice Country') + + @staticmethod + def page_template(url_path: str): + with ui.header(): + with ui.link('', '/').style('text-decoration: none; color: inherit;') as lnk: + ui.html('Nice' + 'CLOUD').classes('text-h3') + menu_visible = '/services/' in url_path # make instantly visible if the initial path is a service + menu_drawer = ui.left_drawer(bordered=True, value=menu_visible, fixed=True).classes('bg-primary') + with ui.footer(): + with ui.element('a').props('href="/about"'): + ui.label('Copyright 2024 by NiceCLOUD Inc.').classes('text-h7') + + with ui.element().classes('p-8'): + yield {'menu_drawer': menu_drawer} # pass menu drawer to all sub elements (views and outlets) + + +class ServicesRouter(SinglePageRouter): + def __init__(self, service, **kwargs): + super().__init__(**kwargs) + assert isinstance(self.parent, MainAppInstance) + self.main_router: MainAppInstance = self.parent + self.service = service + self.add_view('/', self.show_index) + + def update_title(self): + self.target.title = f'NiceCLOUD - {self.service.title}' + + def show_index(self): + self.update_title() + with ui.row() as row: + ui.label(self.service.emoji).classes('text-h4 vertical-middle') + with ui.column(): + ui.label(self.service.title).classes('text-h2') + ui.label(self.service.description) + ui.html('
') + + @staticmethod + def page_template(service_name: str, menu_drawer): + service: ServiceDefinition = services[service_name] + menu_drawer.clear() + with menu_drawer: + menu_drawer.show() + with ui.row() as row: + ui.label(service.emoji) + ui.label(service.title) + row.classes('text-h5 text-white').style('text-shadow: 2px 2px #00000070;') + ui.html('
') + menu_items = service.sub_services + for key, info in menu_items.items(): + with ui.row() as service_element: + ui.label(info.emoji) + ui.label(info.title) + service_element.classes('text-white text-h6 bg-gray cursor-pointer') + service_element.style('text-shadow: 2px 2px #00000070;') + service_element.on('click', lambda url=f'/services/{service_name}/{key}': ui.navigate.to(url)) + yield {'service': service} # pass service object to all sub elements (views and outlets) + + +class SubServiceRouter(SinglePageRouter): + def __init__(self, sub_service, **kwargs): + super().__init__(**kwargs) + assert isinstance(self.parent, ServicesRouter) + self.services_router: ServicesRouter = self.parent + self.sub_service = sub_service + self.add_view('/', self.sub_service_index) + + def update_title(self): + self.target.title = f'NiceCLOUD - {self.sub_service.title}' + + def sub_service_index(self): + self.update_title() + ui.label(self.sub_service.emoji).classes('text-h1') + ui.html('
') + ui.label(self.sub_service.description) + + @staticmethod + def page_template(service: ServiceDefinition, sub_service_name: str): + sub_service: SubServiceDefinition = service.sub_services[sub_service_name] + ui.label(f'{service.title} > {sub_service.title}').classes('text-h4') + ui.html('
') + yield {'sub_service': sub_service} # pass sub_service object to all sub elements (views and outlets) + + +# main app outlet +main_router = ui.outlet('/', router_class=MainAppInstance) +# service outlet +services_router = main_router.outlet('/services/{service_name}', router_class=ServicesRouter) +# sub service outlet +sub_service_router = services_router.outlet('/{sub_service_name}', router_class=SubServiceRouter) + +ui.run(title='NiceCLOUD Portal', show=False) diff --git a/nicegui/elements/router_frame.py b/nicegui/elements/router_frame.py index a0f5daa0a..927fdae20 100644 --- a/nicegui/elements/router_frame.py +++ b/nicegui/elements/router_frame.py @@ -69,6 +69,10 @@ def target_url(self, value: str): """Set the target url of the router frame""" self._props['target_url'] = value + def add_included_path(self, path: str): + """Add a path to the included paths list""" + self._props['included_path_masks'] += [path] + def update_content(self, builder, builder_kwargs, title, target_fragment, sync=False): """Update the content of the router frame diff --git a/nicegui/outlet.py b/nicegui/outlet.py index a4b012087..1871e47f2 100644 --- a/nicegui/outlet.py +++ b/nicegui/outlet.py @@ -6,6 +6,8 @@ from nicegui.elements.router_frame import RouterFrame from nicegui.single_page_target import SinglePageTarget +PAGE_TEMPLATE_METHOD_NAME = "page_template" + class Outlet(SinglePageRouterConfig): """An outlet allows the creation of single page applications which do not reload the page when navigating between @@ -27,6 +29,7 @@ def __init__(self, parent: Optional['SinglePageRouterConfig'] = None, on_instance_created: Optional[Callable[['SinglePageRouter'], None]] = None, on_navigate: Optional[Callable[[str], Optional[Union[SinglePageTarget, str]]]] = None, + router_class: Optional[Callable[..., SinglePageRouter]] = None, **kwargs) -> None: """ :param path: the base path of the single page router. @@ -40,15 +43,25 @@ def __init__(self, :param on_navigate: Optional callback which is called when a navigation event is triggered. Can be used to prevent or modify the navigation. Return the new URL if the navigation should be allowed, modify the URL or return None to prevent the navigation. + :param router_class: Optional class which is used to create the router instance. The class must be a subclass + of SinglePageRouter. If not provided, the default SinglePageRouter is used. + + If the class defines a method with the name 'page_template', this method is used as the outlet builder. :param parent: The parent outlet of this outlet. :param kwargs: Additional arguments """ super().__init__(path, browser_history=browser_history, on_instance_created=on_instance_created, on_navigate=on_navigate, + router_class=router_class, parent=parent, **kwargs) self.outlet_builder: Optional[Callable] = outlet_builder if parent is None: Client.single_page_routes[path] = self + if router_class is not None: + # check if class defines outlet builder function + if hasattr(router_class, PAGE_TEMPLATE_METHOD_NAME): + outlet_builder = getattr(router_class, PAGE_TEMPLATE_METHOD_NAME) + self(outlet_builder) def build_page_template(self, **kwargs): """Setups the content area for the single page router""" @@ -65,12 +78,11 @@ def add_properties(result): if isinstance(result, dict): properties.update(result) - router_frame = SinglePageRouter.get_current_router() + router_frame = SinglePageRouter.current_router() add_properties(next(frame)) # insert ui elements before yield if router_frame is not None: router_frame.update_user_data(properties) yield properties - router_frame = SinglePageRouter.get_current_router() try: add_properties(next(frame)) # if provided insert ui elements after yield if router_frame is not None: @@ -123,7 +135,7 @@ def current_url(self) -> str: Only works when called from within the outlet or view builder function. :return: The current URL of the outlet""" - cur_router = SinglePageRouter.get_current_router() + cur_router = SinglePageRouter.current_router() if cur_router is None: raise ValueError('The current URL can only be retrieved from within a nested outlet or view builder ' 'function.') @@ -133,16 +145,16 @@ def current_url(self) -> str: class OutletView: """Defines a single view / "content page" which is displayed in an outlet""" - def __init__(self, parent_outlet: SinglePageRouterConfig, path: str, title: Optional[str] = None): + def __init__(self, parent: SinglePageRouterConfig, path: str, title: Optional[str] = None): """ - :param parent_outlet: The parent outlet in which this view is displayed + :param parent: The parent outlet in which this view is displayed :param path: The path of the view, relative to the base path of the outlet :param title: Optional title of the view. If a title is set, it will be displayed in the browser tab when the view is active, otherwise the default title of the application is displayed. """ self.path = path self.title = title - self.parent_outlet = parent_outlet + self.parent_outlet = parent @property def url(self) -> str: diff --git a/nicegui/single_page_router.py b/nicegui/single_page_router.py index acba34230..ea20f8f25 100644 --- a/nicegui/single_page_router.py +++ b/nicegui/single_page_router.py @@ -28,7 +28,7 @@ def __init__(self, excluded_paths: Optional[list[str]] = None, use_browser_history: bool = True, change_title: bool = True, - parent_router: 'SinglePageRouter' = None, + parent: 'SinglePageRouter' = None, target_url: Optional[str] = None, user_data: Optional[Dict] = None ): @@ -45,15 +45,15 @@ def __init__(self, self.router_config = config self.base_path = config.base_path if target_url is None: - if parent_router is not None and parent_router.target_url is not None: - target_url = parent_router.target_url + if parent is not None and parent.target_url is not None: + target_url = parent.target_url else: target_url = self.router_config.base_path self.user_data = user_data self.child_routers: dict[str, "SinglePageRouter"] = {} self.use_browser_history = use_browser_history self.change_title = change_title - self.parent_router = parent_router + self.parent = parent # split base path into it's elements base_path_elements = self.router_config.base_path.split('/') # replace all asterisks with the actual path elements from target url where possible @@ -72,8 +72,8 @@ def __init__(self, path_elements[j] = base_path_elements[j] included_paths[i] = '/'.join(path_elements) self.base_path = '/'.join(base_path_elements) - if parent_router is not None: - parent_router._register_child_router(included_paths[0], self) + if parent is not None: + parent._register_child_router(self.base_path, self) self.router_frame = RouterFrame(base_path=self.base_path, target_url=target_url, included_paths=included_paths, @@ -82,6 +82,22 @@ def __init__(self, on_navigate=lambda url, history: self.navigate_to(url, history=history), user_data={'router': self}) self._on_navigate: Optional[Callable[[str], Optional[Union[SinglePageTarget, str]]]] = None + self.views = {} + + def add_view(self, path: str, builder: Callable, title: Optional[str] = None, **kwargs) -> Self: + """Add a view to the router + + :param path: The path of the view + :param builder: The builder function of the view + :param title: The title of the view + :param kwargs: Additional arguments""" + path = path.lstrip('/') + if path in self.views: + raise ValueError(f'View with path {path} already exists') + self.views[path] = RouterView(path, builder, title, **kwargs) + absolute_path = (self.base_path.rstrip('/') + path).rstrip('/') + self.router_frame.add_included_path(absolute_path) + return self @property def target_url(self) -> str: @@ -90,16 +106,21 @@ def target_url(self) -> str: :return: The target url of the router frame""" return self.router_frame.target_url - def resolve_target(self, target: Any, user_data: Optional[Dict] = None) -> SinglePageTarget: + def resolve_target(self, target: Any) -> SinglePageTarget: """Resolves a URL or SPA target to a SinglePageUrl which contains details about the builder function to be called and the arguments to pass to the builder function. :param target: The target object such as a URL or Callable - :param user_data: Optional user data which is passed to the resolver functions :return: The resolved SinglePageTarget object""" if isinstance(target, SinglePageTarget): return target target = self.router_config.resolve_target(target) + if isinstance(target, SinglePageTarget) and not target.valid and target.path.startswith(self.base_path): + rem_path = target.path[len(self.base_path):] + if rem_path in self.views: + target.builder = self.views[rem_path].builder + target.title = self.views[rem_path].title + target.valid = True if target.valid and target.router is None: target.router = self if target is None: @@ -149,7 +170,7 @@ def navigate_to(self, target: [SinglePageTarget, str], server_side=True, sync=Fa {'previous_url_path': self.router_frame.target_url} handler_kwargs['url_path'] = target if isinstance(target, str) else target.original_path if not isinstance(target, SinglePageTarget): - target = self.resolve_target(target, user_data=handler_kwargs) + target = self.resolve_target(target) if target is None or not target.valid: # navigation suppressed return target_url = target.original_path @@ -171,6 +192,8 @@ def navigate_to(self, target: [SinglePageTarget, str], server_side=True, sync=Fa if target.on_pre_update is not None: RouterFrame.run_safe(target.on_pre_update, **handler_kwargs) self.clear() + self.user_data['target'] = target + # check if object address of real target and user_data target are the same self.router_frame.update_content(target.builder, handler_kwargs, target.title, target_fragment, sync) if self.change_title and target.builder and len(self.child_routers) == 0: # note: If the router is just a container for sub routers, the title is not updated here but @@ -207,8 +230,13 @@ def get_user_data() -> Dict: result_dict.update(slot.parent.user_data['router'].user_data) return result_dict + @property + def target(self) -> Optional[SinglePageTarget]: + """The current target of the router frame. Only valid while a view is being built.""" + return self.user_data.get('target', None) + @staticmethod - def get_current_router() -> Optional['SinglePageRouter']: + def current_router() -> Optional['SinglePageRouter']: """Get the current router frame from the context stack :return: The current router or None if no router in the context stack""" @@ -238,6 +266,20 @@ def _update_target_url(self, target_url: str) -> None: cur_router = self for _ in range(PATH_RESOLVING_MAX_RECURSION): cur_router.router_frame.target_url = target_url - cur_router = cur_router.parent_router + cur_router = cur_router.parent if cur_router is None: return + + +class RouterView: + """Defines a single, router instance specific view / "content page" which is displayed in an outlet""" + + def __init__(self, path: str, builder: Callable, title: Optional[str] = None, **kwargs): + self.path = path + self.builder = builder + self.title = title + self.kwargs = kwargs + + def build_page(self, **kwargs): + """Build the page content""" + self.builder(**kwargs) diff --git a/nicegui/single_page_router_config.py b/nicegui/single_page_router_config.py index 08e3d4904..ca6b4c570 100644 --- a/nicegui/single_page_router_config.py +++ b/nicegui/single_page_router_config.py @@ -1,3 +1,4 @@ +import inspect import re import typing from fnmatch import fnmatch @@ -27,6 +28,7 @@ def __init__(self, page_template: Optional[Callable[[], Generator]] = None, on_instance_created: Optional[Callable[['SinglePageRouter'], None]] = None, on_navigate: Optional[Callable[[str], Optional[Union[SinglePageTarget, str]]]] = None, + router_class: Optional[type] = None, **kwargs) -> None: """:param path: the base path of the single page router. :param browser_history: Optional flag to enable or disable the browser history management. Default is True. @@ -38,6 +40,7 @@ def __init__(self, prevent or modify the navigation. Return the new URL if the navigation should be allowed, modify the URL or return None to prevent the navigation. browser tab or window is a new instance. This can be used to initialize the state of the application. + :param router_class: Optional custom router class to use. Default is SinglePageRouter. :param kwargs: Additional arguments for the @page decorators""" super().__init__() self.routes: Dict[str, "SinglePageRouterPath"] = {} @@ -54,6 +57,7 @@ def __init__(self, self.parent_config._register_child_config(self) self.child_routers: List['SinglePageRouterConfig'] = [] self.page_kwargs = kwargs + self.router_class = SinglePageRouter if router_class is None else router_class def setup_pages(self, overwrite=False) -> Self: """Setups the NiceGUI page endpoints and their base UI structure for the root routers @@ -207,14 +211,29 @@ def create_router_instance(self, :param initial_url: The initial URL to initialize the router's content with :param user_data: Optional user data to pass to the content area :return: The created router instance""" - parent_router = SinglePageRouter.get_current_router() - content = SinglePageRouter(config=self, - included_paths=sorted(list(self.included_paths)), - excluded_paths=sorted(list(self.excluded_paths)), - use_browser_history=self.use_browser_history, - parent_router=parent_router, - target_url=initial_url, - user_data=user_data) + + user_data_kwargs = {} + + def prepare_arguments(): + nonlocal parent_router, user_data_kwargs + init_params = inspect.signature(self.router_class.__init__).parameters + param_names = set(init_params.keys()) - {'self'} + user_data_kwargs = {k: v for k, v in user_data.items() if k in param_names} + cur_parent = parent_router + while cur_parent is not None: + user_data_kwargs.update({k: v for k, v in cur_parent.user_data.items() if k in param_names}) + cur_parent = cur_parent.parent + + parent_router = SinglePageRouter.current_router() + prepare_arguments() + content = self.router_class(config=self, + included_paths=sorted(list(self.included_paths)), + excluded_paths=sorted(list(self.excluded_paths)), + use_browser_history=self.use_browser_history, + parent=parent_router, + target_url=initial_url, + user_data=user_data, + **user_data_kwargs) if parent_router is None: # register root routers to the client context.client.single_page_router = content initial_url = content.target_url From 76cf8402c274dc47651a946f581e58650a01b20c Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Thu, 13 Jun 2024 17:04:29 +0200 Subject: [PATCH 52/79] Refined OOP example --- examples/single_page_app_complex/main_oop_version.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/examples/single_page_app_complex/main_oop_version.py b/examples/single_page_app_complex/main_oop_version.py index 08f033220..8c84aa396 100644 --- a/examples/single_page_app_complex/main_oop_version.py +++ b/examples/single_page_app_complex/main_oop_version.py @@ -12,7 +12,8 @@ class OtherApp(SinglePageRouter): def __init__(self, **kwargs): super().__init__(**kwargs) - def other_app_index(self): + @staticmethod + def other_app_index(): ui.label('Welcome to the index page of the other application') @staticmethod @@ -56,8 +57,9 @@ def index(self): # main app index page # add a link to the other app ui.markdown('Click [here](/other_app) to visit the other app.') - @staticmethod - def about_page(): + def about_page(self): + self.menu_drawer.clear() # clear drawer + self.menu_drawer.hide() # hide drawer ui.label('About NiceCLOUD').classes('text-3xl') ui.html('
') ui.label('NiceCLOUD Inc.') From 8a6c8057ce9a364dd663d97956aea52898aa18dc Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Sun, 16 Jun 2024 13:21:10 +0200 Subject: [PATCH 53/79] Refactoring --- nicegui/elements/input.py | 4 ---- nicegui/elements/router_frame.py | 8 ++++++-- nicegui/outlet.py | 16 ++++++++-------- nicegui/single_page_router.py | 11 ++++++----- 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/nicegui/elements/input.py b/nicegui/elements/input.py index 0a2cea2d7..4077dedda 100644 --- a/nicegui/elements/input.py +++ b/nicegui/elements/input.py @@ -70,10 +70,6 @@ def set_autocomplete(self, autocomplete: Optional[List[str]]) -> None: self._props['_autocomplete'] = autocomplete self.update() - def focus(self): - """Focus the element.""" - self.run_method('focus') - def _handle_value_change(self, value: Any) -> None: super()._handle_value_change(value) if self._send_update_on_value_change: diff --git a/nicegui/elements/router_frame.py b/nicegui/elements/router_frame.py index 927fdae20..aa79dd75e 100644 --- a/nicegui/elements/router_frame.py +++ b/nicegui/elements/router_frame.py @@ -73,8 +73,12 @@ def add_included_path(self, path: str): """Add a path to the included paths list""" self._props['included_path_masks'] += [path] - def update_content(self, builder, builder_kwargs, title, target_fragment, - sync=False): + def update_content(self, + builder: Callable, + builder_kwargs: dict, + title: Optional[str], + target_fragment: Optional[str], + sync: bool = False): """Update the content of the router frame :param builder: The builder function which builds the content of the page diff --git a/nicegui/outlet.py b/nicegui/outlet.py index 1871e47f2..20e6541e5 100644 --- a/nicegui/outlet.py +++ b/nicegui/outlet.py @@ -11,16 +11,15 @@ class Outlet(SinglePageRouterConfig): """An outlet allows the creation of single page applications which do not reload the page when navigating between - different views. The outlet is a container for multiple views and can contain nested outlets. + different views. The outlet is a container for multiple views and can contain further, nested outlets. To define a new outlet, use the @ui.outlet decorator on a function which defines the layout of the outlet. The layout function must be a generator function and contain a yield statement to separate the layout from the - content area. The yield can also be used to pass properties to the content are by return a dictionary with the - properties. Each property can be received as function argument in all nested views and outlets. + actual content area. The yield can also be used to pass properties to the content are by return a dictionary + with the properties. Each property can be received as function argument in all nested views and outlets. - Once the outlet is defined, multiple views can be added to the outlet using the @outlet.view decorator on - a function. - """ + Once the outlet is defined, multiple views can be added to the outlet using the @.view decorator on + a function.""" def __init__(self, path: str, @@ -54,14 +53,15 @@ def __init__(self, on_navigate=on_navigate, router_class=router_class, parent=parent, **kwargs) - self.outlet_builder: Optional[Callable] = outlet_builder + self.outlet_builder: Optional[Callable] = None if parent is None: Client.single_page_routes[path] = self if router_class is not None: # check if class defines outlet builder function if hasattr(router_class, PAGE_TEMPLATE_METHOD_NAME): outlet_builder = getattr(router_class, PAGE_TEMPLATE_METHOD_NAME) - self(outlet_builder) + if outlet_builder is not None: + self(outlet_builder) def build_page_template(self, **kwargs): """Setups the content area for the single page router""" diff --git a/nicegui/single_page_router.py b/nicegui/single_page_router.py index ea20f8f25..9d7fb2506 100644 --- a/nicegui/single_page_router.py +++ b/nicegui/single_page_router.py @@ -18,9 +18,7 @@ class SinglePageRouter: When ever a new page is opened, the SinglePageRouter exchanges the content of the current page with the content of the new page. The SinglePageRouter also manages the browser history and title updates. - Multiple SinglePageRouters can be nested to create complex SinglePageApps with multiple content areas. - - See @ui.outlet or SinglePageApp for more information.""" + See ui.outlet for more information.""" def __init__(self, config: 'SinglePageRouterConfig', @@ -188,13 +186,16 @@ def navigate_to(self, target: [SinglePageTarget, str], server_side=True, sync=Fa if len(js_code) > 0: ui.run_javascript(js_code) handler_kwargs = {**target.path_args, **target.query_args, 'target': target} | handler_kwargs - target_fragment = target.fragment + self.update_content(target, handler_kwargs=handler_kwargs, sync=sync) + + def update_content(self, target: SinglePageTarget, handler_kwargs: dict, sync: bool): + """Update the content of the router frame""" if target.on_pre_update is not None: RouterFrame.run_safe(target.on_pre_update, **handler_kwargs) self.clear() self.user_data['target'] = target # check if object address of real target and user_data target are the same - self.router_frame.update_content(target.builder, handler_kwargs, target.title, target_fragment, sync) + self.router_frame.update_content(target.builder, handler_kwargs, target.title, target.fragment, sync) if self.change_title and target.builder and len(self.child_routers) == 0: # note: If the router is just a container for sub routers, the title is not updated here but # in the sub router's update_content method From b679a584a28ef4d85e4e6d502474f5873eb65dae Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sat, 22 Jun 2024 06:08:46 +0200 Subject: [PATCH 54/79] authentication_spa example: simplified layout and better naming --- examples/authentication_spa/main.py | 85 ++++++++++++----------------- 1 file changed, 34 insertions(+), 51 deletions(-) mode change 100644 => 100755 examples/authentication_spa/main.py diff --git a/examples/authentication_spa/main.py b/examples/authentication_spa/main.py old mode 100644 new mode 100755 index 0f02eccbf..f69edf219 --- a/examples/authentication_spa/main.py +++ b/examples/authentication_spa/main.py @@ -1,8 +1,9 @@ +#!/usr/bin/env python3 import html import uuid from typing import Optional, Union -from nicegui import ui, app +from nicegui import app, ui from nicegui.single_page_target import SinglePageTarget INDEX_URL = '/' @@ -20,84 +21,66 @@ def portal_rerouting(url) -> str: @ui.outlet('/', on_navigate=portal_rerouting) -def main_router(): +def main_layout(): with ui.header(): - with ui.link('', '/').style('text-decoration: none; color: inherit;') as lnk: - ui.html('Nice' - 'CLOUD').classes('text-h3') - with ui.element() as border: - border.style('margin: 20pt;') + with ui.link('', '/').style('text-decoration: none; color: inherit;'): + ui.label('SPA Login Example').classes('text-h3') + ui.query('.nicegui-content').classes('flex-center') + with ui.column().classes('m-12'): yield -@main_router.view('/', title='🚪 NiceCLOUD Login') +@main_layout.view('/', title='SPA Login') def main_app_index(): def handle_login(): - username = input_field.value - password = pw.value - inv_pw.clear() + username = username_input.value + password = password_input.value if username in DUMMY_LOGINS and DUMMY_LOGINS[username] == password: - app.storage.tab['login_token'] = uuid.uuid4().hex - app.storage.tab['username'] = username + # NOTE you can also use app.storage.tab to ensure the login is only valid for the current tab + app.storage.user['login_token'] = uuid.uuid4().hex + app.storage.user['username'] = username ui.navigate.to(SECRET_AREA_URL) else: - with inv_pw: - ui.label('Invalid password!').style('color: red;') - - # login page - with ui.column() as col: - ui.label('Login to NiceCLOUD!').classes('text-3xl') - ui.html('
') - input_field = ui.input('Username').style('width: 100%').on('keydown.enter', lambda: pw.focus()) - pw = ui.input('Password', password=True, - password_toggle_button=True).style('width: 100%').on('keydown.enter', handle_login) - col.style('width: 300pt; top: 40%; left: 50%; transform: translate(-50%, -50%); position: absolute;') - # horizontally centered button - with ui.row().style('justify-content: center;'): - ui.button('Login').style('width: 100pt; left: 50%; ' - 'transform: translate(-50%, 0); position: absolute;').on_click(handle_login) - inv_pw = ui.row() - # give user and password hint - ui.html('
') - ui.label('Hint: admin/NicePass').style('color: gray;') - # provide link to try to access secret page w/o login - ui.html('
') - ui.link('Try to access secret area without login', SECRET_AREA_URL).style('color: gray;') + password_input.props('error') + + with ui.column().classes('w-96 mt-[40%] items-center'): + username_input = ui.input('Username').classes('w-full')\ + .on('keydown.enter', lambda: password_input.run_method('focus')) + password_input = ui.input('Password', password=True, password_toggle_button=True) \ + .classes('w-full').props('error-message="Invalid password!"') \ + .on('keydown.enter', handle_login) + ui.button('Login').classes('w-48').on_click(handle_login) + ui.label(f'Hint: {DUMMY_LOGINS}').classes('text-grey mt-12') + ui.link('Try to access secret area without login', SECRET_AREA_URL).classes('text-grey mt-12') def logout(): # logs the user out and redirects to the login page - del app.storage.tab['login_token'] - app.storage.tab.clear() + del app.storage.user['login_token'] ui.navigate.to(INDEX_URL) def check_login(url) -> Optional[Union[str, SinglePageTarget]]: def error_page(): - with ui.column().style('align-items: center; justify-content: center; left: 50%; top: 50%; ' - 'transform: translate(-50%, -50%); position: absolute;'): + with ui.column().classes('w-96 mt-[40%] items-center'): ui.label('⚠️').classes('text-6xl') ui.label('You are not logged in!').classes('text-3xl') - ui.html('
') - ui.button('🚪 Go to login page').on_click(lambda: ui.navigate.to(INDEX_URL)).style('width: 200pt;') + ui.button('Go to login page').on_click(lambda: ui.navigate.to(INDEX_URL)).classes('w-48 mt-12') - if 'login_token' not in app.storage.tab: # check if the user is not logged in + if 'login_token' not in app.storage.user: # check if the user is not logged in return SinglePageTarget(url, builder=error_page, title='Not logged in') return url # default behavior -@main_router.outlet(SECRET_AREA_URL, on_navigate=check_login) -def secret_area_router(): +@main_layout.outlet(SECRET_AREA_URL, on_navigate=check_login) +def secret_area_layout(): yield -@secret_area_router.view('/', title='🔒 Secret Area') +@secret_area_layout.view('/', title='🔒 Secret Area') def secret_area_index(): - with ui.column(): - user_name = app.storage.tab['username'] - esc_name = html.escape(user_name) - ui.html(f'Welcome to the secret area {esc_name}!

').classes('text-3xl') - ui.html('You chose the right place, we have cookies! 🍪

').classes('text-2xl') - ui.button('Logout').on_click(logout) + username = app.storage.user['username'] + ui.label(f'Hello {html.escape(username)}. Welcome to the secret area!').classes('text-3xl') + ui.button('Logout').on_click(logout).classes('w-48 mt-12') ui.run(storage_secret='secret', title='NiceCLOUD') From 1e71e6581784c2eb3af21d237a7b7ad86afb3285 Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sat, 22 Jun 2024 08:44:30 +0200 Subject: [PATCH 55/79] organized imports --- nicegui/single_page_router_config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nicegui/single_page_router_config.py b/nicegui/single_page_router_config.py index ca6b4c570..2af47a99c 100644 --- a/nicegui/single_page_router_config.py +++ b/nicegui/single_page_router_config.py @@ -2,11 +2,11 @@ import re import typing from fnmatch import fnmatch -from typing import Callable, Dict, Union, Optional, Self, List, Set, Generator, Any +from typing import Any, Callable, Dict, Generator, List, Optional, Self, Set, Union from nicegui import ui -from nicegui.context import context from nicegui.client import Client +from nicegui.context import context from nicegui.elements.router_frame import RouterFrame from nicegui.single_page_router import SinglePageRouter from nicegui.single_page_target import SinglePageTarget From 36a8a8fcca02f6f7872eaac54a8fb9a66d262501 Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sat, 22 Jun 2024 08:45:05 +0200 Subject: [PATCH 56/79] improve typing in reslove_target function --- nicegui/single_page_router_config.py | 38 ++++++++++++++-------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/nicegui/single_page_router_config.py b/nicegui/single_page_router_config.py index 2af47a99c..6eb7528f7 100644 --- a/nicegui/single_page_router_config.py +++ b/nicegui/single_page_router_config.py @@ -114,28 +114,28 @@ def resolve_target(self, target: Union[Callable, str]) -> SinglePageTarget: :param target: The URL path to open or a builder function :return: The resolved target. Defines .valid if the target is valid""" - if isinstance(target, Callable): + if callable(target): for target, entry in self.routes.items(): if entry.builder == target: return SinglePageTarget(router_path=entry) - else: - resolved = None - path = target.split('#')[0].split('?')[0] - for cur_router in self.child_routers: - # replace {} placeholders with * to match the fnmatch pattern - mask = SinglePageRouterPath.create_path_mask(cur_router.base_path.rstrip('/') + '/*') - if fnmatch(path, mask) or path == cur_router.base_path: - resolved = cur_router.resolve_target(target) - if resolved.valid: - target = cur_router.base_path - if '*' in mask: - # isolate the real path elements and update target accordingly - target = '/'.join(path.split('/')[:len(cur_router.base_path.split('/'))]) - break - result = SinglePageTarget(target).parse_url_path(routes=self.routes) - if resolved is not None: - result.original_path = resolved.original_path - return result + raise ValueError('The target builder function is not registered in the router.') + resolved = None + path = target.split('#')[0].split('?')[0] + for cur_router in self.child_routers: + # replace {} placeholders with * to match the fnmatch pattern + mask = SinglePageRouterPath.create_path_mask(cur_router.base_path.rstrip('/') + '/*') + if fnmatch(path, mask) or path == cur_router.base_path: + resolved = cur_router.resolve_target(target) + if resolved.valid: + target = cur_router.base_path + if '*' in mask: + # isolate the real path elements and update target accordingly + target = '/'.join(path.split('/')[:len(cur_router.base_path.split('/'))]) + break + result = SinglePageTarget(target).parse_url_path(routes=self.routes) + if resolved is not None: + result.original_path = resolved.original_path + return result def navigate_to(self, target: Union[Callable, str, SinglePageTarget], server_side=True) -> bool: """Navigate to a target From a83e14fdd8c024c483582eb5f67f3b7d5823a9ef Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sat, 22 Jun 2024 09:19:11 +0200 Subject: [PATCH 57/79] fixed storage access --- examples/authentication_spa/main.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/examples/authentication_spa/main.py b/examples/authentication_spa/main.py index f69edf219..36b5c2e3a 100755 --- a/examples/authentication_spa/main.py +++ b/examples/authentication_spa/main.py @@ -14,9 +14,8 @@ def portal_rerouting(url) -> str: # automatically redirect to the secret area if the user is already logged in - if '/' == INDEX_URL: - if 'login_token' in app.storage.tab: - return SECRET_AREA_URL + if 'login_token' in app.storage.user: + return SECRET_AREA_URL return url From ce92506253752543e8aedc11b8fd16a7d4dae101 Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sat, 22 Jun 2024 09:20:55 +0200 Subject: [PATCH 58/79] cleanup and better titles --- examples/authentication_spa/main.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/examples/authentication_spa/main.py b/examples/authentication_spa/main.py index 36b5c2e3a..2f43e71c6 100755 --- a/examples/authentication_spa/main.py +++ b/examples/authentication_spa/main.py @@ -22,14 +22,13 @@ def portal_rerouting(url) -> str: @ui.outlet('/', on_navigate=portal_rerouting) def main_layout(): with ui.header(): - with ui.link('', '/').style('text-decoration: none; color: inherit;'): - ui.label('SPA Login Example').classes('text-h3') + ui.link('SPA Login Example', '/').style('text-decoration: none; color: inherit;').classes('text-h3') ui.query('.nicegui-content').classes('flex-center') with ui.column().classes('m-12'): yield -@main_layout.view('/', title='SPA Login') +@main_layout.view('/', title='Login Page') def main_app_index(): def handle_login(): username = username_input.value @@ -82,4 +81,4 @@ def secret_area_index(): ui.button('Logout').on_click(logout).classes('w-48 mt-12') -ui.run(storage_secret='secret', title='NiceCLOUD') +ui.run(storage_secret='secret', title='SPA Login') From 3344bf0573046a315878ec4fefbf9403ec28dc2c Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sat, 22 Jun 2024 09:22:39 +0200 Subject: [PATCH 59/79] organized imports --- nicegui/outlet.py | 4 ++-- nicegui/single_page_router.py | 4 ++-- nicegui/single_page_target.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/nicegui/outlet.py b/nicegui/outlet.py index 20e6541e5..e2768e0a0 100644 --- a/nicegui/outlet.py +++ b/nicegui/outlet.py @@ -1,9 +1,9 @@ -from typing import Callable, Any, Self, Optional, Generator, Union +from typing import Any, Callable, Generator, Optional, Self, Union from nicegui.client import Client +from nicegui.elements.router_frame import RouterFrame from nicegui.single_page_router import SinglePageRouter from nicegui.single_page_router_config import SinglePageRouterConfig -from nicegui.elements.router_frame import RouterFrame from nicegui.single_page_target import SinglePageTarget PAGE_TEMPLATE_METHOD_NAME = "page_template" diff --git a/nicegui/single_page_router.py b/nicegui/single_page_router.py index 9d7fb2506..f4d53292d 100644 --- a/nicegui/single_page_router.py +++ b/nicegui/single_page_router.py @@ -1,7 +1,7 @@ from fnmatch import fnmatch -from typing import Callable, Any, Optional, Dict, TYPE_CHECKING, Self, Union +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Self, Union -from nicegui import ui, core +from nicegui import core, ui from nicegui.context import context from nicegui.elements.router_frame import RouterFrame from nicegui.single_page_target import SinglePageTarget diff --git a/nicegui/single_page_target.py b/nicegui/single_page_target.py index e4ff07402..5bed84758 100644 --- a/nicegui/single_page_target.py +++ b/nicegui/single_page_target.py @@ -1,10 +1,10 @@ import inspect import urllib.parse -from typing import Dict, Optional, TYPE_CHECKING, Self, Callable, Any +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Self if TYPE_CHECKING: - from nicegui.single_page_router_config import SinglePageRouterPath from nicegui.single_page_router import SinglePageRouter + from nicegui.single_page_router_config import SinglePageRouterPath class SinglePageTarget: From baea9e1e648fde9674f46e729f1be40b85aa8ad0 Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sat, 22 Jun 2024 09:24:04 +0200 Subject: [PATCH 60/79] fix indentation --- nicegui/outlet.py | 2 +- nicegui/single_page_router.py | 2 +- nicegui/single_page_router_config.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/nicegui/outlet.py b/nicegui/outlet.py index e2768e0a0..e41825dd0 100644 --- a/nicegui/outlet.py +++ b/nicegui/outlet.py @@ -131,7 +131,7 @@ def outlet(self, path: str, **kwargs) -> 'Outlet': @property def current_url(self) -> str: """Returns the current URL of the outlet. - + Only works when called from within the outlet or view builder function. :return: The current URL of the outlet""" diff --git a/nicegui/single_page_router.py b/nicegui/single_page_router.py index f4d53292d..147f2874f 100644 --- a/nicegui/single_page_router.py +++ b/nicegui/single_page_router.py @@ -165,7 +165,7 @@ def navigate_to(self, target: [SinglePageTarget, str], server_side=True, sync=Fa if target is None: return handler_kwargs = SinglePageRouter.get_user_data() | self.user_data | self.router_frame.user_data | \ - {'previous_url_path': self.router_frame.target_url} + {'previous_url_path': self.router_frame.target_url} handler_kwargs['url_path'] = target if isinstance(target, str) else target.original_path if not isinstance(target, SinglePageTarget): target = self.resolve_target(target) diff --git a/nicegui/single_page_router_config.py b/nicegui/single_page_router_config.py index 6eb7528f7..082182a42 100644 --- a/nicegui/single_page_router_config.py +++ b/nicegui/single_page_router_config.py @@ -61,7 +61,7 @@ def __init__(self, def setup_pages(self, overwrite=False) -> Self: """Setups the NiceGUI page endpoints and their base UI structure for the root routers - + :param overwrite: Optional flag to force the setup of a given page even if one with a conflicting path is already existing. Default is False. Classes such as SinglePageApp use this flag to avoid conflicts with other routers and resolve those conflicts by rerouting the pages.""" From 3471c1c0f69aa0dbcdb2c9f0ffddac6a43620aaa Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sat, 22 Jun 2024 09:49:59 +0200 Subject: [PATCH 61/79] clear whole storage --- examples/authentication_spa/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/authentication_spa/main.py b/examples/authentication_spa/main.py index 2f43e71c6..d0dac8452 100755 --- a/examples/authentication_spa/main.py +++ b/examples/authentication_spa/main.py @@ -53,7 +53,7 @@ def handle_login(): def logout(): # logs the user out and redirects to the login page - del app.storage.user['login_token'] + app.storage.user.clear() ui.navigate.to(INDEX_URL) From 12d0a331162c6a8e5c8000271d750ce83375bee6 Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sat, 22 Jun 2024 09:25:24 +0200 Subject: [PATCH 62/79] experiment with allowing outlets without yield statement --- examples/authentication_spa/main.py | 11 +++-------- nicegui/outlet.py | 12 +++++++----- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/examples/authentication_spa/main.py b/examples/authentication_spa/main.py index d0dac8452..268931f54 100755 --- a/examples/authentication_spa/main.py +++ b/examples/authentication_spa/main.py @@ -16,7 +16,7 @@ def portal_rerouting(url) -> str: # automatically redirect to the secret area if the user is already logged in if 'login_token' in app.storage.user: return SECRET_AREA_URL - return url + return '/login' @ui.outlet('/', on_navigate=portal_rerouting) @@ -28,7 +28,7 @@ def main_layout(): yield -@main_layout.view('/', title='Login Page') +@main_layout.outlet('/login', title='Login Page') def main_app_index(): def handle_login(): username = username_input.value @@ -69,12 +69,7 @@ def error_page(): return url # default behavior -@main_layout.outlet(SECRET_AREA_URL, on_navigate=check_login) -def secret_area_layout(): - yield - - -@secret_area_layout.view('/', title='🔒 Secret Area') +@main_layout.outlet(SECRET_AREA_URL, title='🔒 Secret Area', on_navigate=check_login) def secret_area_index(): username = app.storage.user['username'] ui.label(f'Hello {html.escape(username)}. Welcome to the secret area!').classes('text-3xl') diff --git a/nicegui/outlet.py b/nicegui/outlet.py index e41825dd0..2356d0cf9 100644 --- a/nicegui/outlet.py +++ b/nicegui/outlet.py @@ -69,9 +69,9 @@ def build_page_template(self, **kwargs): raise ValueError('The outlet builder function is not defined. Use the @outlet decorator to define it or' ' pass it as an argument to the SinglePageRouter constructor.') frame = RouterFrame.run_safe(self.outlet_builder, **kwargs) - if not isinstance(frame, Generator): - raise ValueError('The outlet builder must be a generator function and contain a yield statement' - ' to separate the layout from the content area.') + # if not isinstance(frame, Generator): + # raise ValueError('The outlet builder must be a generator function and contain a yield statement' + # ' to separate the layout from the content area.') properties = {} def add_properties(result): @@ -79,12 +79,14 @@ def add_properties(result): properties.update(result) router_frame = SinglePageRouter.current_router() - add_properties(next(frame)) # insert ui elements before yield + if isinstance(frame, Generator): + add_properties(next(frame)) # insert ui elements before yield if router_frame is not None: router_frame.update_user_data(properties) yield properties try: - add_properties(next(frame)) # if provided insert ui elements after yield + if isinstance(frame, Generator): + add_properties(next(frame)) # if provided insert ui elements after yield if router_frame is not None: router_frame.update_user_data(properties) except StopIteration: From c1d700d7e1c5d210fcb91f8abf9e76c893841528 Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sat, 22 Jun 2024 10:57:19 +0200 Subject: [PATCH 63/79] add to verify simple routing works as expected --- tests/test_single_page_apps.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 tests/test_single_page_apps.py diff --git a/tests/test_single_page_apps.py b/tests/test_single_page_apps.py new file mode 100644 index 000000000..784d82b22 --- /dev/null +++ b/tests/test_single_page_apps.py @@ -0,0 +1,18 @@ +from nicegui import ui +from nicegui.testing import Screen + + +def test_routing_url(screen: Screen): + @ui.outlet('/', on_navigate=lambda _: '/main') + def layout(): + ui.label('main layout') + yield + + @layout.view('/main') + def main_content(): + ui.label('main content') + + screen.open('/') + screen.should_contain('main layout') + screen.should_contain('main content') + assert '/main' in screen.selenium.current_url From 64d948b4fbccf39bcd9df999b4c3bd556de3909c Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sun, 23 Jun 2024 04:39:58 +0200 Subject: [PATCH 64/79] formatting --- nicegui/single_page_router_config.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/nicegui/single_page_router_config.py b/nicegui/single_page_router_config.py index 082182a42..49a217a40 100644 --- a/nicegui/single_page_router_config.py +++ b/nicegui/single_page_router_config.py @@ -66,8 +66,7 @@ def setup_pages(self, overwrite=False) -> Self: already existing. Default is False. Classes such as SinglePageApp use this flag to avoid conflicts with other routers and resolve those conflicts by rerouting the pages.""" for key, route in Client.page_routes.items(): - if route.startswith( - self.base_path.rstrip('/') + '/') and route.rstrip('/') not in self.included_paths: + if route.startswith(self.base_path.rstrip('/') + '/') and route.rstrip('/') not in self.included_paths: self.excluded_paths.add(route) if overwrite: continue From aa0226a0e9902e2f6e2b6cfd2ce35559c5f1a565 Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sun, 23 Jun 2024 04:40:28 +0200 Subject: [PATCH 65/79] renaming and clarification --- nicegui/outlet.py | 2 +- nicegui/single_page_router_config.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/nicegui/outlet.py b/nicegui/outlet.py index e41825dd0..2c0ab5667 100644 --- a/nicegui/outlet.py +++ b/nicegui/outlet.py @@ -98,7 +98,7 @@ def outlet_view(**kwargs): self.outlet_builder = func if self.parent_config is None: - self.setup_pages() + self.setup_page() else: relative_path = self.base_path[len(self.parent_config.base_path):] OutletView(self.parent_config, relative_path)(outlet_view) diff --git a/nicegui/single_page_router_config.py b/nicegui/single_page_router_config.py index 49a217a40..dc35a10d8 100644 --- a/nicegui/single_page_router_config.py +++ b/nicegui/single_page_router_config.py @@ -59,8 +59,8 @@ def __init__(self, self.page_kwargs = kwargs self.router_class = SinglePageRouter if router_class is None else router_class - def setup_pages(self, overwrite=False) -> Self: - """Setups the NiceGUI page endpoints and their base UI structure for the root routers + def setup_page(self, overwrite=False) -> Self: + """Setup the NiceGUI page with all it's endpoints and their base UI structure for the root routers :param overwrite: Optional flag to force the setup of a given page even if one with a conflicting path is already existing. Default is False. Classes such as SinglePageApp use this flag to avoid conflicts with From 3bfd3c8aec3c8be2b806296d1289cce9417a0ab7 Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sun, 23 Jun 2024 04:43:28 +0200 Subject: [PATCH 66/79] move setup_page to subclass because it's only used there --- nicegui/outlet.py | 31 +++++++++++++++++++++++++++ nicegui/single_page_router_config.py | 32 ---------------------------- 2 files changed, 31 insertions(+), 32 deletions(-) diff --git a/nicegui/outlet.py b/nicegui/outlet.py index 2c0ab5667..e0a0fdffd 100644 --- a/nicegui/outlet.py +++ b/nicegui/outlet.py @@ -1,5 +1,6 @@ from typing import Any, Callable, Generator, Optional, Self, Union +from nicegui import ui from nicegui.client import Client from nicegui.elements.router_frame import RouterFrame from nicegui.single_page_router import SinglePageRouter @@ -104,6 +105,36 @@ def outlet_view(**kwargs): OutletView(self.parent_config, relative_path)(outlet_view) return self + def setup_page(self, overwrite=False) -> Self: + """Setup the NiceGUI page with all it's endpoints and their base UI structure for the root routers + + :param overwrite: Optional flag to force the setup of a given page even if one with a conflicting path is + already existing. Default is False. Classes such as SinglePageApp use this flag to avoid conflicts with + other routers and resolve those conflicts by rerouting the pages.""" + for key, route in Client.page_routes.items(): + if route.startswith(self.base_path.rstrip('/') + '/') and route.rstrip('/') not in self.included_paths: + self.excluded_paths.add(route) + if overwrite: + continue + if self.base_path.startswith(route.rstrip('/') + '/'): # '/sub_router' after '/' - forbidden + raise ValueError(f'Another router with path "{route.rstrip("/")}/*" is already registered which ' + f'includes this router\'s base path "{self.base_path}". You can declare the nested ' + f'router first to prioritize it and avoid this issue.') + + @ui.page(self.base_path, **self.page_kwargs) + @ui.page(f'{self.base_path}' + '{_:path}', **self.page_kwargs) # all other pages + async def root_page(request_data=None): + await ui.context.client.connected() # to ensure storage.tab and storage.client availability + initial_url = None + if request_data is not None: + initial_url = request_data['url']['path'] + query = request_data['url'].get('query', None) + if query: + initial_url += '?' + query + self.build_page(initial_url=initial_url) + + return self + def view(self, path: str, title: Optional[str] = None diff --git a/nicegui/single_page_router_config.py b/nicegui/single_page_router_config.py index dc35a10d8..789bfb197 100644 --- a/nicegui/single_page_router_config.py +++ b/nicegui/single_page_router_config.py @@ -4,8 +4,6 @@ from fnmatch import fnmatch from typing import Any, Callable, Dict, Generator, List, Optional, Self, Set, Union -from nicegui import ui -from nicegui.client import Client from nicegui.context import context from nicegui.elements.router_frame import RouterFrame from nicegui.single_page_router import SinglePageRouter @@ -59,36 +57,6 @@ def __init__(self, self.page_kwargs = kwargs self.router_class = SinglePageRouter if router_class is None else router_class - def setup_page(self, overwrite=False) -> Self: - """Setup the NiceGUI page with all it's endpoints and their base UI structure for the root routers - - :param overwrite: Optional flag to force the setup of a given page even if one with a conflicting path is - already existing. Default is False. Classes such as SinglePageApp use this flag to avoid conflicts with - other routers and resolve those conflicts by rerouting the pages.""" - for key, route in Client.page_routes.items(): - if route.startswith(self.base_path.rstrip('/') + '/') and route.rstrip('/') not in self.included_paths: - self.excluded_paths.add(route) - if overwrite: - continue - if self.base_path.startswith(route.rstrip('/') + '/'): # '/sub_router' after '/' - forbidden - raise ValueError(f'Another router with path "{route.rstrip("/")}/*" is already registered which ' - f'includes this router\'s base path "{self.base_path}". You can declare the nested ' - f'router first to prioritize it and avoid this issue.') - - @ui.page(self.base_path, **self.page_kwargs) - @ui.page(f'{self.base_path}' + '{_:path}', **self.page_kwargs) # all other pages - async def root_page(request_data=None): - await ui.context.client.connected() # to ensure storage.tab and storage.client availability - initial_url = None - if request_data is not None: - initial_url = request_data['url']['path'] - query = request_data['url'].get('query', None) - if query: - initial_url += '?' + query - self.build_page(initial_url=initial_url) - - return self - def add_view(self, path: str, builder: Callable, title: Optional[str] = None, on_open: Optional[Callable[[SinglePageTarget], SinglePageTarget]] = None) -> None: """Add a new route to the single page router From 9c4526f9f1cda48d942a65801665e4237fcd21bd Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sun, 23 Jun 2024 05:07:42 +0200 Subject: [PATCH 67/79] improve naming and typing --- nicegui/client.py | 14 ++++++++------ nicegui/outlet.py | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/nicegui/client.py b/nicegui/client.py index 82f2023c9..133be2d0a 100644 --- a/nicegui/client.py +++ b/nicegui/client.py @@ -13,6 +13,8 @@ from fastapi.templating import Jinja2Templates from typing_extensions import Self +from nicegui.outlet import Outlet + from . import background_tasks, binding, core, helpers, json from .awaitable_response import AwaitableResponse from .dependencies import generate_resources @@ -35,10 +37,10 @@ class Client: page_routes: ClassVar[Dict[Callable[..., Any], str]] = {} """Maps page builders to their routes.""" - page_configs: Dict[Callable[..., Any], "page"] = {} + page_configs: ClassVar[Dict[Callable[..., Any], "page"]] = {} """Maps page builders to their page configuration.""" - single_page_routes: Dict[str, Any] = {} + top_level_outlets: ClassVar[Dict[str, Outlet]] = {} """Maps paths to the associated single page routers.""" instances: ClassVar[Dict[str, Client]] = {} @@ -233,10 +235,10 @@ async def send_and_wait(): def open(self, target: Union[Callable[..., Any], str], new_tab: bool = False) -> None: """Open a new page in the client.""" path = target if isinstance(target, str) else self.page_routes[target] - for cur_spr in self.single_page_routes.values(): - target = cur_spr.resolve_target(path) - if target.valid: - cur_spr.navigate_to(path) + for outlet in self.top_level_outlets.values(): + outlet_target = outlet.resolve_target(path) + if outlet_target.valid: + outlet.navigate_to(path) return self.outbox.enqueue_message('open', {'path': path, 'new_tab': new_tab}, self.id) diff --git a/nicegui/outlet.py b/nicegui/outlet.py index e0a0fdffd..44b9a537b 100644 --- a/nicegui/outlet.py +++ b/nicegui/outlet.py @@ -56,7 +56,7 @@ def __init__(self, parent=parent, **kwargs) self.outlet_builder: Optional[Callable] = None if parent is None: - Client.single_page_routes[path] = self + Client.top_level_outlets[path] = self if router_class is not None: # check if class defines outlet builder function if hasattr(router_class, PAGE_TEMPLATE_METHOD_NAME): From d40c9843cd44f12d55fe1da9e5064c1c62ed3ab8 Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sun, 23 Jun 2024 05:07:51 +0200 Subject: [PATCH 68/79] docstring --- nicegui/single_page_router_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nicegui/single_page_router_config.py b/nicegui/single_page_router_config.py index 789bfb197..233bccac8 100644 --- a/nicegui/single_page_router_config.py +++ b/nicegui/single_page_router_config.py @@ -77,7 +77,7 @@ def add_router_entry(self, entry: 'SinglePageRouterPath') -> None: self.routes[entry.path] = entry.verify() def resolve_target(self, target: Union[Callable, str]) -> SinglePageTarget: - """Tries to resolve a target such as a builder function or a URL path w/ route and query parameters. + """Tries to resolve a target such as a builder function or a URL path with route and query parameters. :param target: The URL path to open or a builder function :return: The resolved target. Defines .valid if the target is valid""" From 9678c89db408fa2442753d2037bba119d70d00c5 Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sun, 23 Jun 2024 05:48:34 +0200 Subject: [PATCH 69/79] extract OutletView to own class to break cyclic dependencies --- nicegui/OutletView.py | 42 ++++++++++++++++++++++++++++++++++++++++ nicegui/elements/link.py | 2 +- nicegui/outlet.py | 39 +------------------------------------ 3 files changed, 44 insertions(+), 39 deletions(-) create mode 100644 nicegui/OutletView.py diff --git a/nicegui/OutletView.py b/nicegui/OutletView.py new file mode 100644 index 000000000..d849b61df --- /dev/null +++ b/nicegui/OutletView.py @@ -0,0 +1,42 @@ +from typing import Any, Callable, Optional + +from nicegui.single_page_router_config import SinglePageRouterConfig +from nicegui.single_page_target import SinglePageTarget + + +class OutletView: + """Defines a single view / "content page" which is displayed in an outlet""" + + def __init__(self, parent: SinglePageRouterConfig, path: str, title: Optional[str] = None): + """ + :param parent: The parent outlet in which this view is displayed + :param path: The path of the view, relative to the base path of the outlet + :param title: Optional title of the view. If a title is set, it will be displayed in the browser tab + when the view is active, otherwise the default title of the application is displayed. + """ + self.path = path + self.title = title + self.parent_outlet = parent + + @property + def url(self) -> str: + """The absolute URL of the view + + :return: The absolute URL of the view + """ + return (self.parent_outlet.base_path.rstrip('/') + '/' + self.path.lstrip('/')).rstrip('/') + + def handle_resolve(self, target: SinglePageTarget, **kwargs) -> SinglePageTarget: + """Is called when the target is resolved to this view + + :param target: The resolved target + :return: The resolved target or a modified target + """ + return target + + def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]: + """Decorator for the view function""" + abs_path = self.url + self.parent_outlet.add_view( + abs_path, func, title=self.title, on_open=self.handle_resolve) + return self diff --git a/nicegui/elements/link.py b/nicegui/elements/link.py index 1dd5be596..d3f2fe11b 100644 --- a/nicegui/elements/link.py +++ b/nicegui/elements/link.py @@ -2,8 +2,8 @@ from ..client import Client from ..element import Element +from ..OutletView import OutletView from .mixins.text_element import TextElement -from ..outlet import OutletView class Link(TextElement, component='link.js'): diff --git a/nicegui/outlet.py b/nicegui/outlet.py index 44b9a537b..6941a0309 100644 --- a/nicegui/outlet.py +++ b/nicegui/outlet.py @@ -3,6 +3,7 @@ from nicegui import ui from nicegui.client import Client from nicegui.elements.router_frame import RouterFrame +from nicegui.OutletView import OutletView from nicegui.single_page_router import SinglePageRouter from nicegui.single_page_router_config import SinglePageRouterConfig from nicegui.single_page_target import SinglePageTarget @@ -171,41 +172,3 @@ def current_url(self) -> str: raise ValueError('The current URL can only be retrieved from within a nested outlet or view builder ' 'function.') return cur_router.target_url - - -class OutletView: - """Defines a single view / "content page" which is displayed in an outlet""" - - def __init__(self, parent: SinglePageRouterConfig, path: str, title: Optional[str] = None): - """ - :param parent: The parent outlet in which this view is displayed - :param path: The path of the view, relative to the base path of the outlet - :param title: Optional title of the view. If a title is set, it will be displayed in the browser tab - when the view is active, otherwise the default title of the application is displayed. - """ - self.path = path - self.title = title - self.parent_outlet = parent - - @property - def url(self) -> str: - """The absolute URL of the view - - :return: The absolute URL of the view - """ - return (self.parent_outlet.base_path.rstrip('/') + '/' + self.path.lstrip('/')).rstrip('/') - - def handle_resolve(self, target: SinglePageTarget, **kwargs) -> SinglePageTarget: - """Is called when the target is resolved to this view - - :param target: The resolved target - :return: The resolved target or a modified target - """ - return target - - def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]: - """Decorator for the view function""" - abs_path = self.url - self.parent_outlet.add_view( - abs_path, func, title=self.title, on_open=self.handle_resolve) - return self From ae71e4bd3a734c7bcf3ae53bff1a32eb85697a86 Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sun, 23 Jun 2024 05:48:57 +0200 Subject: [PATCH 70/79] better naming --- nicegui/single_page_router_config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nicegui/single_page_router_config.py b/nicegui/single_page_router_config.py index 233bccac8..f75a9066c 100644 --- a/nicegui/single_page_router_config.py +++ b/nicegui/single_page_router_config.py @@ -109,13 +109,13 @@ def navigate_to(self, target: Union[Callable, str, SinglePageTarget], server_sid :param target: The target to navigate to :param server_side: Optional flag which defines if the call is originated on the server side""" - org_target = target + original_target = target if not isinstance(target, SinglePageTarget): target = self.resolve_target(target) router = context.client.single_page_router if not target.valid or router is None: return False - router.navigate_to(org_target, server_side=server_side) + router.navigate_to(original_target, server_side=server_side) return True def handle_navigate(self, url: str) -> Optional[Union[SinglePageTarget, str]]: From d9ee01f3a67f5241945ccb2d8b53ebb4ba3b9463 Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sun, 23 Jun 2024 05:49:46 +0200 Subject: [PATCH 71/79] resolve cyclic dependencies --- nicegui/client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nicegui/client.py b/nicegui/client.py index 133be2d0a..53e07d5c6 100644 --- a/nicegui/client.py +++ b/nicegui/client.py @@ -13,8 +13,6 @@ from fastapi.templating import Jinja2Templates from typing_extensions import Self -from nicegui.outlet import Outlet - from . import background_tasks, binding, core, helpers, json from .awaitable_response import AwaitableResponse from .dependencies import generate_resources @@ -27,6 +25,8 @@ from .version import __version__ if TYPE_CHECKING: + from nicegui.outlet import Outlet + from .page import page from .single_page_router import SinglePageRouter @@ -37,10 +37,10 @@ class Client: page_routes: ClassVar[Dict[Callable[..., Any], str]] = {} """Maps page builders to their routes.""" - page_configs: ClassVar[Dict[Callable[..., Any], "page"]] = {} + page_configs: ClassVar[Dict[Callable[..., Any], 'page']] = {} """Maps page builders to their page configuration.""" - top_level_outlets: ClassVar[Dict[str, Outlet]] = {} + top_level_outlets: ClassVar[Dict[str, 'Outlet']] = {} """Maps paths to the associated single page routers.""" instances: ClassVar[Dict[str, Client]] = {} From d3c901fb9c791f30dc723963eff6631128e54cfb Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sun, 23 Jun 2024 05:55:52 +0200 Subject: [PATCH 72/79] improve naming and typing --- nicegui/single_page_router.py | 46 +++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/nicegui/single_page_router.py b/nicegui/single_page_router.py index 147f2874f..aa87e6483 100644 --- a/nicegui/single_page_router.py +++ b/nicegui/single_page_router.py @@ -104,28 +104,27 @@ def target_url(self) -> str: :return: The target url of the router frame""" return self.router_frame.target_url - def resolve_target(self, target: Any) -> SinglePageTarget: - """Resolves a URL or SPA target to a SinglePageUrl which contains details about the builder function to + def resolve_url(self, target: str) -> SinglePageTarget: + """Resolves a URL target to a SinglePageUrl which contains details about the builder function to be called and the arguments to pass to the builder function. :param target: The target object such as a URL or Callable :return: The resolved SinglePageTarget object""" - if isinstance(target, SinglePageTarget): - return target - target = self.router_config.resolve_target(target) - if isinstance(target, SinglePageTarget) and not target.valid and target.path.startswith(self.base_path): - rem_path = target.path[len(self.base_path):] + outlet_target = self.router_config.resolve_target(target) + assert outlet_target.path is not None + if isinstance(outlet_target, SinglePageTarget) and not outlet_target.valid and outlet_target.path.startswith(self.base_path): + rem_path = outlet_target.path[len(self.base_path):] if rem_path in self.views: - target.builder = self.views[rem_path].builder - target.title = self.views[rem_path].title - target.valid = True - if target.valid and target.router is None: - target.router = self - if target is None: + outlet_target.builder = self.views[rem_path].builder + outlet_target.title = self.views[rem_path].title + outlet_target.valid = True + if outlet_target.valid and outlet_target.router is None: + outlet_target.router = self + if outlet_target is None: raise NotImplementedError - return target + return outlet_target - def handle_navigate(self, target: str) -> Optional[Union[SinglePageTarget, str]]: + def handle_navigate(self, target: str) -> Union[SinglePageTarget, str, None]: """Is called when there was a navigation event in the browser or by the navigate_to method. By default, the original target is returned. The SinglePageRouter and the router config (the outlet) can @@ -134,11 +133,12 @@ def handle_navigate(self, target: str) -> Optional[Union[SinglePageTarget, str]] :param target: The target URL :return: The target URL, a completely resolved target or None if the navigation is suppressed""" if self._on_navigate is not None: - target = self._on_navigate(target) - if target is None: + custom_target = self._on_navigate(target) + if custom_target is None: return None - if isinstance(target, SinglePageTarget): - return target + if isinstance(custom_target, SinglePageTarget): + return custom_target + target = custom_target new_target = self.router_config.handle_navigate(target) if isinstance(target, SinglePageTarget): return target @@ -146,7 +146,7 @@ def handle_navigate(self, target: str) -> Optional[Union[SinglePageTarget, str]] return new_target return target - def navigate_to(self, target: [SinglePageTarget, str], server_side=True, sync=False, + def navigate_to(self, target: Union[SinglePageTarget, str, None], server_side=True, sync=False, history: bool = True) -> None: """Open a new page in the browser by exchanging the content of the router frame @@ -166,12 +166,16 @@ def navigate_to(self, target: [SinglePageTarget, str], server_side=True, sync=Fa return handler_kwargs = SinglePageRouter.get_user_data() | self.user_data | self.router_frame.user_data | \ {'previous_url_path': self.router_frame.target_url} + if target is None: + # TODO in which cases can the target be None? What should be the behavior? + raise ValueError('Target is None') handler_kwargs['url_path'] = target if isinstance(target, str) else target.original_path if not isinstance(target, SinglePageTarget): - target = self.resolve_target(target) + target = self.resolve_url(target) if target is None or not target.valid: # navigation suppressed return target_url = target.original_path + assert target_url is not None handler_kwargs['target_url'] = target_url self._update_target_url(target_url) js_code = '' From e710c504931db9abe10eb7e2bd8dcbe665dc2bcb Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sun, 23 Jun 2024 06:14:05 +0200 Subject: [PATCH 73/79] fix docstring --- nicegui/single_page_router.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nicegui/single_page_router.py b/nicegui/single_page_router.py index aa87e6483..9e9a829d5 100644 --- a/nicegui/single_page_router.py +++ b/nicegui/single_page_router.py @@ -105,7 +105,7 @@ def target_url(self) -> str: return self.router_frame.target_url def resolve_url(self, target: str) -> SinglePageTarget: - """Resolves a URL target to a SinglePageUrl which contains details about the builder function to + """Resolves a URL target to a SinglePageTarget which contains details about the builder function to be called and the arguments to pass to the builder function. :param target: The target object such as a URL or Callable From b16d9928f440294d91b513287d73d647843e10ef Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sun, 23 Jun 2024 06:28:13 +0200 Subject: [PATCH 74/79] simplify code by getting rid of single_page_router_config.navigate_to --- nicegui/client.py | 5 ++++- nicegui/single_page_router_config.py | 14 -------------- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/nicegui/client.py b/nicegui/client.py index 53e07d5c6..36949dd49 100644 --- a/nicegui/client.py +++ b/nicegui/client.py @@ -13,6 +13,8 @@ from fastapi.templating import Jinja2Templates from typing_extensions import Self +from nicegui.context import context + from . import background_tasks, binding, core, helpers, json from .awaitable_response import AwaitableResponse from .dependencies import generate_resources @@ -238,7 +240,8 @@ def open(self, target: Union[Callable[..., Any], str], new_tab: bool = False) -> for outlet in self.top_level_outlets.values(): outlet_target = outlet.resolve_target(path) if outlet_target.valid: - outlet.navigate_to(path) + assert context.client.single_page_router + context.client.single_page_router.navigate_to(path) return self.outbox.enqueue_message('open', {'path': path, 'new_tab': new_tab}, self.id) diff --git a/nicegui/single_page_router_config.py b/nicegui/single_page_router_config.py index f75a9066c..f9a0443f1 100644 --- a/nicegui/single_page_router_config.py +++ b/nicegui/single_page_router_config.py @@ -104,20 +104,6 @@ def resolve_target(self, target: Union[Callable, str]) -> SinglePageTarget: result.original_path = resolved.original_path return result - def navigate_to(self, target: Union[Callable, str, SinglePageTarget], server_side=True) -> bool: - """Navigate to a target - - :param target: The target to navigate to - :param server_side: Optional flag which defines if the call is originated on the server side""" - original_target = target - if not isinstance(target, SinglePageTarget): - target = self.resolve_target(target) - router = context.client.single_page_router - if not target.valid or router is None: - return False - router.navigate_to(original_target, server_side=server_side) - return True - def handle_navigate(self, url: str) -> Optional[Union[SinglePageTarget, str]]: """Handles a navigation event and returns the new URL if the navigation should be allowed From 340bfd16653537cc0d6f218bab44296cb24cf40e Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sun, 23 Jun 2024 07:49:14 +0200 Subject: [PATCH 75/79] clarification --- nicegui/single_page_router.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nicegui/single_page_router.py b/nicegui/single_page_router.py index 9e9a829d5..11bc94ac7 100644 --- a/nicegui/single_page_router.py +++ b/nicegui/single_page_router.py @@ -150,7 +150,7 @@ def navigate_to(self, target: Union[SinglePageTarget, str, None], server_side=Tr history: bool = True) -> None: """Open a new page in the browser by exchanging the content of the router frame - :param target: The target page or url. + :param target: The target page or path. :param server_side: Optional flag which defines if the call is originated on the server side and thus the browser history should be updated. Default is False. :param sync: Optional flag to define if the content should be updated synchronously. Default is False. From d1b955cc70f0f60a100cb804e6e4301bfc0824ed Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sun, 23 Jun 2024 07:49:22 +0200 Subject: [PATCH 76/79] renaming --- nicegui/single_page_router_config.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/nicegui/single_page_router_config.py b/nicegui/single_page_router_config.py index f9a0443f1..6f218dc42 100644 --- a/nicegui/single_page_router_config.py +++ b/nicegui/single_page_router_config.py @@ -88,16 +88,16 @@ def resolve_target(self, target: Union[Callable, str]) -> SinglePageTarget: raise ValueError('The target builder function is not registered in the router.') resolved = None path = target.split('#')[0].split('?')[0] - for cur_router in self.child_routers: + for router in self.child_routers: # replace {} placeholders with * to match the fnmatch pattern - mask = SinglePageRouterPath.create_path_mask(cur_router.base_path.rstrip('/') + '/*') - if fnmatch(path, mask) or path == cur_router.base_path: - resolved = cur_router.resolve_target(target) + mask = SinglePageRouterPath.create_path_mask(router.base_path.rstrip('/') + '/*') + if fnmatch(path, mask) or path == router.base_path: + resolved = router.resolve_target(target) if resolved.valid: - target = cur_router.base_path + target = router.base_path if '*' in mask: # isolate the real path elements and update target accordingly - target = '/'.join(path.split('/')[:len(cur_router.base_path.split('/'))]) + target = '/'.join(path.split('/')[:len(router.base_path.split('/'))]) break result = SinglePageTarget(target).parse_url_path(routes=self.routes) if resolved is not None: From ef29d1a41d0f329c9ef78eb0b00c39512b8513ac Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Sun, 30 Jun 2024 21:10:00 +0200 Subject: [PATCH 77/79] Removed NiceGUI input focus --- examples/authentication_spa/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/authentication_spa/main.py b/examples/authentication_spa/main.py index 0f02eccbf..380b1bc63 100644 --- a/examples/authentication_spa/main.py +++ b/examples/authentication_spa/main.py @@ -48,7 +48,7 @@ def handle_login(): with ui.column() as col: ui.label('Login to NiceCLOUD!').classes('text-3xl') ui.html('
') - input_field = ui.input('Username').style('width: 100%').on('keydown.enter', lambda: pw.focus()) + input_field = ui.input('Username').style('width: 100%').on('keydown.enter', lambda: pw.run_method('focus')) pw = ui.input('Password', password=True, password_toggle_button=True).style('width: 100%').on('keydown.enter', handle_login) col.style('width: 300pt; top: 40%; left: 50%; transform: translate(-50%, -50%); position: absolute;') From 0737ebeea2ab8b5958979b472f79491e45c230ce Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Sun, 30 Jun 2024 22:34:02 +0200 Subject: [PATCH 78/79] Merged SinglePageRouterConfig and Outlet to single Outlet class --- nicegui/elements/link.py | 2 +- nicegui/outlet.py | 202 +++++++++++++++++- nicegui/{OutletView.py => outlet_view.py} | 8 +- nicegui/single_page_router.py | 10 +- nicegui/single_page_router_config.py | 240 ---------------------- nicegui/single_page_target.py | 14 +- 6 files changed, 210 insertions(+), 266 deletions(-) rename nicegui/{OutletView.py => outlet_view.py} (87%) delete mode 100644 nicegui/single_page_router_config.py diff --git a/nicegui/elements/link.py b/nicegui/elements/link.py index d3f2fe11b..90189c225 100644 --- a/nicegui/elements/link.py +++ b/nicegui/elements/link.py @@ -2,7 +2,7 @@ from ..client import Client from ..element import Element -from ..OutletView import OutletView +from ..outlet_view import OutletView from .mixins.text_element import TextElement diff --git a/nicegui/outlet.py b/nicegui/outlet.py index 9d8a54809..5a0c1e6c0 100644 --- a/nicegui/outlet.py +++ b/nicegui/outlet.py @@ -1,17 +1,21 @@ -from typing import Any, Callable, Generator, Optional, Self, Union +from typing import Any, Callable, Generator, Optional, Self, Union, Dict, Set, List + +import inspect +import re +from fnmatch import fnmatch from nicegui import ui from nicegui.client import Client from nicegui.elements.router_frame import RouterFrame -from nicegui.OutletView import OutletView +from nicegui.outlet_view import OutletView from nicegui.single_page_router import SinglePageRouter -from nicegui.single_page_router_config import SinglePageRouterConfig from nicegui.single_page_target import SinglePageTarget +from nicegui.context import context PAGE_TEMPLATE_METHOD_NAME = "page_template" -class Outlet(SinglePageRouterConfig): +class Outlet: """An outlet allows the creation of single page applications which do not reload the page when navigating between different views. The outlet is a container for multiple views and can contain further, nested outlets. @@ -27,7 +31,7 @@ def __init__(self, path: str, outlet_builder: Optional[Callable] = None, browser_history: bool = True, - parent: Optional['SinglePageRouterConfig'] = None, + parent: Optional['Outlet'] = None, on_instance_created: Optional[Callable[['SinglePageRouter'], None]] = None, on_navigate: Optional[Callable[[str], Optional[Union[SinglePageTarget, str]]]] = None, router_class: Optional[Callable[..., SinglePageRouter]] = None, @@ -51,10 +55,22 @@ def __init__(self, :param parent: The parent outlet of this outlet. :param kwargs: Additional arguments """ - super().__init__(path, browser_history=browser_history, on_instance_created=on_instance_created, - on_navigate=on_navigate, - router_class=router_class, - parent=parent, **kwargs) + super().__init__() + self.routes: Dict[str, 'OutletPath'] = {} + self.base_path = path + self.included_paths: Set[str] = set() + self.excluded_paths: Set[str] = set() + self.on_instance_created: Optional[Callable] = on_instance_created + self.on_navigate: Optional[Callable[[str], Optional[Union[SinglePageTarget, str]]]] = on_navigate + self.use_browser_history = browser_history + self.page_template = None + self._setup_configured = False + self.parent_config = parent + if self.parent_config is not None: + self.parent_config._register_child_outlet(self) + self.child_routers: List['Outlet'] = [] + self.page_kwargs = kwargs + self.router_class = SinglePageRouter if router_class is None else router_class self.outlet_builder: Optional[Callable] = None if parent is None: Client.top_level_outlets[path] = self @@ -138,6 +154,131 @@ async def root_page(request_data=None): return self + def add_view(self, path: str, builder: Callable, title: Optional[str] = None, + on_open: Optional[Callable[[SinglePageTarget], SinglePageTarget]] = None) -> None: + """Add a new route to the single page router + + :param path: The path of the route, including FastAPI path parameters + :param builder: The builder function (the view to be displayed) + :param title: Optional title of the page + :param on_open: Optional on_resolve function which is called when this path was selected. + """ + path_mask = OutletPath.create_path_mask(path.rstrip('/')) + self.included_paths.add(path_mask) + self.routes[path] = OutletPath(path, builder, title, on_open=on_open).verify() + + def add_router_entry(self, entry: 'OutletPath') -> None: + """Adds a fully configured OutletPath to the router + + :param entry: The OutletPath to add""" + self.routes[entry.path] = entry.verify() + + def resolve_target(self, target: Union[Callable, str]) -> SinglePageTarget: + """Tries to resolve a target such as a builder function or a URL path with route and query parameters. + + :param target: The URL path to open or a builder function + :return: The resolved target. Defines .valid if the target is valid""" + if callable(target): + for target, entry in self.routes.items(): + if entry.builder == target: + return SinglePageTarget(router_path=entry) + raise ValueError('The target builder function is not registered in the router.') + resolved = None + path = target.split('#')[0].split('?')[0] + for router in self.child_routers: + # replace {} placeholders with * to match the fnmatch pattern + mask = OutletPath.create_path_mask(router.base_path.rstrip('/') + '/*') + if fnmatch(path, mask) or path == router.base_path: + resolved = router.resolve_target(target) + if resolved.valid: + target = router.base_path + if '*' in mask: + # isolate the real path elements and update target accordingly + target = '/'.join(path.split('/')[:len(router.base_path.split('/'))]) + break + result = SinglePageTarget(target).parse_url_path(routes=self.routes) + if resolved is not None: + result.original_path = resolved.original_path + return result + + def handle_navigate(self, url: str) -> Optional[Union[SinglePageTarget, str]]: + """Handles a navigation event and returns the new URL if the navigation should be allowed + + :param url: The URL to navigate to + :return: The new URL if the navigation should be allowed, None otherwise""" + if self.on_navigate is not None: + new_target = self.on_navigate(url) + if isinstance(new_target, SinglePageTarget): + return new_target + if new_target != url: + return new_target + if self.parent_config is not None: + return self.parent_config.handle_navigate(url) + return url + + def build_page(self, initial_url: Optional[str] = None, **kwargs) -> None: + """Builds the page with the given initial URL + + :param initial_url: The initial URL to initialize the router's content with + :param kwargs: Additional keyword arguments passed to the page template generator function""" + kwargs['url_path'] = initial_url + template = RouterFrame.run_safe(self.build_page_template, **kwargs) + if not isinstance(template, Generator): + raise ValueError('The page template method must yield a value to separate the layout from the content ' + 'area.') + new_user_data = {} + new_properties = next(template) + if isinstance(new_properties, dict): + new_user_data.update(new_properties) + content_area = self.create_router_instance(initial_url, user_data=new_user_data) + try: + new_properties = next(template) + if isinstance(new_properties, dict): + new_user_data.update(new_properties) + except StopIteration: + pass + content_area.update_user_data(new_user_data) + + def create_router_instance(self, + initial_url: Optional[str] = None, + user_data: Optional[Dict] = None) -> SinglePageRouter: + """Creates a new router instance for the current visitor. + + :param initial_url: The initial URL to initialize the router's content with + :param user_data: Optional user data to pass to the content area + :return: The created router instance""" + + user_data_kwargs = {} + + def prepare_arguments(): + nonlocal parent_router, user_data_kwargs + init_params = inspect.signature(self.router_class.__init__).parameters + param_names = set(init_params.keys()) - {'self'} + user_data_kwargs = {k: v for k, v in user_data.items() if k in param_names} + cur_parent = parent_router + while cur_parent is not None: + user_data_kwargs.update({k: v for k, v in cur_parent.user_data.items() if k in param_names}) + cur_parent = cur_parent.parent + + parent_router = SinglePageRouter.current_router() + prepare_arguments() + content = self.router_class(config=self, + included_paths=sorted(list(self.included_paths)), + excluded_paths=sorted(list(self.excluded_paths)), + use_browser_history=self.use_browser_history, + parent=parent_router, + target_url=initial_url, + user_data=user_data, + **user_data_kwargs) + if parent_router is None: # register root routers to the client + context.client.single_page_router = content + initial_url = content.target_url + if self.on_instance_created is not None: + self.on_instance_created(content) + if initial_url is not None: + content.navigate_to(initial_url, server_side=True, sync=True, history=False) + return content + def view(self, path: str, title: Optional[str] = None @@ -174,3 +315,46 @@ def current_url(self) -> str: raise ValueError('The current URL can only be retrieved from within a nested outlet or view builder ' 'function.') return cur_router.target_url + + def _register_child_outlet(self, router_config: 'Outlet') -> None: + """Registers a child outlet config to the parent router config""" + self.child_routers.append(router_config) + + +class OutletPath: + """The OutletPath is a data class which holds the configuration of one router path""" + + def __init__(self, path: str, builder: Callable, title: Union[str, None] = None, + on_open: Optional[Callable[[SinglePageTarget, Any], SinglePageTarget]] = None): + """ + :param path: The path of the route + :param builder: The builder function which is called when the route is opened + :param title: Optional title of the page + :param on_open: Optional on_resolve function which is called when this path was selected.""" + self.path = path + self.builder = builder + self.title = title + self.on_open = on_open + + def verify(self) -> Self: + """Verifies a OutletPath for correctness. Raises a ValueError if the entry is invalid.""" + path = self.path + if '{' in path: + # verify only a single open and close curly bracket is present + elements = path.split('/') + for cur_element in elements: + if '{' in cur_element: + if cur_element.count('{') != 1 or cur_element.count('}') != 1 or len(cur_element) < 3 or \ + not (cur_element.startswith('{') and cur_element.endswith('}')): + raise ValueError('Only simple path parameters are supported. /path/{value}/{another_value}\n' + f'failed for path: {path}') + return self + + @staticmethod + def create_path_mask(path: str) -> str: + """Converts a path to a mask which can be used for fnmatch matching + + /site/{value}/{other_value} --> /site/*/* + :param path: The path to convert + :return: The mask with all path parameters replaced by a wildcard""" + return re.sub(r'{[^}]+}', '*', path) diff --git a/nicegui/OutletView.py b/nicegui/outlet_view.py similarity index 87% rename from nicegui/OutletView.py rename to nicegui/outlet_view.py index d849b61df..415cc9691 100644 --- a/nicegui/OutletView.py +++ b/nicegui/outlet_view.py @@ -1,13 +1,15 @@ -from typing import Any, Callable, Optional +from typing import Any, Callable, Optional, TYPE_CHECKING -from nicegui.single_page_router_config import SinglePageRouterConfig from nicegui.single_page_target import SinglePageTarget +if TYPE_CHECKING: + from nicegui.outlet import Outlet + class OutletView: """Defines a single view / "content page" which is displayed in an outlet""" - def __init__(self, parent: SinglePageRouterConfig, path: str, title: Optional[str] = None): + def __init__(self, parent: 'Outlet', path: str, title: Optional[str] = None): """ :param parent: The parent outlet in which this view is displayed :param path: The path of the view, relative to the base path of the outlet diff --git a/nicegui/single_page_router.py b/nicegui/single_page_router.py index 11bc94ac7..8ad78e576 100644 --- a/nicegui/single_page_router.py +++ b/nicegui/single_page_router.py @@ -8,9 +8,6 @@ PATH_RESOLVING_MAX_RECURSION = 100 -if TYPE_CHECKING: - from nicegui.single_page_router_config import SinglePageRouterConfig - class SinglePageRouter: """The SinglePageRouter manages the SinglePage navigation and content updates for a SinglePageApp instance. @@ -112,7 +109,8 @@ def resolve_url(self, target: str) -> SinglePageTarget: :return: The resolved SinglePageTarget object""" outlet_target = self.router_config.resolve_target(target) assert outlet_target.path is not None - if isinstance(outlet_target, SinglePageTarget) and not outlet_target.valid and outlet_target.path.startswith(self.base_path): + if isinstance(outlet_target, SinglePageTarget) and not outlet_target.valid and outlet_target.path.startswith( + self.base_path): rem_path = outlet_target.path[len(self.base_path):] if rem_path in self.views: outlet_target.builder = self.views[rem_path].builder @@ -165,7 +163,7 @@ def navigate_to(self, target: Union[SinglePageTarget, str, None], server_side=Tr if target is None: return handler_kwargs = SinglePageRouter.get_user_data() | self.user_data | self.router_frame.user_data | \ - {'previous_url_path': self.router_frame.target_url} + {'previous_url_path': self.router_frame.target_url} if target is None: # TODO in which cases can the target be None? What should be the behavior? raise ValueError('Target is None') @@ -206,7 +204,7 @@ def update_content(self, target: SinglePageTarget, handler_kwargs: dict, sync: b title = target.title if target.title is not None else core.app.config.title ui.page_title(title) if target.on_post_update is not None: - RouterFrame.run_safe(target.on_post_update, handler_kwargs) + RouterFrame.run_safe(target.on_post_update, **handler_kwargs) def clear(self) -> None: """Clear the content of the router frame and removes all references to sub frames""" diff --git a/nicegui/single_page_router_config.py b/nicegui/single_page_router_config.py deleted file mode 100644 index 6f218dc42..000000000 --- a/nicegui/single_page_router_config.py +++ /dev/null @@ -1,240 +0,0 @@ -import inspect -import re -import typing -from fnmatch import fnmatch -from typing import Any, Callable, Dict, Generator, List, Optional, Self, Set, Union - -from nicegui.context import context -from nicegui.elements.router_frame import RouterFrame -from nicegui.single_page_router import SinglePageRouter -from nicegui.single_page_target import SinglePageTarget - -if typing.TYPE_CHECKING: - from nicegui.single_page_router import SinglePageRouter - - -class SinglePageRouterConfig: - """The SinglePageRouterConfig allows the development of Single Page Applications (SPAs). - - It registers the root page of the SPAs at a given base path as FastAPI endpoint and manages the routing of nested - routers.""" - - def __init__(self, - path: str, - browser_history: bool = True, - parent: Optional['SinglePageRouterConfig'] = None, - page_template: Optional[Callable[[], Generator]] = None, - on_instance_created: Optional[Callable[['SinglePageRouter'], None]] = None, - on_navigate: Optional[Callable[[str], Optional[Union[SinglePageTarget, str]]]] = None, - router_class: Optional[type] = None, - **kwargs) -> None: - """:param path: the base path of the single page router. - :param browser_history: Optional flag to enable or disable the browser history management. Default is True. - :param parent: The parent router of this router if this router is a nested router. - :param page_template: Optional page template generator function which defines the layout of the page. It - needs to yield a value to separate the layout from the content area. - :param on_instance_created: Optional callback which is called when a new router instance is created. Each - :param on_navigate: Optional callback which is called when a navigation event is triggered. Can be used to - prevent or modify the navigation. Return the new URL if the navigation should be allowed, modify the URL - or return None to prevent the navigation. - browser tab or window is a new instance. This can be used to initialize the state of the application. - :param router_class: Optional custom router class to use. Default is SinglePageRouter. - :param kwargs: Additional arguments for the @page decorators""" - super().__init__() - self.routes: Dict[str, "SinglePageRouterPath"] = {} - self.base_path = path - self.included_paths: Set[str] = set() - self.excluded_paths: Set[str] = set() - self.on_instance_created: Optional[Callable] = on_instance_created - self.on_navigate: Optional[Callable[[str], Optional[Union[SinglePageTarget, str]]]] = on_navigate - self.use_browser_history = browser_history - self.page_template = page_template - self._setup_configured = False - self.parent_config = parent - if self.parent_config is not None: - self.parent_config._register_child_config(self) - self.child_routers: List['SinglePageRouterConfig'] = [] - self.page_kwargs = kwargs - self.router_class = SinglePageRouter if router_class is None else router_class - - def add_view(self, path: str, builder: Callable, title: Optional[str] = None, - on_open: Optional[Callable[[SinglePageTarget], SinglePageTarget]] = None) -> None: - """Add a new route to the single page router - - :param path: The path of the route, including FastAPI path parameters - :param builder: The builder function (the view to be displayed) - :param title: Optional title of the page - :param on_open: Optional on_resolve function which is called when this path was selected. - """ - path_mask = SinglePageRouterPath.create_path_mask(path.rstrip('/')) - self.included_paths.add(path_mask) - self.routes[path] = SinglePageRouterPath(path, builder, title, on_open=on_open).verify() - - def add_router_entry(self, entry: 'SinglePageRouterPath') -> None: - """Adds a fully configured SinglePageRouterPath to the router - - :param entry: The SinglePageRouterPath to add""" - self.routes[entry.path] = entry.verify() - - def resolve_target(self, target: Union[Callable, str]) -> SinglePageTarget: - """Tries to resolve a target such as a builder function or a URL path with route and query parameters. - - :param target: The URL path to open or a builder function - :return: The resolved target. Defines .valid if the target is valid""" - if callable(target): - for target, entry in self.routes.items(): - if entry.builder == target: - return SinglePageTarget(router_path=entry) - raise ValueError('The target builder function is not registered in the router.') - resolved = None - path = target.split('#')[0].split('?')[0] - for router in self.child_routers: - # replace {} placeholders with * to match the fnmatch pattern - mask = SinglePageRouterPath.create_path_mask(router.base_path.rstrip('/') + '/*') - if fnmatch(path, mask) or path == router.base_path: - resolved = router.resolve_target(target) - if resolved.valid: - target = router.base_path - if '*' in mask: - # isolate the real path elements and update target accordingly - target = '/'.join(path.split('/')[:len(router.base_path.split('/'))]) - break - result = SinglePageTarget(target).parse_url_path(routes=self.routes) - if resolved is not None: - result.original_path = resolved.original_path - return result - - def handle_navigate(self, url: str) -> Optional[Union[SinglePageTarget, str]]: - """Handles a navigation event and returns the new URL if the navigation should be allowed - - :param url: The URL to navigate to - :return: The new URL if the navigation should be allowed, None otherwise""" - if self.on_navigate is not None: - new_target = self.on_navigate(url) - if isinstance(new_target, SinglePageTarget): - return new_target - if new_target != url: - return new_target - if self.parent_config is not None: - return self.parent_config.handle_navigate(url) - return url - - def build_page_template(self) -> Generator: - """Builds the page template. Needs to call insert_content_area at some point which defines the exchangeable - content of the page. - - :return: The page template generator function""" - - def default_template(): - yield - - if self.page_template is not None: - return self.page_template() - else: - return default_template() - - def build_page(self, initial_url: Optional[str] = None, **kwargs) -> None: - """Builds the page with the given initial URL - - :param initial_url: The initial URL to initialize the router's content with - :param kwargs: Additional keyword arguments passed to the page template generator function""" - kwargs['url_path'] = initial_url - template = RouterFrame.run_safe(self.build_page_template, **kwargs) - if not isinstance(template, Generator): - raise ValueError('The page template method must yield a value to separate the layout from the content ' - 'area.') - new_user_data = {} - new_properties = next(template) - if isinstance(new_properties, dict): - new_user_data.update(new_properties) - content_area = self.create_router_instance(initial_url, user_data=new_user_data) - try: - new_properties = next(template) - if isinstance(new_properties, dict): - new_user_data.update(new_properties) - except StopIteration: - pass - content_area.update_user_data(new_user_data) - - def create_router_instance(self, - initial_url: Optional[str] = None, - user_data: Optional[Dict] = None) -> SinglePageRouter: - """Creates a new router instance for the current visitor. - - :param initial_url: The initial URL to initialize the router's content with - :param user_data: Optional user data to pass to the content area - :return: The created router instance""" - - user_data_kwargs = {} - - def prepare_arguments(): - nonlocal parent_router, user_data_kwargs - init_params = inspect.signature(self.router_class.__init__).parameters - param_names = set(init_params.keys()) - {'self'} - user_data_kwargs = {k: v for k, v in user_data.items() if k in param_names} - cur_parent = parent_router - while cur_parent is not None: - user_data_kwargs.update({k: v for k, v in cur_parent.user_data.items() if k in param_names}) - cur_parent = cur_parent.parent - - parent_router = SinglePageRouter.current_router() - prepare_arguments() - content = self.router_class(config=self, - included_paths=sorted(list(self.included_paths)), - excluded_paths=sorted(list(self.excluded_paths)), - use_browser_history=self.use_browser_history, - parent=parent_router, - target_url=initial_url, - user_data=user_data, - **user_data_kwargs) - if parent_router is None: # register root routers to the client - context.client.single_page_router = content - initial_url = content.target_url - if self.on_instance_created is not None: - self.on_instance_created(content) - if initial_url is not None: - content.navigate_to(initial_url, server_side=True, sync=True, history=False) - return content - - def _register_child_config(self, router_config: 'SinglePageRouterConfig') -> None: - """Registers a child router config to the parent router config""" - self.child_routers.append(router_config) - - -class SinglePageRouterPath: - """The SinglePageRouterPath is a data class which holds the configuration of one router path""" - - def __init__(self, path: str, builder: Callable, title: Union[str, None] = None, - on_open: Optional[Callable[[SinglePageTarget, Any], SinglePageTarget]] = None): - """ - :param path: The path of the route - :param builder: The builder function which is called when the route is opened - :param title: Optional title of the page - :param on_open: Optional on_resolve function which is called when this path was selected.""" - self.path = path - self.builder = builder - self.title = title - self.on_open = on_open - - def verify(self) -> Self: - """Verifies a SinglePageRouterPath for correctness. Raises a ValueError if the entry is invalid.""" - path = self.path - if '{' in path: - # verify only a single open and close curly bracket is present - elements = path.split('/') - for cur_element in elements: - if '{' in cur_element: - if cur_element.count('{') != 1 or cur_element.count('}') != 1 or len(cur_element) < 3 or \ - not (cur_element.startswith('{') and cur_element.endswith('}')): - raise ValueError('Only simple path parameters are supported. /path/{value}/{another_value}\n' - f'failed for path: {path}') - return self - - @staticmethod - def create_path_mask(path: str) -> str: - """Converts a path to a mask which can be used for fnmatch matching - - /site/{value}/{other_value} --> /site/*/* - :param path: The path to convert - :return: The mask with all path parameters replaced by a wildcard""" - return re.sub(r'{[^}]+}', '*', path) diff --git a/nicegui/single_page_target.py b/nicegui/single_page_target.py index 5bed84758..0646e8763 100644 --- a/nicegui/single_page_target.py +++ b/nicegui/single_page_target.py @@ -4,12 +4,12 @@ if TYPE_CHECKING: from nicegui.single_page_router import SinglePageRouter - from nicegui.single_page_router_config import SinglePageRouterPath + from nicegui.outlet import OutletPath class SinglePageTarget: """A helper class which is used to resolve and URL path, it's query and fragment parameters to find the matching - SinglePageRouterPath and extract path and query parameters.""" + OutletPath and extract path and query parameters.""" def __init__(self, path: Optional[str] = None, @@ -18,7 +18,7 @@ def __init__(self, builder: Optional[Callable] = None, title: Optional[str] = None, router: Optional['SinglePageRouter'] = None, - router_path: Optional['SinglePageRouterPath'] = None, + router_path: Optional['OutletPath'] = None, on_pre_update: Optional[Callable[[Any], None]] = None, on_post_update: Optional[Callable[[Any], None]] = None ): @@ -29,7 +29,7 @@ def __init__(self, :param query_string: The query string of the URL :param title: The title of the URL to be displayed in the browser tab :param router: The SinglePageRouter which is used to resolve the URL - :param router_path: The SinglePageRouterPath which is matched by the URL + :param router_path: The OutletPath which is matched by the URL :param on_pre_update: Optional callback which is called before content is updated. It can be used to modify the target or execute JavaScript code before the content is updated. :param on_post_update: Optional callback which is called after content is updated. It can be used to modify the @@ -47,7 +47,7 @@ def __init__(self, self.builder = builder self.valid = builder is not None self.router = router - self.router_path: Optional["SinglePageRouterPath"] = router_path + self.router_path: Optional["OutletPath"] = router_path self.on_pre_update = on_pre_update self.on_post_update = on_post_update if router_path is not None and router_path.builder is not None: @@ -55,7 +55,7 @@ def __init__(self, self.title = router_path.title self.valid = True - def parse_url_path(self, routes: Dict[str, 'SinglePageRouterPath']) -> Self: + def parse_url_path(self, routes: Dict[str, 'OutletPath']) -> Self: """ Parses the route using the provided routes dictionary and path. @@ -83,7 +83,7 @@ def parse_url_path(self, routes: Dict[str, 'SinglePageRouterPath']) -> Self: self.valid = False return self - def parse_path(self, routes) -> Optional['SinglePageRouterPath']: + def parse_path(self, routes) -> Optional['OutletPath']: """Splits the path into its components, tries to match it with the routes and extracts the path arguments into their corresponding variables. From d582e039caf629f90ee6a911d312ea67afc83cb6 Mon Sep 17 00:00:00 2001 From: Michael Ikemann Date: Sun, 30 Jun 2024 22:43:46 +0200 Subject: [PATCH 79/79] Removed request_data and replaced it by request data provided by the context --- nicegui/outlet.py | 13 ++++++------- nicegui/page.py | 12 ------------ 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/nicegui/outlet.py b/nicegui/outlet.py index 5a0c1e6c0..5aa9b72c5 100644 --- a/nicegui/outlet.py +++ b/nicegui/outlet.py @@ -142,14 +142,13 @@ def setup_page(self, overwrite=False) -> Self: @ui.page(self.base_path, **self.page_kwargs) @ui.page(f'{self.base_path}' + '{_:path}', **self.page_kwargs) # all other pages - async def root_page(request_data=None): + async def root_page(): await ui.context.client.connected() # to ensure storage.tab and storage.client availability - initial_url = None - if request_data is not None: - initial_url = request_data['url']['path'] - query = request_data['url'].get('query', None) - if query: - initial_url += '?' + query + request = context.client.request + initial_url = request.url.path + query = request.url.query + if query: + initial_url += '?' + query self.build_page(initial_url=initial_url) return self diff --git a/nicegui/page.py b/nicegui/page.py index 7f36dd0b3..8b2aaaa90 100644 --- a/nicegui/page.py +++ b/nicegui/page.py @@ -109,18 +109,6 @@ async def decorated(*dec_args, **dec_kwargs) -> Response: with Client(self, request=request) as client: if any(p.name == 'client' for p in inspect.signature(func).parameters.values()): dec_kwargs['client'] = client - if any(p.name == 'request_data' for p in inspect.signature(func).parameters.values()): - url = request.url - dec_kwargs['request_data'] = {'client': - {'host': request.client.host, - 'port': request.client.port}, - 'cookies': request.cookies, - 'url': - {'path': url.path, - 'query': url.query, - 'username': url.username, - 'password': url.password, - 'fragment': url.fragment}} result = func(*dec_args, **dec_kwargs) if helpers.is_coroutine_function(func): async def wait_for_result() -> None: