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 96 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
96 commits
Select commit Hold shift + click to select a range
d3b35ae
Per client data
Apr 1, 2024
ef97d2e
Per client data
Apr 1, 2024
91887a1
Backup single page routing
Apr 1, 2024
3307281
Removed <div> nesting from sample app
Apr 1, 2024
2c7f680
Intermediate backup, per-session user-auth
Apr 2, 2024
7ff4728
Functional single page app login screen.
Apr 2, 2024
969a2d2
Functional single page app login screen.
Apr 2, 2024
b9005ee
Added additional pages to the session_storage demo
Apr 2, 2024
b3d73f1
Reverting PyCharm formatting
Apr 2, 2024
241215a
Made single-page multi-user rdy
Apr 3, 2024
13f29ac
Removed method decoration and replaced it with additional page_config…
Alyxion Apr 3, 2024
21005e1
* Refactored the SinglePageRouter to give the user more control over …
Alyxion Apr 3, 2024
db6b065
Implemented app.storage.session which enables the user to store data …
Alyxion Apr 4, 2024
d9403b4
Merge branch 'main' into feature/per_session_data
Alyxion Apr 4, 2024
eabe01a
Merge branch 'zauberzeug:main' into feature/client_data
Alyxion Apr 4, 2024
1ed5620
Merge branch 'feature/per_session_data'
Alyxion Apr 4, 2024
e2e2006
Merge branch 'feature/client_data'
Alyxion Apr 4, 2024
3fedd36
Replaced Client.state by ObservableDict
Alyxion Apr 5, 2024
8fc9208
Merge branch 'main' into feature/per_session_data
Alyxion Apr 5, 2024
8ecb6f5
Added support for URL route parameters and query parameters
Alyxion Apr 5, 2024
a39e54c
Merge remote-tracking branch 'origin/feature/client_data' into featur…
Alyxion Apr 5, 2024
89a39d4
Merge branch 'feature/per_session_data' into feature/client_data
Alyxion Apr 5, 2024
664a25d
Merge branch 'zauberzeug:main' into feature/client_data
Alyxion Apr 5, 2024
ab67606
Fixed doc.
Alyxion Apr 5, 2024
fac0356
Added page not found handling, still needs a bit more love though to …
Alyxion Apr 5, 2024
0c72d16
Added more fine granular definition of which pages are included in th…
Alyxion Apr 5, 2024
a4fe9cc
Merge branch 'main' into feature/client_data
Alyxion Apr 5, 2024
ea8dad5
Renamed app.storage.session to app.storage.client.
Alyxion Apr 6, 2024
e627934
Merge remote-tracking branch 'origin/feature/per_session_data' into f…
Alyxion Apr 6, 2024
cf21786
Merge branch 'feature/per_session_data' into feature/client_data
Alyxion Apr 6, 2024
84bc6ce
Added support for fragments / hashes. Further refinements
Alyxion Apr 6, 2024
36f3b80
Merge remote-tracking branch 'origin/feature/client_data' into featur…
Alyxion Apr 6, 2024
6699041
Merge branch 'main' into feature/client_data
Alyxion Apr 6, 2024
30a87e3
Refinement of imports. Removed now obsolete SPR_PAGE_BODY.
Alyxion Apr 6, 2024
a923f34
Bug fix: Reference to __singlePageContent in page_layout
Alyxion Apr 6, 2024
efcee63
Bug fix: Handling invalid SPA sub routes
Alyxion Apr 6, 2024
f50f469
Removed per-client storage specific code
Alyxion Apr 6, 2024
87e83b5
Cleaned client and storage class
Alyxion Apr 6, 2024
2cf5058
Fixed double quotes
Alyxion Apr 6, 2024
f4698f9
Merge remote-tracking branch 'nicegui/main' into feature/client_data
Alyxion Apr 13, 2024
215e493
Splitting reload functionality into separate RouterFrame class respon…
Alyxion Apr 14, 2024
3167521
Refactoring of outlet structure and URL resolving, backup
Alyxion Apr 14, 2024
75a5e53
Preparing nested outlets
Alyxion Apr 14, 2024
4b989d9
Preparing nested outlets
Alyxion Apr 14, 2024
9f685fa
Nested outlets are working in general now.
Alyxion May 1, 2024
d5a9009
router_Frame.js event handlers are now removed upon unmount - this fi…
Alyxion May 3, 2024
ef5ab9d
Merge remote-tracking branch 'nicegui/main' into feature/client_data
Alyxion May 3, 2024
07d9a3a
Fixed SinglePageApp and advanced demo to also allow the upgrade of cl…
Alyxion May 3, 2024
ab5dad5
Fixed support for fragment targets
Alyxion May 3, 2024
4f54ce1
Added Page Not Found fallback if an invalid SPA route is taken
Alyxion May 4, 2024
b4dd186
Parameterizable outlets
Alyxion May 10, 2024
cb28497
Merge remote-tracking branch 'nicegui/main' into feature/client_data
Alyxion May 10, 2024
33193fe
Enhanced Outlet demo
Alyxion May 10, 2024
e2bccee
Cleaned outlet demo
Alyxion May 10, 2024
aa81849
Merge remote-tracking branch 'nicegui/main' into feature/client_data
Alyxion May 10, 2024
04be96a
Clean up.
Alyxion May 10, 2024
a112465
Fixed quotes.
Alyxion May 10, 2024
3e5f2c2
Renamed the SinglePageRouter to SinglePageRouterConfig to emphasize i…
Alyxion May 11, 2024
36875ac
Massive refactoring:
Alyxion May 11, 2024
b49d0c0
Added on_resolve to outlet.view which is called when ever a view is s…
Alyxion May 11, 2024
1fe8a96
* Added completely served side handled browser history
Alyxion May 11, 2024
1823110
* Added post- and pre-update callback in SinglePageTarget to execute …
Alyxion May 12, 2024
cfd6478
Merge remote-tracking branch 'nicegui/main' into feature/client_data
Alyxion May 12, 2024
4c8eb01
* Added focus to Input class
Alyxion May 12, 2024
70660e5
Merge remote-tracking branch 'nicegui/main' into feature/client_data
Alyxion May 12, 2024
c3a3edb
Small adjustment to login demo
Alyxion May 12, 2024
e44edaa
Merge remote-tracking branch 'nicegui/main' into feature/client_data
Alyxion Jun 12, 2024
94eed81
Removed SinglePageApp class and associated examples
Alyxion Jun 12, 2024
8f61006
* Removed on_resolve and on_open events from outlet and SinglePageRou…
Alyxion Jun 12, 2024
bb786f0
Updated authentication demo
Alyxion Jun 12, 2024
4a49846
Enhanced SinglePageRouter by the possibility to define dynamic views …
Alyxion Jun 13, 2024
76cf840
Refined OOP example
Alyxion Jun 13, 2024
8a6c805
Refactoring
Alyxion Jun 16, 2024
80cdb09
Merge remote-tracking branch 'nicegui/main' into feature/client_data
Alyxion Jun 16, 2024
b679a58
authentication_spa example: simplified layout and better naming
rodja Jun 22, 2024
1e71e65
organized imports
rodja Jun 22, 2024
36a8a8f
improve typing in reslove_target function
rodja Jun 22, 2024
a83e14f
fixed storage access
rodja Jun 22, 2024
ce92506
cleanup and better titles
rodja Jun 22, 2024
3344bf0
organized imports
rodja Jun 22, 2024
baea9e1
fix indentation
rodja Jun 22, 2024
3471c1c
clear whole storage
rodja Jun 22, 2024
c1d700d
add to verify simple routing works as expected
rodja Jun 22, 2024
64d948b
formatting
rodja Jun 23, 2024
aa0226a
renaming and clarification
rodja Jun 23, 2024
3bfd3c8
move setup_page to subclass because it's only used there
rodja Jun 23, 2024
9c4526f
improve naming and typing
rodja Jun 23, 2024
d40c984
docstring
rodja Jun 23, 2024
9678c89
extract OutletView to own class to break cyclic dependencies
rodja Jun 23, 2024
ae71e4b
better naming
rodja Jun 23, 2024
d9ee01f
resolve cyclic dependencies
rodja Jun 23, 2024
d3c901f
improve naming and typing
rodja Jun 23, 2024
e710c50
fix docstring
rodja Jun 23, 2024
b16d992
simplify code by getting rid of single_page_router_config.navigate_to
rodja Jun 23, 2024
340bfd1
clarification
rodja Jun 23, 2024
d1b955c
renaming
rodja Jun 23, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
84 changes: 84 additions & 0 deletions examples/authentication_spa/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
import html
import uuid
from typing import Optional, Union

from nicegui import app, ui
from nicegui.single_page_target import SinglePageTarget

INDEX_URL = '/'
SECRET_AREA_URL = '/secret_area'

DUMMY_LOGINS = {"admin": "NicePass"}


def portal_rerouting(url) -> str:
# automatically redirect to the secret area if the user is already logged in
if 'login_token' in app.storage.user:
return SECRET_AREA_URL
return url


@ui.outlet('/', on_navigate=portal_rerouting)
def main_layout():
with ui.header():
ui.link('SPA Login Example', '/').style('text-decoration: none; color: inherit;').classes('text-h3')
ui.query('.nicegui-content').classes('flex-center')
with ui.column().classes('m-12'):
yield


@main_layout.view('/', title='Login Page')
def main_app_index():
def handle_login():
username = username_input.value
password = password_input.value
if username in DUMMY_LOGINS and DUMMY_LOGINS[username] == password:
# NOTE you can also use app.storage.tab to ensure the login is only valid for the current tab
app.storage.user['login_token'] = uuid.uuid4().hex
app.storage.user['username'] = username
ui.navigate.to(SECRET_AREA_URL)
else:
password_input.props('error')

with ui.column().classes('w-96 mt-[40%] items-center'):
username_input = ui.input('Username').classes('w-full')\
.on('keydown.enter', lambda: password_input.run_method('focus'))
password_input = ui.input('Password', password=True, password_toggle_button=True) \
.classes('w-full').props('error-message="Invalid password!"') \
.on('keydown.enter', handle_login)
ui.button('Login').classes('w-48').on_click(handle_login)
ui.label(f'Hint: {DUMMY_LOGINS}').classes('text-grey mt-12')
ui.link('Try to access secret area without login', SECRET_AREA_URL).classes('text-grey mt-12')


def logout(): # logs the user out and redirects to the login page
app.storage.user.clear()
ui.navigate.to(INDEX_URL)


def check_login(url) -> Optional[Union[str, SinglePageTarget]]:
def error_page():
with ui.column().classes('w-96 mt-[40%] items-center'):
ui.label('⚠️').classes('text-6xl')
ui.label('You are not logged in!').classes('text-3xl')
ui.button('Go to login page').on_click(lambda: ui.navigate.to(INDEX_URL)).classes('w-48 mt-12')

if 'login_token' not in app.storage.user: # check if the user is not logged in
return SinglePageTarget(url, builder=error_page, title='Not logged in')
return url # default behavior


@main_layout.outlet(SECRET_AREA_URL, on_navigate=check_login)
def secret_area_layout():
yield


@secret_area_layout.view('/', title='🔒 Secret Area')
def secret_area_index():
username = app.storage.user['username']
ui.label(f'Hello {html.escape(username)}. Welcome to the secret area!').classes('text-3xl')
ui.button('Logout').on_click(logout).classes('w-48 mt-12')


ui.run(storage_secret='secret', title='SPA Login')
28 changes: 28 additions & 0 deletions examples/single_page_app_complex/cms_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import os
from typing import Dict

from pydantic import BaseModel, Field


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
136 changes: 136 additions & 0 deletions examples/single_page_app_complex/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# 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.

from nicegui import ui
from nicegui.page_layout import LeftDrawer
from nicegui.single_page_router import SinglePageRouter
from nicegui.single_page_target import SinglePageTarget

from examples.single_page_app_complex.cms_config import SubServiceDefinition, ServiceDefinition, 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():
with ui.element('a').props('href="/about"'):
ui.label('Copyright 2024 by NiceCLOUD Inc.').classes('text-h7')
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_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.view('/about')
def about_page(menu_drawer: LeftDrawer):
menu_drawer.clear()
menu_drawer.hide()
ui.label('About NiceCLOUD').classes('text-3xl')
ui.html('<br>')
ui.label('NiceCLOUD Inc.')
ui.label('1234 Nice Street')
ui.label('Nice City')
ui.label('Nice Country')


@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(service: ServiceDefinition = None,
sub_service: SubServiceDefinition = None):
# 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
SinglePageRouter.current_router().target.title = \
'NiceCLOUD - ' + (f'{sub_service.title}' if sub_service else f'{service.title}')


@services_router.view('/') # service index page
def show_index(service: ServiceDefinition):
update_title(service, None)
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('/') # sub service index page
def sub_service_index(service: ServiceDefinition, sub_service: SubServiceDefinition):
update_title(service, sub_service)
ui.label(sub_service.emoji).classes('text-h1')
ui.html('<br>')
ui.label(sub_service.description)


ui.run(title='NiceCLOUD Portal', show=False)