Skip to content
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

Draft
wants to merge 66 commits into
base: main
Choose a base branch
from

Conversation

Alyxion
Copy link
Contributor

@Alyxion Alyxion commented Apr 3, 2024

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:

  • A persistent WebSocket connection for the whole user session (per tab)
  • A data storage possibility per tab to enable the user to create multiple app instances.
  • In-memory storage of complex objects (e.g. pandas data tables)

image

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

@Alyxion Alyxion changed the title Support for per browser-tab session data and a persistent per-session between page changes Support for per browser-tab session data and a persistent per-session connection between page changes Apr 3, 2024
Michael Ikemann and others added 11 commits April 3, 2024 14:36
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
…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
@rodja
Copy link
Member

rodja commented Apr 4, 2024

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".
@Alyxion
Copy link
Contributor Author

Alyxion commented Apr 4, 2024

Hello @rodja, sure. As this Pull Request here is anyway just a draft yet and the amount of changes of the per-tab data was manageable I extracted the lines into the separate request #2820 and added a unit test to it. As this one is dependent on the other I will keep it the way it is for now.

@Alyxion
Copy link
Contributor Author

Alyxion commented Apr 5, 2024

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.

@Alyxion
Copy link
Contributor Author

Alyxion commented Apr 21, 2024

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.
@rodja
Copy link
Member

rodja commented May 3, 2024

In #3005 the question came up how we handle undefined path routes and restrict access on a view (for example to implement authentification)...
I do not think this must be solved in the initial version but we should think about a way to implement it later without a breaking change.

…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
@Alyxion
Copy link
Contributor Author

Alyxion commented May 3, 2024

In #3005 the question came up how we handle undefined path routes and restrict access on a view (for example to implement authentification)... I do not think this must be solved in the initial version but we should think about a way to implement it later without a breaking change.

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:
Well the lucky coincidence here is that due to SPAs each Outlet has it's own SinglePageRouter which resolves all URLs for other outlets and views "behind" . And this SPR is per client. Several potential approaches. One were to provide an "on_resolve" method such as

def auth_check(url):
     if( .... not authenticated etc. ):
         return "/unauthorised"
     return url

@outlet("/", on_resolve=auth_check)
def my_layout()
  ....

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....

Alyxion added 17 commits May 4, 2024 08:50
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
Fixed SinglePageApp demos.
…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
@Alyxion
Copy link
Contributor Author

Alyxion commented May 12, 2024

Sooooooooo... been a couple of really long days & nights... especially the nested outlets with variable path
parameters will stay a lasting memory but I think the SPA feature now reached a point where it were
I feel overall quite satisfied and I think it were really a huge improvement for the overall NiceGUI development
experience.

Even though critic is of course always welcome, I am really happy with the result so far, the two "NiceCLOUD"
demos - how smooth they run but most importantly also how straight forward they are to implement was really a pleasure.

General overview

What you can now do is...

  • Define so called outlets via ui.outlet. An outlet defines the root page of a Single Page Application (SPA).
    The outlet consists of a path and a template. The path is the URL path that will be matched to the current,
    e.g. "/". The template is the UI template that will be rendered when the path matches. The template needs to contain
    at least one yield statement, which will be replaced by the actual content of the outlet's view or nested
    outlets. A view actually defines the content of the page as it was done via. ui.page before.
  • Outlets can be nested infinitely. This means that you can define a hierarchy of outlets, where each outlet can
    have its several views ("pages") and nested outlets ("page templates"). The single outlet paths will be concatenated
    to form the full path of the SPA. When ever a navigation happens the content of all outlets which path did not change
    will be kept and only the content of the outlet that path did change will be re-rendered, e.g. you move from
    /sub_outlet_a/sub_outlet_b/page_a to /sub_outlet_a/sub_outlet_b/page_b only the content of outlet_b will be
    re-build, if you move from /sub_outlet_a/sub_outlet_b/page_a to /sub_outlet_x/some_page the root outlet / will
    be rendered as it's sub outlet /sub_outlet_x and the page some_page itself.
  • All outlets can easily share data with their child outlets and views. To do so the template builder function can just
    yield a dictionary with the data that should be shared. This data will then automatically be passed to:
    • All view builder functions of the current outlet and all nested outlets
    • All template builder functions of the current outlet and all nested outlets
    • All event handler functions of the current outlet and all nested outlets
  • Outlets can make use of path parameters. At the moment only full path elements can define a path parameter (between
    two slashes). The path parameter will be passed to the template builder and view builder functions as a keyword
    argument.

Events

  • Several event handling functions have been defined to let the developer react fine grained to the user's actions.
    • on_navigate will be called whenever the user navigates to a new page. This event can be used to just track the
      navigation, to redirect the user to a different page or to completely suppress the navigation. (url str in,
      url or None out)
    • on_resolve will be called when the actual URL has been selected and the responsible outlet and view builder
      function have been determined. This event can be used to modify the actual builder function, track more detailed
      arguments such as query parameters or fragment or to change details such as the title of the page.
    • on_load Is called for the actual view when it's going to be rendered. Even though you could even at this point
      still override the builder (as it's called right before the rendering) it's more intended to do some last minute
      modification such as "just" setting the title of the page, e.g. when you make use of path parameters and can not
      know in advance what the title of the view will be.
  • The on_navigate as well as the on_resolve event handlers can be used to very efficiently suppress the
    navigation or to redirect the user to a different page, for example in combination with a credential check for
    certain pages which let the user pass only if he is logged in and redirect him to the login page or show an access
    denied page otherwise.
  • The Client knows about all root outlets and their paths. This means that the client can directly navigate to a
    specific page by just calling the navigate.to function with the path as an argument. So does the browsers once
    a view has been rendered. It will track every link click and back/forward button press and will check if it is
    responsible for the navigation. Each Outlet has it's own RouterFrame object in Python form on the server side and
    as a JavaScript object on the client side. The RouterFrame knows for which path masks it is responsible and also
    knows about all nested outlets and their paths. If a click occurs always only the "deepest" nested outlet which
    still matches the path will be responsible for the navigation.
  • The whole history management and most importantly the "last decision" of the navigation is always done on the server
    side. This was the only way to efficiently redirect or completely suppress the navigation in certain cases.
  • Other than that I also kept the SinglePageApp class alive which is a straight forward alternative for people who
    already built a large application with the ui.page functions and just want to switch to a SPA.

Classes

  • SinglePageRouterConfig - Defines the root class of the SinglePageApp and Outlet class and holds the
    configuration of the SPA such as it's root path, the template builder method, the views defined etc. pp. Only
    one instance is created for all users - usually at the beginning of the application.
  • Outlet subclass of SinglePageRouterConfig. Provides methods to decorate the template builder method via
    ui.outlet and view builder methods via outlet_name.view.
  • OutletView - Defines the link and configuration of a single view of an outlet. Contains the view builder method,
    the path of the view, the title of the view, the event handlers etc. pp.
  • SinglePageApp subclass of SinglePageRouterConfig. The setup method converts all ui.page functions which
    are sharing the same root path to views. Unrelated to the actual whole new Outlet concept, just for convenience.
  • SinglePageRouter - Manages the whole SPA on a "per-outlet" level. So when the user visits the root outlet page,
    e.g. "/", a SinglePageRouter object is created for the root outlet managing the whole navigation on that level.
    If the user then navigates to a nested outlet, e.g. '/sub_app/' a new SinglePageRouter object is created as child
    for that router and so on and so forth. As of now a SinglePageRouter object can only have one child router - as when
    the user navigates to a path way consequently all other paths are not required anymore and are removed from the
    hierarchy and rebuilt from the point of the first path mismatch.
  • RouterFrame - Each SinglePageRouter has a RouterFrame object which defines the container for the dynamic
    content of the outlet and the client side JavaScript object that is responsible for forwarding all client side
    navigation events to the server side. The RouterFrame object knows about all nested RouterFrame objects and
    can so always select the right outlet for the navigation with minimal rebuild depth.
  • SinglePageTarget - Defines the result of a resolved URL. Contains all URL elements such as path, query parameters,
    fragment, selected build function, responsible SinglePageRouter etc. pp. Using the on_resolve event handler
    mentioned above you can either manipulate the result or create an own SinglePageTarget with dynamic configured
    build function etc. pp.

TODOs

  • Documentation. I documented a lot in the code already but I think the SPA alone could easily fill a couple of pages
    in the documentation as it's already quite a large system right now and will probably grow even more.
  • More examples. I think the two "NiceCLOUD" demos are a good start but I think some further examples would be nice.
  • Unit tests: I did not find the time yet, also I wanted to wait until you had a look at the code.
  • Support for async view builders. Would be great if you could add that as I am not so deep into the NiceGUI
    concept and all it's helper functions for coroutines.

Examples

  • examples/outlet/cloud_ui - Demo of a deeply nested Single Page Application making use of path indices, dynamic
    title updates, passing lots of data between outlets etc. etc.
  • examples/outlet/login - A simple login page that grants access to a secure page if the user is logged in. The login
    is stored in the tab data. If the user anyhow tries to access the secure page without being logged in he will get
    an error page which provides a link to the login page. If the user is logged in he will automatically be redirected
    to the secure page if he tries to access the login page.
  • examples/single_page_router/main.py - A super minimalistic example of a Single Page Application that just uses the
    SinglePageApp class to "convert" a classic NiceGUI application.
  • examples/single_page_router/advanced.py - An a bit more advanced example of a Single Page Application that uses
    the SinglePageApp class to convert an app. Making use of navigate_to and fragments.

Unfortunately I did not find the time yet to finalize the PR before hitting the road again tomorrow as I hoped, but
this way we can still review it without time pressure. I will be back in about a week, but will have a look at the
discussions in the meantime.

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')```

@Alyxion
Copy link
Contributor Author

Alyxion commented May 24, 2024

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants