diff --git a/.changeset/13029-nav-data-source.md b/.changeset/13029-nav-data-source.md new file mode 100644 index 0000000000..126eb92a70 --- /dev/null +++ b/.changeset/13029-nav-data-source.md @@ -0,0 +1,13 @@ +--- +'@object-ui/layout': minor +'@object-ui/app-shell': minor +'@object-ui/types': patch +--- + +A navigation entry's API `dataSource` is read, and its `visible` sees the selected value as `data` (objectui#13029). + +The spec lets a navigation entry declare `dataSource: { provider: 'api', read: { url }, path? }` (`BaseNavItemSchema.dataSource`). Only the entry's `visible` reads the value, as `data` (for example `data.total == 1`). The label, the target and `children` do not. + +- `@object-ui/layout`: `VisibilityEvaluator` takes an optional second argument, the entry whose `visible` it judges. The item guard passes it on every path it serves: the rows, the derived area visibility and the Favorites section. The default evaluator reads no binding, so it hides an entry that declares one. An entry without the key renders as before. +- `@object-ui/app-shell`: the sidebar, the `nav:menu` block and the command palette share one `visible` evaluator. It reads each binding the app's navigation declares through the page element's reader (`readElementApi`, with the host's signed-in `apiFetch`). The entry is hidden while the read is pending, after it fails, when `path` selects nothing, or when the spec refuses the binding. An entry without the key is evaluated as before, with no request and no wait. +- `@object-ui/types`: the `dataSource` describe and doc on the navigation item now state what the console does with it. The accept set is unchanged. diff --git a/packages/app-shell/src/chrome/CommandPalette.tsx b/packages/app-shell/src/chrome/CommandPalette.tsx index f5e3f96844..6c14a49e5f 100644 --- a/packages/app-shell/src/chrome/CommandPalette.tsx +++ b/packages/app-shell/src/chrome/CommandPalette.tsx @@ -43,7 +43,7 @@ import { import { useRecordSearch } from '@object-ui/react'; import { usePermissions } from '@object-ui/permissions'; import { useTheme } from './ThemeProvider.js'; -import { useExpressionContext, evaluateNavigationVisibility } from '../providers/ExpressionProvider.js'; +import { useNavVisibility } from '../hooks/useNavMemberGates.js'; import { useObjectTranslation } from '@object-ui/i18n'; import { resolveKeyedI18nLabel, getRecordDisplayName, appRouteSegment } from '../utils/index.js'; import { getIcon } from '../utils/getIcon.js'; @@ -111,7 +111,10 @@ export function CommandPalette(props: CommandPaletteProps) { const navigate = useNavigate(); const { appName } = useParams(); const { setTheme } = useTheme(); - const { evaluator } = useExpressionContext(); + // The shell's one navigation `visible` evaluator, the sidebar's and + // `nav:menu`'s: an entry that declares an API `dataSource` reads its bound + // value as `data`, and is hidden until that read answers (objectui#13029). + const evalVis = useNavVisibility(activeApp); const { t, language } = useObjectTranslation(); // A nav entry's text, the way the sidebar and `nav:menu` name it: an entry // with NO `label` shows its target's current label, else its target's machine @@ -152,7 +155,7 @@ export function CommandPalette(props: CommandPaletteProps) { // Extract navigation items from active app, filtering by visibility expressions const navItems = flattenNavigation(activeApp?.navigation || []).filter( - (item) => evaluateNavigationVisibility(item.visible ?? item.visibleOn, evaluator) + (item) => evalVis(item.visible ?? item.visibleOn, item) ); // The entries the palette shows for the query: a navigation entry or app by diff --git a/packages/app-shell/src/hooks/__tests__/navEntryDataSource-13029.test.tsx b/packages/app-shell/src/hooks/__tests__/navEntryDataSource-13029.test.tsx new file mode 100644 index 0000000000..a8e7b9aa94 --- /dev/null +++ b/packages/app-shell/src/hooks/__tests__/navEntryDataSource-13029.test.tsx @@ -0,0 +1,240 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#13029 — a navigation entry's API `dataSource` is read, and its + * `visible` sees the selected value as `data`, on every navigation surface. + * + * The spec (`BaseNavItemSchema.dataSource`, `ElementApiDataSourceSchema` by + * reference) lets an entry declare `{ provider: 'api', read: { url }, path? }`; + * only its `visible` reads the bound value, as `data`. The shell builds ONE + * evaluator for that, `useNavVisibility`, and each surface takes it from there: + * the sidebar through `useNavMemberGates` (the host below hands the gates to the + * real `NavigationRenderer` exactly as `UnifiedSidebar` does), the `nav:menu` + * block and the command palette directly. + * + * Every surface is mounted over the real `ExpressionProvider`, the real + * `MePermissionsProvider` and a stubbed `apiFetch` on `SchemaRendererContext` + * (the seat `ConsoleShell` fills), and asked the same questions: + * + * (a) `visible: 'data.total == 1'` shows the entry when the bound value has + * `total: 1`, and hides it when it has `total: 2`; + * (b) a pending read hides it, and a failed read (HTTP 500) hides it; + * (c) CONTROLS: an entry without `dataSource` shows on first paint with no + * request; a `visible` with no `data` reference on an entry WITH a + * `dataSource` still evaluates once the read answers; + * (d) `path` is honoured: the value at `path` is `data`, not the whole body. + * + * Each case uses its own URL: `readElementApi` shares a pending request per URL + * for the life of the module, so a request a case leaves pending must not be + * the one the next case asks for. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, cleanup, within, act } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +// Module scope, never a `beforeAll` (AGENTS.md §测试纪律, objectui#3010). +import '@object-ui/components'; +import { SidebarProvider } from '@object-ui/components'; +import { NavigationRenderer } from '@object-ui/layout'; +import { SchemaRenderer, SchemaRendererContext, MetadataCtx, type ApiFetch } from '@object-ui/react'; +import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; +import { createI18n, I18nProvider } from '@object-ui/i18n'; +import type { NavigationItem } from '@object-ui/types'; +import { ExpressionProvider } from '../../providers/ExpressionProvider'; +import { CommandPalette } from '../../chrome/CommandPalette'; +import { CommandPaletteProvider } from '../../context/CommandPaletteProvider'; +import { useNavMemberGates } from '../useNavMemberGates'; +import '../../views/nav-menu-renderer'; + +type Surface = 'sidebar' | 'nav:menu' | 'palette'; +const SURFACES: Surface[] = ['sidebar', 'nav:menu', 'palette']; + +const ANSWER: MePermissionsResponse = { + authenticated: true, + userId: 'u_member', + tenantId: null, + roles: [], + permissionSets: [], + systemPermissions: [], + objects: {}, + fields: {}, +}; + +let urlSeq = 0; +/** A URL no earlier case asked for. */ +function freshUrl(): string { + urlSeq += 1; + return `/api/v1/nav-probe/${urlSeq}`; +} + +/** + * The app: a plain entry, the entry under test (`data.total == 1`), and a + * control bound to the same read whose `visible` names no `data`. + */ +function appFor(url: string, path: string | null = 'summary') { + const dataSource = path === null + ? { provider: 'api', read: { url } } + : { provider: 'api', read: { url }, path }; + const navigation = [ + { id: 'nav_home', type: 'page', pageName: 'home', label: 'Home', visible: true }, + { id: 'nav_onboarding', type: 'page', pageName: 'onboarding', label: 'Onboarding', visible: 'data.total == 1', dataSource }, + { id: 'nav_tour', type: 'page', pageName: 'tour', label: 'Tour', visible: '2 > 1', dataSource }, + ] as NavigationItem[]; + return { name: 'crm', label: 'CRM', active: true, navigation }; +} + +function jsonResponse(body: unknown, status = 200): Response { + return { ok: status >= 200 && status < 300, status, json: async () => body } as unknown as Response; +} + +function metadata(app: Record) { + return { + apps: [app], + objects: [], + dashboards: [], + reports: [], + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async () => null, + getItemsByType: () => [], + getTypeStatus: () => 'ready', + }; +} + +/** The sidebar's hand-off: the member gates, given to the real `NavigationRenderer` as `UnifiedSidebar` gives them. */ +function SidebarHost({ items }: { items: NavigationItem[] }) { + const gates = useNavMemberGates(); + return ( + +
+ +
+
+ ); +} + +function renderSurface(surface: Surface, app: ReturnType, apiFetch: ApiFetch) { + const element = + surface === 'sidebar' ? ( + + ) : surface === 'nav:menu' ? ( + + ) : ( + + {}} /> + + ); + render( + + + + + + + + + + + + + + + , + ); +} + +/** The entry labels the surface draws, in order. */ +function drawn(surface: Surface): string[] { + if (surface === 'palette') { + return Array.from(document.querySelectorAll('[cmdk-item][data-value^="page "]')).map((el) => + (el.textContent ?? '').trim(), + ); + } + const root = + surface === 'sidebar' ? screen.getByTestId('sidebar') : screen.getByRole('navigation', { name: 'App navigation' }); + return within(root) + .queryAllByRole('link') + .map((el) => (el.textContent ?? '').trim()); +} + +/** Let a settled read reach the state it sets. */ +async function settle(apiFetch: ReturnType) { + await waitFor(() => expect(apiFetch).toHaveBeenCalled()); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); +} + +beforeEach(() => { + cleanup(); + localStorage.clear(); + sessionStorage.clear(); +}); + +describe.each(SURFACES)('objectui#13029 — a nav entry reads its API dataSource as `data` (%s)', (surface) => { + it('(a) shown when the bound value has `total: 1`', async () => { + const url = freshUrl(); + const apiFetch = vi.fn(async () => jsonResponse({ summary: { total: 1 } })); + renderSurface(surface, appFor(url), apiFetch); + await waitFor(() => expect(drawn(surface)).toEqual(['Home', 'Onboarding', 'Tour'])); + expect(apiFetch).toHaveBeenCalledWith(url, expect.objectContaining({ method: 'GET' })); + }); + + it('(a) hidden when the bound value has `total: 2`; the control on the same read still evaluates (c)', async () => { + const apiFetch = vi.fn(async () => jsonResponse({ summary: { total: 2 } })); + renderSurface(surface, appFor(freshUrl()), apiFetch); + // `Tour` (`visible: '2 > 1'`, no `data` reference) draws once the read answers. + await waitFor(() => expect(drawn(surface)).toContain('Tour')); + expect(drawn(surface)).toEqual(['Home', 'Tour']); + }); + + it('(b) a pending read hides both bound entries; the plain one draws on first paint', async () => { + const apiFetch = vi.fn(() => new Promise(() => {})); + renderSurface(surface, appFor(freshUrl()), apiFetch); + expect(drawn(surface)).toEqual(['Home']); + await waitFor(() => expect(apiFetch).toHaveBeenCalled()); + expect(drawn(surface)).toEqual(['Home']); + }); + + it('(b) a failed read (HTTP 500) hides both bound entries', async () => { + const apiFetch = vi.fn(async () => jsonResponse({ summary: { total: 1 } }, 500)); + renderSurface(surface, appFor(freshUrl()), apiFetch); + await settle(apiFetch); + expect(drawn(surface)).toEqual(['Home']); + }); + + it('(d) the value at `path` is `data`, not the whole answer', async () => { + // The whole answer has `total: 2`; the value at `summary` has `total: 1`. + const apiFetch = vi.fn(async () => jsonResponse({ total: 2, summary: { total: 1 } })); + renderSurface(surface, appFor(freshUrl()), apiFetch); + await waitFor(() => expect(drawn(surface)).toEqual(['Home', 'Onboarding', 'Tour'])); + }); + + it('(d) without `path`, `data` is the whole answer', async () => { + const apiFetch = vi.fn(async () => jsonResponse({ total: 2, summary: { total: 1 } })); + renderSurface(surface, appFor(freshUrl(), null), apiFetch); + await waitFor(() => expect(drawn(surface)).toContain('Tour')); + expect(drawn(surface)).toEqual(['Home', 'Tour']); + }); + + it('(c) an app with no binding draws its entries on first paint and sends no request', () => { + const apiFetch = vi.fn(async () => jsonResponse({})); + const app = appFor(freshUrl()); + renderSurface(surface, { ...app, navigation: [app.navigation[0]] }, apiFetch); + expect(drawn(surface)).toEqual(['Home']); + expect(apiFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app-shell/src/hooks/useNavMemberGates.ts b/packages/app-shell/src/hooks/useNavMemberGates.ts index 8040a714fc..693474f2af 100644 --- a/packages/app-shell/src/hooks/useNavMemberGates.ts +++ b/packages/app-shell/src/hooks/useNavMemberGates.ts @@ -47,12 +47,39 @@ * permissions payload is bound, as on a frame with no permission provider — * withholds its entry and reports once, instead of drawing it for everyone. * + * ## An entry's API binding (objectui#13029) + * + * An entry may declare the spec's API `dataSource` + * (`{ provider: 'api', read: { url }, path? }`, `BaseNavItemSchema.dataSource`): + * its `visible` reads the value the binding selects as `data` + * (`data.total == 1`). Only `visible` reads it: not the label, not the target, + * and not the entry's `children`. {@link useNavVisibility} is the one evaluator + * every navigation surface of the shell hands its guard: the sidebar and the + * landing through {@link useNavMemberGates}, the `nav:menu` block and the + * command palette directly. It reads each binding an app's navigation declares + * through `@object-ui/react`'s element reader (`readElementApi`, the request a + * page element's binding makes, with the host's `apiFetch`), so there is no + * second fetch. An entry is drawn only once its read has answered and its + * `visible` holds over that value: while the read is pending, when it fails, + * when `path` selects nothing, or when the spec refuses the binding, the entry + * is hidden. An entry without the key is evaluated exactly as before, with no + * read and no wait. + * * Internal module: not re-exported by the package entry. */ -import { useCallback, useMemo } from 'react'; +import { useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { hasVisibleNavigationItems, type NavigationVisibilityOptions } from '@object-ui/layout'; import type { NavigationItem } from '@object-ui/types'; +import type { ExpressionEvaluator } from '@object-ui/core'; +import { + SchemaRendererContext, + isElementApiDataSourceShape, + judgeElementApiDataSource, + readElementApi, + selectElementApiPath, + type ApiFetch, +} from '@object-ui/react'; import { usePermissions } from '@object-ui/permissions'; import { useMetadata } from '../providers/MetadataProvider.js'; import { useExpressionContext, evaluateNavigationVisibility } from '../providers/ExpressionProvider.js'; @@ -87,6 +114,136 @@ function requiredObjectUnion(apps: unknown, current: Record | u return [...out].sort(); } +/** Where one entry's API binding stands for its `visible` (objectui#13029). */ +export type NavEntryDataRead = + | { status: 'absent' } + | { status: 'pending' } + | { status: 'failed' } + | { status: 'ready'; data: unknown }; + +/** A binding's content: the key its read is shared and remembered under. */ +function bindingKey(dataSource: unknown): string { + return JSON.stringify(dataSource); +} + +/** + * Every API binding an app's navigation declares, in its `navigation` and every + * `areas[].navigation`, at every group depth: sorted keys, so the same bindings + * read as the same list. + */ +function navBindingKeys(app: unknown): string[] { + const out = new Set(); + const visit = (items: unknown): void => { + if (!Array.isArray(items)) return; + for (const item of items) { + if (!item || typeof item !== 'object') continue; + const { dataSource, children } = item as { dataSource?: unknown; children?: unknown }; + if (isElementApiDataSourceShape(dataSource)) out.add(bindingKey(dataSource)); + visit(children); + } + }; + const shape = (app && typeof app === 'object' ? app : {}) as { navigation?: unknown; areas?: unknown }; + visit(shape.navigation); + if (Array.isArray(shape.areas)) { + for (const area of shape.areas) { + visit(area && typeof area === 'object' ? (area as { navigation?: unknown }).navigation : undefined); + } + } + return [...out].sort(); +} + +type SettledRead = { ok: true; data: unknown } | { ok: false }; + +/** + * Read every API binding `app`'s navigation declares, and answer where a given + * entry's binding stands. Each binding is read once per change of the set, + * through `readElementApi` with the host's `apiFetch` (the page element's + * reader), and its `path` selected with `selectElementApiPath`; a binding the + * spec refuses is judged by `judgeElementApiDataSource` and never read. + */ +function useNavEntryData(app: unknown): (entry: NavigationItem) => NavEntryDataRead { + const context = useContext(SchemaRendererContext); + const doFetch: ApiFetch = context?.apiFetch ?? fetch; + // Keyed on the bindings' CONTENT, never on the identity of the tree that + // holds them (AGENTS.md #10). + const signature = navBindingKeys(app).join('\n'); + const [answers, setAnswers] = useState>({}); + + useEffect(() => { + if (!signature) return; + let cancelled = false; + const settle = (key: string, read: SettledRead) => { + if (!cancelled) setAnswers((prev) => ({ ...prev, [key]: read })); + }; + for (const key of signature.split('\n')) { + const judged = judgeElementApiDataSource(JSON.parse(key)); + if (judged.status !== 'parsed') continue; + const { read, path } = judged.config; + readElementApi(read.url, doFetch).then( + (body) => { + const selected = selectElementApiPath(body, path); + settle(key, selected.found ? { ok: true, data: selected.value } : { ok: false }); + }, + () => settle(key, { ok: false }), + ); + } + return () => { + cancelled = true; + }; + }, [signature, doFetch]); + + return useCallback( + (entry: NavigationItem): NavEntryDataRead => { + const dataSource = entry.dataSource; + if (!isElementApiDataSourceShape(dataSource)) return { status: 'absent' }; + if (judgeElementApiDataSource(dataSource).status !== 'parsed') return { status: 'failed' }; + const answer = answers[bindingKey(dataSource)]; + if (!answer) return { status: 'pending' }; + return answer.ok ? { status: 'ready', data: answer.data } : { status: 'failed' }; + }, + [answers], + ); +} + +/** + * Should this navigation entry be drawn, as far as its `visible` goes? The one + * decision every navigation surface of the shell makes (objectui#13029): an + * entry that declares an API `dataSource` is hidden until its read is `ready`, + * and then its `visible` is evaluated with the selected value bound as `data`; + * any other entry is `evaluateNavigationVisibility`'s answer, as before. + */ +export function navEntryVisible( + expression: NavigationItem['visible'], + entry: NavigationItem | undefined, + evaluator: ExpressionEvaluator, + readEntry: (entry: NavigationItem) => NavEntryDataRead, +): boolean { + if (entry && isElementApiDataSourceShape(entry.dataSource)) { + const read = readEntry(entry); + if (read.status !== 'ready') return false; + return evaluateNavigationVisibility(expression, evaluator.withContext({ data: read.data })); + } + return evaluateNavigationVisibility(expression, evaluator); +} + +/** + * The `visible` evaluator for `app`'s navigation: {@link navEntryVisible} over + * the shell's expression evaluator and the bindings `app` declares. The sidebar + * (through {@link useNavMemberGates}), the `nav:menu` block and the command + * palette each build theirs here, so they cannot disagree. + */ +export function useNavVisibility( + app: unknown, +): (expression: NavigationItem['visible'], entry?: NavigationItem) => boolean { + const { evaluator } = useExpressionContext(); + const readEntry = useNavEntryData(app); + return useCallback( + (expression: NavigationItem['visible'], entry?: NavigationItem) => + navEntryVisible(expression, entry, evaluator, readEntry), + [evaluator, readEntry], + ); +} + /** The member's gates, as the sidebar applies them. */ export interface NavMemberGates { /** The `visible` evaluator handed to `@object-ui/layout`'s item guard. */ @@ -104,11 +261,9 @@ export interface NavMemberGates { * expression evaluator, `usePermissions()`, and the registered object list. */ export function useNavMemberGates(): NavMemberGates { - const { evaluator, app } = useExpressionContext(); - const evalVis = useCallback( - (expr: NavigationItem['visible']) => evaluateNavigationVisibility(expr, evaluator), - [evaluator], - ); + const { app } = useExpressionContext(); + // objectui#13029 — the entries of the app this frame renders may bind data. + const evalVis = useNavVisibility(app); // Permission check for nav `requiredPermissions` entries. // diff --git a/packages/app-shell/src/views/nav-menu-renderer.tsx b/packages/app-shell/src/views/nav-menu-renderer.tsx index 88ee6b899d..bd10e89230 100644 --- a/packages/app-shell/src/views/nav-menu-renderer.tsx +++ b/packages/app-shell/src/views/nav-menu-renderer.tsx @@ -131,7 +131,7 @@ import { type NavTemplateContext, } from '@object-ui/layout'; import type { NavigationItem } from '@object-ui/types'; -import { useExpressionContext, evaluateNavigationVisibility } from '../providers/ExpressionProvider.js'; +import { useNavVisibility } from '../hooks/useNavMemberGates.js'; import { useNavActionDispatch } from '../hooks/useNavActionDispatch.js'; import { useNavTargetLabel } from '../hooks/useNavTargetLabel.js'; import { useNavDocTargetCheck } from '../hooks/useNavDocTargetCheck.js'; @@ -183,11 +183,9 @@ export const NavMenuRenderer: React.FC = ({ /* ── The three guards, wired to the same providers `UnifiedSidebar` uses ── */ - const { evaluator } = useExpressionContext(); - const evalVis = useCallback( - (expr: NavigationItem['visible']) => evaluateNavigationVisibility(expr, evaluator), - [evaluator], - ); + // The shell's one navigation `visible` evaluator (objectui#13029): an entry + // that declares an API `dataSource` reads its bound value as `data`, and is + // hidden until that read answers. Built below, once the app is known. // `object:action` → object CRUD gate; a bare name is an ADR-0066 system // capability, with the legacy "can read " reading kept as fallback. @@ -210,6 +208,8 @@ export const NavMenuRenderer: React.FC = ({ return matchAppBySegment(list, appName ?? currentAppName ?? null); }, [apps, appName, currentAppName]); + const evalVis = useNavVisibility(activeApp); + // The runtime-capability gate for `requiresObject` entries. objectui#12980 — // it reads the objects the app's entries require, by name (beside the app's // package list when the app names one, the names-only scope when it does @@ -321,7 +321,7 @@ export const NavMenuRenderer: React.FC = ({ // The guard ORDER is `NavigationItemRenderer`'s and // `hasVisibleNavigationItems`'; keeping it identical is what makes the // derived-area predicate above agree with what actually renders. - if (!evalVis(item.visible)) return null; + if (!evalVis(item.visible, item)) return null; if (item.requiredPermissions?.length && !checkPerm(item.requiredPermissions)) return null; if (item.requiresObject && !checkCap('object', item.requiresObject)) return null; if (item.requiresService && !checkCap('service', item.requiresService)) return null; diff --git a/packages/layout/src/NavigationRenderer.tsx b/packages/layout/src/NavigationRenderer.tsx index 6dc9a2c1b0..13f3428829 100644 --- a/packages/layout/src/NavigationRenderer.tsx +++ b/packages/layout/src/NavigationRenderer.tsx @@ -74,6 +74,7 @@ import { useIsMobile, } from '@object-ui/components'; import type { NavigationItem, KeyedI18nLabel } from '@object-ui/types'; +import { isElementApiDataSourceShape } from '@object-ui/react'; // Aliased on import, following PR #4169's convention (as `AppSchemaRenderer` // does): this file has its OWN `resolveLabel` over the KEYED vocabulary, and // the spec's resolver reads the INLINE locale map — neither accepts the other's @@ -141,9 +142,18 @@ const HIDDEN_ICON_LABEL_NOT_FOCUSABLE = * The parameter is the item's own `visible` type, so the spec's * `{ dialect, source }` envelope reaches the evaluator as authored * (objectui#13075). + * + * `entry` is the navigation entry whose `visible` this is (objectui#13029). + * The item guard always passes it. An entry may declare the spec's API + * `dataSource` (`{ provider: 'api', read: { url }, path? }`), whose selected + * value its `visible` reads as `data`; reading that binding is the host's job, + * so an evaluator that answers such an entry reads it through `entry`. The + * default evaluator reads no binding, so it hides an entry that declares one: + * a value nobody read is unknown, and an unknown navigation gate is closed. */ export type VisibilityEvaluator = ( expression: NavigationItem['visible'], + entry?: NavigationItem, ) => boolean; /** @@ -613,7 +623,10 @@ function inheritedNavItemLabel( // Default evaluators (always-visible, always-permitted) // --------------------------------------------------------------------------- -const defaultVisibility: VisibilityEvaluator = (expr) => { +const defaultVisibility: VisibilityEvaluator = (expr, entry) => { + // objectui#13029 — this default reads no API binding, so an entry that + // declares one is hidden: its `visible` is about a value never read. + if (entry && isElementApiDataSourceShape(entry.dataSource)) return false; if (expr === false || expr === 'false') return false; return true; }; @@ -683,7 +696,9 @@ function passesNavItemGuards( checkDocTarget, } = options; - if (!evaluateVisibility(item.visible)) return false; + // The entry rides along so an evaluator can bind its API `dataSource` as + // `data` (objectui#13029); the decision about that binding is the evaluator's. + if (!evaluateVisibility(item.visible, item)) return false; if (item.requiredPermissions?.length && !checkPermission(item.requiredPermissions)) return false; const requiresObject = (item as { requiresObject?: string }).requiresObject; const requiresService = (item as { requiresService?: string }).requiresService; diff --git a/packages/layout/src/__tests__/NavigationRenderer.entryDataSource-13029.test.tsx b/packages/layout/src/__tests__/NavigationRenderer.entryDataSource-13029.test.tsx new file mode 100644 index 0000000000..e629c7ae9f --- /dev/null +++ b/packages/layout/src/__tests__/NavigationRenderer.entryDataSource-13029.test.tsx @@ -0,0 +1,83 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#13029 — the layout half of a navigation entry's API `dataSource`. + * + * The spec lets an entry declare `dataSource: { provider: 'api', read: { url }, + * path? }`, whose selected value its `visible` reads as `data`. Reading the + * binding is the host's job (the console's is `useNavVisibility` in + * `@object-ui/app-shell`), so this layer does two things, both pinned here: + * + * - the item guard hands the evaluator the ENTRY beside its `visible`, on + * every path that shares the guard (the rows, the derived area visibility, + * the Favorites section), so a host evaluator can read the entry's binding; + * - the default evaluator, which reads no binding, hides an entry that + * declares one (an unknown navigation gate is closed). An entry without the + * key is drawn exactly as before. + */ + +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import type { NavigationItem } from '@object-ui/types'; +import { SidebarProvider } from '@object-ui/components'; +import { NavigationRenderer, hasVisibleNavigationItems } from '../NavigationRenderer'; + +const APP = '/apps/crm'; + +const BOUND: NavigationItem = { + id: 'nav_onboarding', + type: 'page', + label: 'Onboarding', + pageName: 'onboarding', + visible: 'data.total == 1', + dataSource: { provider: 'api', read: { url: '/api/v1/cloud/home' }, path: 'summary' }, +}; +const PLAIN: NavigationItem = { id: 'nav_home', type: 'page', label: 'Home', pageName: 'home', visible: true }; + +function renderNav(items: NavigationItem[], props: Record = {}) { + return render( + + + + + , + ); +} + +describe('objectui#13029 — the item guard hands a host evaluator the entry it judges', () => { + it('the evaluator receives each entry beside its `visible`, and its verdict decides the row', () => { + const evaluateVisibility = vi.fn((_expr: unknown, entry?: NavigationItem) => entry?.id !== 'nav_home'); + renderNav([BOUND, PLAIN], { evaluateVisibility }); + expect(evaluateVisibility).toHaveBeenCalledWith('data.total == 1', BOUND); + expect(evaluateVisibility).toHaveBeenCalledWith(true, PLAIN); + expect(screen.getByRole('link', { name: 'Onboarding' })).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Home' })).toBeNull(); + }); + + it('the derived area visibility asks the same evaluator with the entry', () => { + const evaluateVisibility = vi.fn((_expr: unknown, entry?: NavigationItem) => entry === BOUND); + expect(hasVisibleNavigationItems([BOUND], { evaluateVisibility })).toBe(true); + expect(evaluateVisibility).toHaveBeenCalledWith('data.total == 1', BOUND); + }); +}); + +describe('objectui#13029 — the default evaluator reads no binding, so it hides an entry that declares one', () => { + it('a bound entry is not drawn, and an entry without the key is drawn on first paint', () => { + renderNav([BOUND, PLAIN]); + expect(screen.queryByRole('link', { name: 'Onboarding' })).toBeNull(); + expect(screen.getByRole('link', { name: 'Home' })).toBeInTheDocument(); + }); + + it('an area holding only a bound entry derives hidden; one with a plain entry derives shown', () => { + expect(hasVisibleNavigationItems([BOUND])).toBe(false); + expect(hasVisibleNavigationItems([PLAIN])).toBe(true); + }); +}); diff --git a/packages/types/src/app.ts b/packages/types/src/app.ts index bfef32fb0c..8a451885cd 100644 --- a/packages/types/src/app.ts +++ b/packages/types/src/app.ts @@ -300,7 +300,11 @@ export interface NavigationEntryItem { /** * One API data source whose bound value {@link visible} reads as `data` — * the spec's shared nav-item base member, by reference (objectui#12971). - * The spec describes it as planned and not yet rendered. + * The console reads it (objectui#13029): every navigation surface (the + * sidebar, the `nav:menu` block, the command palette) reads the binding with + * the signed-in fetch, and the entry is hidden while the read is pending, + * after it fails, or when `path` selects nothing. Only `visible` reads + * `data`; the label, the target and `children` do not. */ dataSource?: SpecObjectNavItem['dataSource']; diff --git a/packages/types/src/zod/app.zod.ts b/packages/types/src/zod/app.zod.ts index 9e64d7e864..f09573e82f 100644 --- a/packages/types/src/zod/app.zod.ts +++ b/packages/types/src/zod/app.zod.ts @@ -305,9 +305,11 @@ const NavigationItemObject = z.object({ // (objectstack#24332, `ElementApiDataSourceSchema` by reference), whose bound // value an entry's `visible` reads as `data`. Taken by reference for the // reason `runAction` is declared above: this schema strips unknown keys, so an - // authored binding would have validated clean and been thrown away. The spec - // describes it as planned and not yet rendered; the console does not read it. - dataSource: stripImportedDefaults(SpecElementApiDataSourceSchema).optional().describe("One API data source whose bound value the entry's `visible` reads as `data` (`@objectstack/spec` `ElementApiDataSourceSchema`). Planned in the spec; the console does not read it yet"), + // authored binding would have validated clean and been thrown away. The + // console reads it (objectui#13029): the shell's navigation `visible` + // evaluator (`useNavVisibility` in `@object-ui/app-shell`) reads the binding + // and hides the entry until it answers. + dataSource: stripImportedDefaults(SpecElementApiDataSourceSchema).optional().describe("One API data source whose bound value the entry's `visible` reads as `data` (`@objectstack/spec` `ElementApiDataSourceSchema`). The console reads it with the signed-in fetch; the entry stays hidden while the read is pending or after it fails. Only `visible` reads `data`; the label, the target and `children` do not"), requiredPermissions: z.array(z.string()).optional().describe('Required permissions'), // Runtime capability gates. `NavigationRenderer` has always honoured these // and `NavigationItem` has always declared them — only this schema lagged,