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