Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/calm-routes-remember-navigation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@object-ui/layout": patch
"@object-ui/app-shell": patch
---

Keep sidebar area selection and business breadcrumbs aligned with direct app routes.
19 changes: 19 additions & 0 deletions content/docs/guide/designing-app-navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,25 @@ Object entries also support record deep-links — `recordId` (with template
variables like `{current_user_id}`) opens a specific record, which is how
"My Profile"-style entries are built.

## Direct Links Preserve Navigation Context

The console resolves the current route back through the same navigation tree
that generated its URL. Opening, refreshing, or returning to a page,
dashboard, report, object, named view, filtered object slice, or record link
therefore selects the area that owns the entry and renders its ancestor groups
in the breadcrumb. For example, a direct link to a page declared under
`Projects > Time Management` keeps the Projects area active and shows that
business hierarchy instead of a generic Pages category.

When two entries intentionally reuse one page, give each entry a distinct
`params` value. The generated query string then preserves which entry the user
opened, and the same value restores the correct area, group, and breadcrumb
after a refresh.

This behavior depends on a declared navigation target. Routes that are not in
the app's navigation remain valid, but the shell cannot infer an owning area or
business hierarchy for them.

## Quick Checklist

Before publishing an app, scan the navigation for:
Expand Down
3 changes: 3 additions & 0 deletions packages/app-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ function MyDashboard() {
constrained reading width for long conversations
- **Notification Surfaces**: `ConsoleShell` mounts `NotificationProvider` and
every spec `displayType` presents distinctly — no per-app wiring
- **Route-Aware Navigation**: Direct links, refreshes, and browser history
recover the owning area and navigation trail, keeping the sidebar selection
and business breadcrumb aligned with app metadata

## Notifications

Expand Down
68 changes: 61 additions & 7 deletions packages/app-shell/src/layout/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
import type { BreadcrumbItem as BreadcrumbItemType } from '@object-ui/types';
import { useAuth, getUserInitials, useWorkspaceAdminStatus } from '@object-ui/auth';
import { useMetadata } from '../providers/MetadataProvider.js';
import { resolveKeyedI18nLabel, preferLocal, matchAppBySegment, appRouteSegment, appStudioRoutePath } from '../utils/index.js';
import { resolveKeyedI18nLabel, preferLocal, matchAppBySegment, appRouteSegment, appStudioRoutePath, resolveAppNavigationContext } from '../utils/index.js';
import { getIcon } from '../utils/getIcon.js';
import { useMobileViewSwitcher } from './MobileViewSwitcherContext.js';
import { useNavigationContext } from '../context/NavigationContext.js';
Expand All @@ -88,6 +88,25 @@ function humanizeSlug(slug: string): string {
return slug.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}

type TranslationFn = ReturnType<typeof useObjectTranslation>['t'];
type KeyedNavigationLabel = Exclude<Parameters<typeof resolveKeyedI18nLabel>[0], string | undefined>;

function isKeyedNavigationLabel(label: unknown): label is KeyedNavigationLabel {
return typeof label === 'object' && label !== null && 'key' in label && typeof label.key === 'string';
}

function navigationLabel(label: unknown, t: TranslationFn): string {
if (typeof label === 'string') return label;
if (isKeyedNavigationLabel(label)) {
return resolveKeyedI18nLabel(label, t) || label.key;
}
if (label && typeof label === 'object') {
const localized = Object.values(label).find((value) => typeof value === 'string');
if (typeof localized === 'string') return localized;
}
return '';
}

/** Muted `/` separator between path segments */
function PathSep() {
return (
Expand Down Expand Up @@ -331,31 +350,66 @@ export function AppHeader({

const extraSegments: BreadcrumbItemType[] = [];

// Recover the business navigation context from the current route. The
// sidebar and header both rely on resolveActiveNavItem's canonical inverse
// mapping, so a deep link cannot select one area while naming a different
// hierarchy in the breadcrumb.
const {
area: activeNavigationArea,
trail: activeNavigationTrail,
} = resolveAppNavigationContext({
areas: currentApp?.areas || [],
navigation: currentApp?.navigation || [],
pathname: location.pathname,
search: location.search,
basePath: baseHref,
});

if (activeNavigationArea) {
const label = navigationLabel(activeNavigationArea.label, t);
if (label) extraSegments.push({ label });
}
for (const ancestor of activeNavigationTrail.slice(0, -1)) {
const label = navigationLabel(ancestor.label, t);
if (label) extraSegments.push({ label });
}
const hasNavigationContext = activeNavigationTrail.length > 0;
const activeNavigationLabel = navigationLabel(
activeNavigationTrail[activeNavigationTrail.length - 1]?.label,
t,
);

if (isApp) {
if (routeType === 'dashboard') {
extraSegments.push({ label: t('console.breadcrumb.dashboards'), href: baseHref });
if (!hasNavigationContext) extraSegments.push({ label: t('console.breadcrumb.dashboards'), href: baseHref });
if (pathParts[3]) {
const dashboardName = pathParts[3];
// ADR-0048 Phase 2 — prefer the current app's package (container-scoped).
const dashboardDef = preferLocal(metadataDashboards as any[], dashboardName, (currentApp as any)?._packageId);
const fallback = dashboardDef?.label || humanizeSlug(dashboardName);
extraSegments.push({ label: dashboardLabel({ name: dashboardName, label: fallback }) });
extraSegments.push({
label: activeNavigationLabel || dashboardLabel({ name: dashboardName, label: fallback }),
});
}
} else if (routeType === 'page') {
extraSegments.push({ label: t('console.breadcrumb.pages'), href: baseHref });
if (!hasNavigationContext) extraSegments.push({ label: t('console.breadcrumb.pages'), href: baseHref });
if (pathParts[3]) {
const pageName = pathParts[3];
const pageDef = preferLocal(metadataPages as any[], pageName, (currentApp as any)?._packageId);
const fallback = pageDef?.label || humanizeSlug(pageName);
extraSegments.push({ label: pageLabel({ name: pageName, label: fallback }) });
extraSegments.push({
label: activeNavigationLabel || pageLabel({ name: pageName, label: fallback }),
});
}
} else if (routeType === 'report') {
extraSegments.push({ label: t('console.breadcrumb.reports'), href: baseHref });
if (!hasNavigationContext) extraSegments.push({ label: t('console.breadcrumb.reports'), href: baseHref });
if (pathParts[3]) {
const reportName = pathParts[3];
const reportDef = preferLocal(metadataReports as any[], reportName, (currentApp as any)?._packageId);
const fallback = reportDef?.label || humanizeSlug(reportName);
extraSegments.push({ label: reportLabel({ name: reportName, label: fallback }) });
extraSegments.push({
label: activeNavigationLabel || reportLabel({ name: reportName, label: fallback }),
});
}
} else if (routeType === 'system') {
extraSegments.push({ label: t('console.breadcrumb.system') });
Expand Down
48 changes: 31 additions & 17 deletions packages/app-shell/src/layout/UnifiedSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ import { useRecentItems } from '../hooks/useRecentItems.js';
import { useFavorites } from '../hooks/useFavorites.js';
import { useNavPins } from '../hooks/useNavPins.js';
import { useNavActionDispatch } from '../hooks/useNavActionDispatch.js';
import { matchAppBySegment, appRouteSegment } from '../utils/index.js';
import { matchAppBySegment, appRouteSegment, resolveAppNavigationContext } from '../utils/index.js';
import { useHomePath } from '../hooks/useHomePath.js';
// Aliased for symmetry with objectui's own `resolveKeyedI18nLabel` above (the
// names stopped colliding in objectui#4167): this is the spec's resolver (new in
Expand Down Expand Up @@ -205,6 +205,15 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) {
const activeApps = apps.filter((a: any) => a.active !== false && a.hidden !== true);
// ADR-0048 (A) — route segment may be a package id; match by it (name fallback).
const activeApp = matchAppBySegment(apps.filter((a: any) => a.active !== false), activeAppName || currentAppName) || activeApps[0];
const appBasePath = context === 'app' && activeApp ? `/apps/${appRouteSegment(activeApp)}` : '';

// App-level context selectors (e.g. Studio's package scope). Their values
// participate in route matching as well as href generation.
const { contextValues, element: contextSelectorsUI } = useAppContextSelectors(
activeApp?.name || 'home',
activeApp?.contextSelectors,
t,
);

// Drag-reorder and pin persistence
const { applyOrder, handleReorder } = useNavOrder(activeApp?.name || 'home');
Expand Down Expand Up @@ -285,33 +294,38 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) {
);

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.
const routeAreaId = appBasePath
? resolveAppNavigationContext({
areas: visibleAreas,
pathname: location.pathname,
search: location.search,
basePath: appBasePath,
templateContext: {
currentUserId: user?.id ?? null,
currentOrgId: activeOrganization?.id ?? null,
contextValues,
},
}).area?.id ?? null
: null;

// A deep link, refresh, or browser history transition elects the area that
// owns the active navigation item. Routes outside the navigation tree keep
// a still-visible manual choice, preserving area-switcher behaviour on app
// landing and auxiliary pages.
React.useEffect(() => {
if (visibleAreas.length > 0) {
setActiveAreaId(prev => visibleAreas.some((a) => a.id === prev) ? prev : visibleAreas[0].id);
setActiveAreaId(prev => routeAreaId ?? (visibleAreas.some((a) => a.id === prev) ? prev : visibleAreas[0].id));
} else {
setActiveAreaId(null);
}
}, [activeApp?.name, visibleAreaIds]);
}, [activeApp?.name, routeAreaId, visibleAreaIds]);

// Resolve navigation items. The render-time `?? visibleAreas[0]` fallback
// covers the frame between a gating change hiding the active area and the
// effect above re-electing.
const activeArea = visibleAreas.find((a) => a.id === activeAreaId) ?? visibleAreas[0];
const appNavigation: NavigationItem[] = activeArea?.navigation || activeApp?.navigation || [];

// App-level context selectors (e.g. Studio's package scope). Their
// values are injected into nav items as `{<id>}` 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
Expand Down Expand Up @@ -386,7 +400,7 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) {

// Determine which navigation to show based on context
const navigationItems = context === 'home' ? homeNavigation : appNavigation;
const basePath = context === 'app' && activeApp ? `/apps/${appRouteSegment(activeApp)}` : '';
const basePath = appBasePath;
const isStudioApp = context === 'app' && activeApp?.name === 'studio';
// Studio's home link carries the active package scope. Read (and re-emit)
// it through the SAME per-selector key derivation the selector writes with,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,16 @@ vi.mock('../../providers/ExpressionProvider', () => ({
evaluateVisibility: (expr: unknown) => expr !== false && expr !== 'false',
}));

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,
}));
vi.mock('../../utils', async () => {
const { resolveAppNavigationContext } = await import('../../utils/navigationContext');
return {
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,
resolveAppNavigationContext,
};
});

// Lazy lucide DynamicIcon would suspend mid-test; a null icon is enough here.
vi.mock('../../utils/getIcon', () => ({ getIcon: () => () => null }));
Expand Down Expand Up @@ -140,13 +144,13 @@ const gatedSales: NavigationArea = {
navigation: [{ ...salesArea.navigation[0], visible: false }],
};

function sidebarUi(areas: NavigationArea[]) {
function sidebarUi(areas: NavigationArea[], initialEntry = '/apps/crm') {
metadataState = {
apps: [{ name: 'crm', label: 'CRM', active: true, areas }],
objects: [],
};
return (
<MemoryRouter initialEntries={['/apps/crm']}>
<MemoryRouter initialEntries={[initialEntry]}>
<SidebarProvider>
<UnifiedSidebar activeAppName="crm" />
</SidebarProvider>
Expand Down Expand Up @@ -179,6 +183,12 @@ describe('UnifiedSidebar derived area visibility (#3319)', () => {
expect(screen.queryByText('Opportunities')).not.toBeInTheDocument();
});

it('elects the area that owns a direct route instead of the first visible area', () => {
render(sidebarUi([salesArea, serviceArea], '/apps/crm/case'));
expect(screen.getByText('Cases')).toBeInTheDocument();
expect(screen.queryByText('Opportunities')).not.toBeInTheDocument();
});

it('keeps a PARTIALLY gated area visible and active, hiding only the gated item', () => {
const partialSales: NavigationArea = {
...salesArea,
Expand Down
2 changes: 2 additions & 0 deletions packages/app-shell/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export { deriveRelatedLists } from './deriveRelatedLists.js';
export type { DerivedRelatedList } from './deriveRelatedLists.js';

export { preferLocal } from './preferLocal.js';
export { resolveAppNavigationContext } from './navigationContext.js';
export type { AppNavigationContext } from './navigationContext.js';

// Admin-override affordance + audit marker (objectui#5178). Exported because
// the Approval Center (`apps/console`) renders the second timeline and must ask
Expand Down
68 changes: 68 additions & 0 deletions packages/app-shell/src/utils/navigationContext.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import type { NavigationArea, NavigationItem } from '@object-ui/types';
import { resolveAppNavigationContext } from './navigationContext';

const BASE = '/apps/forge';
const areas: NavigationArea[] = [
{
id: 'workspace',
label: 'Workspace',
navigation: [{ id: 'home', type: 'page', label: 'Workbench', pageName: 'workbench' }],
},
{
id: 'project',
label: 'Project',
navigation: [{
id: 'project_management',
type: 'group',
label: 'Project Management',
children: [{
id: 'timesheet',
type: 'page',
label: 'Timesheets',
pageName: 'page_project_timesheet_cost',
params: { nav: 'timesheet' },
}],
}],
},
];

describe('resolveAppNavigationContext', () => {
it('recovers an area, group, and page from a direct page route', () => {
const result = resolveAppNavigationContext({
areas,
pathname: `${BASE}/page/page_project_timesheet_cost`,
search: '?verify=direct-link',
basePath: BASE,
});

expect(result.area?.id).toBe('project');
expect(result.trail.map((item) => item.id)).toEqual(['project_management', 'timesheet']);
});

it('supports apps that use a flat navigation tree', () => {
const navigation: NavigationItem[] = [
{ id: 'reports', type: 'group', label: 'Reports', children: [
{ id: 'margin', type: 'report', label: 'Margin', reportName: 'margin' },
] },
];
const result = resolveAppNavigationContext({
navigation,
pathname: `${BASE}/report/margin`,
search: '',
basePath: BASE,
});

expect(result.area).toBeNull();
expect(result.trail.map((item) => item.id)).toEqual(['reports', 'margin']);
});

it('leaves routes outside the declared navigation unresolved', () => {
expect(resolveAppNavigationContext({
areas,
pathname: `${BASE}/search`,
search: '',
basePath: BASE,
})).toEqual({ area: null, trail: [] });
});
});
29 changes: 29 additions & 0 deletions packages/app-shell/src/utils/navigationContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { resolveActiveNavTrail, type NavTemplateContext } from '@object-ui/layout';
import type { NavigationArea, NavigationItem } from '@object-ui/types';

export interface AppNavigationContext {
area: NavigationArea | null;
trail: NavigationItem[];
}

/** Resolve an app route back to its owning area and navigation trail. */
export function resolveAppNavigationContext(options: {
areas?: NavigationArea[];
navigation?: NavigationItem[];
pathname: string;
search: string;
basePath: string;
templateContext?: NavTemplateContext;
}): AppNavigationContext {
const { areas = [], navigation = [], pathname, search, basePath, templateContext } = options;

for (const area of areas) {
const trail = resolveActiveNavTrail(area.navigation || [], pathname, search, basePath, templateContext);
if (trail.length > 0) return { area, trail };
}

return {
area: null,
trail: resolveActiveNavTrail(navigation, pathname, search, basePath, templateContext),
};
}
Loading