Migrate the solution UI to a multi-page Dash application#
Starting with SAF CLI 4.0.0, the solution template introduces page registration, which requires a multi-page Dash application structure. This is a breaking change for solutions generated with SAF CLI versions prior to 4.0.0.
The instructions below assume that your Solution UI follows the structure of the solution template provided by SAF CLI in versions prior to 4.0.0.
Nevertheless, the changes are applicable with little modification to any Solution UI using Dash. See Dash official documentation for more information. The main procedure consists of:
Enable the multi-page support in your Dash app with
use_pages=True.Register your pages with
dash.register_page.Update your callbacks to use the new page registry and URL-based navigation.
Update Dash app initialization in app.py#
Add use_pages=True to your DashProxy initialization in app.py:
app = DashProxy(
__name__,
suppress_callback_exceptions=True,
transforms=[TriggerTransform(), MultiplexerTransform()],
requests_pathname_prefix=f"{os.getenv('GLOW_UI_PATH_PREFIX', '/')}",
use_pages=True,
)
Register solution pages (first_page.py…)#
Register your step pages (excluding page.py). Add the following instruction at the top of the file, after the imports:
dash.register_page(
__name__,
name="First Step",
path_template="/projects/<project_id>/first-step", # replace first-step with your page name
icon_asset_name="carbon--ibm-engineering-workflow-mgmt.svg", # optional, modify as needed for each page
icon_asset_path="icons", # optional
)
All pages must have a path template that starts with
/projects/<project_id>/followed by a subpath that identifies the step or page, for example,/projects/<project_id>/first-step.There should be only one page with the path template
/projects/<project_id>, without subpath. This will be used as the landing/about/initial page when opening a project.You can use the
orderparameter in theregister_pagefunction to control the order of the pages in the navigation tree. For example, addorder=1to the first page,order=2to the second page, and so on.
See Dash API reference for more details on the parameters of the register_page function.
Use project in your layout functions instead of step:
# def layout(step: FirstStep):
def layout(project: MySolution):
step = project.steps.first_step
Leave it as def layout(): if it doesn’t require step information in the layout.
Update your main callbacks and layout in page.py#
Remove
get_page_list,display_pageandupdate_nav_iconscallbacks. Add the following, replacingMySolutionwith your solution class:from typing import Any from ansys.saf.glow.client import NotFoundException def get_page_list(theme: str) -> list[dict[str, str | bool]]: """Return the page list with icons based on the current theme and registered pages.""" pages = [] for i, page in enumerate(dash.page_registry.values()): if page["module"].split(".")[-1] == "not_found_404": continue page_id = str(i) page_name = page["name"] icon_name = page.get("icon_asset_name", "carbon--ibm-engineering-workflow-mgmt.svg") icon_path = page.get("icon_asset_path", "icons") icon_url = get_asset(icon_name, icon_path, theme) pages.append({"id": page_id, "text": page_name, "icon": icon_url, "expanded": True}) return pages @callback( Output("active-page-index", "data"), Output("active-project-id", "data"), Input("url", "pathname"), ) def resolve_active_page_and_project_information(pathname: str) -> tuple[str | None, str | None]: """Resolve the active page index and project ID based on the current URL.""" # Remove the GLOW_UI_PATH_PREFIX part from pathname relative_pathname = dash.strip_relative_path(pathname) path_parts = relative_pathname.split("/") if relative_pathname else [] for i, page in enumerate(dash.page_registry.values()): if page["module"].split(".")[-1] == "not_found_404": continue template_parts = page["path_template"].strip("/").split("/") # look for same parts, excluding <project_id> variable if len(path_parts) != len(template_parts): continue project_id = None matched = True for tp, pp in zip(template_parts, path_parts, strict=True): if tp == "<project_id>": project_id = pp elif tp != pp: matched = False break if matched: return str(i), project_id return None, None @callback( Output("navbar-content", "children"), Input("active-page-index", "data"), Input("color-scheme-switch", "checked"), prevent_initial_call=True, ) def render_nav_tree(active_index: str | None, switch_on: bool): """Sync the active tree item based on the active page index.""" theme = "dark" if switch_on else "light" return Tree(aio_id="navigation_tree", items=get_page_list(theme), active_item_id=active_index) def _display_404_page() -> Any: for module, page in dash.page_registry.items(): if module.split(".")[-1] == "not_found_404": return page["layout"] return html.H1("404 - Page not found") @callback( Output("page-content", "children"), Input("url", "pathname"), Input("active-page-index", "data"), prevent_initial_call=True, ) def display_page(project: MySolution, active_page_index: str | None): """Return the page layout, passing the project instance. Using dash.page_container does not allow to inject the project instance as argument to the layout function, only supports passing path variables, query parameters or other Inputs/States. """ if active_page_index is None: # triggered if pathname with correct project information but incorrect sub-path. # anyway, the 404 page will be rendered by another callback return no_update try: # verify that the project information is valid, otherwise return 404. # Cannot be done in display_404_page callback because we need that one to succeed if project injection fails. _ = project.project_display_name except NotFoundException: return _display_404_page() pages = list(dash.page_registry.values()) # page_registry is an ordered dict index = int(active_page_index) page = pages[index] # support layout module attribute and layout function (with or without project argument) layout_func = page["layout"] if callable(layout_func): params = inspect.signature(layout_func).parameters if "project" in params: return layout_func(project=project) return layout_func() return layout_func @callback( Output("page-content", "children"), Input("active-page-index", "data"), prevent_initial_call=True, ) def display_404_page(active_page_index: str | None): """Return the 404 page layout when the active page index is None.""" if active_page_index is None: return _display_404_page() return no_update @callback( Output("url", "href"), Input(Tree.ids.selected_item("navigation_tree"), "data"), State("active-project-id", "data"), State("url", "pathname"), prevent_initial_call=True, ) def open_new_page(value, project_id: str | None, pathname: str): """Navigate to the selected page.""" if project_id is None or not value: return no_update item_index = int(Tree.ids.get_index_from_navlink_item_id(value)) pages = list(dash.page_registry.values()) if item_index >= len(pages): return no_update page = pages[item_index] # Add GLOW_UI_PATH_PREFIX to the path returned to the browser target_path = dash.get_relative_path(page["path_template"].replace("<project_id>", project_id)) if target_path == pathname: return no_update return target_path @callback( Output("access-solution-doc", "children"), Output("logo-image", "src"), Input("color-scheme-switch", "checked"), ) def update_nav_icons(switch_on: bool) -> tuple[html.Img, str]: """Update navigation tree icons based on the current theme.""" theme = "dark" if switch_on else "light" return ( html.Img(src=get_asset("teenyicons--doc-solid.svg", "icons", theme)), get_asset("placeholder_logo.png", "logos", theme), )
Note how
get_page_listanddisplay_pageno longer hard code the available pages. Everything is dynamically built and routed using the information of the registered pages. Therefore, future addition or removal of pages will not require any change in these callbacks.In your main layout, change the
refreshattribute of theurlcomponent:# dcc.Location(id="url", refresh=False), dcc.Location(id="url", refresh="callback-nav"),
In your main layout, remove
prevent-displaycomponents and add two new componentsactive-page-indexandactive-project-id:html.Div(id="alerts-container", style={"position": "fixed", "top": 90, "right": 10, "width": 350, "zIndex": 1000}), # dcc.Store(id="prevent-display", data=None), dcc.Store(id="active-page-index", data=None), dcc.Store(id="active-project-id", data=None),
Replace the content of your navigation tree component (
dmc.AppShellNavbar) with:navbar = dmc.AppShellNavbar( html.Div(), id="navbar-content", p="md", )
(Optional) Add a custom 404 page#
You can define a custom 404 page by adding a not_found_404.py module under your UI pages package:
import dash
from dash import html
dash.register_page(__name__)
layout = html.H1("This is our custom 404 content")
When this file is present, users will be shown this content if the URL path does not match any of the registered pages.
See Dash official documentation on default and custom 404 pages.