diff --git a/examples/authentication_spa/main.py b/examples/authentication_spa/main.py new file mode 100755 index 000000000..268931f54 --- /dev/null +++ b/examples/authentication_spa/main.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +import html +import uuid +from typing import Optional, Union + +from nicegui import app, ui +from nicegui.single_page_target import SinglePageTarget + +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 'login_token' in app.storage.user: + return SECRET_AREA_URL + return '/login' + + +@ui.outlet('/', on_navigate=portal_rerouting) +def main_layout(): + with ui.header(): + 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.outlet('/login', title='Login Page') +def main_app_index(): + def handle_login(): + username = username_input.value + password = password_input.value + if username in DUMMY_LOGINS and DUMMY_LOGINS[username] == password: + # 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: + 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 + app.storage.user.clear() + ui.navigate.to(INDEX_URL) + + +def check_login(url) -> Optional[Union[str, SinglePageTarget]]: + def error_page(): + 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.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.user: # check if the user is not logged in + return SinglePageTarget(url, builder=error_page, title='Not logged in') + return url # default behavior + + +@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') + ui.button('Logout').on_click(logout).classes('w-48 mt-12') + + +ui.run(storage_secret='secret', title='SPA Login') 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 new file mode 100644 index 000000000..3972b34ae --- /dev/null +++ b/examples/single_page_app_complex/main.py @@ -0,0 +1,136 @@ +# 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.page_layout import LeftDrawer +from nicegui.single_page_router import SinglePageRouter +from nicegui.single_page_target import SinglePageTarget + +from examples.single_page_app_complex.cms_config import SubServiceDefinition, ServiceDefinition, services + + +# --- 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 + ui.html('
') + ui.label('Other app footer') + + +@other_app_router.view('/') +def other_app_index(): + ui.label('Welcome to the index page of the other application') + + +# --- Main app --- + +@ui.outlet('/') # main app 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') + 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) + + +@main_router.view('/') +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') + 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.') + + +@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] + 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) + + +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 + 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(service: ServiceDefinition): + update_title(service, None) + 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}') # 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} # pass sub_service object to all sub elements (views and outlets) + + +@sub_service_router.view('/') # sub service index page +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', 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..8c84aa396 --- /dev/null +++ b/examples/single_page_app_complex/main_oop_version.py @@ -0,0 +1,160 @@ +# 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) + + @staticmethod + def other_app_index(): + 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.') + + 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.') + 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/examples/single_page_app_complex/services.json b/examples/single_page_app_complex/services.json new file mode 100644 index 000000000..4dc764dc0 --- /dev/null +++ b/examples/single_page_app_complex/services.json @@ -0,0 +1,202 @@ +{ + "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", + "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", + "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", + "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", + "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", + "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", + "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", + "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", + "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/client.py b/nicegui/client.py index f016e11a4..3b3fbdc32 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 @@ -25,7 +27,10 @@ from .version import __version__ if TYPE_CHECKING: + from nicegui.outlet import Outlet + from .page import page + from .single_page_router import SinglePageRouter templates = Jinja2Templates(Path(__file__).parent / 'templates') @@ -34,6 +39,12 @@ class Client: page_routes: ClassVar[Dict[Callable[..., Any], str]] = {} """Maps page builders to their routes.""" + page_configs: ClassVar[Dict[Callable[..., Any], 'page']] = {} + """Maps page builders to their page configuration.""" + + top_level_outlets: ClassVar[Dict[str, 'Outlet']] = {} + """Maps paths to the associated single page routers.""" + instances: ClassVar[Dict[str, Client]] = {} """Maps client IDs to clients.""" @@ -78,6 +89,7 @@ def __init__(self, page: page, *, request: Optional[Request]) -> None: self.page = page self.storage = ObservableDict() + self.single_page_router: Optional[SinglePageRouter] = None self.connect_handlers: List[Union[Callable[..., Any], Awaitable]] = [] self.disconnect_handlers: List[Union[Callable[..., Any], Awaitable]] = [] @@ -226,6 +238,12 @@ 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 outlet in self.top_level_outlets.values(): + outlet_target = outlet.resolve_target(path) + if outlet_target.valid: + 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) def download(self, src: Union[str, bytes], filename: Optional[str] = None, media_type: str = '') -> None: diff --git a/nicegui/elements/link.py b/nicegui/elements/link.py index 2c559f036..90189c225 100644 --- a/nicegui/elements/link.py +++ b/nicegui/elements/link.py @@ -2,6 +2,7 @@ from ..client import Client from ..element import Element +from ..outlet_view import OutletView from .mixins.text_element import TextElement @@ -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 new file mode 100644 index 000000000..08a49b804 --- /dev/null +++ b/nicegui/elements/router_frame.js @@ -0,0 +1,96 @@ +export default { + template: '', + mounted() { + if (this._debug) console.log('Mounted RouterFrame ' + this.base_path); + + let router = this; + + function normalize_path(path) { + let href = path.split('?')[0].split('#')[0] + 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 + 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; + return true; + } + return false; + } + + 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 + '/') || (href === frame)) { + if (router._debug) console.log(href + ' handled by child RouterFrame ' + frame + ', skipping...'); + return true; + } + } + return false; + } + + this.clickEventListener = function (e) { + // Check if the clicked element is a link + // 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)) { + router.$emit('open', href, true); + if (router._debug) console.log('Opening ' + href + ' by ' + router.base_path); + } + } + }; + 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, false); + if (router._debug) console.log('Pop opening ' + href + ' by ' + router.base_path); + } + }; + + 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}, + target_url: {type: String}, + included_path_masks: [], + excluded_path_masks: [], + use_browser_history: {type: Boolean, default: true}, + child_frame_paths: [], + _debug: {type: Boolean, default: false}, + }, +}; diff --git a/nicegui/elements/router_frame.py b/nicegui/elements/router_frame.py new file mode 100644 index 000000000..aa79dd75e --- /dev/null +++ b/nicegui/elements/router_frame.py @@ -0,0 +1,150 @@ +import inspect +from typing import Optional, Any, Callable + +from nicegui import ui, background_tasks + + +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, + 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, + on_navigate: Optional[Callable[[str, Optional[bool]], Any]] = None, + user_data: Optional[dict] = None + ): + """ + :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 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__() + 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 + '/*') + 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['browser_history'] = use_browser_history + self._props['child_frames'] = [] + 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, history=True): + """Navigate to a new url + + :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, history) + + @property + def target_url(self) -> str: + """The current target url of the router frame""" + return self._props['target_url'] + + @target_url.setter + 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: 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 + :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 sync: Optional flag to define if the content should be updated synchronously. Default is False.""" + + def exec_builder(): + """Execute the builder function with the given keyword arguments""" + self.run_safe(builder, **builder_kwargs) + + async def build() -> None: + with self: + exec_builder() + if target_fragment is not None: + await ui.run_javascript(f'window.location.href = "#{target_fragment}";') + + 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._props['child_frame_paths'] = [] + super().clear() + + @property + def child_frame_paths(self) -> list[str]: + """The child paths of the router frame""" + return self._props['child_frame_paths'] + + @child_frame_paths.setter + def child_frame_paths(self, paths: list[str]) -> None: + """Update the child paths of the router frame + + :param paths: The list of child paths""" + self._props['child_frame_paths'] = paths + + @staticmethod + 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 + """ + 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 new file mode 100644 index 000000000..5aa9b72c5 --- /dev/null +++ b/nicegui/outlet.py @@ -0,0 +1,359 @@ +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.outlet_view import OutletView +from nicegui.single_page_router import SinglePageRouter +from nicegui.single_page_target import SinglePageTarget +from nicegui.context import context + +PAGE_TEMPLATE_METHOD_NAME = "page_template" + + +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. + + 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 + 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 @.view decorator on + a function.""" + + def __init__(self, + path: str, + outlet_builder: Optional[Callable] = None, + browser_history: bool = True, + 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, + **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 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__() + 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 + 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) + if outlet_builder is not None: + self(outlet_builder) + + 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 = 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.') + properties = {} + + def add_properties(result): + if isinstance(result, dict): + properties.update(result) + + router_frame = SinglePageRouter.current_router() + 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: + 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: + pass + + def __call__(self, func: Callable[..., Any]) -> Self: + """Decorator for the layout builder / "outlet" function""" + + def outlet_view(**kwargs): + self.build_page(**kwargs) + + self.outlet_builder = func + if self.parent_config is None: + self.setup_page() + else: + relative_path = self.base_path[len(self.parent_config.base_path):] + 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(): + await ui.context.client.connected() # to ensure storage.tab and storage.client availability + 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 + + 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 + ) -> '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 + 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. + """ + return OutletView(self, path, title=title) + + 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, **kwargs) + + @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 = 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.') + 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/outlet_view.py b/nicegui/outlet_view.py new file mode 100644 index 000000000..415cc9691 --- /dev/null +++ b/nicegui/outlet_view.py @@ -0,0 +1,44 @@ +from typing import Any, Callable, Optional, TYPE_CHECKING + +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: '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 + :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/page.py b/nicegui/page.py index 7f692d40a..8b2aaaa90 100644 --- a/nicegui/page.py +++ b/nicegui/page.py @@ -114,6 +114,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(): @@ -142,4 +143,5 @@ async def wait_for_result() -> None: self.api_router.get(self._path, **self.kwargs)(decorated) Client.page_routes[func] = self.path + Client.page_configs[func] = self return func diff --git a/nicegui/single_page_router.py b/nicegui/single_page_router.py new file mode 100644 index 000000000..8ad78e576 --- /dev/null +++ b/nicegui/single_page_router.py @@ -0,0 +1,288 @@ +from fnmatch import fnmatch +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Self, Union + +from nicegui import core, ui +from nicegui.context import context +from nicegui.elements.router_frame import RouterFrame +from nicegui.single_page_target import SinglePageTarget + +PATH_RESOLVING_MAX_RECURSION = 100 + + +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. + + See ui.outlet 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: '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 = config + self.base_path = config.base_path + if target_url is None: + 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 = 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 + 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 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, + excluded_paths=excluded_paths, + 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[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: + """The current target url of the router frame + + :return: The target url of the router frame""" + return self.router_frame.target_url + + def resolve_url(self, target: str) -> SinglePageTarget: + """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 + :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): + rem_path = outlet_target.path[len(self.base_path):] + if rem_path in self.views: + 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 outlet_target + + 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 + 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, a completely resolved target or None if the navigation is suppressed""" + if self._on_navigate is not None: + custom_target = self._on_navigate(target) + if custom_target is None: + return None + 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 + if new_target is None or new_target != target: + return new_target + return target + + 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 + + :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. + :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 + 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 + 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_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 = '' + 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: + 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 len(js_code) > 0: + ui.run_javascript(js_code) + handler_kwargs = {**target.path_args, **target.query_args, 'target': target} | handler_kwargs + 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) + 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_routers.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) + + 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 + + @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 + + @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 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""" + 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_child_router(self, path: str, frame: 'SinglePageRouter') -> None: + """Registers a child router which handles a certain sub path + + :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() -> 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 + 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_target.py b/nicegui/single_page_target.py new file mode 100644 index 000000000..0646e8763 --- /dev/null +++ b/nicegui/single_page_target.py @@ -0,0 +1,123 @@ +import inspect +import urllib.parse +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Self + +if TYPE_CHECKING: + from nicegui.single_page_router import SinglePageRouter + 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 + OutletPath and extract path and query parameters.""" + + def __init__(self, + path: Optional[str] = None, + fragment: Optional[str] = None, + query_string: Optional[str] = None, + builder: Optional[Callable] = None, + title: Optional[str] = None, + router: Optional['SinglePageRouter'] = None, + router_path: Optional['OutletPath'] = 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) + :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 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 + 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 + self.path_args = {} + self.path_elements = [] + self.fragment = fragment + self.query_string = query_string + self.query_args = urllib.parse.parse_qs(self.query_string) + self.title = title + self.builder = builder + self.valid = builder is not None + self.router = router + 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: + self.builder = router_path.builder + self.title = router_path.title + self.valid = True + + def parse_url_path(self, routes: Dict[str, 'OutletPath']) -> 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.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 + 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 + entry = self.parse_path(routes) + self.router_path = entry + 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, 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. + + :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(): + route_elements = route.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""" + 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: + 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 + except ValueError as e: + raise ValueError(f'Could not convert parameter {func_param_name}: {e}') diff --git a/nicegui/ui.py b/nicegui/ui.py index f0fba8fb4..97206e1b6 100644 --- a/nicegui/ui.py +++ b/nicegui/ui.py @@ -116,6 +116,7 @@ 'add_style', 'update', 'page', + 'outlet', 'drawer', 'footer', 'header', @@ -125,6 +126,7 @@ 'right_drawer', 'run', 'run_with', + 'router_frame' ] from .context import context @@ -195,6 +197,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.scene_view import SceneView as scene_view @@ -239,6 +242,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 as outlet from .page_layout import Drawer as drawer from .page_layout import Footer as footer from .page_layout import Header as header 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