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. |
Just to give an update here: Am on it but the nested outlets will still take an 2-3 evenings and at the moment a lot on the road so I didn't find the chance yet. |
Parent SPRs and parent RouteFrames know now about active children and can respect their sub routes accordingly. Open bug: Both the main and the sub routers react to / routes, only one of both should though.
…xed the bug that the state was pushed to the history twice. TODO: If two outlets share the same root path, e.g. / and /spa2, the rf / will still intercept navigation to /spa2.
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. |
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