Browse Source

Implement ui.navigate functionality (#2593)

* Implemented ui.navigate with the `ui.navigate.back()`, `ui.navigate.forward()` and`ui.navigate.to` methods.

* code review, pytest, replace ui.open

* move navigate functions into a class

* minor corrections

* make sure ui.open is still found in web search and redirects to new documentation

* do not warn when using ui.open

* add missing redirect logic

* code review

---------

Co-authored-by: Falko Schindler <falko@zauberzeug.com>
Co-authored-by: Rodja Trappe <rodja@zauberzeug.com>
Alejandro Gil 1 year ago
parent
commit
3e416522fa

+ 3 - 2
examples/authentication/main.py

@@ -40,7 +40,8 @@ app.add_middleware(AuthMiddleware)
 def main_page() -> None:
     with ui.column().classes('absolute-center items-center'):
         ui.label(f'Hello {app.storage.user["username"]}!').classes('text-2xl')
-        ui.button(on_click=lambda: (app.storage.user.clear(), ui.open('/login')), icon='logout').props('outline round')
+        ui.button(on_click=lambda: (app.storage.user.clear(), ui.navigate.to('/login')), icon='logout') \
+            .props('outline round')
 
 
 @ui.page('/subpage')
@@ -53,7 +54,7 @@ def login() -> Optional[RedirectResponse]:
     def try_login() -> None:  # local function to avoid passing username and password as arguments
         if passwords.get(username.value) == password.value:
             app.storage.user.update({'username': username.value, 'authenticated': True})
-            ui.open(app.storage.user.get('referrer_path', '/'))  # go back to where the user wanted to go
+            ui.navigate.to(app.storage.user.get('referrer_path', '/'))  # go back to where the user wanted to go
         else:
             ui.notify('Wrong username or password', color='negative')
 

+ 1 - 1
examples/chat_app/main.py

@@ -30,7 +30,7 @@ async def main(client: Client):
     ui.add_head_html(f'<style>{anchor_style}</style>')
     with ui.footer().classes('bg-white'), ui.column().classes('w-full max-w-3xl mx-auto my-6'):
         with ui.row().classes('w-full no-wrap items-center'):
-            with ui.avatar().on('click', lambda: ui.open(main)):
+            with ui.avatar().on('click', lambda: ui.navigate.to(main)):
                 ui.image(avatar)
             text = ui.input(placeholder='message').on('keydown.enter', send) \
                 .props('rounded outlined input-class=mx-3').classes('flex-grow')

+ 1 - 1
examples/descope_auth/main.py

@@ -8,7 +8,7 @@ from nicegui import ui
 
 @user.login_page
 def login():
-    user.login_form().on('success', lambda: ui.open('/'))
+    user.login_form().on('success', lambda: ui.navigate.to('/'))
 
 
 @user.page('/')

+ 3 - 3
examples/descope_auth/user.py

@@ -37,7 +37,7 @@ async def logout() -> None:
     else:
         logging.error(f'Logout failed: {result}')
         ui.notify('Logout failed', type='negative')
-    ui.open(page.LOGIN_PATH)
+    ui.navigate.to(page.LOGIN_PATH)
 
 
 class page(ui.page):
@@ -65,11 +65,11 @@ class page(ui.page):
             if await self._is_logged_in():
                 if self.path == self.LOGIN_PATH:
                     self._refresh()
-                    ui.open('/')
+                    ui.navigate.to('/')
                     return
             else:
                 if self.path != self.LOGIN_PATH:
-                    ui.open(self.LOGIN_PATH)
+                    ui.navigate.to(self.LOGIN_PATH)
                     return
                 ui.timer(self.SESSION_TOKEN_REFRESH_INTERVAL, self._refresh)
 

+ 1 - 1
examples/nginx_subpath/app/main.py

@@ -5,7 +5,7 @@ from nicegui import ui
 def subpage():
     ui.label('This is a subpage').classes('text-h5 mx-auto mt-24')
     ui.link('Navigate to the index page.', '/').classes('text-lg mx-auto')
-    ui.button('back', on_click=lambda: ui.open('/')).classes('mx-auto')
+    ui.button('back', on_click=lambda: ui.navigate.to('/')).classes('mx-auto')
 
 
 @ui.page('/')

+ 10 - 3
main.py

@@ -1,8 +1,10 @@
 #!/usr/bin/env python3
 import os
 from pathlib import Path
+from typing import Optional
 
-from fastapi import Request
+from fastapi import HTTPException, Request
+from fastapi.responses import RedirectResponse
 from starlette.middleware.sessions import SessionMiddleware
 
 import prometheus
@@ -42,8 +44,13 @@ def _documentation_page() -> None:
 
 
 @ui.page('/documentation/{name}')
-def _documentation_detail_page(name: str) -> None:
-    documentation.render_page(documentation.registry[name])
+def _documentation_detail_page(name: str) -> Optional[RedirectResponse]:
+    if name in documentation.registry:
+        documentation.render_page(documentation.registry[name])
+        return None
+    if name in documentation.redirects:
+        return RedirectResponse(documentation.redirects[name])
+    raise HTTPException(404, f'documentation for "{name}" could not be found')
 
 
 @app.get('/status')

+ 58 - 0
nicegui/functions/navigate.py

@@ -0,0 +1,58 @@
+from typing import Any, Callable, Union
+
+from .. import context
+from ..client import Client
+from ..element import Element
+from .javascript import run_javascript
+
+
+class Navigate:
+    """Navigation functions
+
+    These functions allow you to navigate within the browser history and to external URLs.
+    """
+
+    @staticmethod
+    def back() -> None:
+        """ui.navigate.back
+
+        Navigates back in the browser history.
+        It is equivalent to clicking the back button in the browser.
+        """
+        run_javascript('history.back()')
+
+    @staticmethod
+    def forward() -> None:
+        """ui.navigate.forward
+
+        Navigates forward in the browser history.
+        It is equivalent to clicking the forward button in the browser.
+        """
+        run_javascript('history.forward()')
+
+    @staticmethod
+    def to(target: Union[Callable[..., Any], str, Element], new_tab: bool = False) -> None:
+        """ui.navigate.to (formerly ui.open)
+
+        Can be used to programmatically open a different page or URL.
+
+        When using the `new_tab` parameter, the browser might block the new tab.
+        This is a browser setting and cannot be changed by the application.
+        You might want to use `ui.link` and its `new_tab` parameter instead.
+
+        This functionality was previously available as `ui.open` which is now deprecated.
+
+        Note: When using an `auto-index page </documentation/section_pages_routing#auto-index_page>`_ (e.g. no `@page` decorator), 
+        all clients (i.e. browsers) connected to the page will open the target URL unless a socket is specified.
+        User events like button clicks provide such a socket.
+
+        :param target: page function, NiceGUI element on the same page or string that is a an absolute URL or relative path from base URL
+        :param new_tab: whether to open the target in a new tab (might be blocked by the browser)
+        """
+        if isinstance(target, str):
+            path = target
+        elif isinstance(target, Element):
+            path = f'#c{target.id}'
+        elif callable(target):
+            path = Client.page_routes[target]
+        context.get_client().open(path, new_tab)

+ 3 - 24
nicegui/functions/open.py

@@ -1,30 +1,9 @@
 from typing import Any, Callable, Union
 
-from .. import context
-from ..client import Client
 from ..element import Element
+from .navigate import Navigate as navigate
 
 
 def open(target: Union[Callable[..., Any], str, Element], new_tab: bool = False) -> None:  # pylint: disable=redefined-builtin
-    """Open
-
-    Can be used to programmatically trigger redirects for a specific client.
-
-    When using the `new_tab` parameter, the browser might block the new tab.
-    This is a browser setting and cannot be changed by the application.
-    You might want to use `ui.link` and its `new_tab` parameter instead.
-
-    Note: When using an `auto-index page </documentation/section_pages_routing#auto-index_page>`_ (e.g. no `@page` decorator), 
-    all clients (i.e. browsers) connected to the page will open the target URL unless a socket is specified.
-    User events like button clicks provide such a socket.
-
-    :param target: page function, NiceGUI element on the same page or string that is a an absolute URL or relative path from base URL
-    :param new_tab: whether to open the target in a new tab (might be blocked by the browser)
-    """
-    if isinstance(target, str):
-        path = target
-    elif isinstance(target, Element):
-        path = f'#c{target.id}'
-    elif callable(target):
-        path = Client.page_routes[target]
-    context.get_client().open(path, new_tab)
+    """DEPRECATED: use `ui.navigate.to` instead"""
+    navigate.to(target, new_tab)

+ 2 - 0
nicegui/ui.py

@@ -50,6 +50,7 @@ __all__ = [
     'menu',
     'menu_item',
     'mermaid',
+    'navigate',
     'notification',
     'number',
     'pagination',
@@ -203,6 +204,7 @@ from .elements.video import Video as video
 from .functions.download import download
 from .functions.html import add_body_html, add_head_html
 from .functions.javascript import run_javascript
+from .functions.navigate import Navigate as navigate
 from .functions.notify import notify
 from .functions.on import on
 from .functions.open import open  # pylint: disable=redefined-builtin

+ 27 - 0
tests/test_navigate.py

@@ -0,0 +1,27 @@
+import pytest
+
+from nicegui import ui
+from nicegui.testing import Screen
+
+
+@pytest.mark.parametrize('new_tab', [False, True])
+def test_navigate_to(screen: Screen, new_tab: bool):
+    @ui.page('/test_page')
+    def page():
+        ui.label('Test page')
+        ui.button('Back', on_click=ui.navigate.back)
+    ui.button('Open test page', on_click=lambda: ui.navigate.to('/test_page', new_tab=new_tab))
+    ui.button('Forward', on_click=ui.navigate.forward)
+
+    screen.open('/')
+    screen.click('Open test page')
+    screen.wait(0.5)
+    screen.switch_to(1 if new_tab else 0)
+    screen.should_contain('Test page')
+
+    if not new_tab:
+        screen.click('Back')
+        screen.should_contain('Open test page')
+
+        screen.click('Forward')
+        screen.should_contain('Test page')

+ 0 - 18
tests/test_open.py

@@ -1,18 +0,0 @@
-import pytest
-
-from nicegui import ui
-from nicegui.testing import Screen
-
-
-@pytest.mark.parametrize('new_tab', [False, True])
-def test_open_page(screen: Screen, new_tab: bool):
-    @ui.page('/test_page')
-    def page():
-        ui.label('Test page')
-    ui.button('Open test page', on_click=lambda: ui.open('/test_page', new_tab=new_tab))
-
-    screen.open('/')
-    screen.click('Open test page')
-    screen.wait(0.5)
-    screen.switch_to(1 if new_tab else 0)
-    screen.should_contain('Test page')

+ 2 - 1
website/documentation/__init__.py

@@ -1,4 +1,4 @@
-from .content import overview, registry
+from .content import overview, redirects, registry
 from .intro import create_intro
 from .rendering import render_page
 from .search import build_search_index
@@ -12,5 +12,6 @@ __all__ = [
     'overview',  # ensure documentation tree is built
     'python_window',
     'registry',
+    'redirects',
     'render_page',
 ]

+ 2 - 1
website/documentation/content/__init__.py

@@ -1,7 +1,8 @@
-from .doc import registry
+from .doc import redirects, registry
 from .doc.page import DocumentationPage
 
 __all__ = [
     'DocumentationPage',
     'registry',
+    'redirects',
 ]

+ 2 - 1
website/documentation/content/doc/__init__.py

@@ -1,8 +1,9 @@
-from .api import demo, extra_column, get_page, intro, reference, registry, text, title, ui
+from .api import demo, extra_column, get_page, intro, redirects, reference, registry, text, title, ui
 
 __all__ = [
     'demo',
     'intro',
+    'redirects',
     'reference',
     'registry',
     'text',

+ 4 - 1
website/documentation/content/doc/api.py

@@ -16,6 +16,7 @@ from .page import DocumentationPage
 from .part import Demo, DocumentationPart
 
 registry: Dict[str, DocumentationPage] = {}
+redirects: Dict[str, str] = {}
 
 
 def get_page(documentation: ModuleType) -> DocumentationPage:
@@ -82,7 +83,9 @@ def demo(*args, **kwargs) -> Callable[[Callable], Callable]:
         is_markdown = True
     else:
         element = args[0]
-        doc = element.__init__.__doc__ if isinstance(element, type) else element.__doc__  # type: ignore
+        doc = element.__doc__
+        if isinstance(element, type) and not doc:
+            doc = element.__init__.__doc__  # type: ignore
         title_, description = doc.split('\n', 1)
         is_markdown = False
 

+ 18 - 0
website/documentation/content/navigate_documentation.py

@@ -0,0 +1,18 @@
+from nicegui import ui
+
+from . import doc
+
+
+@doc.demo(ui.navigate)
+def main_demo() -> None:
+    with ui.row():
+        ui.button('Back', on_click=ui.navigate.back)
+        ui.button('Forward', on_click=ui.navigate.forward)
+        ui.button(icon='savings',
+                  on_click=lambda: ui.navigate.to('https://github.com/sponsors/zauberzeug'))
+
+
+@doc.demo(ui.navigate.to)
+def open_github() -> None:
+    url = 'https://github.com/zauberzeug/nicegui/'
+    ui.button('Open GitHub', on_click=lambda: ui.navigate.to(url, new_tab=True))

+ 0 - 9
website/documentation/content/open_documentation.py

@@ -1,9 +0,0 @@
-from nicegui import ui
-
-from . import doc
-
-
-@doc.demo(ui.open)
-def main_demo() -> None:
-    url = 'https://github.com/zauberzeug/nicegui/'
-    ui.button('Open GitHub', on_click=lambda: ui.open(url, new_tab=True))

+ 11 - 3
website/documentation/content/section_pages_routing.py

@@ -2,7 +2,7 @@ import uuid
 
 from nicegui import app, ui
 
-from . import (doc, download_documentation, open_documentation, page_documentation, page_layout_documentation,
+from . import (doc, download_documentation, navigate_documentation, page_documentation, page_layout_documentation,
                page_title_documentation)
 
 CONSTANT_UUID = str(uuid.uuid4())
@@ -58,7 +58,14 @@ def parameter_demo():
 
 
 doc.intro(page_title_documentation)
-doc.intro(open_documentation)
+doc.intro(navigate_documentation)
+
+doc.redirects['open'] = 'navigate#ui_navigate_to_(formerly_ui_open)'
+doc.text('ui.open', f'''
+    The `ui.open` function is deprecated.
+    Use [`ui.navigate.to`]({doc.redirects["open"]}) instead.
+''')
+
 doc.intro(download_documentation)
 
 
@@ -128,4 +135,5 @@ def fastapi_demo():
         return {'min': 0, 'max': max, 'value': random.randint(0, max)}
 
     max = ui.number('max', value=100)
-    ui.button('generate random number', on_click=lambda: ui.open(f'/random/{max.value:.0f}'))
+    ui.button('generate random number',
+              on_click=lambda: ui.navigate.to(f'/random/{max.value:.0f}'))

+ 1 - 1
website/header.py

@@ -70,4 +70,4 @@ def add_header(menu: Optional[ui.left_drawer] = None) -> None:
             with ui.button(icon='more_vert').props('flat color=white round'):
                 with ui.menu().classes('bg-primary text-white text-lg'):
                     for title_, target in menu_items.items():
-                        ui.menu_item(title_, on_click=lambda target=target: ui.open(target))
+                        ui.menu_item(title_, on_click=lambda target=target: ui.navigate.to(target))

+ 1 - 1
website/style.py

@@ -80,7 +80,7 @@ def subheading(text: str, *, link: Optional[str] = None, major: bool = False, an
             async def click():
                 if await ui.run_javascript('!!document.querySelector("div.q-drawer__backdrop")', timeout=5.0):
                     menu.hide()
-                    ui.open(f'#{name}')
+                    ui.navigate.to(f'#{name}')
             ui.link(text, target=f'#{name}').props('data-close-overlay').on('click', click, []) \
                 .classes('font-bold mt-4' if major else '')