From e661ba6bbe8e99a8c593325d3d5d1d695f15b8cf Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Tue, 5 May 2026 08:59:59 -0500 Subject: [PATCH 01/31] 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 02/31] 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 03/31] 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 From 4cc8e3d711347cb22f5e780eb829f295d0fb19aa Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Mon, 11 May 2026 11:02:45 -0500 Subject: [PATCH 04/31] fixt comments and failures --- plugins/ui/src/deephaven/ui/components/link.py | 4 ++-- plugins/ui/src/deephaven/ui/components/router.py | 3 ++- plugins/ui/src/deephaven/ui/hooks/use_navigate.py | 4 ++-- plugins/ui/src/deephaven/ui/types/types.py | 11 ++++++----- plugins/ui/src/js/src/widget/WidgetHandler.test.tsx | 2 +- plugins/ui/test/deephaven/ui/test_routing.py | 7 +++---- 6 files changed, 16 insertions(+), 15 deletions(-) diff --git a/plugins/ui/src/deephaven/ui/components/link.py b/plugins/ui/src/deephaven/ui/components/link.py index aeadf36e2..e6371b866 100644 --- a/plugins/ui/src/deephaven/ui/components/link.py +++ b/plugins/ui/src/deephaven/ui/components/link.py @@ -15,7 +15,7 @@ from .basic import component_element from ..elements import Element from ..types import LinkVariant, NavigationTarget -from ..hooks.use_navigate import _build_navigate_payload +from ..hooks.use_navigate import build_navigate_payload def _parse_link_to(to: str | NavigationTarget) -> dict: @@ -23,7 +23,7 @@ def _parse_link_to(to: str | NavigationTarget) -> dict: if isinstance(to, str): to = {"path": to} - return _build_navigate_payload( + return build_navigate_payload( path=to.get("path"), query_params=to.get("query_params"), fragment=to.get("fragment"), diff --git a/plugins/ui/src/deephaven/ui/components/router.py b/plugins/ui/src/deephaven/ui/components/router.py index a979ace0e..761490678 100644 --- a/plugins/ui/src/deephaven/ui/components/router.py +++ b/plugins/ui/src/deephaven/ui/components/router.py @@ -251,7 +251,8 @@ def _match_route( params["*"] = val if val else "" else: val = match.group(name) - params[name] = val if val is not None else "" + if val is not None: + params[name] = val return element, params return None diff --git a/plugins/ui/src/deephaven/ui/hooks/use_navigate.py b/plugins/ui/src/deephaven/ui/hooks/use_navigate.py index 3d690ddea..611e926f1 100644 --- a/plugins/ui/src/deephaven/ui/hooks/use_navigate.py +++ b/plugins/ui/src/deephaven/ui/hooks/use_navigate.py @@ -90,7 +90,7 @@ def _parse_inline_url(path: str) -> tuple[str, str | None, str | None]: return clean_path, inline_query, inline_fragment -def _build_navigate_payload( +def build_navigate_payload( path: str | None = None, query_params: str | QueryParams | None = None, fragment: str | None = None, @@ -169,7 +169,7 @@ def navigate( "At least one of path, query_params, or fragment must be provided." ) - payload = _build_navigate_payload(path, query_params, fragment, replace) + payload = build_navigate_payload(path, query_params, fragment, replace) send_event(_NAVIGATE_EVENT, payload) return navigate diff --git a/plugins/ui/src/deephaven/ui/types/types.py b/plugins/ui/src/deephaven/ui/types/types.py index 0b74d2582..5f27ee5d0 100644 --- a/plugins/ui/src/deephaven/ui/types/types.py +++ b/plugins/ui/src/deephaven/ui/types/types.py @@ -605,13 +605,14 @@ class NumberRange(TypedDict): ToastVariant = Literal["positive", "negative", "neutral", "info"] -QueryParams = Dict[str, List[str]] +QueryParams = Dict[str, Union[str, List[str]]] """ A type alias for query parameter dictionaries used throughout the routing API. -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. +Keys are parameter names. Values can be either a single string or a list of strings. +Generally, strings or lists can be passed into the API but only lists are returned from the API. +When serialised to a URL, list values repeat the key: +{"tag": ["python", "java"]} becomes ?tag=python&tag=java. """ @@ -624,7 +625,7 @@ class NavigationTarget(TypedDict, total=False): path: str """The path to navigate to (e.g. "/dashboard").""" - query_params: str | QueryParams + query_params: Union[str, QueryParams] """Query string (e.g. "?foo=bar") or a QueryParams dict.""" fragment: str diff --git a/plugins/ui/src/js/src/widget/WidgetHandler.test.tsx b/plugins/ui/src/js/src/widget/WidgetHandler.test.tsx index a8ad7de1c..7f66f1e1b 100644 --- a/plugins/ui/src/js/src/widget/WidgetHandler.test.tsx +++ b/plugins/ui/src/js/src/widget/WidgetHandler.test.tsx @@ -676,7 +676,7 @@ describe('navigate event handling', () => { expect(window.history.replaceState).toHaveBeenCalledWith( null, '', - '/app/widget/local/dashboard/-/new-page?q=1#sec' + '/app/widget/local/dashboard/-/new-page' ); unmount(); diff --git a/plugins/ui/test/deephaven/ui/test_routing.py b/plugins/ui/test/deephaven/ui/test_routing.py index dbd02fc5d..95fd09c2d 100644 --- a/plugins/ui/test/deephaven/ui/test_routing.py +++ b/plugins/ui/test/deephaven/ui/test_routing.py @@ -207,9 +207,8 @@ def test_navigate_path_only(self): 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"], "") + self.assertRaises(KeyError, lambda: payload["queryParams"]) + self.assertRaises(KeyError, lambda: payload["fragment"]) def test_navigate_path_with_inline_query(self): from deephaven.ui.hooks.use_navigate import use_navigate @@ -674,7 +673,7 @@ def user_page(): # With optional segment absent result = _match_route("/users", compiled) self.assertIsNotNone(result) - self.assertEqual(result[1], {"tab": ""}) + self.assertEqual(result[1], {}) def test_no_match_returns_none(self): from deephaven.ui.components.router import _compile_routes, _match_route From 3c3ae1a6cbf96b891575ff460013fc401597c3fe Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Mon, 11 May 2026 11:16:11 -0500 Subject: [PATCH 05/31] docs --- .github/skills/writing-docs/SKILL.md | 2 ++ plugins/ui/src/deephaven/ui/components/link.py | 10 +++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/skills/writing-docs/SKILL.md b/.github/skills/writing-docs/SKILL.md index 99cf01e59..9adc9918d 100644 --- a/.github/skills/writing-docs/SKILL.md +++ b/.github/skills/writing-docs/SKILL.md @@ -7,6 +7,8 @@ description: Write documentation. Use when asked to write documentation, update 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. +The functions themselves should be fully documented with docstrings in the source code. Parameters with multiple lines in their description should use an indented block after the first line. + ### Example ````markdown diff --git a/plugins/ui/src/deephaven/ui/components/link.py b/plugins/ui/src/deephaven/ui/components/link.py index e6371b866..59118f82c 100644 --- a/plugins/ui/src/deephaven/ui/components/link.py +++ b/plugins/ui/src/deephaven/ui/components/link.py @@ -106,13 +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. + 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. + 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. From d18f35a556a8d21cb6491daf1b8f00c8b131b67d Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Mon, 11 May 2026 11:23:06 -0500 Subject: [PATCH 06/31] more docs --- plugins/ui/src/deephaven/ui/components/route.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/ui/src/deephaven/ui/components/route.py b/plugins/ui/src/deephaven/ui/components/route.py index a8ac0cd3a..c36793080 100644 --- a/plugins/ui/src/deephaven/ui/components/route.py +++ b/plugins/ui/src/deephaven/ui/components/route.py @@ -26,13 +26,13 @@ def route( 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. + 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. + an index route). Mutually exclusive with path. Returns: A _Route instance, to be consumed by the router component. From 1e79e03dc8942921515cc9c19717639664010f69 Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Mon, 11 May 2026 11:31:18 -0500 Subject: [PATCH 07/31] even more docs --- plugins/ui/src/deephaven/ui/hooks/use_path.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/ui/src/deephaven/ui/hooks/use_path.py b/plugins/ui/src/deephaven/ui/hooks/use_path.py index 7a6e5ffcf..3e4323a92 100644 --- a/plugins/ui/src/deephaven/ui/hooks/use_path.py +++ b/plugins/ui/src/deephaven/ui/hooks/use_path.py @@ -12,8 +12,8 @@ def use_path(absolute: bool = False) -> str: Args: absolute: If True, returns the full absolute path from the URL. - If False (default), returns the path relative to the - current widget (after /-/). + If False (default), returns the path relative to the + current widget (after /-/). Returns: The current path as a string. From 787a36b1355feed7659d5da20d631a65261f584c Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Mon, 11 May 2026 12:15:33 -0500 Subject: [PATCH 08/31] snapshots --- plugins/ui/docs/snapshots/3f31d33021ba3ef8f8f5b1855a810f60.json | 1 + plugins/ui/docs/snapshots/44bb4ba0cf22f6c9554fca7f8de7b227.json | 1 + plugins/ui/docs/snapshots/4721bddae66df99753c74b80accd5e18.json | 1 + plugins/ui/docs/snapshots/5d6d50732db8d35a0e2bab7f342e61e7.json | 1 + plugins/ui/docs/snapshots/66ee5c4fdc2bca9d397ebc968de28e76.json | 1 + plugins/ui/docs/snapshots/79238d63d61c70255d0c0c430c6eb12c.json | 1 + plugins/ui/docs/snapshots/929e97896758258555c1264620e0c1b3.json | 1 + plugins/ui/docs/snapshots/bbe8b48b28c06aa39ac4f211044f26ab.json | 1 + plugins/ui/docs/snapshots/fef499c4c8c2da2202f0929ce9720f1a.json | 1 + 9 files changed, 9 insertions(+) create mode 100644 plugins/ui/docs/snapshots/3f31d33021ba3ef8f8f5b1855a810f60.json create mode 100644 plugins/ui/docs/snapshots/44bb4ba0cf22f6c9554fca7f8de7b227.json create mode 100644 plugins/ui/docs/snapshots/4721bddae66df99753c74b80accd5e18.json create mode 100644 plugins/ui/docs/snapshots/5d6d50732db8d35a0e2bab7f342e61e7.json create mode 100644 plugins/ui/docs/snapshots/66ee5c4fdc2bca9d397ebc968de28e76.json create mode 100644 plugins/ui/docs/snapshots/79238d63d61c70255d0c0c430c6eb12c.json create mode 100644 plugins/ui/docs/snapshots/929e97896758258555c1264620e0c1b3.json create mode 100644 plugins/ui/docs/snapshots/bbe8b48b28c06aa39ac4f211044f26ab.json create mode 100644 plugins/ui/docs/snapshots/fef499c4c8c2da2202f0929ce9720f1a.json diff --git a/plugins/ui/docs/snapshots/3f31d33021ba3ef8f8f5b1855a810f60.json b/plugins/ui/docs/snapshots/3f31d33021ba3ef8f8f5b1855a810f60.json new file mode 100644 index 000000000..fe83c431d --- /dev/null +++ b/plugins/ui/docs/snapshots/3f31d33021ba3ef8f8f5b1855a810f60.json @@ -0,0 +1 @@ +{"file":"components/router.md","objects":{"app":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.router.router","props":{"children":{"__dhElemName":"deephaven.ui.elements.ContextProviderElement","props":{"children":{"__dhElemName":"__main__.home_page","props":{"children":{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Home page"],"slot":"text"}}}}}}}}},"__dhElemName":"__main__.app"},"state":"{}"}}}} \ No newline at end of file diff --git a/plugins/ui/docs/snapshots/44bb4ba0cf22f6c9554fca7f8de7b227.json b/plugins/ui/docs/snapshots/44bb4ba0cf22f6c9554fca7f8de7b227.json new file mode 100644 index 000000000..ab7bf5821 --- /dev/null +++ b/plugins/ui/docs/snapshots/44bb4ba0cf22f6c9554fca7f8de7b227.json @@ -0,0 +1 @@ +{"file":"components/router.md","objects":{"app":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.router.router","props":{"children":{"__dhElemName":"deephaven.ui.elements.ContextProviderElement","props":{"children":{"__dhElemName":"__main__.dashboard","props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"direction":"column","gap":"size-100","flex":"auto","children":[{"__dhElemName":"__main__.nav_links","props":{"children":{"__dhElemName":"deephaven.ui.components.ButtonGroup","props":{"orientation":"horizontal","align":"start","children":[{"__dhElemName":"deephaven.ui.components.ActionButton","props":{"type":"button","onPress":{"__dhCbid":"cb0"},"children":"Home"}},{"__dhElemName":"deephaven.ui.components.ActionButton","props":{"type":"button","onPress":{"__dhCbid":"cb1"},"children":"All Users"}},{"__dhElemName":"deephaven.ui.components.ActionButton","props":{"type":"button","onPress":{"__dhCbid":"cb2"},"children":"User 1"}},{"__dhElemName":"deephaven.ui.components.ActionButton","props":{"type":"button","onPress":{"__dhCbid":"cb3"},"children":"User 2"}}]}}}},{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Dashboard home"],"slot":"text"}}]}}}}}}}}},"__dhElemName":"__main__.app"},"state":"{}"}}}} \ No newline at end of file diff --git a/plugins/ui/docs/snapshots/4721bddae66df99753c74b80accd5e18.json b/plugins/ui/docs/snapshots/4721bddae66df99753c74b80accd5e18.json new file mode 100644 index 000000000..52b128838 --- /dev/null +++ b/plugins/ui/docs/snapshots/4721bddae66df99753c74b80accd5e18.json @@ -0,0 +1 @@ +{"file":"hooks/use_navigate.md","objects":{"app":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"direction":"column","gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Current path: /"],"slot":"text"}},{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Query params: {}"],"slot":"text"}},{"__dhElemName":"deephaven.ui.components.ActionButton","props":{"type":"button","onPress":{"__dhCbid":"cb0"},"children":"Dashboard"}},{"__dhElemName":"deephaven.ui.components.ActionButton","props":{"type":"button","onPress":{"__dhCbid":"cb1"},"children":"Settings (push)"}},{"__dhElemName":"deephaven.ui.components.ActionButton","props":{"type":"button","onPress":{"__dhCbid":"cb2"},"children":"Jump to section"}},{"__dhElemName":"deephaven.ui.components.ActionButton","props":{"type":"button","onPress":{"__dhCbid":"cb3"},"children":"Filter by tags"}}]}}},"__dhElemName":"__main__.navigation_demo"},"state":"{}"}}}} \ No newline at end of file diff --git a/plugins/ui/docs/snapshots/5d6d50732db8d35a0e2bab7f342e61e7.json b/plugins/ui/docs/snapshots/5d6d50732db8d35a0e2bab7f342e61e7.json new file mode 100644 index 000000000..e0216afdd --- /dev/null +++ b/plugins/ui/docs/snapshots/5d6d50732db8d35a0e2bab7f342e61e7.json @@ -0,0 +1 @@ +{"file":"hooks/use_path.md","objects":{"app":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"direction":"column","gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Current path: /"],"slot":"text"}},{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Absolute path: /"],"slot":"text"}},{"__dhElemName":"deephaven.ui.components.ActionButton","props":{"type":"button","onPress":{"__dhCbid":"cb0"},"children":"Go to Dashboard"}},{"__dhElemName":"deephaven.ui.components.ActionButton","props":{"type":"button","onPress":{"__dhCbid":"cb1"},"children":"Go Home"}}]}}},"__dhElemName":"__main__.path_display"},"state":"{}"}}}} \ No newline at end of file diff --git a/plugins/ui/docs/snapshots/66ee5c4fdc2bca9d397ebc968de28e76.json b/plugins/ui/docs/snapshots/66ee5c4fdc2bca9d397ebc968de28e76.json new file mode 100644 index 000000000..5407fd532 --- /dev/null +++ b/plugins/ui/docs/snapshots/66ee5c4fdc2bca9d397ebc968de28e76.json @@ -0,0 +1 @@ +{"file":"hooks/use_params.md","objects":{"app":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.router.router","props":{"children":{"__dhElemName":"deephaven.ui.elements.ContextProviderElement","props":{"children":{"__dhElemName":"__main__.not_found","props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"direction":"column","gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Page not found: "],"slot":"text"}},{"__dhElemName":"deephaven.ui.components.ActionButton","props":{"type":"button","onPress":{"__dhCbid":"cb0"},"children":"Go to User 1"}}]}}}}}}}}},"__dhElemName":"__main__.app"},"state":"{}"}}}} \ No newline at end of file diff --git a/plugins/ui/docs/snapshots/79238d63d61c70255d0c0c430c6eb12c.json b/plugins/ui/docs/snapshots/79238d63d61c70255d0c0c430c6eb12c.json new file mode 100644 index 000000000..deb7cdb65 --- /dev/null +++ b/plugins/ui/docs/snapshots/79238d63d61c70255d0c0c430c6eb12c.json @@ -0,0 +1 @@ +{"file":"hooks/use_url_components.md","objects":{"app":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"direction":"column","gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Scheme: "],"slot":"text"}},{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Host: "],"slot":"text"}},{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Path: "],"slot":"text"}},{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Query: "],"slot":"text"}},{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Fragment: "],"slot":"text"}}]}}},"__dhElemName":"__main__.url_info"},"state":"{}"}}}} \ No newline at end of file diff --git a/plugins/ui/docs/snapshots/929e97896758258555c1264620e0c1b3.json b/plugins/ui/docs/snapshots/929e97896758258555c1264620e0c1b3.json new file mode 100644 index 000000000..1234ba6bc --- /dev/null +++ b/plugins/ui/docs/snapshots/929e97896758258555c1264620e0c1b3.json @@ -0,0 +1 @@ +{"file":"hooks/use_navigate.md","objects":{"app":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.ActionButton","props":{"type":"button","onPress":{"__dhCbid":"cb0"},"children":"Go to settings"}}},"__dhElemName":"__main__.app"},"state":"{}"}}}} \ No newline at end of file diff --git a/plugins/ui/docs/snapshots/bbe8b48b28c06aa39ac4f211044f26ab.json b/plugins/ui/docs/snapshots/bbe8b48b28c06aa39ac4f211044f26ab.json new file mode 100644 index 000000000..fd139c836 --- /dev/null +++ b/plugins/ui/docs/snapshots/bbe8b48b28c06aa39ac4f211044f26ab.json @@ -0,0 +1 @@ +{"file":"hooks/use_path.md","objects":{"app":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Current path: /"],"slot":"text"}}},"__dhElemName":"__main__.app"},"state":"{}"}}}} \ No newline at end of file diff --git a/plugins/ui/docs/snapshots/fef499c4c8c2da2202f0929ce9720f1a.json b/plugins/ui/docs/snapshots/fef499c4c8c2da2202f0929ce9720f1a.json new file mode 100644 index 000000000..f15ce43f0 --- /dev/null +++ b/plugins/ui/docs/snapshots/fef499c4c8c2da2202f0929ce9720f1a.json @@ -0,0 +1 @@ +{"file":"components/link.md","objects":{"my_nav":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"direction":"column","gap":"size-100","flex":"auto","children":[{"__dhElemName":"deephaven.ui.components.Link","props":{"variant":"primary","navigate":{"path":"/","replace":true},"children":"Home"}},{"__dhElemName":"deephaven.ui.components.Link","props":{"variant":"primary","navigate":{"path":"/search","queryParams":"?q=hello","fragment":"results","replace":true},"children":"Search"}},{"__dhElemName":"deephaven.ui.components.Link","props":{"variant":"primary","navigate":{"path":"/users","queryParams":"?sort=name","replace":true},"children":"Users"}}]}}},"__dhElemName":"__main__.nav"},"state":"{}"}}}} \ No newline at end of file From 06255a2c57a6a2bfc2863b91598ed4c94628b500 Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Mon, 11 May 2026 14:36:52 -0500 Subject: [PATCH 09/31] fixes --- plugins/ui/docs/components/router.md | 16 +++++++++++----- plugins/ui/docs/sidebar.json | 4 ---- .../44bb4ba0cf22f6c9554fca7f8de7b227.json | 1 - .../6c78b32db7fee30fc294faa83d4be561.json | 1 + .../eb738fa99ca2a7a003849ec6c35a65b3.json | 1 + plugins/ui/src/deephaven/ui/components/router.py | 2 +- 6 files changed, 14 insertions(+), 11 deletions(-) delete mode 100644 plugins/ui/docs/snapshots/44bb4ba0cf22f6c9554fca7f8de7b227.json create mode 100644 plugins/ui/docs/snapshots/6c78b32db7fee30fc294faa83d4be561.json create mode 100644 plugins/ui/docs/snapshots/eb738fa99ca2a7a003849ec6c35a65b3.json diff --git a/plugins/ui/docs/components/router.md b/plugins/ui/docs/components/router.md index 66b5fa46b..b5a5c712a 100644 --- a/plugins/ui/docs/components/router.md +++ b/plugins/ui/docs/components/router.md @@ -51,7 +51,13 @@ 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") + user_id = params.get("user_id", None) + if user_id is None: + return ui.flex( + nav_links(), + ui.text("All users page"), + direction="column", + ) return ui.flex( nav_links(), ui.text(f"User profile for user {user_id}"), @@ -128,7 +134,7 @@ This produces the following route table: 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. +4. Optional segments are matched if present but do not prevent a match if absent. 5. Index routes match only the exact parent path. 6. If no route matches, the router renders an error. @@ -141,10 +147,10 @@ This produces the following route table: #### Path patterns - `{var_name}`: Required dynamic segment -- `{var_name?}`: Optional dynamic segment (matches zero or one segment) +- `{var_name?}`: Optional dynamic segment (matches zero or one segments) - `*`: Wildcard, matches any remaining path segments - Static text: Exact match -See [use_params](../hooks/use_params.md) for more details on route parameters. +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}`. +Child paths are appended to parent paths. For example, `ui.route(ui.route(path="{user_id}"), path="users")` produces `/users/{user_id}`. diff --git a/plugins/ui/docs/sidebar.json b/plugins/ui/docs/sidebar.json index c0e313048..4db96490a 100644 --- a/plugins/ui/docs/sidebar.json +++ b/plugins/ui/docs/sidebar.json @@ -362,10 +362,6 @@ "label": "route", "path": "components/router.md" }, - { - "label": "router", - "path": "components/router.md" - }, { "label": "search_field", "path": "components/search_field.md" diff --git a/plugins/ui/docs/snapshots/44bb4ba0cf22f6c9554fca7f8de7b227.json b/plugins/ui/docs/snapshots/44bb4ba0cf22f6c9554fca7f8de7b227.json deleted file mode 100644 index ab7bf5821..000000000 --- a/plugins/ui/docs/snapshots/44bb4ba0cf22f6c9554fca7f8de7b227.json +++ /dev/null @@ -1 +0,0 @@ -{"file":"components/router.md","objects":{"app":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.router.router","props":{"children":{"__dhElemName":"deephaven.ui.elements.ContextProviderElement","props":{"children":{"__dhElemName":"__main__.dashboard","props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"direction":"column","gap":"size-100","flex":"auto","children":[{"__dhElemName":"__main__.nav_links","props":{"children":{"__dhElemName":"deephaven.ui.components.ButtonGroup","props":{"orientation":"horizontal","align":"start","children":[{"__dhElemName":"deephaven.ui.components.ActionButton","props":{"type":"button","onPress":{"__dhCbid":"cb0"},"children":"Home"}},{"__dhElemName":"deephaven.ui.components.ActionButton","props":{"type":"button","onPress":{"__dhCbid":"cb1"},"children":"All Users"}},{"__dhElemName":"deephaven.ui.components.ActionButton","props":{"type":"button","onPress":{"__dhCbid":"cb2"},"children":"User 1"}},{"__dhElemName":"deephaven.ui.components.ActionButton","props":{"type":"button","onPress":{"__dhCbid":"cb3"},"children":"User 2"}}]}}}},{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Dashboard home"],"slot":"text"}}]}}}}}}}}},"__dhElemName":"__main__.app"},"state":"{}"}}}} \ No newline at end of file diff --git a/plugins/ui/docs/snapshots/6c78b32db7fee30fc294faa83d4be561.json b/plugins/ui/docs/snapshots/6c78b32db7fee30fc294faa83d4be561.json new file mode 100644 index 000000000..26415e048 --- /dev/null +++ b/plugins/ui/docs/snapshots/6c78b32db7fee30fc294faa83d4be561.json @@ -0,0 +1 @@ +{"file":"hooks/use_params.md","objects":{"app":{"type":"deephaven.ui.Element","data":{"document":{"props":{"children":{"__dhElemName":"deephaven.ui.components.router.router","props":{"children":{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["No route matches path: /"],"slot":"text"}}}}},"__dhElemName":"__main__.app"},"state":"{}"}}}} \ No newline at end of file diff --git a/plugins/ui/docs/snapshots/eb738fa99ca2a7a003849ec6c35a65b3.json b/plugins/ui/docs/snapshots/eb738fa99ca2a7a003849ec6c35a65b3.json new file mode 100644 index 000000000..28fbf386e --- /dev/null +++ b/plugins/ui/docs/snapshots/eb738fa99ca2a7a003849ec6c35a65b3.json @@ -0,0 +1 @@ +{"file":"components/router.md","objects":{"app":{"type":"deephaven.ui.Element","data":{"document":{"__dhElemName":"__main__.app","props":{"children":{"__dhElemName":"deephaven.ui.components.router.router","props":{"children":{"__dhElemName":"deephaven.ui.elements.ContextProviderElement","props":{"children":{"__dhElemName":"__main__.dashboard","props":{"children":{"__dhElemName":"deephaven.ui.components.Flex","props":{"direction":"column","gap":"size-100","flex":"auto","children":[{"__dhElemName":"__main__.nav_links","props":{"children":{"__dhElemName":"deephaven.ui.components.ButtonGroup","props":{"orientation":"horizontal","align":"start","children":[{"__dhElemName":"deephaven.ui.components.ActionButton","props":{"type":"button","onPress":{"__dhCbid":"cb0"},"children":"Home"}},{"__dhElemName":"deephaven.ui.components.ActionButton","props":{"type":"button","onPress":{"__dhCbid":"cb1"},"children":"All Users"}},{"__dhElemName":"deephaven.ui.components.ActionButton","props":{"type":"button","onPress":{"__dhCbid":"cb2"},"children":"User 1"}},{"__dhElemName":"deephaven.ui.components.ActionButton","props":{"type":"button","onPress":{"__dhCbid":"cb3"},"children":"User 2"}}]}}}},{"__dhElemName":"deephaven.ui.components.Text","props":{"children":["Dashboard home"],"slot":"text"}}]}}}}}}}}}},"state":"{}"}}}} \ No newline at end of file diff --git a/plugins/ui/src/deephaven/ui/components/router.py b/plugins/ui/src/deephaven/ui/components/router.py index 761490678..8987eddbc 100644 --- a/plugins/ui/src/deephaven/ui/components/router.py +++ b/plugins/ui/src/deephaven/ui/components/router.py @@ -3,7 +3,7 @@ import re from typing import Any, Callable -from ..components import text +from .text import text from ..elements import create_context from ..hooks import use_path, use_memo from .route import _Route From 1e478a34a9499e64f6455db59e23cfe10e02bd81 Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Mon, 11 May 2026 15:11:04 -0500 Subject: [PATCH 10/31] attempt escape --- plugins/ui/docs/components/router.md | 4 ++-- plugins/ui/docs/hooks/use_params.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/ui/docs/components/router.md b/plugins/ui/docs/components/router.md index b5a5c712a..eaabc7eda 100644 --- a/plugins/ui/docs/components/router.md +++ b/plugins/ui/docs/components/router.md @@ -146,8 +146,8 @@ This produces the following route table: #### Path patterns -- `{var_name}`: Required dynamic segment -- `{var_name?}`: Optional dynamic segment (matches zero or one segments) +- `\{var_name}`: Required dynamic segment +- `\{var_name?}`: Optional dynamic segment (matches zero or one segments) - `*`: Wildcard, matches any remaining path segments - Static text: Exact match diff --git a/plugins/ui/docs/hooks/use_params.md b/plugins/ui/docs/hooks/use_params.md index 6b577c48a..bc3fd6eac 100644 --- a/plugins/ui/docs/hooks/use_params.md +++ b/plugins/ui/docs/hooks/use_params.md @@ -104,9 +104,9 @@ app = app() ## Route Parameter Patterns -Route parameters are defined by `{var_name}` segments in route paths: +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. +- `\{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. From 49892682cdd553768b15fd07cb62fd06a0f143db Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Mon, 11 May 2026 15:12:21 -0500 Subject: [PATCH 11/31] missed a spot --- plugins/ui/docs/hooks/use_params.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/ui/docs/hooks/use_params.md b/plugins/ui/docs/hooks/use_params.md index bc3fd6eac..ac02ef20e 100644 --- a/plugins/ui/docs/hooks/use_params.md +++ b/plugins/ui/docs/hooks/use_params.md @@ -107,7 +107,7 @@ app = app() 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. +- `\{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. From 71e684797131185dfe71aad999166c0c7048d7ea Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Mon, 11 May 2026 15:18:35 -0500 Subject: [PATCH 12/31] maybe this? --- plugins/ui/docs/components/router.md | 6 +++--- plugins/ui/docs/hooks/use_params.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/ui/docs/components/router.md b/plugins/ui/docs/components/router.md index eaabc7eda..5d30778e6 100644 --- a/plugins/ui/docs/components/router.md +++ b/plugins/ui/docs/components/router.md @@ -146,11 +146,11 @@ This produces the following route table: #### Path patterns -- `\{var_name}`: Required dynamic segment -- `\{var_name?}`: Optional dynamic segment (matches zero or one segments) +- `{var_name}`: Required dynamic segment +- `{var_name?}`: Optional dynamic segment (matches zero or one segments) - `*`: 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. For example, `ui.route(ui.route(path="{user_id}"), path="users")` produces `/users/{user_id}`. +Child paths are appended to parent paths. For example, `ui.route(ui.route(path="{user_id}"), path="users")` produces `/users/{user_id}`. diff --git a/plugins/ui/docs/hooks/use_params.md b/plugins/ui/docs/hooks/use_params.md index ac02ef20e..27d01365d 100644 --- a/plugins/ui/docs/hooks/use_params.md +++ b/plugins/ui/docs/hooks/use_params.md @@ -104,10 +104,10 @@ app = app() ## Route Parameter Patterns -Route parameters are defined by `\{var_name}` segments in route paths: +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. +- `{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. From eb9ad575383daac62c66c7efe47ae629dd1f9a4c Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Mon, 11 May 2026 15:41:42 -0500 Subject: [PATCH 13/31] testing --- plugins/ui/docs/components/router.md | 6 +++--- plugins/ui/docs/hooks/use_params.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/ui/docs/components/router.md b/plugins/ui/docs/components/router.md index 5d30778e6..b5a5c712a 100644 --- a/plugins/ui/docs/components/router.md +++ b/plugins/ui/docs/components/router.md @@ -146,11 +146,11 @@ This produces the following route table: #### Path patterns -- `{var_name}`: Required dynamic segment -- `{var_name?}`: Optional dynamic segment (matches zero or one segments) +- `{var_name}`: Required dynamic segment +- `{var_name?}`: Optional dynamic segment (matches zero or one segments) - `*`: 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. For example, `ui.route(ui.route(path="{user_id}"), path="users")` produces `/users/{user_id}`. +Child paths are appended to parent paths. For example, `ui.route(ui.route(path="{user_id}"), path="users")` produces `/users/{user_id}`. diff --git a/plugins/ui/docs/hooks/use_params.md b/plugins/ui/docs/hooks/use_params.md index 27d01365d..7e472081d 100644 --- a/plugins/ui/docs/hooks/use_params.md +++ b/plugins/ui/docs/hooks/use_params.md @@ -104,10 +104,10 @@ app = app() ## Route Parameter Patterns -Route parameters are defined by `{var_name}` segments in route paths: +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. +- `{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. From 660d9884e90cfccd30df8d68d67b41eaa35f006d Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Mon, 11 May 2026 15:46:04 -0500 Subject: [PATCH 14/31] block --- plugins/ui/docs/hooks/use_params.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/ui/docs/hooks/use_params.md b/plugins/ui/docs/hooks/use_params.md index 7e472081d..aee5ed02c 100644 --- a/plugins/ui/docs/hooks/use_params.md +++ b/plugins/ui/docs/hooks/use_params.md @@ -104,7 +104,7 @@ app = app() ## Route Parameter Patterns -Route parameters are defined by `\{var_name}` segments in route paths: +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. From c8dd2e83d205ff1c5f917b284962e67585df05e1 Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Mon, 11 May 2026 15:50:52 -0500 Subject: [PATCH 15/31] another try --- plugins/ui/docs/hooks/use_params.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/ui/docs/hooks/use_params.md b/plugins/ui/docs/hooks/use_params.md index aee5ed02c..6b577c48a 100644 --- a/plugins/ui/docs/hooks/use_params.md +++ b/plugins/ui/docs/hooks/use_params.md @@ -104,7 +104,7 @@ app = app() ## Route Parameter Patterns -Route parameters are defined by {var_name} segments in route paths: +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. From c65db2f6cf8f66626091782be54f9f94b4b5e82b Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Mon, 11 May 2026 15:51:42 -0500 Subject: [PATCH 16/31] love when docs don't save --- plugins/ui/docs/hooks/use_params.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/ui/docs/hooks/use_params.md b/plugins/ui/docs/hooks/use_params.md index 6b577c48a..5c792bc87 100644 --- a/plugins/ui/docs/hooks/use_params.md +++ b/plugins/ui/docs/hooks/use_params.md @@ -104,7 +104,7 @@ app = app() ## Route Parameter Patterns -Route parameters are defined by `{var_name}` segments in route paths: +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. From 7fc038c1663b7364d69c2efb0d615053919b7eba Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Mon, 11 May 2026 15:56:04 -0500 Subject: [PATCH 17/31] anotha one --- plugins/ui/docs/hooks/use_params.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/ui/docs/hooks/use_params.md b/plugins/ui/docs/hooks/use_params.md index 5c792bc87..9fe66aa74 100644 --- a/plugins/ui/docs/hooks/use_params.md +++ b/plugins/ui/docs/hooks/use_params.md @@ -104,7 +104,7 @@ app = app() ## Route Parameter Patterns -Route parameters are defined by {'{var_name}'} segments in route paths: +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. From 6f14ca76338e5f31f8a8cb4aa90243016bc57723 Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Mon, 11 May 2026 16:06:04 -0500 Subject: [PATCH 18/31] and now for something completely different --- plugins/ui/docs/components/router.md | 28 +++++++++++++++++----------- plugins/ui/docs/hooks/use_params.md | 10 ++++++---- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/plugins/ui/docs/components/router.md b/plugins/ui/docs/components/router.md index b5a5c712a..661499bc8 100644 --- a/plugins/ui/docs/components/router.md +++ b/plugins/ui/docs/components/router.md @@ -107,12 +107,12 @@ 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"}` | +| URL Path | Matched Element | Params | +| ---------------- | --------------- | ------------------------- | +| `/` | `dashboard` | none | +| `/users` | `user_page` | none | +| `/users/42` | `user_page` | `"user_id"` = `"42"` | +| `/anything-else` | `not_found` | `"*"` = `"anything-else"` | ## Recommendations @@ -146,11 +146,17 @@ This produces the following route table: #### Path patterns -- `{var_name}`: Required dynamic segment -- `{var_name?}`: Optional dynamic segment (matches zero or one segments) -- `*`: Wildcard, matches any remaining path segments -- Static text: Exact match +```python skip-test +{var_name} # Required dynamic segment +{var_name?} # Optional dynamic segment (matches zero or one segments) +* # Wildcard, matches any remaining path segments +segment # Static text: Exact match +``` See [`use_params`](../hooks/use_params.md) for more details on route parameters. -Child paths are appended to parent paths. For example, `ui.route(ui.route(path="{user_id}"), path="users")` produces `/users/{user_id}`. +Child paths are appended to parent paths. For example: + +```python skip-test +ui.route(ui.route(path="{user_id}"), path="users") # produces /users/{user_id} +``` diff --git a/plugins/ui/docs/hooks/use_params.md b/plugins/ui/docs/hooks/use_params.md index 9fe66aa74..aa7f673e6 100644 --- a/plugins/ui/docs/hooks/use_params.md +++ b/plugins/ui/docs/hooks/use_params.md @@ -104,11 +104,13 @@ app = app() ## Route Parameter Patterns -Route parameters are defined by {"{var_name}"} segments in route paths: +Route parameters are defined by segments in the route path that are enclosed in curly braces. The following patterns are supported: -- `{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. +```python skip-test +{var_name} # matches a required segment and extracts it as "var_name" in the params dict +{tab?} # matches an optional segment; the parameter is omitted 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. From f30c7342e7d533623cd097c9ecf98708d850476a Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Mon, 11 May 2026 16:09:30 -0500 Subject: [PATCH 19/31] ????? --- plugins/ui/docs/hooks/use_params.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/ui/docs/hooks/use_params.md b/plugins/ui/docs/hooks/use_params.md index aa7f673e6..451fad50e 100644 --- a/plugins/ui/docs/hooks/use_params.md +++ b/plugins/ui/docs/hooks/use_params.md @@ -107,8 +107,8 @@ app = app() Route parameters are defined by segments in the route path that are enclosed in curly braces. The following patterns are supported: ```python skip-test -{var_name} # matches a required segment and extracts it as "var_name" in the params dict -{tab?} # matches an optional segment; the parameter is omitted if the segment is missing +{user_id} # matches a required segment and extracts it as "user_id" in the params dict +{section?} # matches an optional segment; the parameter is omitted if the segment is missing * # matches any remaining path; the value is available as the "*" key ``` From 2a81fd309b33b5513afc4d43a322951604706363 Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Mon, 11 May 2026 16:13:34 -0500 Subject: [PATCH 20/31] try strings --- plugins/ui/docs/components/router.md | 28 +++++++++++----------------- plugins/ui/docs/hooks/use_params.md | 10 ++++------ 2 files changed, 15 insertions(+), 23 deletions(-) diff --git a/plugins/ui/docs/components/router.md b/plugins/ui/docs/components/router.md index 661499bc8..4bcd80f60 100644 --- a/plugins/ui/docs/components/router.md +++ b/plugins/ui/docs/components/router.md @@ -107,12 +107,12 @@ app = app() This produces the following route table: -| URL Path | Matched Element | Params | -| ---------------- | --------------- | ------------------------- | -| `/` | `dashboard` | none | -| `/users` | `user_page` | none | -| `/users/42` | `user_page` | `"user_id"` = `"42"` | -| `/anything-else` | `not_found` | `"*"` = `"anything-else"` | +| URL Path | Matched Element | Params | +| ---------------- | --------------- | ------------------------ | +| `/` | `dashboard` | `{}` | +| `/users` | `user_page` | `{}` | +| `/users/42` | `user_page` | `{"user_id": "42"}` | +| `/anything-else` | `not_found` | `{"*": "anything-else"}` | ## Recommendations @@ -146,17 +146,11 @@ This produces the following route table: #### Path patterns -```python skip-test -{var_name} # Required dynamic segment -{var_name?} # Optional dynamic segment (matches zero or one segments) -* # Wildcard, matches any remaining path segments -segment # Static text: Exact match -``` +- `"{var_name}"`: Required dynamic segment +- `"{var_name?}"`: Optional dynamic segment (matches zero or one segments) +- `"*"`: 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. For example: - -```python skip-test -ui.route(ui.route(path="{user_id}"), path="users") # produces /users/{user_id} -``` +Child paths are appended to parent paths. For example, `ui.route(ui.route(path="{user_id}"), path="users")` produces `"/users/{user_id}"`. diff --git a/plugins/ui/docs/hooks/use_params.md b/plugins/ui/docs/hooks/use_params.md index 451fad50e..3bccc6882 100644 --- a/plugins/ui/docs/hooks/use_params.md +++ b/plugins/ui/docs/hooks/use_params.md @@ -104,13 +104,11 @@ app = app() ## Route Parameter Patterns -Route parameters are defined by segments in the route path that are enclosed in curly braces. The following patterns are supported: +Route parameters are defined by `"{var_name}"` segments in route paths: -```python skip-test -{user_id} # matches a required segment and extracts it as "user_id" in the params dict -{section?} # matches an optional segment; the parameter is omitted if the segment is missing -* # matches any remaining path; the value is available as the "*" key -``` +- `"{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. From 94f5ff49b383189825846b2b0bad22d278c2de4b Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Mon, 11 May 2026 16:17:21 -0500 Subject: [PATCH 21/31] these do not want to be fixed --- plugins/ui/docs/hooks/use_params.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/ui/docs/hooks/use_params.md b/plugins/ui/docs/hooks/use_params.md index 3bccc6882..ccb93aee8 100644 --- a/plugins/ui/docs/hooks/use_params.md +++ b/plugins/ui/docs/hooks/use_params.md @@ -104,7 +104,7 @@ app = app() ## Route Parameter Patterns -Route parameters are defined by `"{var_name}"` segments in route paths: +Route parameters are defined by certain patterns in the route path: - `"{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. From 95a1733547de8c993805bd94a503982dab48f318 Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Mon, 11 May 2026 16:22:53 -0500 Subject: [PATCH 22/31] oops --- plugins/ui/docs/components/router.md | 8 ++++---- plugins/ui/docs/hooks/use_params.md | 8 ++++---- plugins/ui/src/deephaven/ui/hooks/use_params.py | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/plugins/ui/docs/components/router.md b/plugins/ui/docs/components/router.md index 4bcd80f60..b5a5c712a 100644 --- a/plugins/ui/docs/components/router.md +++ b/plugins/ui/docs/components/router.md @@ -146,11 +146,11 @@ This produces the following route table: #### Path patterns -- `"{var_name}"`: Required dynamic segment -- `"{var_name?}"`: Optional dynamic segment (matches zero or one segments) -- `"*"`: Wildcard, matches any remaining path segments +- `{var_name}`: Required dynamic segment +- `{var_name?}`: Optional dynamic segment (matches zero or one segments) +- `*`: 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. For example, `ui.route(ui.route(path="{user_id}"), path="users")` produces `"/users/{user_id}"`. +Child paths are appended to parent paths. For example, `ui.route(ui.route(path="{user_id}"), path="users")` produces `/users/{user_id}`. diff --git a/plugins/ui/docs/hooks/use_params.md b/plugins/ui/docs/hooks/use_params.md index ccb93aee8..6b577c48a 100644 --- a/plugins/ui/docs/hooks/use_params.md +++ b/plugins/ui/docs/hooks/use_params.md @@ -104,11 +104,11 @@ app = app() ## Route Parameter Patterns -Route parameters are defined by certain patterns in the route path: +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. +- `{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. diff --git a/plugins/ui/src/deephaven/ui/hooks/use_params.py b/plugins/ui/src/deephaven/ui/hooks/use_params.py index 5155dd335..29a257a86 100644 --- a/plugins/ui/src/deephaven/ui/hooks/use_params.py +++ b/plugins/ui/src/deephaven/ui/hooks/use_params.py @@ -8,7 +8,7 @@ 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 + Route parameters are defined by "{var_name}" segments in route paths and extracted when the route matches. Returns: From bc6b59cc45fe1ca4f651098ed8830d7fa8dee9b5 Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Mon, 11 May 2026 16:26:48 -0500 Subject: [PATCH 23/31] maybe need that too --- plugins/ui/docs/hooks/use_params.md | 8 ++++---- plugins/ui/src/deephaven/ui/hooks/use_params.py | 4 +--- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/plugins/ui/docs/hooks/use_params.md b/plugins/ui/docs/hooks/use_params.md index 6b577c48a..3bccc6882 100644 --- a/plugins/ui/docs/hooks/use_params.md +++ b/plugins/ui/docs/hooks/use_params.md @@ -104,11 +104,11 @@ app = app() ## Route Parameter Patterns -Route parameters are defined by `{var_name}` segments in route paths: +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. +- `"{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. diff --git a/plugins/ui/src/deephaven/ui/hooks/use_params.py b/plugins/ui/src/deephaven/ui/hooks/use_params.py index 29a257a86..29b8df7ad 100644 --- a/plugins/ui/src/deephaven/ui/hooks/use_params.py +++ b/plugins/ui/src/deephaven/ui/hooks/use_params.py @@ -8,9 +8,7 @@ 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. - + Route parameters are defined by curly-braced segments in route paths. Returns: A dictionary mapping parameter names to their matched string values. Returns an empty dict if no router ancestor exists. From a45ae841a84d61a08d8eff05f728b5ec238006f6 Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Mon, 11 May 2026 16:30:13 -0500 Subject: [PATCH 24/31] wip --- plugins/ui/docs/hooks/use_params.md | 8 ++++---- plugins/ui/src/deephaven/ui/hooks/use_params.py | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/plugins/ui/docs/hooks/use_params.md b/plugins/ui/docs/hooks/use_params.md index 3bccc6882..ac02ef20e 100644 --- a/plugins/ui/docs/hooks/use_params.md +++ b/plugins/ui/docs/hooks/use_params.md @@ -104,11 +104,11 @@ app = app() ## Route Parameter Patterns -Route parameters are defined by `"{var_name}"` segments in route paths: +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. +- `\{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. diff --git a/plugins/ui/src/deephaven/ui/hooks/use_params.py b/plugins/ui/src/deephaven/ui/hooks/use_params.py index 29b8df7ad..f96aa8231 100644 --- a/plugins/ui/src/deephaven/ui/hooks/use_params.py +++ b/plugins/ui/src/deephaven/ui/hooks/use_params.py @@ -9,6 +9,7 @@ def use_params() -> dict[str, str]: Get the route parameters from the nearest ancestor router. Route parameters are defined by curly-braced segments in route paths. + Returns: A dictionary mapping parameter names to their matched string values. Returns an empty dict if no router ancestor exists. From 47cd62290f10d94104e60e58ede47eefb0849d82 Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Mon, 11 May 2026 16:36:38 -0500 Subject: [PATCH 25/31] remove --- plugins/ui/docs/hooks/use_params.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/ui/docs/hooks/use_params.md b/plugins/ui/docs/hooks/use_params.md index ac02ef20e..6b577c48a 100644 --- a/plugins/ui/docs/hooks/use_params.md +++ b/plugins/ui/docs/hooks/use_params.md @@ -104,10 +104,10 @@ app = app() ## Route Parameter Patterns -Route parameters are defined by `\{var_name}` segments in route paths: +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. +- `{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. From b7bc5c1c2c65769e5b92d1edd42f76644d7db943 Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Tue, 12 May 2026 10:38:40 -0500 Subject: [PATCH 26/31] missing tests --- tests/app.d/tests.app | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/app.d/tests.app b/tests/app.d/tests.app index 31e450cea..062c6ad29 100644 --- a/tests/app.d/tests.app +++ b/tests/app.d/tests.app @@ -18,4 +18,5 @@ file_11=ag_grid.py file_12=theme_demo.py file_13=ui_nested_dashboard.py file_14=ui_query_params.py +file_15=ui_routing.py From f5556a6c540ef55dc05a13b6b3597da3eee20f6c Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Tue, 12 May 2026 12:24:12 -0500 Subject: [PATCH 27/31] fix test --- tests/ui_routing.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ui_routing.spec.ts b/tests/ui_routing.spec.ts index 36939623e..bf6bb6856 100644 --- a/tests/ui_routing.spec.ts +++ b/tests/ui_routing.spec.ts @@ -7,7 +7,7 @@ test.describe('UI routing - use_path', () => { await openPanel(page, 'ui_use_path', SELECTORS.REACT_PANEL_VISIBLE); const panel = page.locator(SELECTORS.REACT_PANEL_VISIBLE); - await expect(panel.getByText('path=/')).toBeVisible(); + await expect(panel.getByText('path=/', { exact: true })).toBeVisible(); }); }); From 277b68639636cb4cd82e9ab08dab008647b91edc Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Wed, 13 May 2026 12:20:42 -0500 Subject: [PATCH 28/31] a few improvements --- .../ui/src/deephaven/ui/components/router.py | 22 ++++++++++-------- .../ui/src/deephaven/ui/hooks/use_navigate.py | 8 +++---- plugins/ui/src/deephaven/ui/types/types.py | 23 ++++++++++++------- plugins/ui/src/js/src/events/Navigate.ts | 3 +++ 4 files changed, 35 insertions(+), 21 deletions(-) diff --git a/plugins/ui/src/deephaven/ui/components/router.py b/plugins/ui/src/deephaven/ui/components/router.py index 8987eddbc..7023bb683 100644 --- a/plugins/ui/src/deephaven/ui/components/router.py +++ b/plugins/ui/src/deephaven/ui/components/router.py @@ -194,15 +194,16 @@ def _check_conflicts( def _compile_and_check( routes: list[_Route], -) -> list[tuple[str, Callable[..., Any] | None, list[str], bool]]: +) -> list[tuple[re.Pattern[str], Callable[..., Any] | None, list[str], bool]]: """ - Compile routes and check for conflicts. Returns compiled routes if valid. + Compile routes and check for conflicts. Returns compiled routes with + precompiled regexes. Args: routes: The list of _Route definitions to compile and check. Returns: - A list of compiled route tuples (pattern, element, param_names, is_index). + A list of compiled route tuples (compiled_regex, element, param_names, is_index). Raises: ValueError: If conflicting route paths are detected among siblings. @@ -215,12 +216,16 @@ def _compile_and_check( key=lambda r: _specificity_key(r[0], r[3]), reverse=True, ) - return sorted_routes + # Precompile regexes so _match_route doesn't recompile on every call + return [ + (re.compile(_pattern_to_regex(pattern)), element, param_names, is_index) + for pattern, element, param_names, is_index in sorted_routes + ] def _match_route( path: str, - compiled: list[tuple[str, Callable[..., Any] | None, list[str], bool]], + compiled: list[tuple[re.Pattern[str], Callable[..., Any] | None, list[str], bool]], ) -> tuple[Callable[..., Any] | None, dict[str, str]] | None: """ Match a path against compiled routes. @@ -228,7 +233,7 @@ def _match_route( Args: path: The URL path to match. - compiled: The list of compiled route tuples (pattern, element, param_names, is_index). + compiled: The list of compiled route tuples (compiled_regex, element, param_names, is_index). Returns: A tuple of (element, params) for the best match, or None if no match is found. @@ -240,9 +245,8 @@ def _match_route( # 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 "/") + for regex, element, param_names, _ in compiled: + match = regex.match("/" + normalized_path if normalized_path else "/") if match: params: dict[str, str] = {} for name in param_names: diff --git a/plugins/ui/src/deephaven/ui/hooks/use_navigate.py b/plugins/ui/src/deephaven/ui/hooks/use_navigate.py index 611e926f1..59e8afe99 100644 --- a/plugins/ui/src/deephaven/ui/hooks/use_navigate.py +++ b/plugins/ui/src/deephaven/ui/hooks/use_navigate.py @@ -3,7 +3,7 @@ from typing import Any, Callable from urllib.parse import urlencode, urlsplit -from ..types import QueryParams +from ..types import QueryParamsInput from .use_send_event import use_send_event @@ -30,7 +30,7 @@ def _normalize_path(path: str | None) -> str | None: return path -def _normalize_query_params(query_params: str | QueryParams | None) -> str | None: +def _normalize_query_params(query_params: str | QueryParamsInput | None) -> str | None: """ Normalize query params to a ?-prefixed string. None passthrough, empty clears. @@ -92,7 +92,7 @@ def _parse_inline_url(path: str) -> tuple[str, str | None, str | None]: def build_navigate_payload( path: str | None = None, - query_params: str | QueryParams | None = None, + query_params: str | QueryParamsInput | None = None, fragment: str | None = None, replace: bool | None = None, ) -> dict[str, Any]: @@ -145,7 +145,7 @@ def use_navigate() -> Callable[..., None]: def navigate( path: str | None = None, - query_params: str | QueryParams | None = None, + query_params: str | QueryParamsInput | None = None, fragment: str | None = None, replace: bool | None = None, ) -> None: diff --git a/plugins/ui/src/deephaven/ui/types/types.py b/plugins/ui/src/deephaven/ui/types/types.py index 5f27ee5d0..4784cfd1a 100644 --- a/plugins/ui/src/deephaven/ui/types/types.py +++ b/plugins/ui/src/deephaven/ui/types/types.py @@ -605,14 +605,21 @@ class NumberRange(TypedDict): ToastVariant = Literal["positive", "negative", "neutral", "info"] -QueryParams = Dict[str, Union[str, List[str]]] +QueryParams = Dict[str, List[str]] """ -A type alias for query parameter dictionaries used throughout the routing API. +A type alias for query parameter dictionaries returned by the routing API. -Keys are parameter names. Values can be either a single string or a list of strings. -Generally, strings or lists can be passed into the API but only lists are returned from the API. -When serialised to a URL, list values repeat the key: -{"tag": ["python", "java"]} becomes ?tag=python&tag=java. +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`. +""" + +QueryParamsInput = Dict[str, Union[str, List[str]]] +""" +A type alias for query parameter dictionaries accepted as input. + +Keys are parameter names. Values can be either a single string or a list +of strings. Single strings are treated as one-element lists. """ @@ -625,8 +632,8 @@ class NavigationTarget(TypedDict, total=False): path: str """The path to navigate to (e.g. "/dashboard").""" - query_params: Union[str, QueryParams] - """Query string (e.g. "?foo=bar") or a QueryParams dict.""" + query_params: Union[str, QueryParamsInput] + """Query string (e.g. "?foo=bar") or a QueryParamsInput dict.""" fragment: str """URL fragment, e.g. "section" (leading "#" optional).""" diff --git a/plugins/ui/src/js/src/events/Navigate.ts b/plugins/ui/src/js/src/events/Navigate.ts index 8dad724fe..69a60632c 100644 --- a/plugins/ui/src/js/src/events/Navigate.ts +++ b/plugins/ui/src/js/src/events/Navigate.ts @@ -32,6 +32,7 @@ export const HREF_PARAM = '__href'; const WIDGET_PATH_SEPARATOR = '/-/'; /** Allowed URL schemes for navigation */ +/** Local routing doesn't have a scheme, but future routing might. */ const ALLOWED_SCHEMES = new Set(['http:', 'https:', '']); /** @@ -98,6 +99,7 @@ export function Navigate(params: NavigateParams): void { } // Handle query params: null/undefined = preserve (or clear if path changed), "" = clear + // Query params are cleared if a new path is provided without explicit query params as it is assumed they are not relevant. if (navQueryParams != null) { url.search = navQueryParams; } else if (navPath != null) { @@ -106,6 +108,7 @@ export function Navigate(params: NavigateParams): void { } // Handle fragment: null/undefined = preserve (or clear if path changed), "" = clear + // Fragments are cleared if a new path is provided without an explicit fragment as it is assumed it is not relevant. if (navFragment != null) { url.hash = navFragment ? `#${navFragment}` : ''; } else if (navPath != null) { From e17193292d5942d40a30fdd5fd090c825878c95a Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Wed, 13 May 2026 12:30:20 -0500 Subject: [PATCH 29/31] test fixes --- plugins/ui/test/deephaven/ui/test_routing.py | 40 ++++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/plugins/ui/test/deephaven/ui/test_routing.py b/plugins/ui/test/deephaven/ui/test_routing.py index 95fd09c2d..cca5a6635 100644 --- a/plugins/ui/test/deephaven/ui/test_routing.py +++ b/plugins/ui/test/deephaven/ui/test_routing.py @@ -515,26 +515,26 @@ def user_post(): 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.router import _compile_and_check, _match_route from deephaven.ui.components.route import _Route def home(): return None - compiled = _compile_routes([_Route(path="home", element=home)]) + compiled = _compile_and_check([_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.router import _compile_and_check, _match_route from deephaven.ui.components.route import _Route def user(): return None - compiled = _compile_routes( + compiled = _compile_and_check( [ _Route(path="users/{user_id}", element=user), ] @@ -545,13 +545,13 @@ def 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.router import _compile_and_check, _match_route from deephaven.ui.components.route import _Route def user_post(): return None - compiled = _compile_routes( + compiled = _compile_and_check( [ _Route( path="users", @@ -571,7 +571,7 @@ def user_post(): 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.router import _compile_and_check, _match_route from deephaven.ui.components.route import _Route def settings(): @@ -580,7 +580,7 @@ def settings(): def user(): return "user" - compiled = _compile_routes( + compiled = _compile_and_check( [ _Route(path="users/settings", element=settings), _Route(path="users/{user_id}", element=user), @@ -591,7 +591,7 @@ def user(): 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.router import _compile_and_check, _match_route from deephaven.ui.components.route import _Route def home(): @@ -600,7 +600,7 @@ def home(): def not_found(): return "not_found" - compiled = _compile_routes( + compiled = _compile_and_check( [ _Route(path="home", element=home), _Route(path="*", element=not_found), @@ -616,19 +616,19 @@ def 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.router import _compile_and_check, _match_route from deephaven.ui.components.route import _Route def catch_all(): return None - compiled = _compile_routes([_Route(path="*", element=catch_all)]) + compiled = _compile_and_check([_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.router import _compile_and_check, _match_route from deephaven.ui.components.route import _Route def index(): @@ -637,7 +637,7 @@ def index(): def child(): return "child" - compiled = _compile_routes( + compiled = _compile_and_check( [ _Route( path="users", @@ -654,13 +654,13 @@ def child(): self.assertEqual(result[0], index) def test_optional_param(self): - from deephaven.ui.components.router import _compile_routes, _match_route + from deephaven.ui.components.router import _compile_and_check, _match_route from deephaven.ui.components.route import _Route def user_page(): return None - compiled = _compile_routes( + compiled = _compile_and_check( [ _Route(path="users/{tab?}", element=user_page), ] @@ -676,24 +676,24 @@ def user_page(): self.assertEqual(result[1], {}) def test_no_match_returns_none(self): - from deephaven.ui.components.router import _compile_routes, _match_route + from deephaven.ui.components.router import _compile_and_check, _match_route from deephaven.ui.components.route import _Route def home(): return None - compiled = _compile_routes([_Route(path="home", element=home)]) + compiled = _compile_and_check([_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.router import _compile_and_check, _match_route from deephaven.ui.components.route import _Route def dashboard(): return None - compiled = _compile_routes( + compiled = _compile_and_check( [ _Route(index=True, element=dashboard), ] From e2e0b2230f0da75d598a0e3aca0f8af8b33bc78a Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Mon, 18 May 2026 15:39:08 -0500 Subject: [PATCH 30/31] rework --- .../deephaven/ui/_internal/RenderContext.py | 64 +-------- .../ui/_internal/RootRenderContextProtocol.py | 41 +----- .../ui/src/deephaven/ui/hooks/_url_parse.py | 59 ++++++++ plugins/ui/src/deephaven/ui/hooks/use_path.py | 6 +- .../deephaven/ui/hooks/use_query_params.py | 4 +- .../deephaven/ui/hooks/use_url_components.py | 7 +- .../ui/object_types/ElementMessageStream.py | 121 +++------------- plugins/ui/src/js/src/events/Navigate.test.ts | 53 +------ plugins/ui/src/js/src/events/Navigate.ts | 24 +--- .../src/js/src/widget/WidgetHandler.test.tsx | 72 ++-------- .../ui/src/js/src/widget/WidgetHandler.tsx | 21 +-- .../ui/test/deephaven/ui/test_query_params.py | 133 ++++++------------ plugins/ui/test/deephaven/ui/test_renderer.py | 10 +- plugins/ui/test/deephaven/ui/test_routing.py | 128 +++++------------ plugins/ui/test/deephaven/ui/test_ui_table.py | 10 +- .../ui/test/deephaven/ui/test_utils_root.py | 38 +---- 16 files changed, 211 insertions(+), 580 deletions(-) create mode 100644 plugins/ui/src/deephaven/ui/hooks/_url_parse.py diff --git a/plugins/ui/src/deephaven/ui/_internal/RenderContext.py b/plugins/ui/src/deephaven/ui/_internal/RenderContext.py index 732b84db0..2a625c302 100644 --- a/plugins/ui/src/deephaven/ui/_internal/RenderContext.py +++ b/plugins/ui/src/deephaven/ui/_internal/RenderContext.py @@ -21,7 +21,6 @@ from contextlib import contextmanager from dataclasses import dataclass from .NoContextException import NoContextException -from ..types import QueryParams from .RootRenderContextProtocol import RootRenderContextProtocol, StateUpdateCallable logger = logging.getLogger(__name__) @@ -415,54 +414,13 @@ def _assert_mounted(self) -> None: "RenderContext method called when RenderContext is unmounted" ) - def get_query_params(self) -> QueryParams: + def get_url(self) -> str: """ - Get the URL query parameters received from the frontend. + Get the full URL received from the frontend. Returns: - A dictionary mapping parameter names to lists of values. + The full URL. """ - 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. - - Args: - query_params: New query parameter mapping to store. - """ - self._root.set_query_params(query_params) + return self._root.get_url() def has_state(self, key: StateKey) -> bool: """ @@ -654,17 +612,9 @@ def import_state(self, state: dict[str, Any]) -> None: # Extract URL state fields (prefixed with __) before processing component state. # Only update when the key is explicitly present so recursive calls for child - # 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")) + # contexts (which never carry __url) don't accidentally clear URL state. + if "__url" in state: + self._root.set_url(state.pop("__url")) 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 d6e4a7681..1f2746957 100644 --- a/plugins/ui/src/deephaven/ui/_internal/RootRenderContextProtocol.py +++ b/plugins/ui/src/deephaven/ui/_internal/RootRenderContextProtocol.py @@ -5,7 +5,6 @@ runtime_checkable, Protocol, ) -from ..types import QueryParams StateUpdateCallable = Callable[[], None] """ @@ -28,42 +27,10 @@ def on_queue_render(self, callback: StateUpdateCallable) -> None: """Called when work is being requested for the render loop.""" ... - def get_query_params(self) -> QueryParams: - """Get the current URL query parameters.""" + def get_url(self) -> str: + """Get the full URL sent from the frontend.""" ... - 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.""" + def set_url(self, url: str) -> None: + """Set the full URL.""" ... diff --git a/plugins/ui/src/deephaven/ui/hooks/_url_parse.py b/plugins/ui/src/deephaven/ui/hooks/_url_parse.py new file mode 100644 index 000000000..07881632f --- /dev/null +++ b/plugins/ui/src/deephaven/ui/hooks/_url_parse.py @@ -0,0 +1,59 @@ +from urllib.parse import SplitResult, urlsplit, parse_qs + +from ..types import QueryParams + +# Separator between platform routing and widget routing in the URL +WIDGET_PATH_SEPARATOR = "/-/" + + +def parse_url(url: str | None) -> SplitResult: + """ + Parse a URL string into components. + + Args: + url: The full URL string, or None. + + Returns: + A SplitResult. If url is None or empty, returns a SplitResult + with all empty fields. + """ + return urlsplit(url) if url else urlsplit("") + + +def get_query_params(url: str | None) -> QueryParams: + """ + Extract query parameters from a URL string. + + Args: + url: The full URL string, or None. + + Returns: + A dictionary mapping parameter names to lists of string values. + """ + parsed = parse_url(url) + return parse_qs(parsed.query, keep_blank_values=True) + + +def get_path(url: str | None, absolute: bool = False) -> str: + """ + Extract the path from a URL string. + + The /-/ separator divides platform routing from widget routing. + The section after /-/ is the path relative to the current widget. + + Args: + url: The full URL string, or None. + absolute: If True, returns the full path from the URL. + If False (default), returns the widget-relative path (after /-/). + + Returns: + The path as a string, defaulting to "/" if not found. + """ + parsed = parse_url(url) + if absolute: + return parsed.path or "/" + separator_index = parsed.path.find(WIDGET_PATH_SEPARATOR) + if separator_index == -1: + return "/" + relative = parsed.path[separator_index + len(WIDGET_PATH_SEPARATOR) :] + return f"/{relative}" if relative else "/" diff --git a/plugins/ui/src/deephaven/ui/hooks/use_path.py b/plugins/ui/src/deephaven/ui/hooks/use_path.py index 3e4323a92..529fea0b7 100644 --- a/plugins/ui/src/deephaven/ui/hooks/use_path.py +++ b/plugins/ui/src/deephaven/ui/hooks/use_path.py @@ -1,4 +1,5 @@ from .._internal import get_context +from ._url_parse import get_path def use_path(absolute: bool = False) -> str: @@ -19,6 +20,5 @@ def use_path(absolute: bool = False) -> str: The current path as a string. """ context = get_context() - if absolute: - return context.get_absolute_path() or "/" - return context.get_path() or "/" + url = context.get_url() + return get_path(url, absolute) diff --git a/plugins/ui/src/deephaven/ui/hooks/use_query_params.py b/plugins/ui/src/deephaven/ui/hooks/use_query_params.py index 9fecf8843..b98b975ff 100644 --- a/plugins/ui/src/deephaven/ui/hooks/use_query_params.py +++ b/plugins/ui/src/deephaven/ui/hooks/use_query_params.py @@ -1,5 +1,6 @@ from ..types import QueryParams from .._internal import get_context +from ._url_parse import get_query_params as _get_query_params def use_query_params() -> QueryParams: @@ -10,4 +11,5 @@ def use_query_params() -> QueryParams: A dictionary mapping parameter names to lists of string values. """ context = get_context() - return context.get_query_params() + url = context.get_url() + return _get_query_params(url) diff --git a/plugins/ui/src/deephaven/ui/hooks/use_url_components.py b/plugins/ui/src/deephaven/ui/hooks/use_url_components.py index 7200da9d1..13fd76860 100644 --- a/plugins/ui/src/deephaven/ui/hooks/use_url_components.py +++ b/plugins/ui/src/deephaven/ui/hooks/use_url_components.py @@ -1,6 +1,7 @@ -from urllib.parse import SplitResult, urlsplit +from urllib.parse import SplitResult from .._internal import get_context +from ._url_parse import parse_url def use_url_components() -> SplitResult: @@ -17,5 +18,5 @@ def use_url_components() -> SplitResult: - fragment: Fragment (without leading "#") """ context = get_context() - href = context.get_href() - return urlsplit(href) + url = context.get_url() + return parse_url(url) diff --git a/plugins/ui/src/deephaven/ui/object_types/ElementMessageStream.py b/plugins/ui/src/deephaven/ui/object_types/ElementMessageStream.py index 944bf0b4c..9018b80dd 100644 --- a/plugins/ui/src/deephaven/ui/object_types/ElementMessageStream.py +++ b/plugins/ui/src/deephaven/ui/object_types/ElementMessageStream.py @@ -10,7 +10,7 @@ import traceback from enum import Enum from queue import Queue -from typing import Any, Callable, TypedDict +from typing import Any, Callable from deephaven.plugin.object_type import MessageStream from deephaven.server.executors import submit_task from deephaven.execution_context import ExecutionContext, get_exec_ctx @@ -21,7 +21,6 @@ from ..elements import Element from ..renderer import NodeEncoder, Renderer, RenderedNode from ..renderer.NodeEncoder import CALLABLE_KEY -from ..types import QueryParams from .._internal import ( RenderContext, ExportedRenderState, @@ -34,12 +33,6 @@ logger = logging.getLogger(__name__) -_UrlState = TypedDict("_UrlState", {"__queryParams": QueryParams}) -""" -The URL state sent from the client, containing query parameter names mapped to -their list of string values. -""" - class _RenderState(Enum): """ @@ -177,23 +170,8 @@ class ElementMessageStream(MessageStream, RootRenderContextProtocol): The last document sent to the client. Used to generate a patch for the next document """ - _query_params: QueryParams - """ - The URL query parameters, populated from the frontend. - 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.""" + _url: str + """The full URL.""" def __init__(self, element: Element, connection: MessageStream): """ @@ -211,11 +189,7 @@ def __init__(self, element: Element, connection: MessageStream): self._dispatcher = self._make_dispatcher() self._encoder = NodeEncoder() self._event_encoder = EventEncoder(self._serialize_callables) - self._query_params = {} - self._path = "/" - self._absolute_path = "/" - self._fragment = "" - self._href = "" + self._url = "" self._context = RenderContext(self) self._event_context = EventContext(self._send_event) self._renderer = Renderer(self._context) @@ -330,71 +304,20 @@ def on_queue_render(self, callable: StateUpdateCallable) -> None: self._callable_queue.put(callable) self._queue_render() - def get_query_params(self) -> QueryParams: - return self._query_params - - 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: + def get_url(self) -> str: """ - Get the full URL href. + Get the full URL. """ - return self._href + return self._url - def set_href(self, href: str) -> None: + def set_url(self, url: str) -> None: """ - Set the full URL href. + Set the full URL. Args: - href: The href to set + url: The URL to set """ - self._href = href + self._url = url def start(self) -> None: """ @@ -495,27 +418,17 @@ def _set_state(self, state: ExportedRenderState) -> None: self._context.import_state(state) self._mark_dirty() - def _set_url_state(self, url_state: _UrlState) -> None: + def _set_url_state(self, url_state: dict[str, Any]) -> None: """ - 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. + Update the URL state. Called by the client after a client-side + navigation so that the component re-renders with updated URL state. Args: - url_state: Dict with URL state fields __queryParams, - __path, __absolutePath, __fragment, __href. + url_state: Dict with __url field containing the full URL. """ logger.debug("Setting URL state: %s", url_state) - 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"]) + if "__url" in url_state: + self.set_url(url_state["__url"]) self._mark_dirty() def _serialize_callables(self, node: Any) -> Any: diff --git a/plugins/ui/src/js/src/events/Navigate.test.ts b/plugins/ui/src/js/src/events/Navigate.test.ts index 1a7b81672..aa9c963a8 100644 --- a/plugins/ui/src/js/src/events/Navigate.test.ts +++ b/plugins/ui/src/js/src/events/Navigate.test.ts @@ -1,4 +1,4 @@ -import { Navigate, getWidgetRelativePath } from './Navigate'; +import { Navigate } from './Navigate'; describe('Navigate', () => { let originalLocation: Location; @@ -143,54 +143,3 @@ describe('Navigate', () => { 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 69a60632c..b3ea3bc95 100644 --- a/plugins/ui/src/js/src/events/Navigate.ts +++ b/plugins/ui/src/js/src/events/Navigate.ts @@ -21,12 +21,8 @@ export type NavigateParams = { replace?: boolean | null; }; -// 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'; +// Type sent to the server for current location +export const URL_PARAM = '__url'; /** Separator between platform routing and widget routing in the URL */ const WIDGET_PATH_SEPARATOR = '/-/'; @@ -49,22 +45,6 @@ function getWidgetBasePath(): string { return pathname.substring(0, separatorIndex + WIDGET_PATH_SEPARATOR.length); } -/** - * 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. diff --git a/plugins/ui/src/js/src/widget/WidgetHandler.test.tsx b/plugins/ui/src/js/src/widget/WidgetHandler.test.tsx index 7f66f1e1b..7baf92281 100644 --- a/plugins/ui/src/js/src/widget/WidgetHandler.test.tsx +++ b/plugins/ui/src/js/src/widget/WidgetHandler.test.tsx @@ -103,12 +103,8 @@ it('updates the document when event is received', async () => { 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'); + expect(setStatePayload.params[0]).toHaveProperty('__url'); const listener = mockAddEventListener.mock.calls[0][1]; @@ -192,8 +188,8 @@ it('updates the initial data only when widget has changed', async () => { expect(setStatePayload1.method).toBe('setState'); expect(setStatePayload1.params[0]).toMatchObject({ ...data1.state, - __queryParams: {}, }); + expect(setStatePayload1.params[0]).toHaveProperty('__url'); let listener = addEventListener.mock.calls[0][1]; @@ -264,8 +260,8 @@ it('updates the initial data only when widget has changed', async () => { expect(setStatePayload2.method).toBe('setState'); expect(setStatePayload2.params[0]).toMatchObject({ ...data2.state, - __queryParams: {}, }); + expect(setStatePayload2.params[0]).toHaveProperty('__url'); expect(sendMessage).toHaveBeenCalledTimes(1); // Send the initial document @@ -322,8 +318,8 @@ it('handles rendering widget error if widget is null (query disconnected)', asyn expect(setStatePayloadErr.method).toBe('setState'); expect(setStatePayloadErr.params[0]).toMatchObject({ ...data1.state, - __queryParams: {}, }); + expect(setStatePayloadErr.params[0]).toHaveProperty('__url'); const listener = mockAddEventListener.mock.calls[0][1]; @@ -413,7 +409,7 @@ async function setupWidgetWithListener() { } describe('URL state in sendSetState', () => { - it('includes __queryParams from window.location.search', async () => { + it('includes __url from window.location.href', async () => { // Set up URL with query params const url = new URL('http://localhost/test?foo=bar&baz=qux'); Object.defineProperty(window, 'location', { @@ -442,7 +438,7 @@ describe('URL state in sendSetState', () => { expect(payload.method).toBe('setState'); expect(payload.params[0]).toMatchObject({ key: 'val', - __queryParams: { foo: ['bar'], baz: ['qux'] }, + __url: 'http://localhost/test?foo=bar&baz=qux', }); unmount(); @@ -453,43 +449,6 @@ describe('URL state in sendSetState', () => { writable: true, }); }); - - it('includes multi-value query params', async () => { - const url = new URL('http://localhost/test?tag=python&tag=java'); - Object.defineProperty(window, 'location', { - value: url, - writable: true, - }); - - const widget = makeWidgetDescriptor(); - const mockSendMessage = jest.fn(); - mockWidgetWrapper = { - widget: makeWidget({ - addEventListener: jest.fn(() => jest.fn()), - getDataAsString: jest.fn(() => ''), - sendMessage: mockSendMessage, - }), - error: null, - api: jest.fn() as unknown as typeof dh, - }; - - const { unmount } = render( - makeWidgetHandler({ widgetDescriptor: widget, initialData: undefined }) - ); - - 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(); - - Object.defineProperty(window, 'location', { - value: new URL('http://localhost/'), - writable: true, - }); - }); }); describe('navigate event handling', () => { @@ -630,7 +589,8 @@ describe('navigate event handling', () => { (c: { method: string }) => c.method === 'setUrlState' ); expect(urlStateCall).toBeDefined(); - expect(urlStateCall.params[0]).toHaveProperty('__queryParams'); + expect(urlStateCall.params[0]).toHaveProperty('__url'); + expect(Object.keys(urlStateCall.params[0])).toEqual(['__url']); unmount(); }); @@ -754,7 +714,7 @@ describe('navigate event handling', () => { unmount(); }); - it('sends extended URL state after navigation', async () => { + it('sends URL state after navigation', async () => { const { listener, mockSendMessage, unmount } = await setupWidgetWithListener(); @@ -775,11 +735,8 @@ describe('navigate event handling', () => { (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'); + expect(urlStateCall.params[0]).toHaveProperty('__url'); + expect(Object.keys(urlStateCall.params[0])).toEqual(['__url']); unmount(); }); @@ -802,11 +759,8 @@ describe('popstate listener', () => { (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'); + expect(urlStateCall.params[0]).toHaveProperty('__url'); + expect(Object.keys(urlStateCall.params[0])).toEqual(['__url']); unmount(); }); diff --git a/plugins/ui/src/js/src/widget/WidgetHandler.tsx b/plugins/ui/src/js/src/widget/WidgetHandler.tsx index 25c9b7904..51b69bd43 100644 --- a/plugins/ui/src/js/src/widget/WidgetHandler.tsx +++ b/plugins/ui/src/js/src/widget/WidgetHandler.tsx @@ -58,12 +58,7 @@ import Navigate, { NAVIGATE_EVENT, type NavigateParams, URL_CHANGED_EVENT, - QUERY_PARAM, - PATH_PARAM, - ABSOLUTE_PATH_PARAM, - FRAGMENT_PARAM, - HREF_PARAM, - getWidgetRelativePath, + URL_PARAM, } from '../events/Navigate'; import NavigateContext from '../events/NavigateContext'; import UriExportedObject from './UriExportedObject'; @@ -166,20 +161,8 @@ function WidgetHandler({ ); const getUrlState = useCallback(() => { - const queryParams: Record = {}; - const searchParams = new URLSearchParams(window.location.search); - searchParams.forEach((value, key) => { - if (queryParams[key] == null) { - queryParams[key] = []; - } - queryParams[key].push(value); - }); return { - [QUERY_PARAM]: queryParams, - [PATH_PARAM]: getWidgetRelativePath(), - [ABSOLUTE_PATH_PARAM]: window.location.pathname, - [FRAGMENT_PARAM]: window.location.hash.replace(/^#/, ''), - [HREF_PARAM]: window.location.href, + [URL_PARAM]: window.location.href, }; }, []); diff --git a/plugins/ui/test/deephaven/ui/test_query_params.py b/plugins/ui/test/deephaven/ui/test_query_params.py index e96cc3c4e..0aef83d21 100644 --- a/plugins/ui/test/deephaven/ui/test_query_params.py +++ b/plugins/ui/test/deephaven/ui/test_query_params.py @@ -18,65 +18,6 @@ def make_render_context( return RenderContext(TestRoot(on_change, on_queue)) -class QueryParamsRenderContextTestCase(BaseTestCase): - """Tests for query params storage on RenderContext.""" - - def test_default_query_params_empty(self): - rc = make_render_context() - self.assertEqual(rc.get_query_params(), {}) - - def test_import_state_with_query_params(self): - rc = make_render_context() - state: Dict[str, Any] = { - "__queryParams": {"page": ["1"], "tag": ["python", "java"]}, - } - rc.import_state(state) - self.assertEqual( - rc.get_query_params(), - {"page": ["1"], "tag": ["python", "java"]}, - ) - - def test_import_state_without_query_params(self): - rc = make_render_context() - state: Dict[str, Any] = {"state": {"0": 42}} - rc.import_state(state) - self.assertEqual(rc.get_query_params(), {}) - - def test_import_state_query_params_not_in_exported_state(self): - """__queryParams should be consumed (popped) from the state dict and - should not appear in exported state.""" - rc = make_render_context() - state: Dict[str, Any] = { - "__queryParams": {"q": ["test"]}, - "state": {"0": 1}, - } - rc.import_state(state) - # query params should be readable - self.assertEqual(rc.get_query_params(), {"q": ["test"]}) - # but should not leak into exported state - exported = rc.export_state() - self.assertNotIn("__queryParams", exported) - - def test_import_state_resets_query_params(self): - rc = make_render_context() - rc.import_state({"__queryParams": {"a": ["1"]}}) - self.assertEqual(rc.get_query_params(), {"a": ["1"]}) - # Import again without __queryParams -> should preserve existing - rc.import_state({}) - self.assertEqual(rc.get_query_params(), {"a": ["1"]}) - # Import with explicit empty __queryParams -> should reset - rc.import_state({"__queryParams": {}}) - self.assertEqual(rc.get_query_params(), {}) - - def test_query_params_on_child_context(self): - """Child contexts read query params from the root via _root reference.""" - rc = make_render_context() - rc.import_state({"__queryParams": {"key": ["val"]}}) - with rc.open(): - child = rc.get_child_context("child0") - self.assertEqual(child.get_query_params(), {"key": ["val"]}) - - class UseQueryParamsTestCase(BaseTestCase): """Tests for the use_query_params hook.""" @@ -84,7 +25,7 @@ def test_returns_query_params(self): from deephaven.ui.hooks.use_query_params import use_query_params rc = make_render_context() - rc.import_state({"__queryParams": {"page": ["2"], "sort": ["asc"]}}) + rc.import_state({"__url": "https://example.com/app/-/page?page=2&sort=asc"}) with rc.open(): result = use_query_params() self.assertEqual(result, {"page": ["2"], "sort": ["asc"]}) @@ -92,12 +33,30 @@ def test_returns_query_params(self): def test_returns_empty_when_no_params(self): from deephaven.ui.hooks.use_query_params import use_query_params + rc = make_render_context() + rc.import_state({"__url": "https://example.com/app/-/page"}) + with rc.open(): + result = use_query_params() + self.assertEqual(result, {}) + + def test_returns_empty_when_no_url(self): + from deephaven.ui.hooks.use_query_params import use_query_params + rc = make_render_context() rc.import_state({}) with rc.open(): result = use_query_params() self.assertEqual(result, {}) + def test_multi_value_params(self): + from deephaven.ui.hooks.use_query_params import use_query_params + + rc = make_render_context() + rc.import_state({"__url": "https://example.com/app?tag=python&tag=java"}) + with rc.open(): + result = use_query_params() + self.assertEqual(result, {"tag": ["python", "java"]}) + class UseQueryParamTestCase(BaseTestCase): """Tests for the use_query_param hook.""" @@ -106,7 +65,7 @@ def test_absent_key_returns_none(self): from deephaven.ui.hooks.use_query_param import use_query_param rc = make_render_context() - rc.import_state({"__queryParams": {"other": ["val"]}}) + rc.import_state({"__url": "https://example.com/app?other=val"}) with rc.open(): result = use_query_param("missing") self.assertIsNone(result) @@ -115,7 +74,7 @@ def test_absent_key_returns_list_default(self): from deephaven.ui.hooks.use_query_param import use_query_param rc = make_render_context() - rc.import_state({"__queryParams": {}}) + rc.import_state({"__url": "https://example.com/app"}) with rc.open(): result = use_query_param("missing", []) self.assertEqual(result, []) @@ -125,7 +84,7 @@ def test_present_key_no_value(self): from deephaven.ui.hooks.use_query_param import use_query_param rc = make_render_context() - rc.import_state({"__queryParams": {"foo": [""]}}) + rc.import_state({"__url": "https://example.com/app?foo"}) with rc.open(): result = use_query_param("foo") self.assertEqual(result, "") @@ -134,7 +93,7 @@ def test_present_key_single_value(self): from deephaven.ui.hooks.use_query_param import use_query_param rc = make_render_context() - rc.import_state({"__queryParams": {"page": ["3"]}}) + rc.import_state({"__url": "https://example.com/app?page=3"}) with rc.open(): result = use_query_param("page") self.assertEqual(result, "3") @@ -144,7 +103,7 @@ def test_multi_value_none_default_returns_last(self): from deephaven.ui.hooks.use_query_param import use_query_param rc = make_render_context() - rc.import_state({"__queryParams": {"tag": ["python", "java"]}}) + rc.import_state({"__url": "https://example.com/app?tag=python&tag=java"}) with rc.open(): result = use_query_param("tag") self.assertEqual(result, "java") @@ -153,32 +112,26 @@ def test_multi_value_list_default_returns_all(self): from deephaven.ui.hooks.use_query_param import use_query_param rc = make_render_context() - rc.import_state({"__queryParams": {"tag": ["python", "java"]}}) + rc.import_state({"__url": "https://example.com/app?tag=python&tag=java"}) with rc.open(): result = use_query_param("tag", []) self.assertEqual(result, ["python", "java"]) - def test_empty_values_list(self): - """An empty values list should return '' for None default.""" - from deephaven.ui.hooks.use_query_param import use_query_param - - rc = make_render_context() - rc.import_state({"__queryParams": {"foo": []}}) - with rc.open(): - result = use_query_param("foo") - self.assertEqual(result, "") - class UseSetQueryParamTestCase(BaseTestCase): """Tests for the use_set_query_param hook.""" - def _setup_context_with_event(self, query_params=None): - """Helper to set up a RenderContext + EventContext with a mock send_event.""" + def _setup_context_with_event(self, query_string=""): + """Helper to set up a RenderContext + EventContext with a mock send_event. + + Args: + query_string: Query string to include in the URL (e.g. "page=1&sort=asc"). + """ rc = make_render_context() - state: Dict[str, Any] = {} - if query_params is not None: - state["__queryParams"] = query_params - rc.import_state(state) + url = "https://example.com/app" + if query_string: + url += f"?{query_string}" + rc.import_state({"__url": url}) send_event_mock = Mock() ec = EventContext(send_event_mock) @@ -187,7 +140,7 @@ def _setup_context_with_event(self, query_params=None): def test_setter_sets_string_value(self): from deephaven.ui.hooks.use_set_query_param import use_set_query_param - rc, ec, send_event_mock = self._setup_context_with_event({"page": ["1"]}) + rc, ec, send_event_mock = self._setup_context_with_event("page=1") with rc.open(), ec.open(): setter = use_set_query_param("page") setter("2") @@ -215,9 +168,7 @@ def test_setter_sets_list_value(self): def test_setter_removes_with_none(self): from deephaven.ui.hooks.use_set_query_param import use_set_query_param - rc, ec, send_event_mock = self._setup_context_with_event( - {"page": ["1"], "sort": ["asc"]} - ) + rc, ec, send_event_mock = self._setup_context_with_event("page=1&sort=asc") with rc.open(), ec.open(): setter = use_set_query_param("page") setter(None) @@ -228,7 +179,7 @@ def test_setter_removes_with_none(self): def test_setter_removes_with_empty_list(self): from deephaven.ui.hooks.use_set_query_param import use_set_query_param - rc, ec, send_event_mock = self._setup_context_with_event({"page": ["1"]}) + rc, ec, send_event_mock = self._setup_context_with_event("page=1") with rc.open(), ec.open(): setter = use_set_query_param("page") setter([]) @@ -239,7 +190,7 @@ def test_setter_removes_with_empty_list(self): def test_setter_removes_with_no_args(self): from deephaven.ui.hooks.use_set_query_param import use_set_query_param - rc, ec, send_event_mock = self._setup_context_with_event({"page": ["1"]}) + rc, ec, send_event_mock = self._setup_context_with_event("page=1") with rc.open(), ec.open(): setter = use_set_query_param("page") setter() @@ -251,7 +202,7 @@ def test_setter_preserves_other_params(self): from deephaven.ui.hooks.use_set_query_param import use_set_query_param rc, ec, send_event_mock = self._setup_context_with_event( - {"page": ["1"], "sort": ["asc"], "filter": ["active"]} + "page=1&sort=asc&filter=active" ) with rc.open(), ec.open(): setter = use_set_query_param("page") @@ -277,7 +228,7 @@ def test_setter_replace_false(self): def test_setter_adds_new_param(self): from deephaven.ui.hooks.use_set_query_param import use_set_query_param - rc, ec, send_event_mock = self._setup_context_with_event({"existing": ["val"]}) + rc, ec, send_event_mock = self._setup_context_with_event("existing=val") with rc.open(), ec.open(): setter = use_set_query_param("new_key") setter("new_val") @@ -291,7 +242,7 @@ def test_setter_adds_new_param(self): def test_setter_removes_with_empty_string(self): from deephaven.ui.hooks.use_set_query_param import use_set_query_param - rc, ec, send_event_mock = self._setup_context_with_event({"page": ["1"]}) + rc, ec, send_event_mock = self._setup_context_with_event("page=1") with rc.open(), ec.open(): setter = use_set_query_param("page") setter("") diff --git a/plugins/ui/test/deephaven/ui/test_renderer.py b/plugins/ui/test/deephaven/ui/test_renderer.py index bd6707f0c..ca22f9907 100644 --- a/plugins/ui/test/deephaven/ui/test_renderer.py +++ b/plugins/ui/test/deephaven/ui/test_renderer.py @@ -18,7 +18,7 @@ class _TestRoot: def __init__(self, on_change_fn, on_queue_fn): self._on_change = on_change_fn self._on_queue_render_fn = on_queue_fn - self._query_params: Dict[str, List[str]] = {} + self._url: str = "" def on_change(self, update: Callable[[], None]) -> None: self._on_change(update) @@ -26,11 +26,11 @@ def on_change(self, update: Callable[[], None]) -> None: def on_queue_render(self, update: Callable[[], None]) -> None: self._on_queue_render_fn(update) - def get_query_params(self) -> Dict[str, List[str]]: - return self._query_params + def get_url(self) -> str: + return self._url - def set_query_params(self, query_params: Dict[str, List[str]]) -> None: - self._query_params = query_params + def set_url(self, url: str) -> None: + self._url = url class RendererTestCase(BaseTestCase): diff --git a/plugins/ui/test/deephaven/ui/test_routing.py b/plugins/ui/test/deephaven/ui/test_routing.py index cca5a6635..5cc06eba6 100644 --- a/plugins/ui/test/deephaven/ui/test_routing.py +++ b/plugins/ui/test/deephaven/ui/test_routing.py @@ -24,117 +24,51 @@ def make_render_context( class UrlStateRenderContextTestCase(BaseTestCase): - """Tests for extended URL state on RenderContext.""" + """Tests for URL state on RenderContext (only url is stored).""" - def test_default_path_is_root(self): + def test_default_url_is_empty(self): rc = make_render_context() - self.assertEqual(rc.get_path(), "/") + self.assertEqual(rc.get_url(), "") - 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): + def test_import_state_with_url(self): rc = make_render_context() state: Dict[str, Any] = { - "__href": "https://example.com/widget/-/page?q=1#sec", + "__url": "https://example.com/widget/-/page?q=1#sec", } rc.import_state(state) self.assertEqual( - rc.get_href(), + rc.get_url(), "https://example.com/widget/-/page?q=1#sec", ) - def test_import_state_all_url_fields(self): + def test_import_state_preserves_url_when_absent(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({"__url": "https://example.com/app/-/page1"}) + self.assertEqual(rc.get_url(), "https://example.com/app/-/page1") + # Import without __url should preserve rc.import_state({}) - self.assertEqual(rc.get_path(), "/page1") + self.assertEqual(rc.get_url(), "https://example.com/app/-/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", + "__url": "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) + self.assertNotIn("__url", exported) - def test_child_context_reads_url_state_from_root(self): + def test_child_context_reads_url_from_root(self): rc = make_render_context() rc.import_state( { - "__path": "/users", - "__fragment": "details", + "__url": "https://example.com/app/-/users#details", } ) with rc.open(): child = rc.get_child_context("child0") - self.assertEqual(child.get_path(), "/users") - self.assertEqual(child.get_fragment(), "details") + self.assertEqual(child.get_url(), "https://example.com/app/-/users#details") # ──────────────────────────────────────────────────────────────────── @@ -149,12 +83,12 @@ 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"}) + rc.import_state({"__url": "https://example.com/app/-/dashboard/settings"}) with rc.open(): result = use_path() self.assertEqual(result, "/dashboard/settings") - def test_returns_root_when_no_path(self): + def test_returns_root_when_no_url(self): from deephaven.ui.hooks.use_path import use_path rc = make_render_context() @@ -163,19 +97,35 @@ def test_returns_root_when_no_path(self): result = use_path() self.assertEqual(result, "/") + def test_returns_root_when_no_separator(self): + from deephaven.ui.hooks.use_path import use_path + + rc = make_render_context() + rc.import_state({"__url": "https://example.com/app/page"}) + 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", - } + {"__url": "https://example.com/iriside/embed/widget/q/w/-/page"} ) with rc.open(): result = use_path(absolute=True) self.assertEqual(result, "/iriside/embed/widget/q/w/-/page") + def test_returns_root_relative_at_separator_boundary(self): + from deephaven.ui.hooks.use_path import use_path + + rc = make_render_context() + rc.import_state({"__url": "https://example.com/app/-/"}) + with rc.open(): + result = use_path() + self.assertEqual(result, "/") + # ──────────────────────────────────────────────────────────────────── # use_navigate @@ -388,7 +338,7 @@ def test_returns_split_result(self): rc = make_render_context() rc.import_state( { - "__href": "https://example.com:8080/app/-/page?q=1&tag=py#top", + "__url": "https://example.com:8080/app/-/page?q=1&tag=py#top", } ) with rc.open(): @@ -399,7 +349,7 @@ def test_returns_split_result(self): self.assertEqual(result.query, "q=1&tag=py") self.assertEqual(result.fragment, "top") - def test_returns_empty_for_no_href(self): + def test_returns_empty_for_no_url(self): from deephaven.ui.hooks.use_url_components import use_url_components rc = make_render_context() diff --git a/plugins/ui/test/deephaven/ui/test_ui_table.py b/plugins/ui/test/deephaven/ui/test_ui_table.py index 016f27326..37f17dc2a 100644 --- a/plugins/ui/test/deephaven/ui/test_ui_table.py +++ b/plugins/ui/test/deephaven/ui/test_ui_table.py @@ -11,7 +11,7 @@ class _TestRoot: def __init__(self, on_change_fn, on_queue_fn): self._on_change = on_change_fn self._on_queue_render_fn = on_queue_fn - self._query_params: Dict[str, List[str]] = {} + self._url: str = "" def on_change(self, update: Callable[[], None]) -> None: self._on_change(update) @@ -19,11 +19,11 @@ def on_change(self, update: Callable[[], None]) -> None: def on_queue_render(self, update: Callable[[], None]) -> None: self._on_queue_render_fn(update) - def get_query_params(self) -> Dict[str, List[str]]: - return self._query_params + def get_url(self) -> str: + return self._url - def set_query_params(self, query_params: Dict[str, List[str]]) -> None: - self._query_params = query_params + def set_url(self, url: str) -> None: + self._url = url class UITableTestCase(BaseTestCase): diff --git a/plugins/ui/test/deephaven/ui/test_utils_root.py b/plugins/ui/test/deephaven/ui/test_utils_root.py index 38c58c89e..aaa81919b 100644 --- a/plugins/ui/test/deephaven/ui/test_utils_root.py +++ b/plugins/ui/test/deephaven/ui/test_utils_root.py @@ -18,11 +18,7 @@ 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 = "" + self._url: str = "" def on_change(self, update: Callable[[], None]) -> None: self._on_change(update) @@ -30,32 +26,8 @@ def on_change(self, update: Callable[[], None]) -> None: def on_queue_render(self, update: Callable[[], None]) -> None: self._on_queue_render(update) - def get_query_params(self) -> Dict[str, List[str]]: - return self._query_params + def get_url(self) -> str: + return self._url - 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 + def set_url(self, url: str) -> None: + self._url = url From 4055480280bf409e96eaf32f8413b30781ecf429 Mon Sep 17 00:00:00 2001 From: Joe Numainville Date: Mon, 18 May 2026 16:33:30 -0500 Subject: [PATCH 31/31] fix tests --- plugins/ui/src/deephaven/ui/hooks/_url_parse.py | 2 ++ plugins/ui/src/js/src/widget/WidgetHandler.tsx | 9 +++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/plugins/ui/src/deephaven/ui/hooks/_url_parse.py b/plugins/ui/src/deephaven/ui/hooks/_url_parse.py index 07881632f..740d3b30f 100644 --- a/plugins/ui/src/deephaven/ui/hooks/_url_parse.py +++ b/plugins/ui/src/deephaven/ui/hooks/_url_parse.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from urllib.parse import SplitResult, urlsplit, parse_qs from ..types import QueryParams diff --git a/plugins/ui/src/js/src/widget/WidgetHandler.tsx b/plugins/ui/src/js/src/widget/WidgetHandler.tsx index 51b69bd43..afd560035 100644 --- a/plugins/ui/src/js/src/widget/WidgetHandler.tsx +++ b/plugins/ui/src/js/src/widget/WidgetHandler.tsx @@ -160,11 +160,12 @@ function WidgetHandler({ [widget] ); - const getUrlState = useCallback(() => { - return { + const getUrlState = useCallback( + () => ({ [URL_PARAM]: window.location.href, - }; - }, []); + }), + [] + ); const sendSetState = useCallback( (newState: Record = {}) => {