-
-
Notifications
You must be signed in to change notification settings - Fork 480
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Introduce API to build Single Page Applications (SPAs) #2811
base: main
Are you sure you want to change the base?
Conversation
Integrated single page app into Client.open so navigation to SPA pages is redirected. Fixed bug with forward and backwards navigation between SPA pages. Collecting data from original pages to able to apply the original page title.
Integrated single page app into Client.open so navigation to SPA pages is redirected. Fixed bug with forward and backwards navigation between SPA pages. Collecting data from original pages to able to apply the original page title.
Fixed a bug which could occur when open was called before the UI was set up
…s registry in Client. General clean-up Added titles to sample app Added docu to SPA
4b89fb3
to
13f29ac
Compare
…the structure of the root page, the possibility to override the class and implement custom routing and to react to the creation of sessions. * Added samples for the single page router * Refactored the Login sample with the new possibilities and making use of Pydantic as an example for a cleaner code base
Thank you @Alyxion. There are a lot of interesting things in here. While you are right that a certain kind of applications my want to combine SPA and "per tab storage", it would make review and discussions simpler if you could create two separate pull requests for these features. |
…in the current Client instance - which in practice means "per browser tab".
Moved context import to top of the file
Added support for query and URL path parameters such as required by the modularization example. https://github.com/Alyxion/nicegui/blob/feature/client_data/examples/modularization/main.py works really like a charm now in regards of user experience when switching pages. At the moment it still throws a "Found top level layout element "Header" inside element "SinglePageRouterFrame". Top level layout elements should not be nested but must be direct children of the page content. This will be raising an exception in NiceGUI 1.5" warning due to the fact that it does not know yet that it actually is in the page content in that case. Update: Fixed the warning. PageLayout now also accepts the SPA root as valid parent for top level elements. |
In #3005 the question came up how we handle undefined path routes and restrict access on a view (for example to implement authentification)... |
…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
Made it possible to directly jump into nested pages Added the possibility to pass the FastAPI request data into the builder function
As all Outlet and OutletViews are well registered we of course always know when the page can no tbe resolved. It would be straight forward to just fall back to another OutletView which e.g. shows the NiceGUI Page not found site. Alternatively we would just redirect to a standard page doing the same. Regarding access:
Other than that made good progress this week, will continue next week with more testing and finalizing path and query parameters... the devil was really in the detail, lots of debugging.... |
The initial page build is now synchronous with the integration of the RouterFrame to prevent ugly progressive page updates on the initial site visit.
* Created enhanced outlet demo * Made it possible to yield variables from outlets and to pass them to nested views and outlets * Fixed bug which caused complex hyperlinks affecting a whole div not being catched by the SPA link handler * It is now possible to make use of path variables in outlets and views * Type correctness for variables passed via path is now only enforced if the user defined a type at all
Added the possibility to access the current RouterFrame from every view and nested outlet builder method Passed url_path to outlet builder methods
…t is a singleton, static object created and configured once.
* Split the functionality of the RouterFrame into RouterFrame (just element related UI update logic) and SinglePageRouter (a per page/per user instance managing the actual routing) * Renamed the old SinglePageRouter to SinglePageRouterConfig * Removed router specific elements from the SinglePageTarget to also make it usable purely with a target builder function and a title (and later favicon etc)
…elected. The on_resolve method allows overriding the selected target parge and changing it's title.
* Preparation for more detailed event handling on resolving and navigating to SPA pages
…specific commands when an SPA content as exchanged * Added on_resolve, on_navigate and on_open events to the Outlet class to enable it to intercept and/or redirect or update certain pages change * Add on_resolve, on_navigate and on_open to SinglePageRouter, allowing the user to define these events just for specific instances * Bugfix: Title updated twice on an SPA page change sometimes. It is ensured now that the title is only update by views changes. * BugFix: For Outlets with path variables always the whole hierarchy was rebuilt on ever page change. This is fixed now.
* Added Login outlet demo
Sooooooooo... been a couple of really long days & nights... especially the nested outlets with variable path Even though critic is of course always welcome, I am really happy with the result so far, the two "NiceCLOUD" General overviewWhat you can now do is...
Events
Classes
TODOs
Examples
Unfortunately I did not find the time yet to finalize the PR before hitting the road again tomorrow as I hoped, but For reference and discussion here the "NiceCLOUD" example making use of the most features: # Advanced demo showing how to use the ui.outlet and outlet.view decorators to create a nested multi-page app with a
# static header, footer and menu which is shared across all pages and hidden when the user navigates to the root page.
import os
from typing import Dict
from pydantic import BaseModel, Field
from nicegui import ui
from nicegui.page_layout import LeftDrawer
from nicegui.single_page_target import SinglePageTarget
# --- Load service data for fake cloud provider portal
class SubServiceDefinition(BaseModel):
title: str = Field(..., description='The title of the sub-service', examples=['Digital Twin'])
emoji: str = Field(..., description='An emoji representing the sub-service', examples=['🤖'])
description: str = Field(..., description='A short description of the sub-service',
examples=['Manage your digital twin'])
class ServiceDefinition(BaseModel):
title: str = Field(..., description='The title of the cloud service', examples=['Virtual Machines'])
emoji: str = Field(..., description='An emoji representing the cloud service', examples=['💻'])
description: str = Field(..., description='A short description of the cloud service',
examples=['Create and manage virtual machines'])
sub_services: Dict[str, SubServiceDefinition] = Field(...,
description='The sub-services of the cloud service')
class ServiceDefinitions(BaseModel):
services: Dict[str, ServiceDefinition] = Field(...,
description='The cloud services provided by the cloud provider')
services = ServiceDefinitions.parse_file(os.path.join(os.path.dirname(__file__), 'services.json')).services
# --- 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('<hr>')
yield
ui.html('<hr>')
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('<span style="color:white">Nice</span>'
'<span style="color:black">CLOUD</span>').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():
ui.label('Copyright 2024 by My Company')
with ui.element().classes('p-8'):
yield {'menu_drawer': menu_drawer} # pass menu drawer to all sub elements (views and outlets)
@main_router.view('/')
def main_app_index(menu_drawer: LeftDrawer): # main app index page
menu_drawer.clear() # clear drawer
menu_drawer.hide() # hide drawer
ui.label('Welcome to NiceCLOUD!').classes('text-3xl')
ui.html('<br>')
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('<br><br>')
# add a link to the other app
ui.markdown('Click [here](/other_app) to visit the other app.')
@main_router.outlet('/services/{service_name}') # service outlet
def services_router(service_name: str, menu_drawer: LeftDrawer):
service: ServiceDefinition = services[service_name]
menu_drawer.clear()
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('<br>')
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(target: SinglePageTarget,
service: ServiceDefinition = None,
sub_service: SubServiceDefinition = None) -> SinglePageTarget:
# Is called for every page within the service_router and sub_service_router via the on_load callback
# and updates the title of each page
if target.router is not None:
target.title = 'NiceCLOUD - ' + (f'{sub_service.title}' if sub_service else f'{service.title}')
return target
@services_router.view('/', on_open=update_title) # service index page
def show_index(service: ServiceDefinition):
with ui.row() as row:
ui.label(service.emoji).classes('text-h4 vertical-middle')
with ui.column():
ui.label(service.title).classes('text-h2')
ui.label(service.description)
ui.html('<br>')
@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('<br>')
yield {'sub_service': sub_service} # pass sub_service object to all sub elements (views and outlets)
@sub_service_router.view('/', on_open=update_title) # sub service index page
def sub_service_index(sub_service: SubServiceDefinition):
ui.label(sub_service.emoji).classes('text-h1')
ui.html('<br>')
ui.label(sub_service.description)
ui.run(title='NiceCLOUD Portal')``` |
Just FYI I am occupied for the next ~10 days w/ private topics. As I guess you may have been on vacation as many others :)...: If something is still annoying you or should be renamed/refactored etc. just let me know till then, otherwise I would then finalize the docu when I am back and finalize everything. In the meantime and as I am usually not a big fan of too C-style, non-object oriented approaches... even though its of course awesome for beginners... I will also still think about how the outlets etc. could potentially be realized in a more class based / object oriented way as alternative to the "function" approach. |
Thanks for the heads-up @Alyxion. I'm eager to review the code as far as it has already become but have not found the time yet. I hope to finish it before your holidays end. |
Thanks for your magnificat work @Alyxion. I finally found the time to delve into this PR. Sorry for the long delay.
Thats it for now. @falkoschindler and me are eager to get this released, but before we do a line-by-line code review this (and probably some other questions which will come up) should be resolved beforehand. |
Thanks for the extensive review.
Yes, I also thought about it, it's actually tempting to do so. Will have a look at how easy this is doable.
Well, the on_resolve more behaves like a DNS server & URL parser, you have the chance to modify or enhance the query parameters, target, the fragment and all these details, so the things a browser would if you actually really finally open a website. on_navigate is far more higher level, you don't have access to all these details nor can you change them (except you parse the URL yourself and reassamble it).
I added it just for convenience, e.g. you could add a shared tracker on all pages. But I can of course remove it.
OK. Just wanted to give the users who might already have larger apps to easily switch, but sometimes it might be right to just make a hard cut.
Sure.
Hm, it's actually 150 lines, many other examples have similar lengths and instead of a json file PNG images, XML files etc., nobody is forced to read them. Hard-code hacking the menu architecture instead were actually against my personal clean code standards to be honest. We could of course shorten the JSON itself to have fewer entries.
Ok.
So basically the most minimal version of the _complex one. Sure. :)
Same here, I think it's a big step regarding how the navigation "feels" for the user w/o all the ... quite visible.... page-switch latencies caused by loading the JS, winding up Vue etc. Actually without being able to go into detail we just started a major project based upon NiceGUI where this feature will play a quite important role, so even though we can of course fork it would be of course be great so use the official release instead as soon as possible :). |
I see. Still, I would love this to be simplified. Mainly the functional overlap of both callbacks bothers me. Both can be used to modify the actual url. Maybe we could find an inspiration in the FastAPI/Starlette middlewares? The dispatch function gets a request object and returns a response object. With
For such cases I think it's better to derive from outlet to not have too many fee flowing function.
I understand. On the other hand we try to make examples focus on one specific thing. In this case it would be the use of nested outlets. How to read and parse a super-minimal cms is quite another thing. Maybe we should simply implement a few content pages directly? I'll give it a try and provide a pull request in the next days. Then we will have something solid to discuss.
It was just a hunch. I don't know how well it would integrate. I'll give it a try myself when I go into the complex demo. |
I just had a deeper look into the concept, sounds like a good idea overall. As at the moment the Pull Request is insanely large anyway already and the call chain is On-Navigate -> Default Navigation if URL not modified -> On Resolve -> Default Resolve if not modified and one wants to return "just a basic forward URL" in most cases where as needs to be able to return a SinglePageTarget if one wants more control I would suggest that on_navigate (for now) is allowed to return all result types a future middleware chain could return, e.g. Union[str, SinglePargeTarget] and so (for now) cover both functionalities until we potentially add a more complex / adjustable middle ware chain.
Ok.
Sure, looking forward to :).
Sure. I have a bit of time the next week and hope to get it to a point where you can then do a "line by line" review. |
Sounds good.
🎉 |
NiceGUI is for us at Lechler a really awesome solution and step forward from Streamlit in regards of the visualization of live and streaming data as it puts the dev far more in control of which sub elements and page regions are updated when.
On the other hand it is still lacking three for us very crucial features Streamlit offers:
This (still work in progress) pull request tries to resolve at least most of the points above. It shall not yet resolve the situation that a user has an unstable internet connection and thus looses the connection to a server completely and needs to reconnect.
Persistent connection
In a scenario where you want to serve your NiceGUI solution not to hundreds of users there is after a certain point no way around scaling the solution over multiple processes, CPUs or over multiple servers.
If you need to load/keep alive large amounts of data per user w/o involving an external database this requires that the whole user session, even between page changes, is bound to one single process on one specific server. Streamlits uses a SPA approach here thus it creates a WebSockets connection once and all follow-up page and tab changes are just virtual thus changing the URL and browser history in the browser using pushstate but never really loading a new page using GET.
As discussed in the Add app.storage.tab or similar (1308) and in Discord there are several use cases where this is crucial to retain in-memory data on a "per session" basis, see below, which consequently requires that there is such a session in the first place.
Per tab storage
A data storage possibility per tab is cruicial to enable the user to create multiple app instances with different login credentials, configurations and views on a per tab basis. This is on purpose volatile so that user-credentials, critical business data etc. are gone once the browser tab was closed and the connection timed out. This shall match the current behavior of st.session_state.
In-memory storage of complex objects
The possibility to store living, non-JSON compatible objects such as Pandas tables, ML model weights etc. on a "per tab" basis and make them as easy accessible among different pages, global helper classes etc. as currently app.storage.user.
Update: Extracted the app.storage.session feature into a separate pull request 2820