Skip to content

Commit

Permalink
Fixed SinglePageApp and advanced demo to also allow the upgrade of cl…
Browse files Browse the repository at this point in the history
…assical, 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
  • Loading branch information
Alyxion committed May 3, 2024
1 parent ef5ab9d commit 07d9a3a
Show file tree
Hide file tree
Showing 11 changed files with 236 additions and 111 deletions.
11 changes: 9 additions & 2 deletions examples/outlet/main.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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')
Expand All @@ -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')
Expand Down
34 changes: 13 additions & 21 deletions examples/single_page_router/advanced.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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()
3 changes: 2 additions & 1 deletion examples/single_page_router/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -17,5 +18,5 @@ def about():
ui.link('Index', '/')


router = SinglePageRouter('/').reroute_pages()
router = SinglePageApp('/').reroute_pages()
ui.run()
20 changes: 13 additions & 7 deletions nicegui/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -137,12 +138,13 @@ def build_response(self, request: Request, status_code: int = 200) -> Response:
'request': request,
'version': __version__,
'elements': elements.replace('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;')
.replace('`', '&#96;')
.replace('$', '&#36;'),
.replace('<', '&lt;')
.replace('>', '&gt;')
.replace('`', '&#96;')
.replace('$', '&#36;'),
'head_html': self.head_html,
'body_html': '<style>' + '\n'.join(vue_styles) + '</style>\n' + self.body_html + '\n' + '\n'.join(vue_html),
'body_html': '<style>' + '\n'.join(vue_styles) + '</style>\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),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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)
Expand Down
9 changes: 8 additions & 1 deletion nicegui/elements/link.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'):
Expand All @@ -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')

Expand Down
35 changes: 25 additions & 10 deletions nicegui/elements/router_frame.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
export default {
template: '<slot></slot>',
mounted() {
if(this._debug) console.log('Mounted RouterFrame ' + this.base_path);
if (this._debug) console.log('Mounted RouterFrame ' + this.base_path);

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) {
// 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;
Expand All @@ -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);
Expand All @@ -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);
}
}
};
Expand All @@ -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},
Expand Down
54 changes: 39 additions & 15 deletions nicegui/elements/router_frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'] = []
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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:
Expand Down
Loading

0 comments on commit 07d9a3a

Please sign in to comment.