From 2e302c1ad82d83d073dd814bfa3d198d147189e8 Mon Sep 17 00:00:00 2001 From: Alex Strick van Linschoten Date: Tue, 9 Sep 2025 15:34:52 +0200 Subject: [PATCH 1/4] feat(ui): add descriptive page titles (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `usePageTitle` hook with route mapping - Wire into `RootLayout` to cover public + app routes - Granular Settings labels (Members, API Tokens, Notifications, Secrets(+detail), Connectors, Service Accounts, Profile, General) - Project-scoped labels (Repositories, Profile) Validation: - pnpm dev and confirm “
- ZenML Dashboard” updates on navigation - pnpm lint, pnpm format --- src/hooks/usePageTitle.ts | 77 ++++++++++++++++++++++++++++++++++++++ src/layouts/RootLayout.tsx | 4 ++ 2 files changed, 81 insertions(+) create mode 100644 src/hooks/usePageTitle.ts diff --git a/src/hooks/usePageTitle.ts b/src/hooks/usePageTitle.ts new file mode 100644 index 000000000..e5ab49f83 --- /dev/null +++ b/src/hooks/usePageTitle.ts @@ -0,0 +1,77 @@ +import { useEffect, useMemo } from "react"; +import { matchPath, useLocation } from "react-router-dom"; +import { routes } from "@/router/routes"; + +/** + * Sets a descriptive document.title based on the current route. + * Phase 1: static mapping by top-level sections; generic labels for detail pages. + * + * Why this approach: + * - Centralizes mapping against route patterns, preventing path string drift. + * - Uses end: true matching to avoid false positives (e.g., "/" matching everything). + * - Keeps logic minimal and synchronous to avoid flicker and extra fetches. + */ +export function usePageTitle(): void { + const location = useLocation(); + const pathname = location.pathname; + + const baseTitle = useMemo(() => { + // Helper to match exact route patterns (end=true by default for specificity). + const is = (pattern: string, end: boolean = true) => + matchPath({ path: pattern, end }, pathname) !== null; + + // Public/standalone pages + if (is(routes.login)) return "Login"; + if (is(routes.survey)) return "Survey"; + if (is(routes.onboarding)) return "Onboarding"; + if (is(routes.activateUser)) return "Activate Account"; + if (is(routes.activateServer)) return "Activate Server"; + if (is(routes.devices.verify)) return "Devices Verify"; + if (is(routes.upgrade)) return "Upgrade"; + + // Non-project scoped + if (is(routes.home)) return "Overview"; + if (is(routes.projects.overview)) return "Projects"; + if (is(routes.stacks.overview)) return "Stacks"; + if (is(routes.components.overview)) return "Components"; + + // Non-project Settings: match specific routes first so they don't get shadowed by the generic prefix check. + if (is(routes.settings.profile)) return "Profile Settings"; + if (is(routes.settings.members)) return "Members"; + if (is(routes.settings.apiTokens)) return "API Tokens"; + if (is(routes.settings.notifications)) return "Notifications"; + if (is(routes.settings.secrets.detail(":id"))) return "Secret"; + if (is(routes.settings.secrets.overview)) return "Secrets"; + if (is(routes.settings.connectors.overview)) return "Connectors"; + if (is(routes.settings.service_accounts.overview)) return "Service Accounts"; + if (is(routes.settings.general)) return "Settings"; + + // Settings (and subpages) fallback + // We intentionally use a prefix check to cover all nested settings paths not covered above. + if (pathname.startsWith("/settings")) return "Settings"; + + // Components detail/edit/create + if (is(routes.components.detail(":componentId"))) return "Component"; + if (is(routes.components.edit(":componentId"))) return "Component"; + if (is(routes.components.create)) return "Components"; + + // Project-scoped tabs + if (is(routes.projects.pipelines.overview)) return "Pipelines"; + if (is(routes.projects.pipelines.namespace(":namespace"))) return "Pipeline"; + if (is(routes.projects.runs.overview)) return "Pipeline Runs"; + if (is(routes.projects.runs.detail(":runId"))) return "Run"; + if (is(routes.projects.models.overview)) return "Models"; + if (is(routes.projects.artifacts.overview)) return "Artifacts"; + if (is(routes.projects.templates.overview)) return "Run Templates"; + + // Project-scoped settings + if (is(routes.projects.settings.repositories.overview)) return "Repositories"; + if (is(routes.projects.settings.profile)) return "Profile Settings"; + + return undefined; + }, [pathname]); + + useEffect(() => { + document.title = baseTitle ? `${baseTitle} - ZenML Dashboard` : "ZenML Dashboard"; + }, [baseTitle]); +} \ No newline at end of file diff --git a/src/layouts/RootLayout.tsx b/src/layouts/RootLayout.tsx index f3d5fc002..d608df517 100644 --- a/src/layouts/RootLayout.tsx +++ b/src/layouts/RootLayout.tsx @@ -2,11 +2,15 @@ import { useServerInfo } from "@/data/server/info-query"; import { routes } from "@/router/routes"; import { useEffect } from "react"; import { Outlet, useNavigate } from "react-router-dom"; +import { usePageTitle } from "@/hooks/usePageTitle"; export function RootLayout() { const navigate = useNavigate(); const { data } = useServerInfo({ throwOnError: true }); + // Set browser titles for both public and authenticated routes. + usePageTitle(); + useEffect(() => { if (data && data.active === false) { navigate(routes.activateServer + `?redirect=${routes.onboarding}`, { replace: true }); From 4d22245310ca3650cf3f8e3e94140d8c037a115f Mon Sep 17 00:00:00 2001 From: Alex Strick van Linschoten Date: Tue, 9 Sep 2025 16:52:00 +0200 Subject: [PATCH 2/4] Update src/hooks/usePageTitle.ts --- src/hooks/usePageTitle.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/hooks/usePageTitle.ts b/src/hooks/usePageTitle.ts index e5ab49f83..d0fa38f1e 100644 --- a/src/hooks/usePageTitle.ts +++ b/src/hooks/usePageTitle.ts @@ -4,12 +4,6 @@ import { routes } from "@/router/routes"; /** * Sets a descriptive document.title based on the current route. - * Phase 1: static mapping by top-level sections; generic labels for detail pages. - * - * Why this approach: - * - Centralizes mapping against route patterns, preventing path string drift. - * - Uses end: true matching to avoid false positives (e.g., "/" matching everything). - * - Keeps logic minimal and synchronous to avoid flicker and extra fetches. */ export function usePageTitle(): void { const location = useLocation(); From d654526fbfb29b9d0a3fe056f800b621690380a0 Mon Sep 17 00:00:00 2001 From: Alex Strick van Linschoten Date: Tue, 21 Oct 2025 14:00:31 +0200 Subject: [PATCH 3/4] Fix TS compilation errors --- src/hooks/usePageTitle.ts | 42 ++++++++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/src/hooks/usePageTitle.ts b/src/hooks/usePageTitle.ts index d0fa38f1e..ca23ffcbb 100644 --- a/src/hooks/usePageTitle.ts +++ b/src/hooks/usePageTitle.ts @@ -3,8 +3,8 @@ import { matchPath, useLocation } from "react-router-dom"; import { routes } from "@/router/routes"; /** - * Sets a descriptive document.title based on the current route. - */ + * Sets a descriptive document.title based on the current route. + */ export function usePageTitle(): void { const location = useLocation(); const pathname = location.pathname; @@ -27,6 +27,12 @@ export function usePageTitle(): void { if (is(routes.home)) return "Overview"; if (is(routes.projects.overview)) return "Projects"; if (is(routes.stacks.overview)) return "Stacks"; + // Stack creation flow titles are placed immediately after Stacks overview + if (is(routes.stacks.create.index)) return "Create Stack"; + if (is(routes.stacks.create.newInfra)) return "Create Stack: New Infrastructure"; + if (is(routes.stacks.create.manual)) return "Create Stack: Manual"; + if (is(routes.stacks.create.existingInfra)) return "Create Stack: Existing Infrastructure"; + if (is(routes.stacks.create.terraform)) return "Create Stack: Terraform"; if (is(routes.components.overview)) return "Components"; // Non-project Settings: match specific routes first so they don't get shadowed by the generic prefix check. @@ -36,12 +42,19 @@ export function usePageTitle(): void { if (is(routes.settings.notifications)) return "Notifications"; if (is(routes.settings.secrets.detail(":id"))) return "Secret"; if (is(routes.settings.secrets.overview)) return "Secrets"; + if (is(routes.settings.connectors.detail.configuration(":connectorId"))) + return "Connector Configuration"; + if (is(routes.settings.connectors.detail.components(":connectorId"))) + return "Connector Components"; + if (is(routes.settings.connectors.detail.resources(":connectorId"))) + return "Connector Resources"; if (is(routes.settings.connectors.overview)) return "Connectors"; + if (is(routes.settings.service_accounts.detail(":id"))) return "Service Account"; if (is(routes.settings.service_accounts.overview)) return "Service Accounts"; if (is(routes.settings.general)) return "Settings"; // Settings (and subpages) fallback - // We intentionally use a prefix check to cover all nested settings paths not covered above. + // Use a prefix check to cover nested settings paths that are not explicitly handled above. if (pathname.startsWith("/settings")) return "Settings"; // Components detail/edit/create @@ -49,14 +62,29 @@ export function usePageTitle(): void { if (is(routes.components.edit(":componentId"))) return "Component"; if (is(routes.components.create)) return "Components"; - // Project-scoped tabs + // Project-scoped: Pipelines and detail sub-tabs if (is(routes.projects.pipelines.overview)) return "Pipelines"; - if (is(routes.projects.pipelines.namespace(":namespace"))) return "Pipeline"; + if (is(routes.projects.pipelines.detail.runs(":pipelineId"))) return "Pipeline"; + if (is(routes.projects.pipelines.detail.snapshots(":pipelineId"))) return "Pipeline Snapshots"; + if (is(routes.projects.pipelines.detail.deployments(":pipelineId"))) + return "Pipeline Deployments"; + + // Project-scoped: Snapshots + if (is(routes.projects.snapshots.overview)) return "Snapshots"; + if (is(routes.projects.snapshots.detail.overview(":snapshotId"))) return "Snapshot"; + if (is(routes.projects.snapshots.detail.runs(":snapshotId"))) return "Snapshot Runs"; + + // Project-scoped: Runs if (is(routes.projects.runs.overview)) return "Pipeline Runs"; if (is(routes.projects.runs.detail(":runId"))) return "Run"; + + // Project-scoped: Deployments + if (is(routes.projects.deployments.overview)) return "Deployments"; + if (is(routes.projects.deployments.detail.overview(":deploymentId"))) return "Deployment"; + + // Project-scoped: Other tabs if (is(routes.projects.models.overview)) return "Models"; if (is(routes.projects.artifacts.overview)) return "Artifacts"; - if (is(routes.projects.templates.overview)) return "Run Templates"; // Project-scoped settings if (is(routes.projects.settings.repositories.overview)) return "Repositories"; @@ -68,4 +96,4 @@ export function usePageTitle(): void { useEffect(() => { document.title = baseTitle ? `${baseTitle} - ZenML Dashboard` : "ZenML Dashboard"; }, [baseTitle]); -} \ No newline at end of file +} From 76d710a580a46b1cdbfa685e50547c463b6418d4 Mon Sep 17 00:00:00 2001 From: Alex Strick van Linschoten Date: Tue, 21 Oct 2025 15:20:13 +0200 Subject: [PATCH 4/4] Add missing page title --- src/hooks/usePageTitle.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/hooks/usePageTitle.ts b/src/hooks/usePageTitle.ts index ca23ffcbb..f17fd5228 100644 --- a/src/hooks/usePageTitle.ts +++ b/src/hooks/usePageTitle.ts @@ -48,6 +48,7 @@ export function usePageTitle(): void { return "Connector Components"; if (is(routes.settings.connectors.detail.resources(":connectorId"))) return "Connector Resources"; + if (is(routes.settings.connectors.create)) return "Create Connector"; if (is(routes.settings.connectors.overview)) return "Connectors"; if (is(routes.settings.service_accounts.detail(":id"))) return "Service Account"; if (is(routes.settings.service_accounts.overview)) return "Service Accounts";