Skip to content
Merged
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
30 changes: 30 additions & 0 deletions .changeset/app-shell-sidebars-derived-area-visibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"@object-ui/app-shell": patch
---

`AppSidebar` and `UnifiedSidebar` area switchers now adopt the derived area
visibility introduced for `AppSchemaRenderer` in objectui#3311, closing the
same visible-but-empty gap in the console shells (objectui#3319).

Both sidebars inlined their own area switcher without any area-level
filtering, so an area whose navigation items were **all** gated away
(`visible` expression, `requiredPermissions`, `requiresObject` /
`requiresService` capability gates) still appeared in the switcher and
rendered an empty navigation — and a fully gated *first* area was even
auto-activated, landing the user on an empty sidebar.

## What changed

- **Shared predicate, not a second implementation.** Both switchers now call
`hasVisibleNavigationItems` from `@object-ui/layout` — the exact guards
`NavigationRenderer` applies per item — so the switcher can never disagree
with the rendered navigation. In `UnifiedSidebar`, `action` items count as
content (it wires `onAction`, framework#4509); in `AppSidebar` they do not
(it wires none).
- **The active area is elected among visible areas only**: first visible by
default, re-elected when the active area is gated away, and a gating change
that merely *reveals* an area never steals the user's current selection.
- `areas: any[]` tightened to `NavigationArea[]` in both components.

No authorable area-level key is introduced — visibility stays derived, per
the objectui#3311 ruling.
117 changes: 72 additions & 45 deletions packages/app-shell/src/layout/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ import {
Home,
ListTree,
} from 'lucide-react';
import { NavigationRenderer, resolveHref, resolveActiveNavItem } from '@object-ui/layout';
import type { NavigationItem } from '@object-ui/types';
import { NavigationRenderer, resolveHref, resolveActiveNavItem, hasVisibleNavigationItems } from '@object-ui/layout';
import type { NavigationArea, NavigationItem } from '@object-ui/types';
import { useMetadata } from '../providers/MetadataProvider';
import { useExpressionContext, evaluateVisibility } from '../providers/ExpressionProvider';
import { useAuth, useIsWorkspaceAdmin, getUserInitials } from '@object-ui/auth';
Expand Down Expand Up @@ -191,45 +191,6 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
// Navigation pin persistence via localStorage
const { togglePin, applyPins } = useNavPins();

// Area management — track selected area when app defines areas
const areas: any[] = activeApp?.areas || [];
const [activeAreaId, setActiveAreaId] = React.useState<string | null>(
() => areas.length > 0 ? areas[0].id : null,
);

// Reset area when app changes or areas become available
React.useEffect(() => {
if (areas.length > 0) {
setActiveAreaId(prev => areas.some((a: any) => a.id === prev) ? prev : areas[0].id);
} else {
setActiveAreaId(null);
}
}, [activeAppName, areas.length]);

// Resolve navigation items: area navigation > flat navigation > empty
const activeArea = areas.find((a: any) => a.id === activeAreaId);
const resolvedNavigation: 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.
const { contextValues, element: contextSelectorsUI } = useAppContextSelectors(
activeAppName,
activeApp?.contextSelectors,
t,
);

// Apply saved order and pin state to navigation items
const processedNavigation = React.useMemo(() => {
const ordered = applyOrder(resolvedNavigation);
return applyPins(ordered);
}, [resolvedNavigation, applyOrder, applyPins]);

// Search filter state for sidebar navigation
const [navSearchQuery, setNavSearchQuery] = React.useState('');

// Recent section collapsed by default
const [recentExpanded, setRecentExpanded] = React.useState(false);

// Visibility evaluation from Console expression context
const { evaluator } = useExpressionContext();
const evalVis = React.useCallback(
Expand Down Expand Up @@ -277,6 +238,71 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
[registeredObjectNames],
);

// Area management — track selected area when app defines areas.
//
// Area visibility is DERIVED from the items inside (objectui#3311 /
// objectui#3319): the switcher offers an area iff at least one of its
// navigation items survives the same item-level guards NavigationRenderer
// applies (`visible`, `requiredPermissions`, runtime capabilities,
// action-dispatcher presence). The active area is elected among the
// VISIBLE areas only, so the user is never landed in — or stranded on —
// an area that renders nothing. Same derivation as `AppSchemaRenderer`
// (@object-ui/layout); the predicate is shared, not re-implemented.
const areas: NavigationArea[] = activeApp?.areas || [];
const visibleAreas = areas.filter((area) =>
hasVisibleNavigationItems(area.navigation, {
evaluateVisibility: evalVis,
checkPermission: checkPerm,
checkCapability: checkCap,
// This sidebar wires no `onAction` on its NavigationRenderer, so
// `action` items never render here and cannot carry an area's
// visibility either (framework#4509).
hasActionHandler: false,
}),
);
const [activeAreaId, setActiveAreaId] = React.useState<string | null>(
() => visibleAreas.length > 0 ? visibleAreas[0].id : null,
);

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.
React.useEffect(() => {
if (visibleAreas.length > 0) {
setActiveAreaId(prev => visibleAreas.some((a) => a.id === prev) ? prev : visibleAreas[0].id);
} else {
setActiveAreaId(null);
}
}, [activeAppName, visibleAreaIds]);

// Resolve navigation items: area navigation > flat navigation > empty.
// 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 resolvedNavigation: 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.
const { contextValues, element: contextSelectorsUI } = useAppContextSelectors(
activeAppName,
activeApp?.contextSelectors,
t,
);

// Apply saved order and pin state to navigation items
const processedNavigation = React.useMemo(() => {
const ordered = applyOrder(resolvedNavigation);
return applyPins(ordered);
}, [resolvedNavigation, applyOrder, applyPins]);

// Search filter state for sidebar navigation
const [navSearchQuery, setNavSearchQuery] = React.useState('');

// Recent section collapsed by default
const [recentExpanded, setRecentExpanded] = React.useState(false);

const basePath = activeApp ? `/apps/${appRouteSegment(activeApp) ?? activeAppName}` : '';

// Fallback system navigation when no active app exists — routes into the Setup app.
Expand Down Expand Up @@ -435,18 +461,19 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
<SidebarContent>
{activeApp ? (
<>
{/* Area Switcher — shown when app defines areas */}
{areas.length > 1 && (
{/* Area Switcher — offered only when MULTIPLE areas are visible;
area visibility is derived from the items inside (#3319) */}
{visibleAreas.length > 1 && (
<SidebarGroup>
<SidebarGroupLabel className="flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5" />
{t('sidebar.area', { defaultValue: 'Area' })}
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{areas.map((area: any) => {
{visibleAreas.map((area) => {
const AreaIcon = getIcon(area.icon);
const isActiveArea = area.id === activeAreaId;
const isActiveArea = area.id === activeArea?.id;
return (
<SidebarMenuItem key={area.id}>
<SidebarMenuButton
Expand Down
152 changes: 90 additions & 62 deletions packages/app-shell/src/layout/UnifiedSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ import {
Home,
Layers,
} from 'lucide-react';
import { NavigationRenderer } from '@object-ui/layout';
import type { NavigationItem } from '@object-ui/types';
import { NavigationRenderer, hasVisibleNavigationItems } from '@object-ui/layout';
import type { NavigationArea, NavigationItem } from '@object-ui/types';
import { useMetadata } from '../providers/MetadataProvider';
import { useExpressionContext, evaluateVisibility } from '../providers/ExpressionProvider';
import { usePermissions } from '@object-ui/permissions';
Expand Down Expand Up @@ -197,22 +197,97 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) {
const { applyOrder, handleReorder } = useNavOrder(activeApp?.name || 'home');
const { togglePin, applyPins } = useNavPins();

// Area management
const areas: any[] = activeApp?.areas || [];
// Visibility evaluation
const { evaluator } = useExpressionContext();
const evalVis = React.useCallback(
(expr: string | boolean | undefined) => evaluateVisibility(expr, evaluator),
[evaluator],
);

// Permission check for nav `requiredPermissions` entries.
//
// Two authored forms:
// - `object:action` → object CRUD gate.
// - bare name → an ADR-0066 system capability, checked against the union of
// the user's permission-set `systemPermissions` (from /me/permissions) —
// the SAME subset rule the server applies to `AppSchema.requiredPermissions`.
// This used to be misread as `can(<name>, 'read')` only, so a nav item
// requiring a capability was hidden even from users whose permission set
// granted it (admins included) — `requiredPermissions` degenerated into a
// "hide from everyone" switch. The object-read fallback stays for nav
// items that gate on a plain object name.
const { can, hasCapabilities } = usePermissions();
const checkPerm = React.useCallback(
(permissions: string[]) => permissions.every((perm: string) => {
const parts = perm.split(':');
if (parts.length >= 2) {
return can(parts[0], parts[1] as any);
}
return hasCapabilities([perm]) || can(perm, 'read');
}),
[can, hasCapabilities],
);

// Runtime capability gate: hide nav items targeting objects/services
// not registered in this runtime (e.g. cloud-only `sys_app`).
const registeredObjectNames = React.useMemo(
() => new Set<string>((metadataObjects || []).map((o: any) => o?.name).filter(Boolean)),
[metadataObjects],
);
const checkCap = React.useCallback(
(kind: 'object' | 'service', name: string): boolean => {
if (kind === 'object') {
if (registeredObjectNames.size === 0) return true;
return registeredObjectNames.has(name);
}
return true;
},
[registeredObjectNames],
);

// Area management.
//
// Area visibility is DERIVED from the items inside (objectui#3311 /
// objectui#3319): the switcher offers an area iff at least one of its
// navigation items survives the same item-level guards NavigationRenderer
// applies (`visible`, `requiredPermissions`, runtime capabilities,
// action-dispatcher presence). The active area is elected among the
// VISIBLE areas only, so the user is never landed in — or stranded on —
// an area that renders nothing. Same derivation as `AppSchemaRenderer`
// (@object-ui/layout); the predicate is shared, not re-implemented.
const areas: NavigationArea[] = activeApp?.areas || [];
const visibleAreas = areas.filter((area) =>
hasVisibleNavigationItems(area.navigation, {
evaluateVisibility: evalVis,
checkPermission: checkPerm,
checkCapability: checkCap,
// This sidebar always wires `onAction={dispatchNavAction}` on its
// NavigationRenderer (framework#4509), so `action` items render and
// count as area content.
hasActionHandler: !!dispatchNavAction,
}),
);
const [activeAreaId, setActiveAreaId] = React.useState<string | null>(
() => areas.length > 0 ? areas[0].id : null,
() => visibleAreas.length > 0 ? visibleAreas[0].id : null,
);

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.
React.useEffect(() => {
if (areas.length > 0) {
setActiveAreaId(prev => areas.some((a: any) => a.id === prev) ? prev : areas[0].id);
if (visibleAreas.length > 0) {
setActiveAreaId(prev => visibleAreas.some((a) => a.id === prev) ? prev : visibleAreas[0].id);
} else {
setActiveAreaId(null);
}
}, [activeApp?.name, areas.length]);
}, [activeApp?.name, visibleAreaIds]);

// Resolve navigation items
const activeArea = areas.find((a: any) => a.id === activeAreaId);
// 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
Expand Down Expand Up @@ -310,54 +385,6 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) {
// Recent section collapsed by default
const [recentExpanded, setRecentExpanded] = React.useState(false);

// Visibility evaluation
const { evaluator } = useExpressionContext();
const evalVis = React.useCallback(
(expr: string | boolean | undefined) => evaluateVisibility(expr, evaluator),
[evaluator],
);

// Permission check for nav `requiredPermissions` entries.
//
// Two authored forms:
// - `object:action` → object CRUD gate.
// - bare name → an ADR-0066 system capability, checked against the union of
// the user's permission-set `systemPermissions` (from /me/permissions) —
// the SAME subset rule the server applies to `AppSchema.requiredPermissions`.
// This used to be misread as `can(<name>, 'read')` only, so a nav item
// requiring a capability was hidden even from users whose permission set
// granted it (admins included) — `requiredPermissions` degenerated into a
// "hide from everyone" switch. The object-read fallback stays for nav
// items that gate on a plain object name.
const { can, hasCapabilities } = usePermissions();
const checkPerm = React.useCallback(
(permissions: string[]) => permissions.every((perm: string) => {
const parts = perm.split(':');
if (parts.length >= 2) {
return can(parts[0], parts[1] as any);
}
return hasCapabilities([perm]) || can(perm, 'read');
}),
[can, hasCapabilities],
);

// Runtime capability gate: hide nav items targeting objects/services
// not registered in this runtime (e.g. cloud-only `sys_app`).
const registeredObjectNames = React.useMemo(
() => new Set<string>((metadataObjects || []).map((o: any) => o?.name).filter(Boolean)),
[metadataObjects],
);
const checkCap = React.useCallback(
(kind: 'object' | 'service', name: string): boolean => {
if (kind === 'object') {
if (registeredObjectNames.size === 0) return true;
return registeredObjectNames.has(name);
}
return true;
},
[registeredObjectNames],
);

const isStudioHomeActive = isStudioApp && location.pathname.replace(/\/+$/, '') === basePath;

return (
Expand Down Expand Up @@ -420,18 +447,19 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) {
</SidebarGroup>
)}

{/* Area Switcher */}
{areas.length > 1 && (
{/* Area Switcher — offered only when MULTIPLE areas are visible;
area visibility is derived from the items inside (#3319) */}
{visibleAreas.length > 1 && (
<SidebarGroup>
<SidebarGroupLabel className="flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5" />
{t('sidebar.area', { defaultValue: 'Area' })}
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{areas.map((area: any) => {
{visibleAreas.map((area) => {
const AreaIcon = getIcon(area.icon);
const isActiveArea = area.id === activeAreaId;
const isActiveArea = area.id === activeArea?.id;
return (
<SidebarMenuItem key={area.id}>
<SidebarMenuButton
Expand Down
Loading
Loading