From 8edc75f10ab3203a69aa981e9ad40e0d60cb91ac Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 18:46:32 +0000 Subject: [PATCH] feat(layout): derive area visibility from item visibility (#3311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec 17.0.0 retired the authorable area-level visible/requiredPermissions; following it in #3315 left a fully gated area rendering as visible-but-empty in the AreaSwitcher. Per the #3311 ruling (option C), area visibility is now DERIVED: an area appears in the switcher iff at least one of its items survives the exact item-level guards NavigationRenderer applies (visible expression, requiredPermissions, requiresObject/requiresService capability gates, and the onAction dispatcher presence for action items — framework#4509). Separators never count; groups count only through their children; an area with no items at all derives hidden the same way. The active area is elected among visible areas only, so a fully gated first area is never auto-activated, and a gating change that hides the active area re-elects the first visible one — while a change that merely reveals an area never yanks the user away. New export: hasVisibleNavigationItems from @object-ui/layout. No authorable key is involved — nothing for a metadata author to get wrong, and nothing for the spec's .strict() area object to reject. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NVPjPzmmAJ2Ngtvgg5MSRa --- .../area-visibility-derived-from-items.md | 41 +++ packages/layout/src/AppSchemaRenderer.tsx | 82 +++-- packages/layout/src/NavigationRenderer.tsx | 74 +++++ .../src/__tests__/AppSchemaRenderer.test.tsx | 284 +++++++++++++++++- packages/types/src/app.ts | 7 +- 5 files changed, 454 insertions(+), 34 deletions(-) create mode 100644 .changeset/area-visibility-derived-from-items.md diff --git a/.changeset/area-visibility-derived-from-items.md b/.changeset/area-visibility-derived-from-items.md new file mode 100644 index 000000000..6b8c75213 --- /dev/null +++ b/.changeset/area-visibility-derived-from-items.md @@ -0,0 +1,41 @@ +--- +"@object-ui/layout": minor +--- + +`AppSchemaRenderer` now derives area visibility from the items inside the +area, closing the visible-but-empty regression the spec 17.0.0 area-key +retirement left behind (objectui#3311, option C of the recorded ruling). + +Spec 17.0.0 retired the authorable area-level `visible` / +`requiredPermissions` (`AREA_VISIBLE_RETIRED` / +`AREA_REQUIRED_PERMISSIONS_RETIRED`) — an area is a layout grouping, not an +access boundary — and objectui followed in #3315 by deleting the area +switcher's filter. Correct on the contract, but it changed the navigation +surface: an area whose items are **all** gated away used to disappear from +the switcher and instead rendered as a selectable, empty area. + +## What changed + +- **Area visibility is now derived, not authored.** An area appears in the + switcher iff at least one of its navigation items survives the exact + item-level guards `NavigationRenderer` applies: the `visible` expression, + `requiredPermissions`, the `requiresObject` / `requiresService` runtime + capability gates, and — for `action` items — the presence of an `onAction` + dispatcher (framework#4509: without one they are not rendered, so they + cannot carry an area either). Separators never count; a `group` counts only + through its children. +- **The active area is elected among visible areas only.** A fully gated + first area is no longer auto-activated, and when a gating change hides the + currently active area the shell re-elects the first visible one. A gating + change that merely *reveals* an area never yanks the user away from where + they are. +- **An area with no items at all derives the same way**: no visible item → + hidden. (Boundary recorded in objectui#3311.) +- New export `hasVisibleNavigationItems(items, options)` from + `@object-ui/layout` — the predicate behind the derivation, usable by other + shells that render their own area switchers. + +No authorable key is involved anywhere: the platform's `.strict()` area +object still rejects the retired keys, and the derivation — computed from the +same guards that decide what renders — cannot disagree with the rendered +navigation, so there is nothing for a metadata author to get wrong. diff --git a/packages/layout/src/AppSchemaRenderer.tsx b/packages/layout/src/AppSchemaRenderer.tsx index 416c0f661..1ca0fa605 100644 --- a/packages/layout/src/AppSchemaRenderer.tsx +++ b/packages/layout/src/AppSchemaRenderer.tsx @@ -41,6 +41,7 @@ import { menuItemToNavigationItem } from '@object-ui/types'; import { AppShell, type AppShellBranding } from './AppShell'; import { NavigationRenderer, + hasVisibleNavigationItems, resolveIcon, resolveLabel, type VisibilityEvaluator, @@ -121,17 +122,24 @@ export interface AppSchemaRendererProps { // --------------------------------------------------------------------------- /** - * Areas are NOT gated here any more. `@objectstack/spec` 17.0.0 retired - * `visible` and `requiredPermissions` at area level - * (`AREA_VISIBLE_RETIRED` / `AREA_REQUIRED_PERMISSIONS_RETIRED`): an area is a - * layout grouping, not an access boundary, so gating belongs on the navigation - * ITEM — which `NavigationRenderer` still enforces, via the same `evalVis` / - * `checkPerm` this component used to apply one level up. The spec's area object - * is `.strict()`, so no v17-valid app can carry the retired keys and this - * filter had become unreachable for every app the platform accepts. + * Renders the switcher for the areas the current user can still see. * - * Consequence worth knowing: an area whose items are all gated away now renders - * as a visible-but-empty area rather than disappearing from the switcher. + * Areas carry no authorable gate of their own: `@objectstack/spec` 17.0.0 + * retired `visible` and `requiredPermissions` at area level + * (`AREA_VISIBLE_RETIRED` / `AREA_REQUIRED_PERMISSIONS_RETIRED`) — an area is + * a layout grouping, not an access boundary, so gating belongs on the + * navigation ITEM, which `NavigationRenderer` still enforces via the same + * `evalVis` / `checkPerm` this component used to apply one level up. The + * spec's area object is `.strict()`, so no v17-valid app can carry the + * retired keys. + * + * Area visibility is instead DERIVED (objectui#3311): `AppSchemaRenderer` + * lists an area here iff `hasVisibleNavigationItems` finds at least one item + * in it that survives the item-level guards. An area whose items are all + * gated away disappears from the switcher — the same UX the retired keys used + * to produce — without resurrecting any authorable key for the platform's + * strict schema to reject. An area with no items at all derives the same way + * (no visible item → hidden). */ function AreaSwitcher({ areas, @@ -272,6 +280,7 @@ function InternalSidebar({ sidebarHeader, sidebarFooter, sidebarExtra, + visibleAreas, activeAreaId, setActiveAreaId, resolvedNavigation, @@ -290,6 +299,8 @@ function InternalSidebar({ sidebarHeader?: React.ReactNode; sidebarFooter?: React.ReactNode; sidebarExtra?: React.ReactNode; + /** Areas with at least one visible item — derived, not authored (#3311). */ + visibleAreas: NavigationArea[]; activeAreaId: string | null; setActiveAreaId: (id: string) => void; resolvedNavigation: NavigationItem[]; @@ -300,7 +311,6 @@ function InternalSidebar({ onReorder?: (reorderedItems: NavigationItem[]) => void; }) { const Icon = resolveIcon(schema.logo); - const areas = schema.areas ?? []; const [searchQuery, setSearchQuery] = useState(''); return ( @@ -349,10 +359,11 @@ function InternalSidebar({ - {/* Area Switcher */} - {areas.length > 1 && activeAreaId && ( + {/* Area Switcher — only areas with a visible item, and only when + there is more than one of them left to switch between (#3311) */} + {visibleAreas.length > 1 && activeAreaId && ( @@ -393,7 +404,9 @@ function InternalSidebar({ * Responsibilities: * - Reads `name`, `title`, `description`, `logo`, `favicon` for branding * - Renders sidebar navigation from `navigation` or `areas[].navigation` - * - Area switcher when multiple `areas` are defined + * - Area switcher when multiple areas are VISIBLE — area visibility is + * derived from the items inside, not authored (objectui#3311): an area + * whose items are all gated away is hidden and never auto-activated * - Mobile modes: `drawer` (sheet overlay, default), `bottom_nav` (fixed * bottom bar), `hamburger` (collapsed sidebar) * - Evaluates `visible` expressions and `requiredPermissions` on every item @@ -448,24 +461,46 @@ export function AppSchemaRenderer({ const flatNavigation = schema.navigation ?? legacyNavigation; // --- Area management --- + // + // Area visibility is DERIVED from the items inside (objectui#3311): an area + // is visible iff at least one of its navigation items survives the same + // item-level guards `NavigationRenderer` applies (`visible`, + // `requiredPermissions`, runtime capabilities, action-dispatcher presence). + // Spec 17.0.0 retired the authorable area-level keys; this derivation + // restores the "fully gated area disappears" UX without any authorable key. + // 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. const areas = schema.areas ?? []; + const visibleAreas = areas.filter((area) => + hasVisibleNavigationItems(area.navigation, { + evaluateVisibility: evalVis, + checkPermission: checkPerm, + checkCapability: checkCap, + hasActionHandler: !!onAction, + }), + ); const [activeAreaId, setActiveAreaId] = useState( - () => areas.length > 0 ? areas[0].id : null, + () => visibleAreas.length > 0 ? visibleAreas[0].id : null, ); - const areaIds = areas.map((a) => a.id).join(','); + const visibleAreaIds = visibleAreas.map((a) => a.id).join(','); useEffect(() => { - if (areas.length > 0) { + if (visibleAreas.length > 0) { setActiveAreaId((prev) => - areas.some((a) => a.id === prev) ? prev : areas[0].id, + visibleAreas.some((a) => a.id === prev) ? prev : visibleAreas[0].id, ); } else { setActiveAreaId(null); } - }, [schema.name, areaIds]); - - const activeArea = areas.find((a) => a.id === activeAreaId); + }, [schema.name, visibleAreaIds]); + + // Resolve the EFFECTIVE active area at render time rather than trusting the + // state: when a gating change hides the currently active area, the effect + // above re-elects on the next tick — this fallback keeps the in-between + // frame from rendering the hidden area's (empty) navigation. + const activeArea = + visibleAreas.find((a) => a.id === activeAreaId) ?? visibleAreas[0]; const resolvedNavigation: NavigationItem[] = activeArea?.navigation ?? flatNavigation; // --- Branding --- @@ -487,7 +522,8 @@ export function AppSchemaRenderer({ sidebarHeader={sidebarHeader} sidebarFooter={sidebarFooter} sidebarExtra={sidebarExtra} - activeAreaId={activeAreaId} + visibleAreas={visibleAreas} + activeAreaId={activeArea?.id ?? null} setActiveAreaId={setActiveAreaId} resolvedNavigation={resolvedNavigation} enableSearch={enableSearch} diff --git a/packages/layout/src/NavigationRenderer.tsx b/packages/layout/src/NavigationRenderer.tsx index dd725c1a5..35bdd17b9 100644 --- a/packages/layout/src/NavigationRenderer.tsx +++ b/packages/layout/src/NavigationRenderer.tsx @@ -342,6 +342,80 @@ const defaultPermission: PermissionChecker = () => true; const defaultCapability: CapabilityChecker = () => true; +// --------------------------------------------------------------------------- +// Derived area visibility (objectui#3311) +// --------------------------------------------------------------------------- + +/** Guard callbacks for {@link hasVisibleNavigationItems}. */ +export interface NavigationVisibilityOptions { + /** Evaluator for item `visible` expressions. Defaults to always-visible. */ + evaluateVisibility?: VisibilityEvaluator; + /** Checker for item `requiredPermissions`. Defaults to always-permitted. */ + checkPermission?: PermissionChecker; + /** Checker for `requiresObject` / `requiresService`. Defaults to pass. */ + checkCapability?: CapabilityChecker; + /** + * Whether the host wires an `onAction` dispatcher. Without one, `action` + * items are not rendered at all (framework#4509 — a nav entry that looks + * clickable and silently does nothing is worse than an absent one), so + * they cannot carry an area's visibility either. Defaults to `false`, + * matching a renderer with no `onAction` prop. + */ + hasActionHandler?: boolean; +} + +/** + * Whether a navigation tree contains at least one item that would actually + * render under the given guards — the exact guards `NavigationItemRenderer` + * applies per item: the `visible` expression, `requiredPermissions`, the + * `requiresObject` / `requiresService` runtime-capability gates, and (for + * `action` items) the presence of an action dispatcher. + * + * Non-content nodes never count: a `separator` is a visual divider, and a + * `group` counts only through its children — a group whose children are all + * gated away contributes nothing a user can navigate to. + * + * This is the predicate behind DERIVED area visibility (objectui#3311). + * `@objectstack/spec` 17.0.0 retired the authorable area-level `visible` / + * `requiredPermissions` (`AREA_VISIBLE_RETIRED` / + * `AREA_REQUIRED_PERMISSIONS_RETIRED`): an area is a layout grouping, not an + * access boundary. What replaces those keys is not a new key but this + * derivation: an area is visible iff something inside it is. Because it is + * computed from the same guards that decide what renders, it can never + * disagree with the rendered navigation — and there is nothing for a + * metadata author to get wrong. An area with no items at all derives the + * same way (no visible item → hidden). + */ +export function hasVisibleNavigationItems( + items: NavigationItem[], + options: NavigationVisibilityOptions = {}, +): boolean { + const { + evaluateVisibility = defaultVisibility, + checkPermission = defaultPermission, + checkCapability = defaultCapability, + hasActionHandler = false, + } = options; + + for (const item of items) { + // Same guard order as NavigationItemRenderer. + if (!evaluateVisibility(item.visible)) continue; + if (item.requiredPermissions?.length && !checkPermission(item.requiredPermissions)) continue; + if (item.requiresObject && !checkCapability('object', item.requiresObject)) continue; + if (item.requiresService && !checkCapability('service', item.requiresService)) continue; + + if (item.type === 'separator') continue; + if (item.type === 'group') { + if (hasVisibleNavigationItems(item.children ?? [], options)) return true; + continue; + } + if (item.type === 'action' && !hasActionHandler) continue; + + return true; + } + return false; +} + // --------------------------------------------------------------------------- // Internal helper: resolve href from NavigationItem // --------------------------------------------------------------------------- diff --git a/packages/layout/src/__tests__/AppSchemaRenderer.test.tsx b/packages/layout/src/__tests__/AppSchemaRenderer.test.tsx index 2c28af144..e6a740440 100644 --- a/packages/layout/src/__tests__/AppSchemaRenderer.test.tsx +++ b/packages/layout/src/__tests__/AppSchemaRenderer.test.tsx @@ -12,6 +12,7 @@ import { render, screen, fireEvent } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import type { AppComponentSchema, NavigationItem, NavigationArea } from '@object-ui/types'; import { AppSchemaRenderer } from '../AppSchemaRenderer'; +import { hasVisibleNavigationItems } from '../NavigationRenderer'; /** Wrap component in MemoryRouter */ function renderApp( @@ -70,6 +71,15 @@ const serviceArea: NavigationArea = { ], }; +const marketingArea: NavigationArea = { + id: 'area-marketing', + label: 'Marketing', + icon: 'Megaphone', + navigation: [ + { id: 'a3', type: 'object', label: 'Campaigns', icon: 'Send', objectName: 'campaign' }, + ], +}; + const schemaWithAreas: AppComponentSchema = { type: 'app', name: 'crm', @@ -189,15 +199,20 @@ describe('AppSchemaRenderer', () => { // `visible` / `requiredPermissions` were retired at AREA level // (`AREA_VISIBLE_RETIRED` / `AREA_REQUIRED_PERMISSIONS_RETIRED`): an area is a // layout grouping, not an access boundary. These two tests used to assert the - // area itself was hidden; they now assert the capability still exists one - // level down, which is where the spec moved it. Losing the gate entirely — - // rather than relocating it — is the regression worth catching, so the - // item-level assertions below are deliberately the same scenarios. + // area itself was hidden; after #3315 they asserted the capability still + // exists one level down, which is where the spec moved it. Losing the gate + // entirely — rather than relocating it — is the regression worth catching. + // + // Since #3311 the gated item needs a VISIBLE sibling here: an area whose + // items are ALL gated is now derived-hidden and never activated (see the + // "derived area visibility" block below), which would make a lone-gated-item + // assertion vacuous. A partially gated area stays visible and active, which + // is exactly what keeps "the ITEM is gated, the area is not" load-bearing. - // NB: the gated item must live in the FIRST area — that is the one the - // switcher activates by default, so it is the only area whose navigation is - // actually rendered. Gating an item in a non-active area asserts nothing: - // it is absent either way. + // NB: the gated item must live in the ACTIVE area — the first VISIBLE area + // is the one the switcher activates by default, so it is the only area whose + // navigation is actually rendered. Gating an item in a non-active area + // asserts nothing: it is absent either way. it('gates the navigation ITEM by visibility, not the area', () => { const schema: AppComponentSchema = { @@ -207,7 +222,10 @@ describe('AppSchemaRenderer', () => { areas: [ { ...salesArea, - navigation: [{ ...salesArea.navigation[0], visible: false }], + navigation: [ + { ...salesArea.navigation[0], visible: false }, + { id: 'a1b', type: 'object', label: 'Quotes', objectName: 'quote' }, + ], }, serviceArea, ], @@ -215,8 +233,10 @@ describe('AppSchemaRenderer', () => { renderApp(schema, { evaluateVisibility: (expr) => expr !== false, }); - // The area still appears in the switcher… + // The partially gated area still appears in the switcher, stays active, + // and renders its visible item… expect(screen.getByText('Sales')).toBeTruthy(); + expect(screen.getByText('Quotes')).toBeTruthy(); // …but the item it gates does not render. expect(screen.queryByText('Opportunities')).toBeNull(); }); @@ -231,6 +251,7 @@ describe('AppSchemaRenderer', () => { ...salesArea, navigation: [ { ...salesArea.navigation[0], requiredPermissions: ['sales:admin'] }, + { id: 'a1b', type: 'object', label: 'Quotes', objectName: 'quote' }, ], }, serviceArea, @@ -240,9 +261,252 @@ describe('AppSchemaRenderer', () => { checkPermission: (perms) => !perms.includes('sales:admin'), }); expect(screen.getByText('Sales')).toBeTruthy(); + expect(screen.getByText('Quotes')).toBeTruthy(); expect(screen.queryByText('Opportunities')).toBeNull(); }); + // --- Derived area visibility (#3311) --- + // + // Spec 17.0.0 retired the authorable area-level `visible` / + // `requiredPermissions`; #3315 followed suit, which left an area whose items + // are ALL gated rendering as visible-but-empty in the switcher. Per the + // #3311 ruling (option C), area visibility is now DERIVED from the items + // inside — the same item-level guards NavigationRenderer applies — so a + // fully gated area disappears again, with no authorable key involved. An + // area with no items at all derives the same way: nothing visible → hidden. + + describe('derived area visibility (#3311)', () => { + const gatedSales: NavigationArea = { + ...salesArea, + navigation: [{ ...salesArea.navigation[0], visible: false }], + }; + + it('keeps every area in the switcher when every area has a visible item', () => { + renderApp({ type: 'app', name: 'crm', title: 'CRM', areas: [salesArea, serviceArea, marketingArea] }); + expect(screen.getByText('Sales')).toBeTruthy(); + expect(screen.getByText('Service')).toBeTruthy(); + expect(screen.getByText('Marketing')).toBeTruthy(); + // First area is active. + expect(screen.getByText('Opportunities')).toBeTruthy(); + }); + + it('hides an area whose items are ALL gated and activates the first visible area', () => { + renderApp( + { type: 'app', name: 'crm', title: 'CRM', areas: [gatedSales, serviceArea, marketingArea] }, + { evaluateVisibility: (expr) => expr !== false }, + ); + // The fully gated area is not offered in the switcher… + expect(screen.queryByText('Sales')).toBeNull(); + expect(screen.getByText('Service')).toBeTruthy(); + expect(screen.getByText('Marketing')).toBeTruthy(); + // …and it is never auto-activated: the first VISIBLE area's navigation + // renders instead of a visible-but-empty Sales area. + expect(screen.getByText('Cases')).toBeTruthy(); + expect(screen.queryByText('Opportunities')).toBeNull(); + }); + + it('hides an area whose items all fail their permission checks', () => { + const adminSales: NavigationArea = { + ...salesArea, + navigation: [ + { ...salesArea.navigation[0], requiredPermissions: ['sales:admin'] }, + ], + }; + renderApp( + { type: 'app', name: 'crm', title: 'CRM', areas: [adminSales, serviceArea, marketingArea] }, + { checkPermission: (perms) => !perms.includes('sales:admin') }, + ); + expect(screen.queryByText('Sales')).toBeNull(); + expect(screen.getByText('Service')).toBeTruthy(); + expect(screen.getByText('Cases')).toBeTruthy(); + }); + + it('hides the switcher entirely when only one area remains visible', () => { + renderApp( + { type: 'app', name: 'crm', title: 'CRM', areas: [gatedSales, serviceArea] }, + { evaluateVisibility: (expr) => expr !== false }, + ); + // One visible area = nothing to switch between: no switcher at all, + // so neither area label renders — but the visible area's nav does. + expect(screen.queryByText('Sales')).toBeNull(); + expect(screen.queryByText('Service')).toBeNull(); + expect(screen.getByText('Cases')).toBeTruthy(); + }); + + it('renders no switcher and no area navigation when every area is fully gated', () => { + renderApp( + { + type: 'app', + name: 'crm', + title: 'CRM', + areas: [ + gatedSales, + { ...serviceArea, navigation: [{ ...serviceArea.navigation[0], visible: false }] }, + ], + }, + { evaluateVisibility: (expr) => expr !== false }, + ); + expect(screen.queryByText('Sales')).toBeNull(); + expect(screen.queryByText('Service')).toBeNull(); + expect(screen.queryByText('Opportunities')).toBeNull(); + expect(screen.queryByText('Cases')).toBeNull(); + // The shell itself still renders. + expect(screen.getByTestId('page-content')).toBeTruthy(); + }); + + it('treats an area with no items at all like a fully gated one (hidden)', () => { + // Boundary decision recorded in #3311: an empty area derives exactly + // like an all-gated one — no visible item, no entry in the switcher. + const emptyArea: NavigationArea = { id: 'area-empty', label: 'Empty', navigation: [] }; + renderApp({ type: 'app', name: 'crm', title: 'CRM', areas: [emptyArea, salesArea, serviceArea] }); + expect(screen.queryByText('Empty')).toBeNull(); + expect(screen.getByText('Sales')).toBeTruthy(); + expect(screen.getByText('Service')).toBeTruthy(); + // Active area skips the empty one. + expect(screen.getByText('Opportunities')).toBeTruthy(); + }); + + it('derives through groups: an area whose groups have no visible child is hidden', () => { + const groupedGated: NavigationArea = { + id: 'area-grouped', + label: 'Grouped', + navigation: [ + { + id: 'grp', + type: 'group', + label: 'Tools', + children: [ + { id: 'grp-1', type: 'object', label: 'Hidden Tool', objectName: 'tool', visible: false }, + ], + }, + ], + }; + renderApp( + { type: 'app', name: 'crm', title: 'CRM', areas: [groupedGated, serviceArea, marketingArea] }, + { evaluateVisibility: (expr) => expr !== false }, + ); + expect(screen.queryByText('Grouped')).toBeNull(); + expect(screen.getByText('Service')).toBeTruthy(); + expect(screen.getByText('Cases')).toBeTruthy(); + }); + + it('re-derives when gating changes: revoking a permission hides the active area and re-elects', () => { + const adminSales: NavigationArea = { + ...salesArea, + navigation: [ + { ...salesArea.navigation[0], requiredPermissions: ['sales:admin'] }, + ], + }; + const schema: AppComponentSchema = { + type: 'app', + name: 'crm', + title: 'CRM', + areas: [adminSales, serviceArea, marketingArea], + }; + const ui = (checkPermission: (perms: string[]) => boolean) => ( + + +
Page Content
+
+
+ ); + const view = render(ui(() => true)); + // Permission granted: Sales is visible and active. + expect(screen.getByText('Sales')).toBeTruthy(); + expect(screen.getByText('Opportunities')).toBeTruthy(); + + // Permission revoked: Sales derives hidden, drops out of the switcher, + // and the shell re-elects the first visible area. + view.rerender(ui((perms) => !perms.includes('sales:admin'))); + expect(screen.queryByText('Sales')).toBeNull(); + expect(screen.queryByText('Opportunities')).toBeNull(); + expect(screen.getByText('Cases')).toBeTruthy(); + + // Permission granted again: Sales reappears in the switcher, but the + // user's current area is NOT yanked away — Service stays active. + view.rerender(ui(() => true)); + expect(screen.getByText('Sales')).toBeTruthy(); + expect(screen.getByText('Cases')).toBeTruthy(); + expect(screen.queryByText('Opportunities')).toBeNull(); + }); + }); + + // --- hasVisibleNavigationItems (the predicate behind #3311) --- + + describe('hasVisibleNavigationItems', () => { + it('returns false for an empty tree', () => { + expect(hasVisibleNavigationItems([])).toBe(false); + }); + + it('ignores separators — a divider is not content', () => { + expect( + hasVisibleNavigationItems([{ id: 's1', type: 'separator', label: '' }]), + ).toBe(false); + }); + + it('counts an action item only when the host wires a dispatcher (framework#4509)', () => { + const items: NavigationItem[] = [ + { + id: 'act1', + type: 'action', + label: 'Export', + actionDef: { actionName: 'export_data' }, + }, + ]; + expect(hasVisibleNavigationItems(items)).toBe(false); + expect(hasVisibleNavigationItems(items, { hasActionHandler: true })).toBe(true); + }); + + it('applies the runtime capability gates (requiresObject / requiresService)', () => { + const items: NavigationItem[] = [ + { id: 'n1', type: 'object', label: 'Apps', objectName: 'sys_app', requiresObject: 'sys_app' }, + ]; + expect( + hasVisibleNavigationItems(items, { checkCapability: () => false }), + ).toBe(false); + expect( + hasVisibleNavigationItems(items, { checkCapability: () => true }), + ).toBe(true); + }); + + it('applies a group\'s own guards before recursing into its children', () => { + const items: NavigationItem[] = [ + { + id: 'grp', + type: 'group', + label: 'Admin', + requiredPermissions: ['admin'], + children: [ + { id: 'grp-1', type: 'object', label: 'Users', objectName: 'user' }, + ], + }, + ]; + // The child is visible, but the group itself is gated → nothing counts. + expect( + hasVisibleNavigationItems(items, { checkPermission: () => false }), + ).toBe(false); + expect( + hasVisibleNavigationItems(items, { checkPermission: () => true }), + ).toBe(true); + }); + + it('does not count a group with no visible child', () => { + const items: NavigationItem[] = [ + { + id: 'grp', + type: 'group', + label: 'Tools', + children: [ + { id: 'grp-1', type: 'object', label: 'Hidden', objectName: 'tool', visible: false }, + ], + }, + ]; + expect( + hasVisibleNavigationItems(items, { evaluateVisibility: (expr) => expr !== false }), + ).toBe(false); + }); + }); + // --- Mobile bottom_nav mode --- it('renders mobile bottom nav when mobileNavMode is bottom_nav', () => { diff --git a/packages/types/src/app.ts b/packages/types/src/app.ts index 4517af969..742774132 100644 --- a/packages/types/src/app.ts +++ b/packages/types/src/app.ts @@ -301,7 +301,12 @@ export interface NavigationItem { * `AppSchemaRenderer`'s area switcher filtered areas by `visible` and * `requiredPermissions`. That filter is gone, and the gating it did now happens * one level down in `NavigationRenderer`, which is where the spec moved it. - * The premise mismatch is recorded in objectui#3311. + * The premise mismatch is recorded in objectui#3311. Per that issue's ruling, + * area visibility is now DERIVED rather than authored: `AppSchemaRenderer` + * hides an area from the switcher when none of its items survive the + * item-level guards (`hasVisibleNavigationItems` in `@object-ui/layout`), so + * the pre-17 "fully gated area disappears" UX is back without any authorable + * area-level key. * * One key is pinned locally, for a reason that outlives a spec release: *