From d9524d3532992f9fa0484cf4e764bbd3bdedee15 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 03:56:55 +0000 Subject: [PATCH 1/4] wip: sidebar elects the area holding the current page Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibz6e1jru1WvD8e9e9E --- .../app-shell/src/layout/UnifiedSidebar.tsx | 60 +++-- ...iedSidebar.areaFollowsRoute-28497.test.tsx | 231 ++++++++++++++++++ .../src/layout/sidebarAreaElection.ts | 79 ++++++ 3 files changed, 349 insertions(+), 21 deletions(-) create mode 100644 ui/packages/app-shell/src/layout/__tests__/UnifiedSidebar.areaFollowsRoute-28497.test.tsx create mode 100644 ui/packages/app-shell/src/layout/sidebarAreaElection.ts diff --git a/ui/packages/app-shell/src/layout/UnifiedSidebar.tsx b/ui/packages/app-shell/src/layout/UnifiedSidebar.tsx index 97a35522d39..999280656da 100644 --- a/ui/packages/app-shell/src/layout/UnifiedSidebar.tsx +++ b/ui/packages/app-shell/src/layout/UnifiedSidebar.tsx @@ -76,6 +76,7 @@ import { useConsoleSidebarRail, useConsoleSidebarRailPanel } from './consoleSide import { SidebarItemMenu, sidebarItemDesignPath, favoriteDesignPath } from './SidebarItemMenu.js'; import { SortableFavoriteRows, type FavoriteRowDragProps } from './SortableFavoriteRows.js'; import { favoriteAppSegment } from './listViewFavorites.js'; +import { electSidebarArea, type SidebarAreaRoute } from './sidebarAreaElection.js'; import type { FavoriteItem } from '../hooks/useFavorites.js'; /** @@ -256,6 +257,24 @@ export function UnifiedSidebar({ activeAppName, railOverlay = false }: UnifiedSi can, } = useNavMemberGates(); + // App-level context selectors (e.g. Studio's package scope). Their + // values are injected into nav items as `{}` template vars so a + // single dropdown transparently scopes every secondary menu. + const { contextValues, element: contextSelectorsUI } = useAppContextSelectors( + activeApp?.name || 'home', + activeApp?.contextSelectors, + t, + ); + + // The template context every navigation href here resolves with; the area + // election below matches routes with it too, so it agrees with the drawn + // highlight. + const navTemplateContext = { + currentUserId: user?.id ?? null, + currentOrgId: activeOrganization?.id ?? null, + contextValues, + }; + // Area management. // // Area visibility is DERIVED from the items inside (objectui#3311 / @@ -303,22 +322,30 @@ export function UnifiedSidebar({ activeAppName, railOverlay = false }: UnifiedSi hasActionHandler: !!dispatchNavAction, }), ); + // The route the area election reads: in an app, the area whose navigation + // holds the entry the current page addresses is shown (`electSidebarArea`). + const areaRoute: SidebarAreaRoute | null = + context === 'app' && activeApp + ? { + pathname: location.pathname, + search: location.search, + basePath: `/apps/${appRouteSegment(activeApp)}`, + templateContext: navTemplateContext, + } + : null; const [activeAreaId, setActiveAreaId] = React.useState( - () => visibleAreas.length > 0 ? visibleAreas[0].id : null, + () => electSidebarArea(visibleAreas, null, areaRoute), ); const visibleAreaIds = visibleAreas.map((a) => a.id).join(','); - // Re-elect when the app changes or the visible-area set changes. Keeping - // `prev` whenever it is still visible means merely REVEALING a new area - // never steals the user's current selection. + // Re-elect when the app, the visible-area set or the route changes. The + // current area stays while it is still visible and the page is one of its + // entries' (or no area's), so merely REVEALING a new area never steals the + // user's selection, and a user's own pick holds on a page it addresses. React.useEffect(() => { - if (visibleAreas.length > 0) { - setActiveAreaId(prev => visibleAreas.some((a) => a.id === prev) ? prev : visibleAreas[0].id); - } else { - setActiveAreaId(null); - } - }, [activeApp?.name, visibleAreaIds]); + setActiveAreaId((prev) => electSidebarArea(visibleAreas, prev, areaRoute)); + }, [activeApp?.name, visibleAreaIds, location.pathname, location.search, context]); // Resolve navigation items. The render-time `?? visibleAreas[0]` fallback // covers the frame between a gating change hiding the active area and the @@ -326,15 +353,6 @@ export function UnifiedSidebar({ activeAppName, railOverlay = false }: UnifiedSi const activeArea = visibleAreas.find((a) => a.id === activeAreaId) ?? visibleAreas[0]; const appNavigation: NavigationItem[] = activeArea?.navigation || readableAppNavigation || []; - // App-level context selectors (e.g. Studio's package scope). Their - // values are injected into nav items as `{}` template vars so a - // single dropdown transparently scopes every secondary menu. - const { contextValues, element: contextSelectorsUI } = useAppContextSelectors( - activeApp?.name || 'home', - activeApp?.contextSelectors, - t, - ); - // Home navigation items. For workspace admins we surface the full system // ("Administration") nav right here on /home — previously the home context // showed ONLY a "Home" link, so a fresh env (no apps yet) rendered a bare @@ -637,7 +655,7 @@ export function UnifiedSidebar({ activeAppName, railOverlay = false }: UnifiedSi locale={language} onAction={dispatchNavAction} t={t} - templateContext={{ currentUserId: user?.id ?? null, currentOrgId: activeOrganization?.id ?? null, contextValues }} + templateContext={navTemplateContext} /> {/* The app's report index is an entry its navigation declares, @@ -731,7 +749,7 @@ export function UnifiedSidebar({ activeAppName, railOverlay = false }: UnifiedSi locale={language} onAction={dispatchNavAction} t={t} - templateContext={{ currentUserId: user?.id ?? null, currentOrgId: activeOrganization?.id ?? null, contextValues }} + templateContext={navTemplateContext} /> {/* Starred Apps */} diff --git a/ui/packages/app-shell/src/layout/__tests__/UnifiedSidebar.areaFollowsRoute-28497.test.tsx b/ui/packages/app-shell/src/layout/__tests__/UnifiedSidebar.areaFollowsRoute-28497.test.tsx new file mode 100644 index 00000000000..5d173a1a228 --- /dev/null +++ b/ui/packages/app-shell/src/layout/__tests__/UnifiedSidebar.areaFollowsRoute-28497.test.tsx @@ -0,0 +1,231 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * UnifiedSidebar — the sidebar shows the area that holds the current page. + * + * In an app with several areas, the area elected on the first render, and + * whenever the route moves to a page no entry of the shown area addresses, is + * the one whose navigation holds the entry the route addresses: the entry + * `resolveActiveNavItem` elects, the same match that highlights the row. With + * none, the current area stays, else the first visible one. A reader's own + * area pick holds while the page is one its entries address. + * + * `@object-ui/components` and `@object-ui/layout` stay REAL, so the highlight + * asserted below is the one `NavigationRenderer` draws. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { MemoryRouter, useNavigate, type NavigateFunction } from 'react-router-dom'; +import type { NavigationArea } from '@object-ui/types'; + +// --------------------------------------------------------------------------- +// Mocks — providers and console-only chrome. +// --------------------------------------------------------------------------- + +// Partial mock: @object-ui/components also imports from @object-ui/i18n +// (createSafeTranslation), so the rest of the module must stay real. +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (key: string, options?: Record) => String(options?.defaultValue ?? key), + }), + useObjectLabel: () => ({ + objectLabel: ({ label }: { label?: string }) => label, + viewLabel: (_o: string, _v: string, fallback?: string) => fallback, + dashboardLabel: ({ label }: { label?: string }) => label, + }), +})); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: null, activeOrganization: null }), + useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }), +})); + +// Mutable permission state — swapped per test, re-read on every render. +let permissionsState: { + can: (objectName: string, action: string) => boolean; + hasCapabilities: (caps: string[]) => boolean; +}; +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + usePermissions: () => permissionsState, + }; +}); + +let metadataState: { apps: unknown[]; objects: unknown[] }; +vi.mock('../../providers/MetadataProvider', () => ({ + useMetadata: () => metadataState, +})); + +vi.mock('../../providers/ExpressionProvider', () => ({ + useExpressionContext: () => ({ evaluator: null }), + // Mirrors the real evaluateVisibility's literal handling — enough for + // fixtures gating with `visible: false`. + evaluateVisibility: (expr: unknown) => expr !== false && expr !== 'false', + evaluateNavigationVisibility: (expr: unknown) => expr !== false && expr !== 'false', + useHasExpressionScope: () => true, +})); + +vi.mock('../../utils', () => ({ + resolveKeyedI18nLabel: (label: unknown) => (typeof label === 'string' ? label : ''), + matchAppBySegment: (apps: Array<{ name?: string }>, segment?: string) => + apps.find((a) => a?.name === segment), + appRouteSegment: (app: { name?: string }) => app?.name, +})); + +// Lazy lucide DynamicIcon would suspend mid-test; a null icon is enough here. +vi.mock('../../utils/getIcon', () => ({ getIcon: () => () => null })); + +vi.mock('../../hooks/useRecentItems', () => ({ useRecentItems: () => ({ recentItems: [] }) })); +vi.mock('../../hooks/useFavorites', () => ({ + useFavorites: () => ({ favorites: [], removeFavorite: vi.fn() }), +})); +vi.mock('../../hooks/useNavPins', () => ({ + useNavPins: () => ({ togglePin: vi.fn(), applyPins: (items: unknown) => items }), +})); +const dispatchNavAction = vi.fn(); +vi.mock('../../hooks/useNavActionDispatch', () => ({ + useNavActionDispatch: () => dispatchNavAction, +})); +vi.mock('../../context/NavigationContext', () => ({ + useNavigationContext: () => ({ context: 'app', currentAppName: 'crm' }), +})); +// The sidebar also imports the per-selector scope-key derivation (#3500) to +// build Studio's home link; keep those exports on the mock or the module is +// missing them at import time. +vi.mock('../ContextSelectors', () => ({ + useAppContextSelectors: () => ({ contextValues: {}, element: null }), + contextSelectorQueryKey: (id: string) => (id === 'active_package' ? 'package' : id), + STUDIO_PACKAGE_SELECTOR_ID: 'active_package', +})); +vi.mock('../LocalizedSidebarTrigger', () => ({ + LocalizedSidebarTrigger: () => null, +})); + +import { SidebarProvider } from '@object-ui/components'; +import { UnifiedSidebar } from '../UnifiedSidebar'; + +import { SidebarProvider } from '@object-ui/components'; +import { UnifiedSidebar } from '../UnifiedSidebar'; + +// --------------------------------------------------------------------------- +// Fixtures — labels are pairwise distinct so a hit is unambiguous. +// --------------------------------------------------------------------------- + +const salesArea: NavigationArea = { + id: 'area-sales', + label: 'Sales', + navigation: [ + { id: 'nav-opportunity', type: 'object', label: 'Opportunities', objectName: 'opportunity' }, + { id: 'nav-account-sales', type: 'object', label: 'Accounts (sales)', objectName: 'account' }, + ], +}; + +const serviceArea: NavigationArea = { + id: 'area-service', + label: 'Service', + navigation: [ + { + id: 'grp-service', + type: 'group', + label: 'Support', + children: [{ id: 'nav-case', type: 'object', label: 'Cases', objectName: 'case' }], + }, + { id: 'nav-account-service', type: 'object', label: 'Accounts (service)', objectName: 'account' }, + ], +}; + +let navigate: NavigateFunction; +function NavigateProbe() { + navigate = useNavigate(); + return null; +} + +function renderAt(path: string) { + metadataState = { + apps: [{ name: 'crm', label: 'CRM', active: true, areas: [salesArea, serviceArea] }], + objects: [], + }; + return render( + + + + + + , + ); +} + +/** The switcher row of an area — the button whose text is the area label. */ +function areaButton(label: string): HTMLElement { + const button = screen.getByText(label).closest('[data-sidebar="menu-button"]'); + if (!(button instanceof HTMLElement)) throw new Error(`no switcher row for area ${label}`); + return button; +} + +function navRow(label: string): HTMLElement { + const row = screen.getByText(label).closest('[data-active]'); + if (!(row instanceof HTMLElement)) throw new Error(`no navigation row for ${label}`); + return row; +} + +beforeEach(() => { + permissionsState = { can: () => true, hasCapabilities: () => true }; + localStorage.clear(); +}); + +describe('UnifiedSidebar elects the area that holds the current page', () => { + it('opens on the second area when the route addresses one of its entries, with that entry highlighted', () => { + renderAt('/apps/crm/case'); + expect(areaButton('Service')).toHaveAttribute('data-active', 'true'); + expect(areaButton('Sales')).toHaveAttribute('data-active', 'false'); + expect(navRow('Cases')).toHaveAttribute('data-active', 'true'); + expect(screen.queryByText('Opportunities')).not.toBeInTheDocument(); + }); + + it('control: a route on a first-area entry keeps the first area', () => { + renderAt('/apps/crm/opportunity'); + expect(areaButton('Sales')).toHaveAttribute('data-active', 'true'); + expect(navRow('Opportunities')).toHaveAttribute('data-active', 'true'); + expect(screen.queryByText('Cases')).not.toBeInTheDocument(); + }); + + it('keeps the first area when no entry of any area addresses the route', () => { + renderAt('/apps/crm/dashboard/overview'); + expect(areaButton('Sales')).toHaveAttribute('data-active', 'true'); + expect(screen.getByText('Opportunities')).toBeInTheDocument(); + }); + + it('follows the route to the area holding the new page when the shown area addresses none of it', () => { + renderAt('/apps/crm/opportunity'); + expect(areaButton('Sales')).toHaveAttribute('data-active', 'true'); + act(() => navigate('/apps/crm/case/record/42')); + expect(areaButton('Service')).toHaveAttribute('data-active', 'true'); + expect(navRow('Cases')).toHaveAttribute('data-active', 'true'); + }); + + it("holds the reader's own area pick while the page is one the picked area addresses", () => { + renderAt('/apps/crm/account'); + // Both areas address the account list; the earlier area is elected. + expect(areaButton('Sales')).toHaveAttribute('data-active', 'true'); + fireEvent.click(screen.getByText('Service')); + expect(areaButton('Service')).toHaveAttribute('data-active', 'true'); + act(() => navigate('/apps/crm/account/record/7')); + expect(areaButton('Service')).toHaveAttribute('data-active', 'true'); + expect(navRow('Accounts (service)')).toHaveAttribute('data-active', 'true'); + }); + + it('holds a pick of an area that does not address the page until the route moves', () => { + renderAt('/apps/crm/opportunity'); + fireEvent.click(screen.getByText('Service')); + expect(areaButton('Service')).toHaveAttribute('data-active', 'true'); + act(() => navigate('/apps/crm/opportunity/record/1')); + expect(areaButton('Sales')).toHaveAttribute('data-active', 'true'); + }); +}); diff --git a/ui/packages/app-shell/src/layout/sidebarAreaElection.ts b/ui/packages/app-shell/src/layout/sidebarAreaElection.ts new file mode 100644 index 00000000000..f728a760cf7 --- /dev/null +++ b/ui/packages/app-shell/src/layout/sidebarAreaElection.ts @@ -0,0 +1,79 @@ +/** + * Which navigation area the Console sidebar shows for the current route. + * + * "The entry the current route addresses" is the entry + * `resolveActiveNavItem` elects, the same match `NavigationRenderer` uses to + * highlight a row, so the elected area and the highlighted entry always agree. + * No second route matcher lives here. + * + * @module + */ + +import { resolveActiveNavItem } from '@object-ui/layout'; +import type { NavigationArea, NavigationItem } from '@object-ui/types'; + +type NavTemplateContext = Parameters[4]; + +/** The route an area is judged against. */ +export interface SidebarAreaRoute { + pathname: string; + search: string; + basePath: string; + templateContext?: NavTemplateContext; +} + +/** Whether an entry of `area` addresses the route. */ +export function areaAddressesRoute(area: NavigationArea, route: SidebarAreaRoute): boolean { + return ( + resolveActiveNavItem( + area.navigation ?? [], + route.pathname, + route.search, + route.basePath, + route.templateContext, + ) !== null + ); +} + +/** + * The id of the area whose navigation holds the entry the route addresses, or + * `null` when no area's entry does. The match runs once over every area's + * entries together, so the most specific entry wins across areas, and a tie + * goes to the earlier area, as it does to the earlier row within one menu. + */ +export function areaForRoute(areas: NavigationArea[], route: SidebarAreaRoute): string | null { + const active = resolveActiveNavItem( + areas.flatMap((area) => area.navigation ?? []), + route.pathname, + route.search, + route.basePath, + route.templateContext, + ); + if (!active) return null; + const holds = (items: NavigationItem[] | undefined): boolean => + !!items?.some((item) => item === active || holds(item.children)); + return areas.find((area) => holds(area.navigation))?.id ?? null; +} + +/** + * The area the sidebar shows, given the one it shows now. + * + * The current area stays while one of its entries addresses the route, so a + * reader's own area pick is not overridden on a page that area holds. + * Otherwise the area holding the entry the route addresses is elected. With + * none, the current area stays, else the first visible one. + */ +export function electSidebarArea( + visibleAreas: NavigationArea[], + currentId: string | null, + route: SidebarAreaRoute | null, +): string | null { + if (visibleAreas.length === 0) return null; + const current = visibleAreas.find((area) => area.id === currentId); + if (route) { + if (current && areaAddressesRoute(current, route)) return current.id; + const routed = areaForRoute(visibleAreas, route); + if (routed) return routed; + } + return current ? current.id : visibleAreas[0].id; +} From 1d6bc8f08ee72cda6b0ac8ddc4061258589f2c93 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 03:59:22 +0000 Subject: [PATCH 2/4] test: pin the sidebar area election against the current route Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibz6e1jru1WvD8e9e9E --- .../__tests__/UnifiedSidebar.areaFollowsRoute-28497.test.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/ui/packages/app-shell/src/layout/__tests__/UnifiedSidebar.areaFollowsRoute-28497.test.tsx b/ui/packages/app-shell/src/layout/__tests__/UnifiedSidebar.areaFollowsRoute-28497.test.tsx index 5d173a1a228..5b6457391fc 100644 --- a/ui/packages/app-shell/src/layout/__tests__/UnifiedSidebar.areaFollowsRoute-28497.test.tsx +++ b/ui/packages/app-shell/src/layout/__tests__/UnifiedSidebar.areaFollowsRoute-28497.test.tsx @@ -111,9 +111,6 @@ vi.mock('../LocalizedSidebarTrigger', () => ({ import { SidebarProvider } from '@object-ui/components'; import { UnifiedSidebar } from '../UnifiedSidebar'; -import { SidebarProvider } from '@object-ui/components'; -import { UnifiedSidebar } from '../UnifiedSidebar'; - // --------------------------------------------------------------------------- // Fixtures — labels are pairwise distinct so a hit is unambiguous. // --------------------------------------------------------------------------- From 1038dac782083ae120e203112a63e1544ca31a13 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 04:00:40 +0000 Subject: [PATCH 3/4] chore: changeset for the sidebar area election Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibz6e1jru1WvD8e9e9E --- .changeset/28497-sidebar-area-follows-route.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/28497-sidebar-area-follows-route.md diff --git a/.changeset/28497-sidebar-area-follows-route.md b/.changeset/28497-sidebar-area-follows-route.md new file mode 100644 index 00000000000..c7f67e8461f --- /dev/null +++ b/.changeset/28497-sidebar-area-follows-route.md @@ -0,0 +1,10 @@ +--- +'@object-ui/app-shell': patch +--- + +fix(console): the sidebar opens on the area that holds the current page + +Clause-②: no +Metadata-protocol: none + +In an app with several `areas`, the Console sidebar always opened on the first visible area, so a deep link, a bookmark or a per-audience `landing` that named an entry of a later area showed a menu that did not contain the page the reader was on. Now, on the first render and whenever the route moves to a page no entry of the shown area addresses, the sidebar shows the area whose navigation holds the entry the route addresses, and that entry is highlighted. The match is the one that already highlights the active row, so the area and the highlight agree. With no area holding such an entry, the sidebar keeps the area it shows, else the first visible one. A reader's own area pick holds while they stay on a page an entry of that area addresses. From f04c19ec3e60c131c090b2561346a5cc778840c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 05:05:04 +0000 Subject: [PATCH 4/4] test: hand the router's navigate out through an effect, not a render-time reassignment Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibz6e1jru1WvD8e9e9E --- .../UnifiedSidebar.areaFollowsRoute-28497.test.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/ui/packages/app-shell/src/layout/__tests__/UnifiedSidebar.areaFollowsRoute-28497.test.tsx b/ui/packages/app-shell/src/layout/__tests__/UnifiedSidebar.areaFollowsRoute-28497.test.tsx index 5b6457391fc..548457b574a 100644 --- a/ui/packages/app-shell/src/layout/__tests__/UnifiedSidebar.areaFollowsRoute-28497.test.tsx +++ b/ui/packages/app-shell/src/layout/__tests__/UnifiedSidebar.areaFollowsRoute-28497.test.tsx @@ -138,12 +138,21 @@ const serviceArea: NavigationArea = { ], }; -let navigate: NavigateFunction; +/** The router's navigate, handed out of the tree once it has rendered. */ +const router: { navigate: NavigateFunction | null } = { navigate: null }; function NavigateProbe() { - navigate = useNavigate(); + const navigate = useNavigate(); + React.useEffect(() => { + router.navigate = navigate; + }, [navigate]); return null; } +function navigate(path: string) { + if (!router.navigate) throw new Error('the router has not rendered'); + void router.navigate(path); +} + function renderAt(path: string) { metadataState = { apps: [{ name: 'crm', label: 'CRM', active: true, areas: [salesArea, serviceArea] }],