diff --git a/.server-changes/fix-side-menu-active-state.md b/.server-changes/fix-side-menu-active-state.md new file mode 100644 index 00000000000..e8a474c6017 --- /dev/null +++ b/.server-changes/fix-side-menu-active-state.md @@ -0,0 +1,7 @@ +--- +area: webapp +type: fix +--- + +Keep side menu items highlighted when viewing detail pages and sub-routes within a section. + diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index 64bae92c2cd..450c417d0c8 100644 --- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -154,6 +154,7 @@ export function OrganizationSettingsSideMenu({ activeIconColor="text-text-bright" inactiveIconColor="text-text-dimmed" to={v3PrivateConnectionsPath(organization)} + matchPrefix data-action="private-connections" /> )} diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index a5bfe8d6bf9..201f800735e 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -743,6 +743,8 @@ export function SideMenu({ ? "hidden" : "shown"; + const environmentPath = v3EnvironmentPath(organization, project, environment); + // The customizable sections (everything except Tasks/Runs/Sessions), in DEFAULT order. The // user's saved order/hidden preferences are applied at render below. const staticSections = buildSideMenuSections({ @@ -941,7 +943,12 @@ export function SideMenu({ icon={TasksIcon} activeIconColor="text-tasks" inactiveIconColor="text-text-dimmed" - to={v3EnvironmentPath(organization, project, environment)} + to={environmentPath} + activePathPrefix={[ + `${environmentPath}/tasks`, + `${environmentPath}/agents`, + `${environmentPath}/schedules`, + ]} data-action="tasks" isCollapsed={isCollapsed} yieldActiveToFavorite @@ -952,6 +959,7 @@ export function SideMenu({ activeIconColor="text-runs" inactiveIconColor="text-text-dimmed" to={v3RunsPath(organization, project, environment)} + matchPrefix data-action="runs" isCollapsed={isCollapsed} yieldActiveToFavorite @@ -962,6 +970,7 @@ export function SideMenu({ activeIconColor="text-sessions" inactiveIconColor="text-text-dimmed" to={v3SessionsPath(organization, project, environment)} + matchPrefix data-action="sessions" isCollapsed={isCollapsed} yieldActiveToFavorite @@ -976,6 +985,7 @@ export function SideMenu({ activeIconColor="text-webhooks" inactiveIconColor="text-text-dimmed" to={v3WebhooksPath(organization, project, environment)} + matchPrefix data-action="webhooks" badge={} isCollapsed={isCollapsed} @@ -1141,7 +1151,7 @@ function CustomizableSideMenuSection({ activeIconColor={item.activeIconColor} inactiveIconColor={item.inactiveIconColor ?? "text-text-dimmed"} to={item.to} - matchPrefix={item.matchPrefix} + matchPrefix={item.matchPrefix ?? true} data-action={item.dataAction} badge={item.badge} isCollapsed={isCollapsed} diff --git a/apps/webapp/app/components/navigation/SideMenuItem.tsx b/apps/webapp/app/components/navigation/SideMenuItem.tsx index 6b959783c01..3cbc8121c66 100644 --- a/apps/webapp/app/components/navigation/SideMenuItem.tsx +++ b/apps/webapp/app/components/navigation/SideMenuItem.tsx @@ -61,6 +61,50 @@ export function SideMenuLabel({ ); } +function matchesPathPrefix(pathName: string, prefix: string): boolean { + const normalized = prefix.replace(/\/+$/, ""); + if (normalized.length === 0) { + return false; + } + return pathName === normalized || pathName.startsWith(`${normalized}/`); +} + +/** + * Returns whether a side menu item should appear active for `pathName`. + * Matches `to` exactly, `to` on a `/` boundary when `matchPrefix` is true, or + * any prefix in `activePathPrefix` (for items whose `to` is a shared root such + * as the environment URL while its sub-pages live under dedicated segments). + */ +export function isSideMenuPathActive({ + pathName, + to, + matchPrefix = false, + activePathPrefix, +}: { + pathName: string; + to: string; + matchPrefix?: boolean; + activePathPrefix?: string | readonly string[]; +}): boolean { + if (pathName === to) { + return true; + } + + const prefixes: string[] = []; + if (matchPrefix) { + prefixes.push(to); + } + if (activePathPrefix !== undefined) { + if (typeof activePathPrefix === "string") { + prefixes.push(activePathPrefix); + } else { + prefixes.push(...activePathPrefix); + } + } + + return prefixes.some((prefix) => matchesPathPrefix(pathName, prefix)); +} + export function SideMenuItem({ icon, activeIconColor, @@ -80,6 +124,7 @@ export function SideMenuItem({ isActive: isActiveOverride, yieldActiveToFavorite = false, matchPrefix = false, + activePathPrefix, "data-action": dataAction, }: { icon?: RenderIcon; @@ -101,6 +146,8 @@ export function SideMenuItem({ /** Overrides the default pathname === to active check (e.g. favorites match on full URL). */ isActive?: boolean; matchPrefix?: boolean; + /** Additional path prefixes (matched on a `/` boundary) that activate this item. */ + activePathPrefix?: string | readonly string[]; /** * In menus that render the Favorites section (the main project menu), an active favorite owns * the highlight, so the plain item yields its active state to it. Menus without a favorites @@ -116,7 +163,7 @@ export function SideMenuItem({ const activeFavoriteId = useActiveFavoriteId(); const isActive = isActiveOverride ?? - ((pathName === to || (matchPrefix && pathName.startsWith(`${to}/`))) && + (isSideMenuPathActive({ pathName, to, matchPrefix, activePathPrefix }) && (!yieldActiveToFavorite || activeFavoriteId === undefined)); const isIndented = indented && !isCollapsed; diff --git a/apps/webapp/app/components/navigation/sideMenuSections.tsx b/apps/webapp/app/components/navigation/sideMenuSections.tsx index 9e17aa3fbdf..4cb3baf24d2 100644 --- a/apps/webapp/app/components/navigation/sideMenuSections.tsx +++ b/apps/webapp/app/components/navigation/sideMenuSections.tsx @@ -154,9 +154,7 @@ export function buildSideMenuSections({ id: "metrics", title: "Observability", items: [ - ...(alertsInObservability - ? [{ ...alertsItem, matchPrefix: true, badge: }] - : []), + ...(alertsInObservability ? [{ ...alertsItem, badge: }] : []), ...(isAdmin || featureFlags.hasLogsPageAccess ? [ { @@ -206,6 +204,7 @@ export function buildSideMenuSections({ icon: ChartBarIcon, activeIconColor: "text-metrics", to: v3DashboardsLandingPath(organization, project, environment), + matchPrefix: false, dataAction: "dashboards-landing", action: dashboards?.action, after: dashboards?.after, @@ -355,5 +354,13 @@ export function buildSideMenuSections({ ], }); - return staticSections; + // Stay highlighted on detail/sub-pages unless an item explicitly opts out (e.g. Dashboards, + // whose child routes are rendered as their own menu items). + return staticSections.map((section) => ({ + ...section, + items: section.items.map((item) => ({ + ...item, + matchPrefix: item.matchPrefix ?? true, + })), + })); } diff --git a/apps/webapp/test/sideMenuPathActive.test.ts b/apps/webapp/test/sideMenuPathActive.test.ts new file mode 100644 index 00000000000..ffbaaa32997 --- /dev/null +++ b/apps/webapp/test/sideMenuPathActive.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { isSideMenuPathActive } from "~/components/navigation/SideMenuItem"; + +describe("isSideMenuPathActive", () => { + // Catches: prefix-matched items losing active state on detail pages or matching sibling routes with a shared prefix + it("matches exact paths and sub-paths on a '/' boundary without bleeding into sibling routes", () => { + const to = "/orgs/org-1/projects/proj-1/env/dev/concurrency"; + + expect(isSideMenuPathActive({ pathName: to, to, matchPrefix: true })).toBe(true); + expect(isSideMenuPathActive({ pathName: `${to}/queues`, to, matchPrefix: true })).toBe(true); + expect( + isSideMenuPathActive({ + pathName: "/orgs/org-1/projects/proj-1/env/dev/concurrency-limits", + to, + matchPrefix: true, + }) + ).toBe(false); + expect(isSideMenuPathActive({ pathName: `${to}/queues`, to, matchPrefix: false })).toBe(false); + }); + + // Catches: Tasks activePathPrefix failing to match task, agent, and schedule detail pages or matching unrelated env sections + it("matches Tasks across its dedicated sub-path prefixes without matching sibling environment routes", () => { + const envPath = "/orgs/org-1/projects/proj-1/env/dev"; + const activePathPrefix = [`${envPath}/tasks`, `${envPath}/agents`, `${envPath}/schedules`]; + + expect(isSideMenuPathActive({ pathName: envPath, to: envPath, activePathPrefix })).toBe(true); + expect( + isSideMenuPathActive({ + pathName: `${envPath}/tasks/standard/my-task`, + to: envPath, + activePathPrefix, + }) + ).toBe(true); + expect( + isSideMenuPathActive({ + pathName: `${envPath}/agents/my-agent`, + to: envPath, + activePathPrefix, + }) + ).toBe(true); + expect( + isSideMenuPathActive({ + pathName: `${envPath}/schedules/sched_123`, + to: envPath, + activePathPrefix, + }) + ).toBe(true); + + expect( + isSideMenuPathActive({ + pathName: `${envPath}/runs/run_123`, + to: envPath, + activePathPrefix, + }) + ).toBe(false); + expect( + isSideMenuPathActive({ + pathName: `${envPath}/webhooks/wh_123`, + to: envPath, + activePathPrefix, + }) + ).toBe(false); + }); + + // Catches: root "/" or trailing-slash prefixes matching unrelated routes or failing to match exact normalized paths + it("ignores empty or root prefixes while normalizing trailing slashes", () => { + expect(isSideMenuPathActive({ pathName: "/orgs/org-1", to: "/", matchPrefix: true })).toBe( + false + ); + expect( + isSideMenuPathActive({ + pathName: "/orgs/org-1/settings/private-connections", + to: "/orgs/org-1/settings/private-connections/", + matchPrefix: true, + }) + ).toBe(true); + expect( + isSideMenuPathActive({ + pathName: "/orgs/org-1/settings/private-connections/new", + to: "/orgs/org-1/settings/private-connections/", + matchPrefix: true, + }) + ).toBe(true); + }); +}); diff --git a/apps/webapp/test/sideMenuSections.test.ts b/apps/webapp/test/sideMenuSections.test.ts index ee8019dbccc..29ce5950a3e 100644 --- a/apps/webapp/test/sideMenuSections.test.ts +++ b/apps/webapp/test/sideMenuSections.test.ts @@ -78,7 +78,20 @@ describe("buildSideMenuSections alerts placement", () => { expect(ids.indexOf("alerts")).toBe(ids.indexOf("bulk-actions") + 1); const alerts = manage.items.find((i) => i.id === "alerts")!; expect(alerts.badge).toBeUndefined(); - expect(alerts.matchPrefix).toBeUndefined(); + expect(alerts.matchPrefix).toBe(true); + }); + + // Catches: section items losing active highlight on detail pages or Dashboards overriding child dashboard links + it("defaults section items to matchPrefix: true while keeping Dashboards exact", () => { + const items = build(true).flatMap((s) => s.items); + const dashboards = items.find((i) => i.id === "dashboards")!; + expect(dashboards.matchPrefix).toBe(false); + + const otherItems = items.filter((i) => i.id !== "dashboards"); + expect(otherItems.length).toBeGreaterThan(0); + for (const item of otherItems) { + expect(item.matchPrefix, `expected ${item.id} to match prefix`).toBe(true); + } }); it("moves Alerts into Observability when the alerts flag is on", () => {