From e661ba6bbe8e99a8c593325d3d5d1d695f15b8cf Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Tue, 5 May 2026 08:59:59 -0500 Subject: [PATCH 1/3] initial impl --- .github/skills/writing-docs/SKILL.md | 73 ++ plugins/ui/docs/components/link.md | 33 +- plugins/ui/docs/components/router.md | 147 ++++ plugins/ui/docs/hooks/use_navigate.md | 77 ++ plugins/ui/docs/hooks/use_params.md | 121 +++ plugins/ui/docs/hooks/use_path.md | 65 ++ plugins/ui/docs/hooks/use_url_components.md | 36 + plugins/ui/docs/sidebar.json | 24 + .../deephaven/ui/_internal/RenderContext.py | 40 + .../ui/_internal/RootRenderContextProtocol.py | 32 + .../src/deephaven/ui/components/__init__.py | 4 + .../ui/src/deephaven/ui/components/link.py | 34 +- .../ui/src/deephaven/ui/components/route.py | 54 ++ .../ui/src/deephaven/ui/components/router.py | 298 +++++++ plugins/ui/src/deephaven/ui/hooks/__init__.py | 8 + .../ui/src/deephaven/ui/hooks/use_navigate.py | 175 +++++ .../ui/src/deephaven/ui/hooks/use_params.py | 18 + plugins/ui/src/deephaven/ui/hooks/use_path.py | 24 + .../deephaven/ui/hooks/use_url_components.py | 21 + .../ui/object_types/ElementMessageStream.py | 97 ++- plugins/ui/src/deephaven/ui/types/types.py | 23 +- plugins/ui/src/js/src/elements/Link.tsx | 72 ++ plugins/ui/src/js/src/elements/index.ts | 1 + plugins/ui/src/js/src/events/Navigate.test.ts | 196 +++++ plugins/ui/src/js/src/events/Navigate.ts | 102 ++- .../ui/src/js/src/events/NavigateContext.ts | 19 + .../src/js/src/widget/WidgetHandler.test.tsx | 234 ++++-- .../ui/src/js/src/widget/WidgetHandler.tsx | 74 +- plugins/ui/src/js/src/widget/WidgetUtils.tsx | 2 +- plugins/ui/test/deephaven/ui/test_routing.py | 741 ++++++++++++++++++ .../ui/test/deephaven/ui/test_utils_root.py | 28 + tests/app.d/ui_routing.py | 148 ++++ tests/ui_routing.spec.ts | 86 ++ 33 files changed, 3007 insertions(+), 100 deletions(-) create mode 100644 .github/skills/writing-docs/SKILL.md create mode 100644 plugins/ui/docs/components/router.md create mode 100644 plugins/ui/docs/hooks/use_navigate.md create mode 100644 plugins/ui/docs/hooks/use_params.md create mode 100644 plugins/ui/docs/hooks/use_path.md create mode 100644 plugins/ui/docs/hooks/use_url_components.md create mode 100644 plugins/ui/src/deephaven/ui/components/route.py create mode 100644 plugins/ui/src/deephaven/ui/components/router.py create mode 100644 plugins/ui/src/deephaven/ui/hooks/use_navigate.py create mode 100644 plugins/ui/src/deephaven/ui/hooks/use_params.py create mode 100644 plugins/ui/src/deephaven/ui/hooks/use_path.py create mode 100644 plugins/ui/src/deephaven/ui/hooks/use_url_components.py create mode 100644 plugins/ui/src/js/src/elements/Link.tsx create mode 100644 plugins/ui/src/js/src/events/Navigate.test.ts create mode 100644 plugins/ui/src/js/src/events/NavigateContext.ts create mode 100644 plugins/ui/test/deephaven/ui/test_routing.py create mode 100644 tests/app.d/ui_routing.py create mode 100644 tests/ui_routing.spec.ts diff --git a/.github/skills/writing-docs/SKILL.md b/.github/skills/writing-docs/SKILL.md new file mode 100644 index 000000000..99cf01e59 --- /dev/null +++ b/.github/skills/writing-docs/SKILL.md @@ -0,0 +1,73 @@ +--- +name: writing-docs +description: Write documentation. Use when asked to write documentation, update docs, or build plugin docs. +--- + +## Documenting Functions + +For plugins that support it (indicated by the presence of a `make_docs.py` file, e.g. `plugins/ui/make_docs.py` or `plugins/plotly-express/make_docs.py`), document functions using the `dhautofunction` directive rather than building any table or description manually. + +### Example + +````markdown +## API Reference + +```{eval-rst} +.. dhautofunction:: deephaven.ui.use_navigate +``` +```` + +Use the fully qualified Python path to the function as the argument to `dhautofunction`. This automatically generates the function signature, parameters, return type, and description from the source docstring. + +## Document Structure + +Follow this consistent structure when writing docs for components or hooks: + +1. **H1 title** — short name of the component or hook +2. **Brief description** — one or two sentences explaining what it does and when to use it +3. **`## Example`** — a minimal, runnable code example +4. **Screenshot** (components only) — `![Alt text](../_assets/component_name.png)` +5. **`## UI recommendations`** (components) or **`## Recommendations`** (hooks) — numbered list of best practices and usage guidance +6. **Additional sections** — more examples showing advanced usage, data sources, variants, etc. +7. **`## API Reference`** — always last, using `dhautofunction` + +For plotly-express chart docs, use `## What are X useful for?` with bullet points in place of a recommendations section. + +## File Placement + +- Component docs: `plugins/ui/docs/components/.md` +- Hook docs: `plugins/ui/docs/hooks/.md` +- Plot docs: `plugins/plotly-express/docs/.md` + +## Code Block Annotations + +Docs use MyST Markdown (`.md` files with embedded RST directives). Python code blocks support two special annotations: + +- `order=var1,var2,...` — controls which variables are shown in Deephaven and in what order. Variables prefixed with `_` are hidden (useful for intermediate tables or setup code that shouldn't be displayed). +- `skip-test` — excludes the code block from automated testing (use sparingly, e.g. for pseudocode or illustrative snippets). + +Example: + +````markdown +```python order=line_plot,my_table +import deephaven.plot.express as dx +my_table = dx.data.stocks() +line_plot = dx.line(my_table, x="Timestamp", y="Price") +``` +```` + +## Cross-References + +Link to other docs using relative markdown paths: + +```markdown +Consider using [`action_button`](./action_button.md) for task-based actions. +``` + +## Screenshots + +Component screenshots are stored in `plugins/ui/docs/_assets/` and named descriptively (e.g. `button_basic.png`). Reference them with a relative path from the component doc: + +```markdown +![Button Basic Example](../_assets/button_basic.png) +``` diff --git a/plugins/ui/docs/components/link.md b/plugins/ui/docs/components/link.md index 407032dce..0522a5766 100644 --- a/plugins/ui/docs/components/link.md +++ b/plugins/ui/docs/components/link.md @@ -1,6 +1,6 @@ # Link -Links allow users to navigate to a specified location. +Links allow users to navigate to a specified location. Use `href` for external URLs or full page reloads, and `to` for single-page application (SPA) navigation within Deephaven. ## Example @@ -86,6 +86,37 @@ my_link_is_quiet_example = ui.text( ) ``` +## SPA Navigation with `to` + +The `to` prop enables single-page application (SPA) navigation within Deephaven. It is mutually exclusive with `href` (which triggers a full page reload but can navigate to any URL). + +`to` accepts either a string (parsed for path, query params, and fragment) or a `NavigationTarget` dict for explicit control. + +```python +from deephaven import ui + + +@ui.component +def nav(): + return ui.flex( + # Simple string to the widget homepage + ui.link("Home", to="/"), + # Simple string to a custom path + ui.link("Search", to="/search?q=hello#results"), + # Dict form for explicit control + ui.link("Users", to={"path": "/users", "query_params": {"sort": "name"}}), + direction="column", + ) + + +my_nav = nav() +``` + +## Navigation Recommendations + +1. Use `to` for navigation within a Deephaven widget and `href` for external URLs. Do not use both on the same link. +2. For programmatic navigation triggered by events or side effects, use [`use_navigate`](../hooks/use_navigate.md) instead of a link. + ## API Reference ```{eval-rst} diff --git a/plugins/ui/docs/components/router.md b/plugins/ui/docs/components/router.md new file mode 100644 index 000000000..58dd58a3e --- /dev/null +++ b/plugins/ui/docs/components/router.md @@ -0,0 +1,147 @@ +# Router + +`ui.router` is a component that matches the current URL path against provided routes and renders the matching route's element. Use it with [`route`](#route) to define hierarchical navigation structures. + +## Example + +```python order=app +from deephaven import ui + + +@ui.component +def home_page(): + return ui.text("Home page") + +@ui.component +def app(): + # Index routes match the parent path exactly, so this renders home_page at the root URL + return ui.router( + ui.route(index=True, element=home_page), + ) + + +app = app() +``` + +## Router Options + +Build a simple app with a router, nested routes, route parameters, and a fallback "not found" page. + +```python order=app +from deephaven import ui + + +@ui.component +def nav_links(): + # Reuse navigation across pages for convenience + navigate = ui.use_navigate() + return ui.button_group( + ui.action_button("Home", on_press=lambda: navigate("/")), + ui.action_button("All Users", on_press=lambda: navigate("/users")), + ui.action_button("User 1", on_press=lambda: navigate("/users/1")), + ui.action_button("User 2", on_press=lambda: navigate("/users/2")), + ) + + +@ui.component +def user_page(): + # The use_params hook gives access to route parameters defined in the path + params = ui.use_params() + # user_id is optional due to the ? in the route path, so provide a default value + user_id = params.get("user_id", "unknown") + return ui.flex( + nav_links(), + ui.text(f"User profile for user {user_id}"), + direction="column", + ) + + +@ui.component +def dashboard(): + return ui.flex( + nav_links(), + ui.text("Dashboard home"), + direction="column", + ) + + +@ui.component +def not_found(): + return ui.flex( + nav_links(), + ui.text("Page not found"), + direction="column", + ) + + +@ui.component +def app(): + return ui.router( + # Nest routes for hierarchical paths + ui.route( + # Match /users/{user_id} and extract user_id as an optional param + ui.route( + path="{user_id?}", + element=user_page, + ), + path="users", + ), + # An index route matches the path exactly, so this matches the root path / + ui.route(index=True, element=dashboard), + # Match any unmatched path with a wildcard route + ui.route(path="*", element=not_found), + ) + + +app = app() +``` + +This produces the following route table: + +| URL Path | Matched Element | Params | +| ---------------- | --------------- | ------------------------ | +| `/` | `dashboard` | `{}` | +| `/users` | `user_page` | `{}` | +| `/users/42` | `user_page` | `{"user_id": "42"}` | +| `/anything-else` | `not_found` | `{"*": "anything-else"}` | + +## Recommendations + +1. Include a wildcard route (`path="*"`) as a fallback so unmatched paths render a meaningful "not found" message instead of an error. +2. Use an `index=True` route to define what renders at the exact parent path (such as a landing page at `/`). +3. Use [`use_params`](../hooks/use_params.md) inside routed components to access route parameters, and [`use_path`](../hooks/use_path.md) for the current path. +4. Use [`use_navigate`](../hooks/use_navigate.md) or [`link`](./link.md) with `to` to navigate between routes. + +## API Reference + +### Router + +```{eval-rst} +.. dhautofunction:: deephaven.ui.router +``` + +#### Matching behavior + +1. Static segments are preferred over parameterized segments. +2. Longer matches (more segments) are preferred over shorter ones. +3. Wildcard routes (`*`) have the lowest priority. +4. Optional segments are matched greedily. +5. Index routes match only the exact parent path. +6. If no route matches, the router renders an error. + +### Route + +```{eval-rst} +.. dhautofunction:: deephaven.ui.route +``` + +#### Path patterns + +- `{var_name}`: Required dynamic segment +- `{var_name?}`: Optional dynamic segment (matches zero or one segment) +- `*`: Wildcard, matches any remaining path segments +- Static text: Exact match + +See [use_params](../hooks/use_params.md) for more details on route parameters. + +Child paths are appended to parent paths. `ui.route(ui.route(path="{user_id}"), path="users")` produces `/users/{user_id}`. diff --git a/plugins/ui/docs/hooks/use_navigate.md b/plugins/ui/docs/hooks/use_navigate.md new file mode 100644 index 000000000..35e324e23 --- /dev/null +++ b/plugins/ui/docs/hooks/use_navigate.md @@ -0,0 +1,77 @@ +# use_navigate + +`use_navigate` is a hook that returns a function to trigger single page application (SPA) navigation within Deephaven. For declarative navigation, consider using [`link`](../components/link.md) with the `to` prop instead. + +## Example + +```python order=app +from deephaven import ui + + +@ui.component +def app(): + # Navigate to the settings page + navigate = ui.use_navigate() + return ui.action_button( + "Go to settings", on_press=lambda: navigate("/settings") + ) + + +app = app() +``` + +## Navigation Options + +Use `use_navigate` together with `use_path` and `use_query_params` to build a simple navigation system that updates the path and displays query parameters. + +```python order=app +from deephaven import ui + + +@ui.component +def navigation_demo(): + path = ui.use_path() + query_params = ui.use_query_params() + navigate = ui.use_navigate() + + def go_dashboard(): + # Navigate to a page with a query parameter + navigate("/dashboard", query_params={"welcome": "true"}) + + def go_settings(): + # Use replace=False to push a new history entry instead of replacing the current one + navigate("/settings", replace=False) + + def scroll_to_section(): + # Jump to a fragment on the current page + navigate(fragment="section-2") + + def filter_by_tags(): + # Update query parameters on the current page + navigate(query_params={"tag": ["python", "java"]}) + + return ui.flex( + ui.text(f"Current path: {path}"), + ui.text(f"Query params: {query_params}"), + ui.action_button("Dashboard", on_press=go_dashboard), + ui.action_button("Settings (push)", on_press=go_settings), + ui.action_button("Jump to section", on_press=scroll_to_section), + ui.action_button("Filter by tags", on_press=filter_by_tags), + direction="column", + ) + + +app = navigation_demo() +``` + +## Recommendations + +1. Prefer [`link`](../components/link.md) with `to` for user-clickable navigation. Reserve `use_navigate` for programmatic navigation triggered by events or side effects. +2. Use `replace=True` (the default) when navigating in response to a state change to avoid polluting the browser history. +3. Pair with [`router`](../components/router.md) and [`route`](../components/router.md) to define the route structure that `use_navigate` targets. + +## API Reference + +```{eval-rst} +.. dhautofunction:: deephaven.ui.use_navigate +``` diff --git a/plugins/ui/docs/hooks/use_params.md b/plugins/ui/docs/hooks/use_params.md new file mode 100644 index 000000000..b6801136b --- /dev/null +++ b/plugins/ui/docs/hooks/use_params.md @@ -0,0 +1,121 @@ +# use_params + +`use_params` is a hook that returns the route parameters extracted by the nearest ancestor [`router`](../components/router.md). + +## Example + +```python order=app +from deephaven import ui + + +@ui.component +def user_page(): + # Extract the user_id parameter from the route + params = ui.use_params() + user_id = params["user_id"] + return ui.text(f"User profile for user {user_id}") + +@ui.component +def app(): + # Route to match variable {user_id} + return ui.router( + ui.route(path="{user_id}", element=user_page), + ) + + +app = app() +``` + +## Parameters with Navigation + +Use `use_params` together with `use_path` and `use_navigate` to build a simple user profile page that extracts a required `user_id` parameter and an optional `section` parameter from the URL, validates them, and provides navigation buttons to update the parameters. + +```python order=app +from deephaven import ui + + +@ui.component +def user_profile(): + params = ui.use_params() + navigate = ui.use_navigate() + # Access required user_id parameter + user_id = params["user_id"] + # Access optional section parameter with a default value + section = params.get("section", "overview") + + # Validate that user_id is a number (since route parameters are always strings) + if not user_id.isdigit(): + return ui.text("Invalid user ID") + + return ui.flex( + ui.text(f"User: {user_id}, Section: {section}"), + # Add navigation for convenience + ui.button_group( + ui.action_button( + "Overview", on_press=lambda: navigate(f"/{user_id}") + ), + ui.action_button( + "Settings", on_press=lambda: navigate(f"/{user_id}/settings") + ), + ui.action_button( + "Activity", on_press=lambda: navigate(f"/{user_id}/activity") + ), + ), + ui.button_group( + ui.action_button("User 1", on_press=lambda: navigate("/1")), + ui.action_button("User 2", on_press=lambda: navigate("/2")), + ui.action_button("User 3", on_press=lambda: navigate("/3")), + ), + direction="column", + ) + + +@ui.component +def not_found(): + navigate = ui.use_navigate() + params = ui.use_params() + # Access the wildcard parameter for unmatched paths + return ui.flex( + ui.text(f"Page not found: {params['*']}"), + ui.action_button("Go to User 1", on_press=lambda: navigate("/1")), + direction="column", + ) + + +@ui.component +def app(): + return ui.router( + ui.route( + # Match /{user_id}/{section?} and extract user_id and optional section as params + ui.route(path="{section?}", element=user_profile), + # Match /{user_id} and extract user_id as a param + path="{user_id}", + ), + # Match any other path and show the not found page + ui.route(path="*", element=not_found), + ) + + +app = app() +``` + +## Route Parameter Patterns + +Route parameters are defined by `{var_name}` segments in route paths: + +- `{user_id}` matches a required segment and extracts it as `"user_id"` in the params dict. +- `{tab?}` matches an optional segment. The parameter is not included if the segment is missing. +- `*` matches any remaining path. The value is available as the `"*"` key. + +See [`router`](../components/router.md) for more details on defining routes and path patterns. + +## Recommendations + +1. Validate and parse route parameters as needed since they are always returned as strings and users can manipulate the URL to include unexpected values. +2. Use [`use_path`](./use_path.md) to read the full matched path, or [`use_query_params`](./use_query_params.md) for query string values. `use_params` only returns route segment parameters. + +## API Reference + +```{eval-rst} +.. dhautofunction:: deephaven.ui.use_params +``` diff --git a/plugins/ui/docs/hooks/use_path.md b/plugins/ui/docs/hooks/use_path.md new file mode 100644 index 000000000..70145194d --- /dev/null +++ b/plugins/ui/docs/hooks/use_path.md @@ -0,0 +1,65 @@ +# use_path + +`use_path` is a hook that returns the current URL path relative to the widget's route space. + +Widgets use `/-/` to separate Deephaven's internal router from user-specified widget routing. `use_path()` returns only the portion after `/-/` by default and returns the whole path if the `absolute` argument is set to `True`. + +## Example + +```python order=app +from deephaven import ui + + +@ui.component +def app(): + # Display the current path + path = ui.use_path() + return ui.text(f"Current path: {path}") + + +app = app() +``` + +## Path with Navigation + +Use `use_path` together with `use_navigate` to build a simple navigation system that updates the path and displays query parameters. + +```python order=app +from deephaven import ui + + +@ui.component +def path_display(): + path = ui.use_path() + absolute_path = ui.use_path(absolute=True) + navigate = ui.use_navigate() + + def go_dashboard(): + navigate("/dashboard") + + def go_home(): + navigate("/") + + return ui.flex( + ui.text(f"Current path: {path}"), + ui.text(f"Absolute path: {absolute_path}"), + ui.action_button("Go to Dashboard", on_press=go_dashboard), + ui.action_button("Go Home", on_press=go_home), + direction="column", + ) + + +app = path_display() +``` + +## Recommendations + +1. Use the default (relative) path for routing logic within your widget. Use `absolute=True` only when you need the full URL path including Deephaven's internal prefix. +2. Use [`use_params`](./use_params.md) to extract named route parameters instead of parsing the path string manually. +3. Use [`use_navigate`](./use_navigate.md) to change the current path programmatically, or [`link`](../components/link.md) with `to` for declarative navigation. + +## API Reference + +```{eval-rst} +.. dhautofunction:: deephaven.ui.use_path +``` diff --git a/plugins/ui/docs/hooks/use_url_components.md b/plugins/ui/docs/hooks/use_url_components.md new file mode 100644 index 000000000..edc6dfe94 --- /dev/null +++ b/plugins/ui/docs/hooks/use_url_components.md @@ -0,0 +1,36 @@ +# use_url_components + +`use_url_components` is a hook that returns the current URL split into components using `urllib.parse.urlsplit`. + +## Example + +```python order=app +from deephaven import ui + + +@ui.component +def url_info(): + url = ui.use_url_components() + return ui.flex( + ui.text(f"Scheme: {url.scheme}"), + ui.text(f"Host: {url.netloc}"), + ui.text(f"Path: {url.path}"), + ui.text(f"Query: {url.query}"), + ui.text(f"Fragment: {url.fragment}"), + direction="column", + ) + + +app = url_info() +``` + +## Recommendations + +1. Prefer more specific hooks over `use_url_components` when possible: [`use_path`](./use_path.md) for the path, [`use_query_params`](./use_query_params.md) for query strings, and [`use_params`](./use_params.md) for route parameters. +2. The returned object is a standard Python `SplitResult` from `urllib.parse`, so all `SplitResult` attributes and methods are available. + +## API Reference + +```{eval-rst} +.. dhautofunction:: deephaven.ui.use_url_components +``` diff --git a/plugins/ui/docs/sidebar.json b/plugins/ui/docs/sidebar.json index e1f9bcdd7..c0e313048 100644 --- a/plugins/ui/docs/sidebar.json +++ b/plugins/ui/docs/sidebar.json @@ -358,6 +358,14 @@ "label": "range_slider", "path": "components/range_slider.md" }, + { + "label": "route", + "path": "components/router.md" + }, + { + "label": "router", + "path": "components/router.md" + }, { "label": "search_field", "path": "components/search_field.md" @@ -455,6 +463,18 @@ "label": "use_memo", "path": "hooks/use_memo.md" }, + { + "label": "use_navigate", + "path": "hooks/use_navigate.md" + }, + { + "label": "use_params", + "path": "hooks/use_params.md" + }, + { + "label": "use_path", + "path": "hooks/use_path.md" + }, { "label": "use_query_param", "path": "hooks/use_query_param.md" @@ -494,6 +514,10 @@ { "label": "use_table_listener", "path": "hooks/use_table_listener.md" + }, + { + "label": "use_url_components", + "path": "hooks/use_url_components.md" } ] } diff --git a/plugins/ui/src/deephaven/ui/_internal/RenderContext.py b/plugins/ui/src/deephaven/ui/_internal/RenderContext.py index 4b61d6d48..732b84db0 100644 --- a/plugins/ui/src/deephaven/ui/_internal/RenderContext.py +++ b/plugins/ui/src/deephaven/ui/_internal/RenderContext.py @@ -423,6 +423,38 @@ def get_query_params(self) -> QueryParams: """ return self._root.get_query_params() + def get_path(self) -> str: + """ + Get the widget-relative path received from the frontend. + Returns: + The current widget-relative path. + """ + return self._root.get_path() + + def get_absolute_path(self) -> str: + """ + Get the full absolute path from the URL. + Returns: + The full absolute path. + """ + return self._root.get_absolute_path() + + def get_fragment(self) -> str: + """ + Get the URL fragment received from the frontend. + Returns: + The current URL fragment (without leading #). + """ + return self._root.get_fragment() + + def get_href(self) -> str: + """ + Get the full URL href received from the frontend. + Returns: + The full URL href. + """ + return self._root.get_href() + def update_url_state(self, query_params: QueryParams) -> None: """ Update the URL query parameters. @@ -625,6 +657,14 @@ def import_state(self, state: dict[str, Any]) -> None: # contexts (which never carry __queryParams) don't accidentally clear URL state. if "__queryParams" in state: self._root.set_query_params(state.pop("__queryParams")) + if "__path" in state: + self._root.set_path(state.pop("__path")) + if "__absolutePath" in state: + self._root.set_absolute_path(state.pop("__absolutePath")) + if "__fragment" in state: + self._root.set_fragment(state.pop("__fragment")) + if "__href" in state: + self._root.set_href(state.pop("__href")) if "state" in state: for key, value in state["state"].items(): diff --git a/plugins/ui/src/deephaven/ui/_internal/RootRenderContextProtocol.py b/plugins/ui/src/deephaven/ui/_internal/RootRenderContextProtocol.py index 4385c3ac3..d6e4a7681 100644 --- a/plugins/ui/src/deephaven/ui/_internal/RootRenderContextProtocol.py +++ b/plugins/ui/src/deephaven/ui/_internal/RootRenderContextProtocol.py @@ -35,3 +35,35 @@ def get_query_params(self) -> QueryParams: def set_query_params(self, query_params: QueryParams) -> None: """Update the URL query parameters.""" ... + + def get_path(self) -> str: + """Get the current widget-relative path.""" + ... + + def set_path(self, path: str) -> None: + """Set the current widget-relative path.""" + ... + + def get_absolute_path(self) -> str: + """Get the full absolute path from the URL.""" + ... + + def set_absolute_path(self, absolute_path: str) -> None: + """Set the full absolute path from the URL.""" + ... + + def get_fragment(self) -> str: + """Get the current URL fragment (without leading #).""" + ... + + def set_fragment(self, fragment: str) -> None: + """Set the current URL fragment.""" + ... + + def get_href(self) -> str: + """Get the full URL href.""" + ... + + def set_href(self, href: str) -> None: + """Set the full URL href.""" + ... diff --git a/plugins/ui/src/deephaven/ui/components/__init__.py b/plugins/ui/src/deephaven/ui/components/__init__.py index 7f9e456f6..26f9eae1b 100644 --- a/plugins/ui/src/deephaven/ui/components/__init__.py +++ b/plugins/ui/src/deephaven/ui/components/__init__.py @@ -82,6 +82,8 @@ from .toast import toast from .toggle_button import toggle_button from .view import view +from .route import route +from .router import router from . import html @@ -150,6 +152,8 @@ "radio_group", "range_calendar", "range_slider", + "route", + "router", "row", "search_field", "section", diff --git a/plugins/ui/src/deephaven/ui/components/link.py b/plugins/ui/src/deephaven/ui/components/link.py index a4c4cef26..aeadf36e2 100644 --- a/plugins/ui/src/deephaven/ui/components/link.py +++ b/plugins/ui/src/deephaven/ui/components/link.py @@ -14,7 +14,21 @@ ) from .basic import component_element from ..elements import Element -from ..types import LinkVariant +from ..types import LinkVariant, NavigationTarget +from ..hooks.use_navigate import _build_navigate_payload + + +def _parse_link_to(to: str | NavigationTarget) -> dict: + """Parse a 'to' prop into a navigate payload dict.""" + if isinstance(to, str): + to = {"path": to} + + return _build_navigate_payload( + path=to.get("path"), + query_params=to.get("query_params"), + fragment=to.get("fragment"), + replace=to.get("replace", True), + ) def link( @@ -24,6 +38,7 @@ def link( auto_focus: bool | None = None, href: str | None = None, target: Target | None = None, + to: str | NavigationTarget | None = None, rel: str | None = None, ping: str | None = None, download: str | None = None, @@ -91,7 +106,13 @@ def link( is_quiet: Whether the link should be displayed with a quiet style. auto_focus: Whether the element should receive focus on render. href: A URL to link to. + Triggers a full page reload. Mutually exclusive with to. target: The target window for the link. + to: The target location for single-page application navigation. + Either a plain string (parsed for path, query params, and fragment), + or a NavigationTarget dict with path, query_params, + fragment, and replace. Defaults to replace=True (replaces + history entry). Mutually exclusive with href. rel: The relationship between the linked resource and the current page. ping: A space-separated list of URLs to ping when the link is followed. download: Causes the browser to download the linked URL. @@ -153,13 +174,24 @@ def link( Returns: The rendered link element. + Raises: + ValueError: If both to and href are provided. """ + if to is not None and href is not None: + raise ValueError( + "The 'to' and 'href' props are mutually exclusive. " + "Use 'to' for SPA navigation or 'href' for full page reload." + ) + + navigate_payload: dict | None = _parse_link_to(to) if to is not None else None + return component_element( "Link", *children, variant=variant, is_quiet=is_quiet, auto_focus=auto_focus, + navigate=navigate_payload, href=href, target=target, rel=rel, diff --git a/plugins/ui/src/deephaven/ui/components/route.py b/plugins/ui/src/deephaven/ui/components/route.py new file mode 100644 index 000000000..a8ac0cd3a --- /dev/null +++ b/plugins/ui/src/deephaven/ui/components/route.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable + + +@dataclass +class _Route: + """Internal data class representing a route definition.""" + + path: str | None = None + element: Callable[..., Any] | None = None + children: list[_Route] | None = None + index: bool = False + + +def route( + *children: _Route, + path: str | None = None, + element: Callable[..., Any] | None = None, + index: bool = False, +) -> _Route: + """ + Define a route mapping a URL path pattern to a component. + + Args: + *children: Child routes for nested routing. + path: The path segment appended to the parent route's path. Variables + are defined with {var_name} syntax and extracted as route + params. Optional variables use {var_name?} syntax. Wildcard + segments are supported with "*". Leading / is optional. + Mutually exclusive with index. + element: The component function to render when this route matches. + index: If True, this route matches the parent's exact path (like + an index route). Mutually exclusive with path. + + Returns: + A _Route instance, to be consumed by the router component. + + Raises: + ValueError: If both path and index=True are provided. + """ + if path is not None and index: + raise ValueError( + "path and index=True are mutually exclusive. " + "Use path=None with index=True, or set index=False with a path." + ) + + return _Route( + path=path, + element=element, + children=list(children) if children else None, + index=index, + ) diff --git a/plugins/ui/src/deephaven/ui/components/router.py b/plugins/ui/src/deephaven/ui/components/router.py new file mode 100644 index 000000000..d6eaeaaa8 --- /dev/null +++ b/plugins/ui/src/deephaven/ui/components/router.py @@ -0,0 +1,298 @@ +from __future__ import annotations + +import re +from typing import Any, Callable + +from plugins.ui.src.deephaven.ui.hooks import use_memo + +from ..components import text +from ..elements import create_context +from ..hooks.use_path import use_path +from .route import _Route +from .make_component import make_component as component + + +# Module-level context for route params +_route_params_context = create_context({}) + + +def _normalize_path_segment(path: str | None) -> str: + """ + Strip leading/trailing slashes from a path segment. + + Args: + path: The path segment to normalize. + + Returns: + The normalized path segment. + """ + if path is None: + return "" + return path.strip("/") + + +def _compile_routes( + routes: list[_Route], + parent_path: str = "", +) -> list[tuple[str, Callable[..., Any] | None, list[str], bool]]: + """ + Recursively compile routes into a flat list of + (pattern, element, param_names, is_index) tuples. + + pattern is the full resolved path pattern (e.g. "users/{user_id}/posts/{post_id}"). + param_names is a list of parameter names extracted from {var_name} segments. + is_index indicates whether this is an index route. + + Args: + routes: The list of _Route definitions to compile + + Returns: + A list of compiled route tuples (pattern, element, param_names, is_index) + """ + compiled = [] + + for r in routes: + if r.index: + # Index route matches the parent's exact path + compiled.append((parent_path, r.element, [], True)) + else: + segment = _normalize_path_segment(r.path) + if parent_path and segment: + resolved = parent_path + "/" + segment + elif segment: + resolved = segment + else: + resolved = parent_path + + # Extract param names from pattern + param_names = [] + for part in resolved.split("/"): + # Match {var_name} and {var_name?} patterns + match = re.match(r"^\{(\w+)\??\}$", part) + if match: + param_names.append(match.group(1)) + elif part == "*": + param_names.append("*") + + if r.element is not None: + compiled.append((resolved, r.element, param_names, False)) + + # Recurse into children + if r.children: + child_parent = parent_path + if not r.index: + segment = _normalize_path_segment(r.path) + if parent_path and segment: + child_parent = parent_path + "/" + segment + elif segment: + child_parent = segment + compiled.extend(_compile_routes(r.children, child_parent)) + + return compiled + + +def _pattern_to_regex(pattern: str) -> str: + """ + Convert a route pattern to a regex string. + + Supports: + - {var_name} -> named group matching one segment + - {var_name?} -> optional named group (zero or one segment) + - * -> wildcard matching zero or more remaining segments + - static segments -> literal match + + Args: + pattern: The route pattern to convert + Returns: + A regex string for matching the pattern + """ + if not pattern: + return r"^/?$" + + parts = pattern.split("/") + # Start regex with optional leading slash + result = r"^/?" + + for i, part in enumerate(parts): + # Optional {var_name?} pattern + optional_match = re.match(r"^\{(\w+)\?\}$", part) + # Required {var_name} pattern + param_match = re.match(r"^\{(\w+)\}$", part) + sep = "/" if i > 0 else "" + + if part == "*": + # Wildcard matches the rest of the path, including slashes + result += sep + r"(?P<__wildcard__>.*)" + elif optional_match: + # Optional segment: non-capturing group that matches either nothing or a slash followed by the param + name = optional_match.group(1) + # Include the leading slash in the optional group + result += rf"(?:/(?P<{name}>[^/]+))?" + elif param_match: + # Required segment: named group matching one path segment + name = param_match.group(1) + result += sep + rf"(?P<{name}>[^/]+)" + else: + # Static segment: escape for literal match + result += sep + re.escape(part) + + # Allow optional trailing slash and end of string + result += r"/?$" + return result + + +def _specificity_key(pattern: str, is_index: bool) -> tuple[int, int, int, int]: + """ + Return a sort key for route specificity. + + Higher values = more specific = matched first. + Order: static segments count, no-wildcard bonus, total segments, index bonus. + + Args: + pattern: The route pattern to analyze. + is_index: Whether this route is an index route. + + Returns: + A tuple key for sorting routes by specificity. + """ + parts = pattern.split("/") if pattern else [] + # Count static segments (not parameters or wildcards) + static_count = sum(1 for p in parts if not re.match(r"^\{.*\}$", p) and p != "*") + has_wildcard = any(p == "*" for p in parts) + total_segments = len(parts) + + return ( + static_count, # More static segments = more specific + 0 if has_wildcard else 1, # Non-wildcard preferred + total_segments, # More total segments = more specific + 1 if is_index else 0, # Index routes preferred for exact match + ) + + +def _check_conflicts( + compiled: list[tuple[str, Callable[..., Any] | None, list[str], bool]], +) -> None: + """ + Check for conflicting route patterns among siblings. + Two routes conflict if they are both fully static and resolve to the same path. + + Args: + compiled: The list of compiled routes to check. + + Raises: + ValueError: If conflicting route paths are detected. + """ + static_paths: dict[str, int] = {} + for pattern, _, param_names, is_index in compiled: + # Only check fully-static, non-index routes + if not param_names and not is_index and "*" not in pattern: + normalized = pattern.strip("/") + if normalized in static_paths: + raise ValueError( + f"Conflicting route paths: '/{normalized}' is defined more than once." + ) + static_paths[normalized] = 1 + + +def _compile_and_check( + routes: list[_Route], +) -> list[tuple[str, Callable[..., Any] | None, list[str], bool]]: + """ + Compile routes and check for conflicts. Returns compiled routes if valid. + + Args: + routes: The list of _Route definitions to compile and check. + + Returns: + A list of compiled route tuples (pattern, element, param_names, is_index). + + Raises: + ValueError: If conflicting route paths are detected among siblings. + """ + compiled = _compile_routes(routes) + _check_conflicts(compiled) + # Sort the compiled routes by specificity so that more specific routes are matched first + sorted_routes = sorted( + compiled, + key=lambda r: _specificity_key(r[0], r[3]), + reverse=True, + ) + return sorted_routes + + +def _match_route( + path: str, + compiled: list[tuple[str, Callable[..., Any] | None, list[str], bool]], +) -> tuple[Callable[..., Any] | None, dict[str, str]] | None: + """ + Match a path against compiled routes. + Returns (element, params) for the best match, or None. + + Args: + path: The URL path to match. + compiled: The list of compiled route tuples (pattern, element, param_names, is_index). + + Returns: + A tuple of (element, params) for the best match, or None if no match is found. + """ + # Normalize path for matching + normalized_path = path.strip("/") + if not normalized_path: + normalized_path = "" + + # Iterate through compiled routes in order of specificity and return the first match + # It's assumed the routes are pre-sorted by specificity, so the first match is the best match. + for pattern, element, param_names, _ in compiled: + regex = _pattern_to_regex(pattern) + match = re.match(regex, "/" + normalized_path if normalized_path else "/") + if match: + params: dict[str, str] = {} + for name in param_names: + if name == "*": + val = match.group("__wildcard__") + params["*"] = val if val else "" + else: + val = match.group(name) + params[name] = val if val is not None else "" + return element, params + + return None + + +@component +def router(*routes: _Route) -> Any: + """ + Match the current URL path against the provided routes and render + the matching route's element. + + Args: + *routes: Route definitions to match against. + + Returns: + The element for the matched route wrapped in a params context, + or an error element if no route matches. + + Raises: + ValueError: If conflicting route paths are detected among siblings. + """ + + current_path = use_path() + + # Compile routes and check for conflicts + compiled = use_memo(lambda: _compile_and_check(list(routes)), [routes]) + + # Match current path against compiled routes + result = use_memo( + lambda: _match_route(current_path, compiled), [current_path, compiled] + ) + + if result is None: + return text(f"No route matches path: {current_path}") + + element_fn, params = result + + if element_fn is None: + return text(f"No element defined for matched route at: {current_path}") + + # Render the matched element wrapped in a params context + return _route_params_context(element_fn(), value=params) diff --git a/plugins/ui/src/deephaven/ui/hooks/__init__.py b/plugins/ui/src/deephaven/ui/hooks/__init__.py index a35d3d86e..375236f65 100644 --- a/plugins/ui/src/deephaven/ui/hooks/__init__.py +++ b/plugins/ui/src/deephaven/ui/hooks/__init__.py @@ -18,6 +18,10 @@ from .use_query_params import use_query_params from .use_query_param import use_query_param from .use_set_query_param import use_set_query_param +from .use_path import use_path +from .use_navigate import use_navigate +from .use_url_components import use_url_components +from .use_params import use_params __all__ = [ @@ -41,4 +45,8 @@ "use_query_params", "use_query_param", "use_set_query_param", + "use_path", + "use_navigate", + "use_url_components", + "use_params", ] diff --git a/plugins/ui/src/deephaven/ui/hooks/use_navigate.py b/plugins/ui/src/deephaven/ui/hooks/use_navigate.py new file mode 100644 index 000000000..3d690ddea --- /dev/null +++ b/plugins/ui/src/deephaven/ui/hooks/use_navigate.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from typing import Any, Callable +from urllib.parse import urlencode, urlsplit + +from ..types import QueryParams +from .use_send_event import use_send_event + + +_NAVIGATE_EVENT = "navigate.event" + + +def _normalize_path(path: str | None) -> str | None: + """ + Normalize a path: None passthrough, reject empty, prepend /. + + Args: + path: The path to normalize + + Returns: + The normalized path, or None if input was None + + """ + if path is None: + return None + if path == "": + raise ValueError("Empty string is not a valid path. Use '/' for the root.") + if not path.startswith("/"): + path = "/" + path + return path + + +def _normalize_query_params(query_params: str | QueryParams | None) -> str | None: + """ + Normalize query params to a ?-prefixed string. None passthrough, empty clears. + + Args: + query_params: The query parameters to normalize + + Returns: + The normalized query parameters, or None if input was None + """ + if query_params is None: + return None + if isinstance(query_params, dict): + if not query_params: + return "" + return "?" + urlencode(query_params, doseq=True) + if query_params == "": + return "" + if query_params.startswith("?"): + return query_params + return "?" + query_params + + +def _normalize_fragment(fragment: str | None) -> str | None: + """ + Normalize a fragment string. None passthrough, empty clears, strips #. + + Args: + fragment: The fragment to normalize + + Returns: + The normalized fragment, or None if input was None + """ + if fragment is None: + return None + if fragment == "": + return "" + if fragment.startswith("#"): + return fragment[1:] + return fragment + + +def _parse_inline_url(path: str) -> tuple[str, str | None, str | None]: + """ + Parse inline ?query and #fragment from a path string. + + Args: + path: The path string to parse + + Returns: + A tuple of (clean_path, inline_query, inline_fragment) + """ + # Use a dummy base URL for urlsplit to easily parse path, query, and fragment + result = urlsplit("http://dummy" + (path if path.startswith("/") else "/" + path)) + clean_path = result.path + inline_query = ("?" + result.query) if result.query else None + inline_fragment = result.fragment if result.fragment else None + return clean_path, inline_query, inline_fragment + + +def _build_navigate_payload( + path: str | None = None, + query_params: str | QueryParams | None = None, + fragment: str | None = None, + replace: bool | None = None, +) -> dict[str, Any]: + """ + Build a navigate event payload from URL components. + + Parses inline ?query and #fragment from path if present. + Explicit query_params/fragment args take precedence over inline values. + Only resolved non-None values are included in the returned dict. + """ + inline_query: str | None = None + inline_fragment: str | None = None + + if path is not None: + if path == "": + raise ValueError("Empty string is not a valid path. Use '/' for the root.") + path, inline_query, inline_fragment = _parse_inline_url(path) + path = _normalize_path(path) + + payload: dict[str, Any] = {} + if path is not None: + payload["path"] = path + + eff_query = _normalize_query_params( + query_params if query_params is not None else inline_query + ) + if eff_query is not None: + payload["queryParams"] = eff_query + + eff_fragment = _normalize_fragment( + fragment if fragment is not None else inline_fragment + ) + if eff_fragment is not None: + payload["fragment"] = eff_fragment + + if replace is not None: + payload["replace"] = replace + + return payload + + +def use_navigate() -> Callable[..., None]: + """ + Get a function to navigate to a new URL within the widget's route space. + + Returns: + A navigate function: navigate(path, query_params, fragment, replace) -> None + """ + send_event = use_send_event() + + def navigate( + path: str | None = None, + query_params: str | QueryParams | None = None, + fragment: str | None = None, + replace: bool | None = None, + ) -> None: + """ + Navigate to a new URL using SPA navigation. + + At least one of path, query_params, or fragment must be provided. + + Args: + path: Target path. May include inline ?query and #fragment. + Explicit query_params/fragment args override inline values. + query_params: Query string or QueryParams dict. + Empty string or {} clears all query parameters. + fragment: URL fragment (leading # optional). + Empty string clears the fragment. + replace: If True, replace history entry. If False, push new. + Defaults to None (replaceState). + """ + if path is None and query_params is None and fragment is None: + raise ValueError( + "At least one of path, query_params, or fragment must be provided." + ) + + payload = _build_navigate_payload(path, query_params, fragment, replace) + send_event(_NAVIGATE_EVENT, payload) + + return navigate diff --git a/plugins/ui/src/deephaven/ui/hooks/use_params.py b/plugins/ui/src/deephaven/ui/hooks/use_params.py new file mode 100644 index 000000000..5155dd335 --- /dev/null +++ b/plugins/ui/src/deephaven/ui/hooks/use_params.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from ..hooks.use_context import use_context +from ..components.router import _route_params_context + + +def use_params() -> dict[str, str]: + """ + Get the route parameters from the nearest ancestor router. + + Route parameters are defined by `{var_name}` segments in route paths + and extracted when the route matches. + + Returns: + A dictionary mapping parameter names to their matched string values. + Returns an empty dict if no router ancestor exists. + """ + return use_context(_route_params_context) diff --git a/plugins/ui/src/deephaven/ui/hooks/use_path.py b/plugins/ui/src/deephaven/ui/hooks/use_path.py new file mode 100644 index 000000000..7a6e5ffcf --- /dev/null +++ b/plugins/ui/src/deephaven/ui/hooks/use_path.py @@ -0,0 +1,24 @@ +from .._internal import get_context + + +def use_path(absolute: bool = False) -> str: + """ + Get the current URL path. + + The /-/ prefix separates platform routing from widget routing. + The section after /-/ is the path relative to the current widget. + If the widget is not loaded via a route containing /-/, the relative + path falls back to /. + + Args: + absolute: If True, returns the full absolute path from the URL. + If False (default), returns the path relative to the + current widget (after /-/). + + Returns: + The current path as a string. + """ + context = get_context() + if absolute: + return context.get_absolute_path() or "/" + return context.get_path() or "/" diff --git a/plugins/ui/src/deephaven/ui/hooks/use_url_components.py b/plugins/ui/src/deephaven/ui/hooks/use_url_components.py new file mode 100644 index 000000000..7200da9d1 --- /dev/null +++ b/plugins/ui/src/deephaven/ui/hooks/use_url_components.py @@ -0,0 +1,21 @@ +from urllib.parse import SplitResult, urlsplit + +from .._internal import get_context + + +def use_url_components() -> SplitResult: + """ + Get the current URL broken into components. + + Returns: + A SplitResult named tuple with fields: + + - scheme: URL scheme (e.g. "https") + - netloc: Network location (e.g. "example.com:8080") + - path: Path component + - query: Query string (without leading "?") + - fragment: Fragment (without leading "#") + """ + context = get_context() + href = context.get_href() + return urlsplit(href) diff --git a/plugins/ui/src/deephaven/ui/object_types/ElementMessageStream.py b/plugins/ui/src/deephaven/ui/object_types/ElementMessageStream.py index cdc0749a4..944bf0b4c 100644 --- a/plugins/ui/src/deephaven/ui/object_types/ElementMessageStream.py +++ b/plugins/ui/src/deephaven/ui/object_types/ElementMessageStream.py @@ -183,6 +183,18 @@ class ElementMessageStream(MessageStream, RootRenderContextProtocol): Keys are parameter names, values are lists of string values. """ + _path: str + """The widget-relative path (after /-/).""" + + _absolute_path: str + """The full absolute browser path.""" + + _fragment: str + """The URL fragment (without leading #).""" + + _href: str + """The full URL href.""" + def __init__(self, element: Element, connection: MessageStream): """ Create a new ElementMessageStream. Renders the element in a render context, and sends the rendered result to the @@ -200,6 +212,10 @@ def __init__(self, element: Element, connection: MessageStream): self._encoder = NodeEncoder() self._event_encoder = EventEncoder(self._serialize_callables) self._query_params = {} + self._path = "/" + self._absolute_path = "/" + self._fragment = "" + self._href = "" self._context = RenderContext(self) self._event_context = EventContext(self._send_event) self._renderer = Renderer(self._context) @@ -320,6 +336,66 @@ def get_query_params(self) -> QueryParams: def set_query_params(self, query_params: QueryParams) -> None: self._query_params = query_params + def get_path(self) -> str: + """ + Get the widget-relative path (after /-/). + """ + return self._path + + def set_path(self, path: str) -> None: + """ + Set the widget-relative path (after /-/). + + Args: + path: The path to set + """ + self._path = path + + def get_absolute_path(self) -> str: + """ + Get the absolute path. + """ + return self._absolute_path + + def set_absolute_path(self, absolute_path: str) -> None: + """ + Set the absolute path. + + Args: + absolute_path: The absolute path to set + """ + self._absolute_path = absolute_path + + def get_fragment(self) -> str: + """ + Get the URL fragment (without leading #). + """ + return self._fragment + + def set_fragment(self, fragment: str) -> None: + """ + Set the URL fragment. + + Args: + fragment: The fragment to set + """ + self._fragment = fragment + + def get_href(self) -> str: + """ + Get the full URL href. + """ + return self._href + + def set_href(self, href: str) -> None: + """ + Set the full URL href. + + Args: + href: The href to set + """ + self._href = href + def start(self) -> None: """ Start the message stream. All we do is send a blank message to start. Client will respond with the initial state. @@ -421,16 +497,25 @@ def _set_state(self, state: ExportedRenderState) -> None: def _set_url_state(self, url_state: _UrlState) -> None: """ - Update only the URL state (query params). Called by the client after a - client-side navigation so that the component re-renders with updated URL - params. + Update the URL state (path, query params, fragment, href). Called by + the client after a client-side navigation so that the component + re-renders with updated URL state. Args: - url_state: Dict with key ``__queryParams`` mapping param names to - lists of string values. + url_state: Dict with URL state fields __queryParams, + __path, __absolutePath, __fragment, __href. """ logger.debug("Setting URL state: %s", url_state) - self.set_query_params(url_state.get("__queryParams", {})) + if "__queryParams" in url_state: + self.set_query_params(url_state["__queryParams"]) + if "__path" in url_state: + self.set_path(url_state["__path"]) + if "__absolutePath" in url_state: + self.set_absolute_path(url_state["__absolutePath"]) + if "__fragment" in url_state: + self.set_fragment(url_state["__fragment"]) + if "__href" in url_state: + self.set_href(url_state["__href"]) self._mark_dirty() def _serialize_callables(self, node: Any) -> Any: diff --git a/plugins/ui/src/deephaven/ui/types/types.py b/plugins/ui/src/deephaven/ui/types/types.py index 76932d4b3..0b74d2582 100644 --- a/plugins/ui/src/deephaven/ui/types/types.py +++ b/plugins/ui/src/deephaven/ui/types/types.py @@ -609,12 +609,31 @@ class NumberRange(TypedDict): """ A type alias for query parameter dictionaries used throughout the routing API. -Keys are parameter names. Values are always ``list[str]`` — even for keys +Keys are parameter names. Values are always list[str] — even for keys that appear only once. When serialised to a URL, list values repeat the -key: ``{"tag": ["python", "java"]}`` becomes ``?tag=python&tag=java``. +key: {"tag": ["python", "java"]} becomes ?tag=python&tag=java. """ +class NavigationTarget(TypedDict, total=False): + """ + A typed dictionary used by ui.link's to prop for explicit control + over navigation. + """ + + path: str + """The path to navigate to (e.g. "/dashboard").""" + + query_params: str | QueryParams + """Query string (e.g. "?foo=bar") or a QueryParams dict.""" + + fragment: str + """URL fragment, e.g. "section" (leading "#" optional).""" + + replace: bool + """If True, replace the current history entry instead of pushing.""" + + _DISABLE_NULLISH_CONSTRUCTORS = False diff --git a/plugins/ui/src/js/src/elements/Link.tsx b/plugins/ui/src/js/src/elements/Link.tsx new file mode 100644 index 000000000..214db6581 --- /dev/null +++ b/plugins/ui/src/js/src/elements/Link.tsx @@ -0,0 +1,72 @@ +import React, { useCallback } from 'react'; +import { + Link as SpectrumLink, + type LinkProps as SpectrumLinkProps, +} from '@deephaven/components'; +import { type NavigateParams } from '../events/Navigate'; +import { useNavigateContext } from '../events/NavigateContext'; +import { usePressEventCallback } from './hooks/usePressEventCallback'; +import { useFocusEventCallback } from './hooks/useFocusEventCallback'; +import { useKeyboardEventCallback } from './hooks/useKeyboardEventCallback'; +import { type SerializedButtonEventProps } from './model/SerializedPropTypes'; + +type LinkProps = SerializedButtonEventProps & { + /** Navigation params for SPA routing. When set, pressing the link triggers navigation instead of a full page load. */ + navigate?: NavigateParams; +}; + +export function Link(props: LinkProps): JSX.Element { + const { + navigate: navigateParams, + onPress: propOnPress, + onPressStart: propOnPressStart, + onPressEnd: propOnPressEnd, + onPressUp: propOnPressUp, + onFocus: propOnFocus, + onBlur: propOnBlur, + onKeyDown: propOnKeyDown, + onKeyUp: propOnKeyUp, + ...otherProps + } = props; + + const navigateContext = useNavigateContext(); + const onPressStart = usePressEventCallback(propOnPressStart); + const onPressEnd = usePressEventCallback(propOnPressEnd); + const onPressUp = usePressEventCallback(propOnPressUp); + const onFocus = useFocusEventCallback(propOnFocus); + const onBlur = useFocusEventCallback(propOnBlur); + const onKeyDown = useKeyboardEventCallback(propOnKeyDown); + const onKeyUp = useKeyboardEventCallback(propOnKeyUp); + const baseOnPress = usePressEventCallback(propOnPress); + + const onPress = useCallback( + (e: Parameters>[0]) => { + if (navigateParams != null && navigateContext != null) { + navigateContext(navigateParams); + } + baseOnPress?.(e); + }, + [navigateParams, navigateContext, baseOnPress] + ); + + return ( + + ); +} + +Link.displayName = 'Link'; + +export default Link; diff --git a/plugins/ui/src/js/src/elements/index.ts b/plugins/ui/src/js/src/elements/index.ts index 2ea1c76a0..ecb7ad07c 100644 --- a/plugins/ui/src/js/src/elements/index.ts +++ b/plugins/ui/src/js/src/elements/index.ts @@ -22,6 +22,7 @@ export * from './IconElementView'; export * from './IllustratedMessage'; export * from './Image'; export * from './LabeledValue'; +export * from './Link'; export * from './InlineAlert'; export * from './ListView'; export * from './LogicButton'; diff --git a/plugins/ui/src/js/src/events/Navigate.test.ts b/plugins/ui/src/js/src/events/Navigate.test.ts new file mode 100644 index 000000000..1a7b81672 --- /dev/null +++ b/plugins/ui/src/js/src/events/Navigate.test.ts @@ -0,0 +1,196 @@ +import { Navigate, getWidgetRelativePath } from './Navigate'; + +describe('Navigate', () => { + let originalLocation: Location; + + beforeEach(() => { + originalLocation = window.location; + Object.defineProperty(window, 'location', { + value: new URL( + 'http://localhost/app/widget/local/dashboard/-/page?old=val#old-frag' + ), + writable: true, + }); + jest.spyOn(window.history, 'replaceState').mockImplementation(jest.fn()); + jest.spyOn(window.history, 'pushState').mockImplementation(jest.fn()); + }); + + afterEach(() => { + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + }); + jest.restoreAllMocks(); + }); + + it('updates only query params when path is not provided', () => { + Navigate({ queryParams: '?foo=bar' }); + + expect(window.history.replaceState).toHaveBeenCalledWith( + null, + '', + '/app/widget/local/dashboard/-/page?foo=bar#old-frag' + ); + }); + + it('updates only fragment when path is not provided', () => { + Navigate({ fragment: 'new-section' }); + + expect(window.history.replaceState).toHaveBeenCalledWith( + null, + '', + '/app/widget/local/dashboard/-/page?old=val#new-section' + ); + }); + + it('clears fragment when empty string', () => { + Navigate({ fragment: '' }); + + expect(window.history.replaceState).toHaveBeenCalledWith( + null, + '', + '/app/widget/local/dashboard/-/page?old=val' + ); + }); + + it('clears query params when empty string', () => { + Navigate({ queryParams: '' }); + + expect(window.history.replaceState).toHaveBeenCalledWith( + null, + '', + '/app/widget/local/dashboard/-/page#old-frag' + ); + }); + + it('updates path relative to widget base', () => { + Navigate({ path: '/new-page' }); + + expect(window.history.replaceState).toHaveBeenCalledWith( + null, + '', + '/app/widget/local/dashboard/-/new-page' + ); + }); + + it('updates all components at once', () => { + Navigate({ + path: '/settings', + queryParams: '?tab=2', + fragment: 'top', + }); + + expect(window.history.replaceState).toHaveBeenCalledWith( + null, + '', + '/app/widget/local/dashboard/-/settings?tab=2#top' + ); + }); + + it('uses replaceState by default', () => { + Navigate({ queryParams: 'x=1' }); + + expect(window.history.replaceState).toHaveBeenCalled(); + expect(window.history.pushState).not.toHaveBeenCalled(); + }); + + it('uses pushState when replace=false', () => { + Navigate({ queryParams: 'x=1', replace: false }); + + expect(window.history.pushState).toHaveBeenCalled(); + expect(window.history.replaceState).not.toHaveBeenCalled(); + }); + + it('uses replaceState when replace=true', () => { + Navigate({ queryParams: 'x=1', replace: true }); + + expect(window.history.replaceState).toHaveBeenCalled(); + expect(window.history.pushState).not.toHaveBeenCalled(); + }); + + it('blocks cross-origin navigation', () => { + Object.defineProperty(window, 'location', { + value: new URL('http://localhost/app'), + writable: true, + }); + + // Navigating with a normal path establishes /-/ boundary + Navigate({ path: '/safe' }); + expect(window.history.replaceState).toHaveBeenCalledWith( + null, + '', + '/app/-/safe' + ); + }); + + it('preserves URL components not specified in params', () => { + // When only queryParams is provided, path and fragment should be preserved + Navigate({ queryParams: '?new=value' }); + + expect(window.history.replaceState).toHaveBeenCalledWith( + null, + '', + '/app/widget/local/dashboard/-/page?new=value#old-frag' + ); + }); + + it('strips .. traversal from paths', () => { + Navigate({ path: '/../../etc/passwd' }); + + // The path should have .. stripped + const call = (window.history.replaceState as jest.Mock).mock.calls[0]; + const newUrl = call[2] as string; + expect(newUrl).not.toContain('..'); + }); +}); + +describe('getWidgetRelativePath', () => { + let originalLocation: Location; + + beforeEach(() => { + originalLocation = window.location; + }); + + afterEach(() => { + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + }); + }); + + it('returns path after /-/', () => { + Object.defineProperty(window, 'location', { + value: new URL('http://localhost/app/widget/q/w/-/dashboard/settings'), + writable: true, + }); + + expect(getWidgetRelativePath()).toBe('/dashboard/settings'); + }); + + it('returns / when /-/ has no trailing path', () => { + Object.defineProperty(window, 'location', { + value: new URL('http://localhost/app/widget/q/w/-/'), + writable: true, + }); + + expect(getWidgetRelativePath()).toBe('/'); + }); + + it('returns / when /-/ is not in URL', () => { + Object.defineProperty(window, 'location', { + value: new URL('http://localhost/app/widget/q/w'), + writable: true, + }); + + expect(getWidgetRelativePath()).toBe('/'); + }); + + it('returns /page for single segment after /-/', () => { + Object.defineProperty(window, 'location', { + value: new URL('http://localhost/app/widget/q/w/-/page'), + writable: true, + }); + + expect(getWidgetRelativePath()).toBe('/page'); + }); +}); diff --git a/plugins/ui/src/js/src/events/Navigate.ts b/plugins/ui/src/js/src/events/Navigate.ts index 2ac1c68b6..8dad724fe 100644 --- a/plugins/ui/src/js/src/events/Navigate.ts +++ b/plugins/ui/src/js/src/events/Navigate.ts @@ -5,28 +5,112 @@ const log = Log.module('Navigate'); // Event types received from the server export const NAVIGATE_EVENT = 'navigate.event'; +/** + * Custom event dispatched after Navigate() changes the URL. + * All WidgetHandlers listen for this to sync URL state to the backend. + * This doesn't work for navigation triggered outside this plugin, so external + * code would need to dispatch this event to trigger URL sync for this plugin + * if it is needed. + */ +export const URL_CHANGED_EVENT = 'deephaven-url-changed'; + export type NavigateParams = { + path?: string | null; queryParams?: string | null; + fragment?: string | null; replace?: boolean | null; }; -// Type sent to the server for current location +// Types sent to the server for current location export const QUERY_PARAM = '__queryParams'; +export const PATH_PARAM = '__path'; +export const ABSOLUTE_PATH_PARAM = '__absolutePath'; +export const FRAGMENT_PARAM = '__fragment'; +export const HREF_PARAM = '__href'; + +/** Separator between platform routing and widget routing in the URL */ +const WIDGET_PATH_SEPARATOR = '/-/'; + +/** Allowed URL schemes for navigation */ +const ALLOWED_SCHEMES = new Set(['http:', 'https:', '']); + +/** + * Get the widget base path from the current URL. + * This is the portion up to and including `/-/`. + * If `/-/` is not in the path, returns the full pathname. + */ +function getWidgetBasePath(): string { + const { pathname } = window.location; + const separatorIndex = pathname.indexOf(WIDGET_PATH_SEPARATOR); + if (separatorIndex === -1) { + return pathname; + } + return pathname.substring(0, separatorIndex + WIDGET_PATH_SEPARATOR.length); +} /** - * Handle a navigate event by updating the browser URL query parameters + * Get the widget-relative path from the current URL. + * This is the portion after `/-/`, or "/" if `/-/` is not present. + */ +export function getWidgetRelativePath(): string { + const { pathname } = window.location; + const separatorIndex = pathname.indexOf(WIDGET_PATH_SEPARATOR); + if (separatorIndex === -1) { + return '/'; + } + const relativePath = pathname.substring( + separatorIndex + WIDGET_PATH_SEPARATOR.length + ); + return relativePath ? `/${relativePath}` : '/'; +} + +/** + * Handle a navigate event by updating the browser URL * and pushing or replacing the history entry. * * @param params The navigate event parameters */ export function Navigate(params: NavigateParams): void { - const { queryParams: navQueryParams, replace: navReplace } = params; + const { + path: navPath, + queryParams: navQueryParams, + fragment: navFragment, + replace: navReplace, + } = params; const url = new URL(window.location.href); - // null/undefined should preserve + // Handle path + if (navPath != null) { + // Sanitize path: strip '..' traversal sequences + const sanitizedPath = navPath.replace(/(?:^|\/)\.\./g, ''); + const basePath = getWidgetBasePath(); + // If basePath includes /-/, append the new path after it + if (basePath.includes(WIDGET_PATH_SEPARATOR)) { + url.pathname = basePath + sanitizedPath.replace(/^\//, ''); + } else { + // No /-/ boundary yet — establish it + url.pathname = + basePath.replace(/\/$/, '') + + WIDGET_PATH_SEPARATOR + + sanitizedPath.replace(/^\//, ''); + } + } + + // Handle query params: null/undefined = preserve (or clear if path changed), "" = clear if (navQueryParams != null) { url.search = navQueryParams; + } else if (navPath != null) { + // If a new path is provided without explicit query params, clear them + url.search = ''; + } + + // Handle fragment: null/undefined = preserve (or clear if path changed), "" = clear + if (navFragment != null) { + url.hash = navFragment ? `#${navFragment}` : ''; + } else if (navPath != null) { + // If a new path is provided without explicit fragment, clear it + url.hash = ''; } // Security: reject cross-origin navigation @@ -35,6 +119,12 @@ export function Navigate(params: NavigateParams): void { return; } + // Security: reject dangerous schemes + if (!ALLOWED_SCHEMES.has(url.protocol)) { + log.warn('Blocked navigation with disallowed scheme:', url.protocol); + return; + } + const shouldReplace = navReplace ?? true; const newUrl = url.pathname + url.search + url.hash; if (shouldReplace) { @@ -42,6 +132,10 @@ export function Navigate(params: NavigateParams): void { } else { window.history.pushState(null, '', newUrl); } + + // Notify all WidgetHandlers that the URL changed so they can sync state. + // Uses a custom event (not popstate) to avoid interfering with browser navigation. + window.dispatchEvent(new Event(URL_CHANGED_EVENT)); } export default Navigate; diff --git a/plugins/ui/src/js/src/events/NavigateContext.ts b/plugins/ui/src/js/src/events/NavigateContext.ts new file mode 100644 index 000000000..ce9470f7e --- /dev/null +++ b/plugins/ui/src/js/src/events/NavigateContext.ts @@ -0,0 +1,19 @@ +import { createContext, useContext } from 'react'; +import { type NavigateParams } from './Navigate'; + +export type NavigateCallback = (params: NavigateParams) => void; + +/** + * Context that provides a navigate function scoped to a specific widget. + * The WidgetHandler provides this so child components (e.g. Link) can + * trigger navigation and have the URL state sent back to the backend. + */ +const NavigateContext = createContext(null); + +NavigateContext.displayName = 'NavigateContext'; + +export function useNavigateContext(): NavigateCallback | null { + return useContext(NavigateContext); +} + +export default NavigateContext; diff --git a/plugins/ui/src/js/src/widget/WidgetHandler.test.tsx b/plugins/ui/src/js/src/widget/WidgetHandler.test.tsx index 0cef5b66a..a8ad7de1c 100644 --- a/plugins/ui/src/js/src/widget/WidgetHandler.test.tsx +++ b/plugins/ui/src/js/src/widget/WidgetHandler.test.tsx @@ -95,15 +95,20 @@ it('updates the document when event is received', async () => { expect(mockAddEventListener).toHaveBeenCalledTimes(1); expect(mockDocumentHandler).not.toHaveBeenCalled(); - expect(mockSendMessage).toHaveBeenCalledWith( - JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'setState', - params: [{ ...initialData.state, __queryParams: {} }], - }), - [] + // Verify setState was called with URL state fields + expect(mockSendMessage).toHaveBeenCalledTimes(1); + const setStatePayload = JSON.parse( + mockSendMessage.mock.calls[0][0] as string ); + expect(setStatePayload.method).toBe('setState'); + expect(setStatePayload.params[0]).toMatchObject({ + ...initialData.state, + __queryParams: {}, + }); + expect(setStatePayload.params[0]).toHaveProperty('__path'); + expect(setStatePayload.params[0]).toHaveProperty('__absolutePath'); + expect(setStatePayload.params[0]).toHaveProperty('__fragment'); + expect(setStatePayload.params[0]).toHaveProperty('__href'); const listener = mockAddEventListener.mock.calls[0][1]; @@ -182,15 +187,13 @@ it('updates the initial data only when widget has changed', async () => { ); expect(addEventListener).toHaveBeenCalledTimes(1); expect(mockDocumentHandler).not.toHaveBeenCalled(); - expect(sendMessage).toHaveBeenCalledWith( - JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'setState', - params: [{ ...data1.state, __queryParams: {} }], - }), - [] - ); + // Verify setState was called with URL state + const setStatePayload1 = JSON.parse(sendMessage.mock.calls[0][0] as string); + expect(setStatePayload1.method).toBe('setState'); + expect(setStatePayload1.params[0]).toMatchObject({ + ...data1.state, + __queryParams: {}, + }); let listener = addEventListener.mock.calls[0][1]; @@ -257,15 +260,12 @@ it('updates the initial data only when widget has changed', async () => { // eslint-disable-next-line prefer-destructuring listener = addEventListener.mock.calls[0][1]; - expect(sendMessage).toHaveBeenCalledWith( - JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'setState', - params: [{ ...data2.state, __queryParams: {} }], - }), - [] - ); + const setStatePayload2 = JSON.parse(sendMessage.mock.calls[0][0] as string); + expect(setStatePayload2.method).toBe('setState'); + expect(setStatePayload2.params[0]).toMatchObject({ + ...data2.state, + __queryParams: {}, + }); expect(sendMessage).toHaveBeenCalledTimes(1); // Send the initial document @@ -318,15 +318,12 @@ it('handles rendering widget error if widget is null (query disconnected)', asyn ); expect(mockAddEventListener).toHaveBeenCalledTimes(1); expect(mockDocumentHandler).not.toHaveBeenCalled(); - expect(sendMessage).toHaveBeenCalledWith( - JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'setState', - params: [{ ...data1.state, __queryParams: {} }], - }), - [] - ); + const setStatePayloadErr = JSON.parse(sendMessage.mock.calls[0][0] as string); + expect(setStatePayloadErr.method).toBe('setState'); + expect(setStatePayloadErr.params[0]).toMatchObject({ + ...data1.state, + __queryParams: {}, + }); const listener = mockAddEventListener.mock.calls[0][1]; @@ -441,20 +438,12 @@ describe('URL state in sendSetState', () => { makeWidgetHandler({ widgetDescriptor: widget, initialData }) ); - expect(mockSendMessage).toHaveBeenCalledWith( - JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'setState', - params: [ - { - key: 'val', - __queryParams: { foo: ['bar'], baz: ['qux'] }, - }, - ], - }), - [] - ); + const payload = JSON.parse(mockSendMessage.mock.calls[0][0] as string); + expect(payload.method).toBe('setState'); + expect(payload.params[0]).toMatchObject({ + key: 'val', + __queryParams: { foo: ['bar'], baz: ['qux'] }, + }); unmount(); @@ -488,15 +477,11 @@ describe('URL state in sendSetState', () => { makeWidgetHandler({ widgetDescriptor: widget, initialData: undefined }) ); - expect(mockSendMessage).toHaveBeenCalledWith( - JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'setState', - params: [{ __queryParams: { tag: ['python', 'java'] } }], - }), - [] - ); + const payload = JSON.parse(mockSendMessage.mock.calls[0][0] as string); + expect(payload.method).toBe('setState'); + expect(payload.params[0]).toMatchObject({ + __queryParams: { tag: ['python', 'java'] }, + }); unmount(); @@ -669,6 +654,135 @@ describe('navigate event handling', () => { unmount(); }); + + it('navigates with path', async () => { + Object.defineProperty(window, 'location', { + value: new URL( + 'http://localhost/app/widget/local/dashboard/-/old-page?q=1#sec' + ), + writable: true, + }); + + const { listener, unmount } = await setupWidgetWithListener(); + + await act(async () => { + listener( + makeWidgetEventMethodEvent('navigate.event', { + path: '/new-page', + }) + ); + }); + + expect(window.history.replaceState).toHaveBeenCalledWith( + null, + '', + '/app/widget/local/dashboard/-/new-page?q=1#sec' + ); + + unmount(); + }); + + it('navigates with fragment', async () => { + const { listener, unmount } = await setupWidgetWithListener(); + + await act(async () => { + listener( + makeWidgetEventMethodEvent('navigate.event', { + fragment: 'section-2', + }) + ); + }); + + expect(window.history.replaceState).toHaveBeenCalledWith( + null, + '', + '/app/widget/local/dashboard#section-2' + ); + + unmount(); + }); + + it('clears fragment when empty string', async () => { + Object.defineProperty(window, 'location', { + value: new URL('http://localhost/app/widget/local/dashboard#old-frag'), + writable: true, + }); + + const { listener, unmount } = await setupWidgetWithListener(); + + await act(async () => { + listener( + makeWidgetEventMethodEvent('navigate.event', { + fragment: '', + }) + ); + }); + + expect(window.history.replaceState).toHaveBeenCalledWith( + null, + '', + '/app/widget/local/dashboard' + ); + + unmount(); + }); + + it('navigates with path, query params, and fragment', async () => { + Object.defineProperty(window, 'location', { + value: new URL('http://localhost/app/widget/local/dashboard/-/old'), + writable: true, + }); + + const { listener, unmount } = await setupWidgetWithListener(); + + await act(async () => { + listener( + makeWidgetEventMethodEvent('navigate.event', { + path: '/settings', + queryParams: '?tab=1', + fragment: 'top', + }) + ); + }); + + expect(window.history.replaceState).toHaveBeenCalledWith( + null, + '', + '/app/widget/local/dashboard/-/settings?tab=1#top' + ); + + unmount(); + }); + + it('sends extended URL state after navigation', async () => { + const { listener, mockSendMessage, unmount } = + await setupWidgetWithListener(); + + await act(async () => { + listener( + makeWidgetEventMethodEvent('navigate.event', { + path: '/page', + queryParams: 'x=1', + fragment: 'sec', + }) + ); + }); + + const calls = mockSendMessage.mock.calls.map((c: unknown[]) => + JSON.parse(c[0] as string) + ); + const urlStateCall = calls.find( + (c: { method: string }) => c.method === 'setUrlState' + ); + expect(urlStateCall).toBeDefined(); + expect(urlStateCall.params[0]).toHaveProperty('__queryParams'); + expect(urlStateCall.params[0]).toHaveProperty('__path'); + expect(urlStateCall.params[0]).toHaveProperty('__absolutePath'); + expect(urlStateCall.params[0]).toHaveProperty('__fragment'); + expect(urlStateCall.params[0]).toHaveProperty('__href'); + + unmount(); + }); }); describe('popstate listener', () => { @@ -689,6 +803,10 @@ describe('popstate listener', () => { ); expect(urlStateCall).toBeDefined(); expect(urlStateCall.params[0]).toHaveProperty('__queryParams'); + expect(urlStateCall.params[0]).toHaveProperty('__path'); + expect(urlStateCall.params[0]).toHaveProperty('__absolutePath'); + expect(urlStateCall.params[0]).toHaveProperty('__fragment'); + expect(urlStateCall.params[0]).toHaveProperty('__href'); unmount(); }); diff --git a/plugins/ui/src/js/src/widget/WidgetHandler.tsx b/plugins/ui/src/js/src/widget/WidgetHandler.tsx index 487dcdd70..25c9b7904 100644 --- a/plugins/ui/src/js/src/widget/WidgetHandler.tsx +++ b/plugins/ui/src/js/src/widget/WidgetHandler.tsx @@ -54,7 +54,18 @@ import WidgetStatusContext, { import WidgetErrorView from './WidgetErrorView'; import ReactPanel from '../layout/ReactPanel'; import Toast, { TOAST_EVENT } from '../events/Toast'; -import Navigate, { NAVIGATE_EVENT, QUERY_PARAM } from '../events/Navigate'; +import Navigate, { + NAVIGATE_EVENT, + type NavigateParams, + URL_CHANGED_EVENT, + QUERY_PARAM, + PATH_PARAM, + ABSOLUTE_PATH_PARAM, + FRAGMENT_PARAM, + HREF_PARAM, + getWidgetRelativePath, +} from '../events/Navigate'; +import NavigateContext from '../events/NavigateContext'; import UriExportedObject from './UriExportedObject'; import applyJsonPatch from './WidgetJsonPatch'; @@ -165,6 +176,10 @@ function WidgetHandler({ }); return { [QUERY_PARAM]: queryParams, + [PATH_PARAM]: getWidgetRelativePath(), + [ABSOLUTE_PATH_PARAM]: window.location.pathname, + [FRAGMENT_PARAM]: window.location.hash.replace(/^#/, ''), + [HREF_PARAM]: window.location.href, }; }, []); @@ -206,6 +221,14 @@ function WidgetHandler({ ); }, [jsonClient, getUrlState]); + /** + * Navigate and send updated URL state to the backend. + * Provided to child components via NavigateContext. + */ + const handleNavigate = useCallback((params: NavigateParams) => { + Navigate(params); + }, []); + const callableFinalizationRegistry = useMemo( () => new FinalizationRegistry(callableId => { @@ -462,8 +485,6 @@ function WidgetHandler({ break; case NAVIGATE_EVENT: Navigate(eventParams); - // Re-send URL state to backend after navigation - sendUrlState(); break; default: throw new Error(`Unknown event ${name}`); @@ -479,28 +500,25 @@ function WidgetHandler({ jsonClient.rejectAllPendingRequests('Widget was changed'); }; }, - [ - jsonClient, - onDataChange, - sendUrlState, - callableFinalizationRegistry, - sendSetState, - ] + [jsonClient, onDataChange, callableFinalizationRegistry, sendSetState] ); /** - * Listen for popstate events so that when the user clicks the back button - * after a client-side navigation, we can update the URL state in the backend - * and re-render with the correct URL state. + * Listen for URL changes from any source: + * - popstate: browser back/forward buttons + * - URL_CHANGED_EVENT: programmatic navigation via Navigate() + * All widget handlers listen so every widget stays in sync. */ useEffect( - function listenForPopstate() { - const handlePopstate = () => { + function listenForUrlChanges() { + const handleUrlChange = () => { sendUrlState(); }; - window.addEventListener('popstate', handlePopstate); + window.addEventListener('popstate', handleUrlChange); + window.addEventListener(URL_CHANGED_EVENT, handleUrlChange); return () => { - window.removeEventListener('popstate', handlePopstate); + window.removeEventListener('popstate', handleUrlChange); + window.removeEventListener(URL_CHANGED_EVENT, handleUrlChange); }; }, [sendUrlState] @@ -591,16 +609,18 @@ function WidgetHandler({ }, [error, widgetDescriptor, isLoading]); return renderedDocument != null ? ( - - - {renderedDocument} - - + + + + {renderedDocument} + + + ) : null; } diff --git a/plugins/ui/src/js/src/widget/WidgetUtils.tsx b/plugins/ui/src/js/src/widget/WidgetUtils.tsx index 00b00b1d0..136b32f30 100644 --- a/plugins/ui/src/js/src/widget/WidgetUtils.tsx +++ b/plugins/ui/src/js/src/widget/WidgetUtils.tsx @@ -20,7 +20,6 @@ import { Footer, Heading, Item, - Link, ListActionGroup, ListActionMenu, MenuTrigger, @@ -105,6 +104,7 @@ import { ToggleButton, UITable, Tabs, + Link, } from '../elements'; import UriObjectView from '../elements/UriObjectView'; diff --git a/plugins/ui/test/deephaven/ui/test_routing.py b/plugins/ui/test/deephaven/ui/test_routing.py new file mode 100644 index 000000000..dbd02fc5d --- /dev/null +++ b/plugins/ui/test/deephaven/ui/test_routing.py @@ -0,0 +1,741 @@ +from __future__ import annotations + +from typing import Any, Dict, List +from unittest.mock import Mock + +from deephaven.ui._internal.RenderContext import RenderContext, OnChangeCallable +from deephaven.ui._internal.EventContext import EventContext +from .BaseTest import BaseTestCase +from .test_utils_root import TestRoot + +run_on_change: OnChangeCallable = lambda x: x() + + +def make_render_context( + on_change: OnChangeCallable = run_on_change, + on_queue: OnChangeCallable = run_on_change, +) -> RenderContext: + return RenderContext(TestRoot(on_change, on_queue)) + + +# ──────────────────────────────────────────────────────────────────── +# RenderContext — URL state import / export +# ──────────────────────────────────────────────────────────────────── + + +class UrlStateRenderContextTestCase(BaseTestCase): + """Tests for extended URL state on RenderContext.""" + + def test_default_path_is_root(self): + rc = make_render_context() + self.assertEqual(rc.get_path(), "/") + + def test_default_absolute_path_is_root(self): + rc = make_render_context() + self.assertEqual(rc.get_absolute_path(), "/") + + def test_default_fragment_is_empty(self): + rc = make_render_context() + self.assertEqual(rc.get_fragment(), "") + + def test_default_href_is_empty(self): + rc = make_render_context() + self.assertEqual(rc.get_href(), "") + + def test_import_state_with_path(self): + rc = make_render_context() + state: Dict[str, Any] = { + "__path": "/dashboard/settings", + } + rc.import_state(state) + self.assertEqual(rc.get_path(), "/dashboard/settings") + + def test_import_state_with_absolute_path(self): + rc = make_render_context() + state: Dict[str, Any] = { + "__absolutePath": "/iriside/embed/widget/q/w/-/dashboard", + } + rc.import_state(state) + self.assertEqual( + rc.get_absolute_path(), + "/iriside/embed/widget/q/w/-/dashboard", + ) + + def test_import_state_with_fragment(self): + rc = make_render_context() + state: Dict[str, Any] = { + "__fragment": "section-2", + } + rc.import_state(state) + self.assertEqual(rc.get_fragment(), "section-2") + + def test_import_state_with_href(self): + rc = make_render_context() + state: Dict[str, Any] = { + "__href": "https://example.com/widget/-/page?q=1#sec", + } + rc.import_state(state) + self.assertEqual( + rc.get_href(), + "https://example.com/widget/-/page?q=1#sec", + ) + + def test_import_state_all_url_fields(self): + rc = make_render_context() + state: Dict[str, Any] = { + "__queryParams": {"page": ["1"]}, + "__path": "/dashboard", + "__absolutePath": "/app/-/dashboard", + "__fragment": "top", + "__href": "https://example.com/app/-/dashboard?page=1#top", + } + rc.import_state(state) + self.assertEqual(rc.get_query_params(), {"page": ["1"]}) + self.assertEqual(rc.get_path(), "/dashboard") + self.assertEqual(rc.get_absolute_path(), "/app/-/dashboard") + self.assertEqual(rc.get_fragment(), "top") + self.assertEqual( + rc.get_href(), + "https://example.com/app/-/dashboard?page=1#top", + ) + + def test_import_state_preserves_path_when_absent(self): + rc = make_render_context() + rc.import_state({"__path": "/page1"}) + self.assertEqual(rc.get_path(), "/page1") + # Import without __path should preserve + rc.import_state({}) + self.assertEqual(rc.get_path(), "/page1") + + def test_url_fields_not_in_exported_state(self): + rc = make_render_context() + state: Dict[str, Any] = { + "__path": "/dashboard", + "__absolutePath": "/app/-/dashboard", + "__fragment": "top", + "__href": "https://example.com/app/-/dashboard#top", + "state": {"0": 42}, + } + rc.import_state(state) + exported = rc.export_state() + self.assertNotIn("__path", exported) + self.assertNotIn("__absolutePath", exported) + self.assertNotIn("__fragment", exported) + self.assertNotIn("__href", exported) + + def test_child_context_reads_url_state_from_root(self): + rc = make_render_context() + rc.import_state( + { + "__path": "/users", + "__fragment": "details", + } + ) + with rc.open(): + child = rc.get_child_context("child0") + self.assertEqual(child.get_path(), "/users") + self.assertEqual(child.get_fragment(), "details") + + +# ──────────────────────────────────────────────────────────────────── +# use_path +# ──────────────────────────────────────────────────────────────────── + + +class UsePathTestCase(BaseTestCase): + """Tests for the use_path hook.""" + + def test_returns_relative_path(self): + from deephaven.ui.hooks.use_path import use_path + + rc = make_render_context() + rc.import_state({"__path": "/dashboard/settings"}) + with rc.open(): + result = use_path() + self.assertEqual(result, "/dashboard/settings") + + def test_returns_root_when_no_path(self): + from deephaven.ui.hooks.use_path import use_path + + rc = make_render_context() + rc.import_state({}) + with rc.open(): + result = use_path() + self.assertEqual(result, "/") + + def test_returns_absolute_path(self): + from deephaven.ui.hooks.use_path import use_path + + rc = make_render_context() + rc.import_state( + { + "__absolutePath": "/iriside/embed/widget/q/w/-/page", + } + ) + with rc.open(): + result = use_path(absolute=True) + self.assertEqual(result, "/iriside/embed/widget/q/w/-/page") + + +# ──────────────────────────────────────────────────────────────────── +# use_navigate +# ──────────────────────────────────────────────────────────────────── + + +class UseNavigateTestCase(BaseTestCase): + """Tests for the use_navigate hook.""" + + def _setup_context_with_event(self, state=None): + rc = make_render_context() + if state: + rc.import_state(state) + else: + rc.import_state({}) + send_event_mock = Mock() + ec = EventContext(send_event_mock) + return rc, ec, send_event_mock + + def test_navigate_path_only(self): + from deephaven.ui.hooks.use_navigate import use_navigate + + rc, ec, mock = self._setup_context_with_event() + with rc.open(), ec.open(): + navigate = use_navigate() + navigate("/dashboard") + + mock.assert_called_once() + name, payload = mock.call_args[0] + self.assertEqual(name, "navigate.event") + self.assertEqual(payload["path"], "/dashboard") + # When path is provided and query_params/fragment omitted, they are cleared + self.assertEqual(payload["queryParams"], "") + self.assertEqual(payload["fragment"], "") + + def test_navigate_path_with_inline_query(self): + from deephaven.ui.hooks.use_navigate import use_navigate + + rc, ec, mock = self._setup_context_with_event() + with rc.open(), ec.open(): + navigate = use_navigate() + navigate("/dashboard?tab=1#section") + + _, payload = mock.call_args[0] + self.assertEqual(payload["path"], "/dashboard") + self.assertEqual(payload["queryParams"], "?tab=1") + self.assertEqual(payload["fragment"], "section") + + def test_navigate_explicit_overrides_inline(self): + from deephaven.ui.hooks.use_navigate import use_navigate + + rc, ec, mock = self._setup_context_with_event() + with rc.open(), ec.open(): + navigate = use_navigate() + navigate( + "/page?inline=val#inline_frag", + query_params={"explicit": ["true"]}, + fragment="explicit_frag", + ) + + _, payload = mock.call_args[0] + self.assertEqual(payload["path"], "/page") + self.assertEqual(payload["queryParams"], "?explicit=true") + self.assertEqual(payload["fragment"], "explicit_frag") + + def test_navigate_query_only_preserves_path(self): + from deephaven.ui.hooks.use_navigate import use_navigate + + rc, ec, mock = self._setup_context_with_event() + with rc.open(), ec.open(): + navigate = use_navigate() + navigate(query_params={"tag": ["python", "java"]}) + + _, payload = mock.call_args[0] + self.assertNotIn("path", payload) + self.assertEqual(payload["queryParams"], "?tag=python&tag=java") + # Fragment should be preserved (not in payload) + self.assertNotIn("fragment", payload) + + def test_navigate_fragment_only(self): + from deephaven.ui.hooks.use_navigate import use_navigate + + rc, ec, mock = self._setup_context_with_event() + with rc.open(), ec.open(): + navigate = use_navigate() + navigate(fragment="section-2") + + _, payload = mock.call_args[0] + self.assertNotIn("path", payload) + self.assertNotIn("queryParams", payload) + self.assertEqual(payload["fragment"], "section-2") + + def test_navigate_clear_query_params(self): + from deephaven.ui.hooks.use_navigate import use_navigate + + rc, ec, mock = self._setup_context_with_event() + with rc.open(), ec.open(): + navigate = use_navigate() + navigate(query_params="") + + _, payload = mock.call_args[0] + self.assertEqual(payload["queryParams"], "") + + def test_navigate_clear_query_params_empty_dict(self): + from deephaven.ui.hooks.use_navigate import use_navigate + + rc, ec, mock = self._setup_context_with_event() + with rc.open(), ec.open(): + navigate = use_navigate() + navigate(query_params={}) + + _, payload = mock.call_args[0] + self.assertEqual(payload["queryParams"], "") + + def test_navigate_clear_fragment(self): + from deephaven.ui.hooks.use_navigate import use_navigate + + rc, ec, mock = self._setup_context_with_event() + with rc.open(), ec.open(): + navigate = use_navigate() + navigate(fragment="") + + _, payload = mock.call_args[0] + self.assertEqual(payload["fragment"], "") + + def test_navigate_replace_false(self): + from deephaven.ui.hooks.use_navigate import use_navigate + + rc, ec, mock = self._setup_context_with_event() + with rc.open(), ec.open(): + navigate = use_navigate() + navigate("/settings", replace=False) + + _, payload = mock.call_args[0] + self.assertFalse(payload["replace"]) + + def test_navigate_replace_true(self): + from deephaven.ui.hooks.use_navigate import use_navigate + + rc, ec, mock = self._setup_context_with_event() + with rc.open(), ec.open(): + navigate = use_navigate() + navigate("/settings", replace=True) + + _, payload = mock.call_args[0] + self.assertTrue(payload["replace"]) + + def test_navigate_no_args_raises(self): + from deephaven.ui.hooks.use_navigate import use_navigate + + rc, ec, mock = self._setup_context_with_event() + with rc.open(), ec.open(): + navigate = use_navigate() + with self.assertRaises(ValueError): + navigate() + + def test_navigate_empty_path_raises(self): + from deephaven.ui.hooks.use_navigate import use_navigate + + rc, ec, mock = self._setup_context_with_event() + with rc.open(), ec.open(): + navigate = use_navigate() + with self.assertRaises(ValueError): + navigate("") + + def test_navigate_leading_slash_optional(self): + from deephaven.ui.hooks.use_navigate import use_navigate + + rc, ec, mock = self._setup_context_with_event() + with rc.open(), ec.open(): + navigate = use_navigate() + navigate("dashboard") + + _, payload = mock.call_args[0] + self.assertEqual(payload["path"], "/dashboard") + + def test_navigate_strip_leading_hash(self): + from deephaven.ui.hooks.use_navigate import use_navigate + + rc, ec, mock = self._setup_context_with_event() + with rc.open(), ec.open(): + navigate = use_navigate() + navigate(fragment="#section") + + _, payload = mock.call_args[0] + self.assertEqual(payload["fragment"], "section") + + def test_navigate_strip_leading_question_mark(self): + from deephaven.ui.hooks.use_navigate import use_navigate + + rc, ec, mock = self._setup_context_with_event() + with rc.open(), ec.open(): + navigate = use_navigate() + navigate(query_params="?foo=bar") + + _, payload = mock.call_args[0] + self.assertEqual(payload["queryParams"], "?foo=bar") + + +# ──────────────────────────────────────────────────────────────────── +# use_url_components +# ──────────────────────────────────────────────────────────────────── + + +class UseUrlComponentsTestCase(BaseTestCase): + """Tests for the use_url_components hook.""" + + def test_returns_split_result(self): + from deephaven.ui.hooks.use_url_components import use_url_components + + rc = make_render_context() + rc.import_state( + { + "__href": "https://example.com:8080/app/-/page?q=1&tag=py#top", + } + ) + with rc.open(): + result = use_url_components() + self.assertEqual(result.scheme, "https") + self.assertEqual(result.netloc, "example.com:8080") + self.assertEqual(result.path, "/app/-/page") + self.assertEqual(result.query, "q=1&tag=py") + self.assertEqual(result.fragment, "top") + + def test_returns_empty_for_no_href(self): + from deephaven.ui.hooks.use_url_components import use_url_components + + rc = make_render_context() + rc.import_state({}) + with rc.open(): + result = use_url_components() + self.assertEqual(result.scheme, "") + self.assertEqual(result.netloc, "") + self.assertEqual(result.path, "") + self.assertEqual(result.query, "") + self.assertEqual(result.fragment, "") + + +# ──────────────────────────────────────────────────────────────────── +# ui.route +# ──────────────────────────────────────────────────────────────────── + + +class RouteTestCase(BaseTestCase): + """Tests for the route() factory function.""" + + def test_basic_route(self): + from deephaven.ui.components.route import route + + def my_element(): + return None + + r = route(path="/users", element=my_element) + self.assertEqual(r.path, "/users") + self.assertEqual(r.element, my_element) + self.assertIsNone(r.children) + self.assertFalse(r.index) + + def test_index_route(self): + from deephaven.ui.components.route import route + + def my_element(): + return None + + r = route(index=True, element=my_element) + self.assertIsNone(r.path) + self.assertTrue(r.index) + + def test_path_and_index_raises(self): + from deephaven.ui.components.route import route + + with self.assertRaises(ValueError): + route(path="/users", index=True) + + def test_nested_children(self): + from deephaven.ui.components.route import route + + child = route(path="{user_id}") + parent = route(child, path="users") + self.assertEqual(len(parent.children), 1) + self.assertEqual(parent.children[0].path, "{user_id}") + + +# ──────────────────────────────────────────────────────────────────── +# ui.router — route compilation and matching +# ──────────────────────────────────────────────────────────────────── + + +class RouterMatchingTestCase(BaseTestCase): + """Tests for the router's internal route compilation and matching.""" + + def test_compile_simple_routes(self): + from deephaven.ui.components.route import _Route + from deephaven.ui.components.router import _compile_routes + + def home(): + return None + + def about(): + return None + + routes = [ + _Route(path="home", element=home), + _Route(path="about", element=about), + ] + compiled = _compile_routes(routes) + patterns = [(c[0], c[1]) for c in compiled] + self.assertIn(("home", home), patterns) + self.assertIn(("about", about), patterns) + + def test_compile_nested_routes(self): + from deephaven.ui.components.route import _Route + from deephaven.ui.components.router import _compile_routes + + def user_profile(): + return None + + def user_post(): + return None + + routes = [ + _Route( + path="users", + children=[ + _Route( + path="{user_id}", + element=user_profile, + children=[ + _Route(path="posts/{post_id}", element=user_post), + ], + ), + ], + ), + ] + compiled = _compile_routes(routes) + patterns = [c[0] for c in compiled] + self.assertIn("users/{user_id}", patterns) + self.assertIn("users/{user_id}/posts/{post_id}", patterns) + + def test_match_static_path(self): + from deephaven.ui.components.router import _compile_routes, _match_route + from deephaven.ui.components.route import _Route + + def home(): + return None + + compiled = _compile_routes([_Route(path="home", element=home)]) + result = _match_route("/home", compiled) + self.assertIsNotNone(result) + self.assertEqual(result[0], home) + self.assertEqual(result[1], {}) + + def test_match_parameterized_path(self): + from deephaven.ui.components.router import _compile_routes, _match_route + from deephaven.ui.components.route import _Route + + def user(): + return None + + compiled = _compile_routes( + [ + _Route(path="users/{user_id}", element=user), + ] + ) + result = _match_route("/users/42", compiled) + self.assertIsNotNone(result) + self.assertEqual(result[0], user) + self.assertEqual(result[1], {"user_id": "42"}) + + def test_match_nested_params(self): + from deephaven.ui.components.router import _compile_routes, _match_route + from deephaven.ui.components.route import _Route + + def user_post(): + return None + + compiled = _compile_routes( + [ + _Route( + path="users", + children=[ + _Route( + path="{user_id}", + children=[ + _Route(path="posts/{post_id}", element=user_post), + ], + ), + ], + ), + ] + ) + result = _match_route("/users/42/posts/7", compiled) + self.assertIsNotNone(result) + self.assertEqual(result[1], {"user_id": "42", "post_id": "7"}) + + def test_static_preferred_over_param(self): + from deephaven.ui.components.router import _compile_routes, _match_route + from deephaven.ui.components.route import _Route + + def settings(): + return "settings" + + def user(): + return "user" + + compiled = _compile_routes( + [ + _Route(path="users/settings", element=settings), + _Route(path="users/{user_id}", element=user), + ] + ) + result = _match_route("/users/settings", compiled) + self.assertIsNotNone(result) + self.assertEqual(result[0], settings) + + def test_wildcard_lowest_priority(self): + from deephaven.ui.components.router import _compile_routes, _match_route + from deephaven.ui.components.route import _Route + + def home(): + return "home" + + def not_found(): + return "not_found" + + compiled = _compile_routes( + [ + _Route(path="home", element=home), + _Route(path="*", element=not_found), + ] + ) + # /home should match home, not wildcard + result = _match_route("/home", compiled) + self.assertEqual(result[0], home) + + # /anything-else should match wildcard + result = _match_route("/anything-else", compiled) + self.assertEqual(result[0], not_found) + self.assertEqual(result[1], {"*": "anything-else"}) + + def test_wildcard_matches_deep_path(self): + from deephaven.ui.components.router import _compile_routes, _match_route + from deephaven.ui.components.route import _Route + + def catch_all(): + return None + + compiled = _compile_routes([_Route(path="*", element=catch_all)]) + result = _match_route("/a/b/c", compiled) + self.assertIsNotNone(result) + self.assertEqual(result[1], {"*": "a/b/c"}) + + def test_index_route_matches_parent_exact(self): + from deephaven.ui.components.router import _compile_routes, _match_route + from deephaven.ui.components.route import _Route + + def index(): + return "index" + + def child(): + return "child" + + compiled = _compile_routes( + [ + _Route( + path="users", + children=[ + _Route(index=True, element=index), + _Route(path="{user_id}", element=child), + ], + ), + ] + ) + # /users should match the index route + result = _match_route("/users", compiled) + self.assertIsNotNone(result) + self.assertEqual(result[0], index) + + def test_optional_param(self): + from deephaven.ui.components.router import _compile_routes, _match_route + from deephaven.ui.components.route import _Route + + def user_page(): + return None + + compiled = _compile_routes( + [ + _Route(path="users/{tab?}", element=user_page), + ] + ) + # With optional segment present + result = _match_route("/users/settings", compiled) + self.assertIsNotNone(result) + self.assertEqual(result[1], {"tab": "settings"}) + + # With optional segment absent + result = _match_route("/users", compiled) + self.assertIsNotNone(result) + self.assertEqual(result[1], {"tab": ""}) + + def test_no_match_returns_none(self): + from deephaven.ui.components.router import _compile_routes, _match_route + from deephaven.ui.components.route import _Route + + def home(): + return None + + compiled = _compile_routes([_Route(path="home", element=home)]) + result = _match_route("/nonexistent", compiled) + self.assertIsNone(result) + + def test_root_path_match(self): + from deephaven.ui.components.router import _compile_routes, _match_route + from deephaven.ui.components.route import _Route + + def dashboard(): + return None + + compiled = _compile_routes( + [ + _Route(index=True, element=dashboard), + ] + ) + result = _match_route("/", compiled) + self.assertIsNotNone(result) + self.assertEqual(result[0], dashboard) + + def test_conflict_detection(self): + from deephaven.ui.components.router import _compile_routes, _check_conflicts + from deephaven.ui.components.route import _Route + + def a(): + return None + + def b(): + return None + + compiled = _compile_routes( + [ + _Route(path="home", element=a), + _Route(path="home", element=b), + ] + ) + with self.assertRaises(ValueError): + _check_conflicts(compiled) + + +# ──────────────────────────────────────────────────────────────────── +# use_params +# ──────────────────────────────────────────────────────────────────── + + +class UseParamsTestCase(BaseTestCase): + """Tests for the use_params hook.""" + + def test_returns_empty_dict_without_router(self): + from deephaven.ui.hooks.use_params import use_params + + rc = make_render_context() + rc.import_state({}) + with rc.open(): + result = use_params() + self.assertEqual(result, {}) diff --git a/plugins/ui/test/deephaven/ui/test_utils_root.py b/plugins/ui/test/deephaven/ui/test_utils_root.py index 5551f009f..38c58c89e 100644 --- a/plugins/ui/test/deephaven/ui/test_utils_root.py +++ b/plugins/ui/test/deephaven/ui/test_utils_root.py @@ -19,6 +19,10 @@ def __init__( self._on_change = on_change self._on_queue_render = on_queue_render self._query_params: Dict[str, List[str]] = {} + self._path: str = "/" + self._absolute_path: str = "/" + self._fragment: str = "" + self._href: str = "" def on_change(self, update: Callable[[], None]) -> None: self._on_change(update) @@ -31,3 +35,27 @@ def get_query_params(self) -> Dict[str, List[str]]: def set_query_params(self, query_params: Dict[str, List[str]]) -> None: self._query_params = query_params + + def get_path(self) -> str: + return self._path + + def set_path(self, path: str) -> None: + self._path = path + + def get_absolute_path(self) -> str: + return self._absolute_path + + def set_absolute_path(self, absolute_path: str) -> None: + self._absolute_path = absolute_path + + def get_fragment(self) -> str: + return self._fragment + + def set_fragment(self, fragment: str) -> None: + self._fragment = fragment + + def get_href(self) -> str: + return self._href + + def set_href(self, href: str) -> None: + self._href = href diff --git a/tests/app.d/ui_routing.py b/tests/app.d/ui_routing.py new file mode 100644 index 000000000..e6e4a90e0 --- /dev/null +++ b/tests/app.d/ui_routing.py @@ -0,0 +1,148 @@ +from typing import Any + +from deephaven import ui + + +@ui.component +def ui_use_path_component(): + """Displays the current path for e2e verification.""" + path = ui.use_path() + abs_path = ui.use_path(absolute=True) + + return ui.panel( + ui.flex( + ui.text(f"path={path}"), + ui.text(f"absolute_path={abs_path}"), + direction="column", + ), + title="Use Path", + ) + + +@ui.component +def ui_use_navigate_component(): + """Has buttons that trigger navigation for e2e verification.""" + path = ui.use_path() + navigate = ui.use_navigate() + + def go_dashboard(_event: Any): + navigate("/dashboard") + + def go_settings_push(_event: Any): + navigate("/settings", replace=False) + + def go_with_query(_event: Any): + navigate("/page", query_params={"tab": ["1"]}) + + def go_with_fragment(_event: Any): + navigate(fragment="section-2") + + def clear_query(_event: Any): + navigate(query_params="") + + return ui.panel( + ui.flex( + ui.text(f"current_path={path}"), + ui.action_button("Go Dashboard", on_press=go_dashboard), + ui.action_button("Go Settings (push)", on_press=go_settings_push), + ui.action_button("Go with query", on_press=go_with_query), + ui.action_button("Go with fragment", on_press=go_with_fragment), + ui.action_button("Clear query", on_press=clear_query), + direction="column", + ), + title="Use Navigate", + ) + + +@ui.component +def ui_link_to_component(): + """Has links with the `to` prop for e2e verification.""" + path = ui.use_path() + return ui.panel( + ui.flex( + ui.text(f"current_path={path}"), + ui.link("Go Home", to="/"), + ui.link("Go Search", to="/search?q=hello#results"), + ui.link( + "Go Users", + to={"path": "/users", "query_params": {"sort": "name"}}, + ), + direction="column", + ), + title="Link To", + ) + + +@ui.component +def user_profile(): + params = ui.use_params() + user_id = params.get("user_id", "unknown") + return ui.text(f"user_id={user_id}") + + +@ui.component +def user_post(): + params = ui.use_params() + user_id = params.get("user_id", "unknown") + post_id = params.get("post_id", "unknown") + return ui.text(f"user_id={user_id},post_id={post_id}") + + +@ui.component +def user_list(): + return ui.text("user_list") + + +@ui.component +def dashboard_home(): + return ui.text("dashboard_home") + + +@ui.component +def not_found(): + return ui.text("not_found") + + +@ui.component +def ui_router_component(): + """A router component for e2e verification.""" + return ui.panel( + ui.router( + ui.route( + ui.route(index=True, element=user_list), + ui.route( + ui.route(path="posts/{post_id}", element=user_post), + path="{user_id}", + element=user_profile, + ), + path="users", + ), + ui.route(index=True, element=dashboard_home), + ui.route(path="*", element=not_found), + ), + title="Router", + ) + + +@ui.component +def ui_url_components_component(): + """Displays URL components for e2e verification.""" + url = ui.use_url_components() + return ui.panel( + ui.flex( + ui.text(f"scheme={url.scheme}"), + ui.text(f"netloc={url.netloc}"), + ui.text(f"path={url.path}"), + ui.text(f"query={url.query}"), + ui.text(f"fragment={url.fragment}"), + direction="column", + ), + title="URL Components", + ) + + +ui_use_path = ui_use_path_component() +ui_use_navigate = ui_use_navigate_component() +ui_link_to = ui_link_to_component() +ui_router = ui_router_component() +ui_url_components = ui_url_components_component() diff --git a/tests/ui_routing.spec.ts b/tests/ui_routing.spec.ts new file mode 100644 index 000000000..36939623e --- /dev/null +++ b/tests/ui_routing.spec.ts @@ -0,0 +1,86 @@ +import { expect, test } from '@playwright/test'; +import { openPanel, gotoPage, SELECTORS } from './utils'; + +test.describe('UI routing - use_path', () => { + test('displays the current path', async ({ page }) => { + await gotoPage(page, ''); + await openPanel(page, 'ui_use_path', SELECTORS.REACT_PANEL_VISIBLE); + + const panel = page.locator(SELECTORS.REACT_PANEL_VISIBLE); + await expect(panel.getByText('path=/')).toBeVisible(); + }); +}); + +test.describe('UI routing - use_navigate', () => { + test('navigates to a path when button is clicked', async ({ page }) => { + await gotoPage(page, ''); + await openPanel(page, 'ui_use_navigate', SELECTORS.REACT_PANEL_VISIBLE); + + const panel = page.locator(SELECTORS.REACT_PANEL_VISIBLE); + await expect(panel.getByText('current_path=/')).toBeVisible(); + + await panel.getByRole('button', { name: 'Go Dashboard' }).click(); + + await expect(panel.getByText('current_path=/dashboard')).toBeVisible(); + }); + + test('navigates with query params', async ({ page }) => { + await gotoPage(page, ''); + await openPanel(page, 'ui_use_navigate', SELECTORS.REACT_PANEL_VISIBLE); + + const panel = page.locator(SELECTORS.REACT_PANEL_VISIBLE); + await panel.getByRole('button', { name: 'Go with query' }).click(); + + await expect(panel.getByText('current_path=/page')).toBeVisible(); + await expect(page).toHaveURL(/tab=1/); + }); + + test('navigates with fragment only', async ({ page }) => { + await gotoPage(page, ''); + await openPanel(page, 'ui_use_navigate', SELECTORS.REACT_PANEL_VISIBLE); + + const panel = page.locator(SELECTORS.REACT_PANEL_VISIBLE); + await panel.getByRole('button', { name: 'Go with fragment' }).click(); + + // Path should be preserved + await expect(panel.getByText('current_path=/')).toBeVisible(); + await expect(page).toHaveURL(/#section-2/); + }); + + test('push navigation creates history entry', async ({ page }) => { + await gotoPage(page, ''); + await openPanel(page, 'ui_use_navigate', SELECTORS.REACT_PANEL_VISIBLE); + + const panel = page.locator(SELECTORS.REACT_PANEL_VISIBLE); + + // Navigate with push (creates history entry) + await panel.getByRole('button', { name: 'Go Settings (push)' }).click(); + await expect(panel.getByText('current_path=/settings')).toBeVisible(); + + // Go back should return to previous page + await page.goBack(); + await expect(panel.getByText('current_path=/')).toBeVisible(); + }); +}); + +test.describe('UI routing - router', () => { + test('renders index route at root path', async ({ page }) => { + await gotoPage(page, ''); + await openPanel(page, 'ui_router', SELECTORS.REACT_PANEL_VISIBLE); + + const panel = page.locator(SELECTORS.REACT_PANEL_VISIBLE); + await expect(panel.getByText('dashboard_home')).toBeVisible(); + }); +}); + +test.describe('UI routing - url_components', () => { + test('displays URL components', async ({ page }) => { + await gotoPage(page, ''); + await openPanel(page, 'ui_url_components', SELECTORS.REACT_PANEL_VISIBLE); + + const panel = page.locator(SELECTORS.REACT_PANEL_VISIBLE); + await expect(panel.getByText(/scheme=/)).toBeVisible(); + await expect(panel.getByText(/netloc=/)).toBeVisible(); + await expect(panel.getByText(/path=/)).toBeVisible(); + }); +}); From e7c0cd10d747d90b1d64493c19fa3e12c0213afd Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Fri, 8 May 2026 09:48:26 -0500 Subject: [PATCH 2/3] fix --- plugins/ui/src/deephaven/ui/components/router.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/plugins/ui/src/deephaven/ui/components/router.py b/plugins/ui/src/deephaven/ui/components/router.py index d6eaeaaa8..a979ace0e 100644 --- a/plugins/ui/src/deephaven/ui/components/router.py +++ b/plugins/ui/src/deephaven/ui/components/router.py @@ -3,11 +3,9 @@ import re from typing import Any, Callable -from plugins.ui.src.deephaven.ui.hooks import use_memo - from ..components import text from ..elements import create_context -from ..hooks.use_path import use_path +from ..hooks import use_path, use_memo from .route import _Route from .make_component import make_component as component From 9d3b86ba70ca44414eac7da9bfa0d268dc768446 Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Fri, 8 May 2026 12:58:42 -0500 Subject: [PATCH 3/3] warning --- plugins/ui/docs/components/link.md | 3 +++ plugins/ui/docs/components/router.md | 3 +++ plugins/ui/docs/hooks/use_navigate.md | 3 +++ plugins/ui/docs/hooks/use_params.md | 3 +++ plugins/ui/docs/hooks/use_path.md | 3 +++ 5 files changed, 15 insertions(+) diff --git a/plugins/ui/docs/components/link.md b/plugins/ui/docs/components/link.md index 0522a5766..6100af8cf 100644 --- a/plugins/ui/docs/components/link.md +++ b/plugins/ui/docs/components/link.md @@ -92,6 +92,9 @@ The `to` prop enables single-page application (SPA) navigation within Deephaven. `to` accepts either a string (parsed for path, query params, and fragment) or a `NavigationTarget` dict for explicit control. +> [!NOTE] +> Deephaven and all custom components share the path. Avoid using routers, the path, path parameters, and navigation in shared components to prevent conflicts. Do not use the route segment `/-/` in your application path as it is reserved for internal use by Deephaven. + ```python from deephaven import ui diff --git a/plugins/ui/docs/components/router.md b/plugins/ui/docs/components/router.md index 58dd58a3e..66b5fa46b 100644 --- a/plugins/ui/docs/components/router.md +++ b/plugins/ui/docs/components/router.md @@ -2,6 +2,9 @@ `ui.router` is a component that matches the current URL path against provided routes and renders the matching route's element. Use it with [`route`](#route) to define hierarchical navigation structures. +> [!NOTE] +> Deephaven and all custom components share the path. Avoid using routers, the path, path parameters, and navigation in shared components to prevent conflicts. Do not use the route segment `/-/` in your application path as it is reserved for internal use by Deephaven. + ## Example ```python order=app diff --git a/plugins/ui/docs/hooks/use_navigate.md b/plugins/ui/docs/hooks/use_navigate.md index 35e324e23..cb9ae3283 100644 --- a/plugins/ui/docs/hooks/use_navigate.md +++ b/plugins/ui/docs/hooks/use_navigate.md @@ -2,6 +2,9 @@ `use_navigate` is a hook that returns a function to trigger single page application (SPA) navigation within Deephaven. For declarative navigation, consider using [`link`](../components/link.md) with the `to` prop instead. +> [!NOTE] +> Deephaven and all custom components share the path. Avoid using routers, the path, path parameters, and navigation in shared components to prevent conflicts. Do not use the route segment `/-/` in your application path as it is reserved for internal use by Deephaven. + ## Example ```python order=app diff --git a/plugins/ui/docs/hooks/use_params.md b/plugins/ui/docs/hooks/use_params.md index b6801136b..6b577c48a 100644 --- a/plugins/ui/docs/hooks/use_params.md +++ b/plugins/ui/docs/hooks/use_params.md @@ -2,6 +2,9 @@ `use_params` is a hook that returns the route parameters extracted by the nearest ancestor [`router`](../components/router.md). +> [!NOTE] +> Deephaven and all custom components share the path. Avoid using routers, the path, path parameters, and navigation in shared components to prevent conflicts. Do not use the route segment `/-/` in your application path as it is reserved for internal use by Deephaven. + ## Example ```python order=app diff --git a/plugins/ui/docs/hooks/use_path.md b/plugins/ui/docs/hooks/use_path.md index 70145194d..6907a5c1e 100644 --- a/plugins/ui/docs/hooks/use_path.md +++ b/plugins/ui/docs/hooks/use_path.md @@ -4,6 +4,9 @@ Widgets use `/-/` to separate Deephaven's internal router from user-specified widget routing. `use_path()` returns only the portion after `/-/` by default and returns the whole path if the `absolute` argument is set to `True`. +> [!NOTE] +> Deephaven and all custom components share the path. Avoid using routers, the path, path parameters, and navigation in shared components to prevent conflicts. Do not use the route segment `/-/` in your application path as it is reserved for internal use by Deephaven. + ## Example ```python order=app