From 9f60df0374bbc99a9cc7e272d95ae37f15f29b56 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 17:08:27 +0000 Subject: [PATCH 1/3] feat(app-shell): renderers for the `app:launcher` and `nav:menu` page blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the 2026-08-26 maintainer ruling on objectstack#12183 — the two `PageComponentType` members that are purely metadata-driven. Both are registered in app-shell, following objectui#6757, because they read this package's providers; `@object-ui/components` depends on neither `@object-ui/layout`, `@object-ui/permissions` nor `react-router-dom`. `resolveNavItemLabel` becomes an export of `@object-ui/layout` (additive, no behaviour change) so the authored menu and the sidebar cannot show one nav entry under two names. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB --- .../6661-app-launcher-nav-menu-renderers.md | 53 +++ packages/app-shell/src/index.ts | 12 + .../phase1-page-blocks.render.test.tsx | 290 ++++++++++++++ .../src/views/app-launcher-renderer.tsx | 157 ++++++++ .../app-shell/src/views/nav-menu-renderer.tsx | 376 ++++++++++++++++++ packages/layout/src/NavigationRenderer.tsx | 12 +- 6 files changed, 899 insertions(+), 1 deletion(-) create mode 100644 .changeset/6661-app-launcher-nav-menu-renderers.md create mode 100644 packages/app-shell/src/views/__tests__/phase1-page-blocks.render.test.tsx create mode 100644 packages/app-shell/src/views/app-launcher-renderer.tsx create mode 100644 packages/app-shell/src/views/nav-menu-renderer.tsx diff --git a/.changeset/6661-app-launcher-nav-menu-renderers.md b/.changeset/6661-app-launcher-nav-menu-renderers.md new file mode 100644 index 0000000000..4f820de395 --- /dev/null +++ b/.changeset/6661-app-launcher-nav-menu-renderers.md @@ -0,0 +1,53 @@ +--- +'@object-ui/app-shell': minor +'@object-ui/layout': minor +--- + +Renderers for the `app:launcher` and `nav:menu` page blocks (objectui#6661). +Phase 1 of the 2026-08-26 maintainer ruling on objectstack#12183 — the two +`PageComponentType` members that are purely metadata-driven, so nothing had to +ship before their renderers could. Phase 2 (`global:search` / +`global:notifications`) landed in objectui#6757 and set the pattern this +follows. + +A page that declared either member drew a dashed box. The two symptoms were not +the same, which is worth recording because it decides what "fixed" looks like +for each: + +- `nav:menu` is in `PALETTE_PLACEHOLDER_BLOCKS`, registered eagerly, so it drew + the literal "Component Placeholder" scaffold in every host. +- `app:launcher` is only in `PROTOCOL_COMPONENTS`, registered when a host opts + in via `registerPlaceholders()` — which just `apps/console` does. So it drew + the scaffold in the console and `SchemaRenderer`'s red OBJUI-001 "Unknown + component type" panel everywhere else. + +Neither block adds a data layer — each mounts plumbing that was already live, +and neither issues a request or touches an adapter: + +- `app:launcher` reads the metadata app registry (`useMetadata().apps`, which + `MetadataProvider` fetches eagerly) through the shared `filterActiveApps` + predicate, and draws it with `HomeAppsStrip` — the console's own launcher + grid — so an authored launcher and the Home launcher cannot drift into two + looks for one thing. +- `nav:menu` reads the active app's navigation tree from that same registry and + renders it as page content, taking every derived fact from `@object-ui/layout`: + hrefs from `resolveHref`, labels from `resolveNavItemLabel`, the active row + from `resolveActiveNavItem`, and the item-level guards (`visible`, + `requiredPermissions`, `requiresObject` / `requiresService`) in the order + `NavigationItemRenderer` applies them, wired to the same console providers + `AppSidebar` wires them to. `action` items dispatch through + `useNavActionDispatch`, so framework#4509's "renders but dead-clicks" shape is + not reintroduced. + +`nav:menu` does not mount `NavigationRenderer` itself: that renders through +`SidebarMenuButton`, whose `useSidebar()` throws outside the shell's +`SidebarProvider`, and a page block has to render standalone. `@object-ui/layout` +therefore exports `resolveNavItemLabel`, which was module-private — an additive +export with no behaviour change, so the sidebar and an authored menu cannot show +one nav entry under two names. + +Both registrations publish **no** `inputs`: `ComponentPropsMap` declares an empty +shape for each, and both use `skipFallback: true` so neither claims the bare +`launcher` / `menu` keys. This does not change the Studio page palette — +`app:launcher` remains recorded there as a shell singleton, which is a palette +decision independent of whether a declared type renders. diff --git a/packages/app-shell/src/index.ts b/packages/app-shell/src/index.ts index 22003e1439..81077ab8f5 100644 --- a/packages/app-shell/src/index.ts +++ b/packages/app-shell/src/index.ts @@ -309,6 +309,18 @@ import './views/record-approvals-renderer.js'; // `global:notifications`. import './views/global-search-renderer.js'; import './views/global-notifications-renderer.js'; +// `app:launcher` / `nav:menu` — Phase 1 of that same 2026-08-26 ruling +// (objectui#6661): the two `PageComponentType` members that are purely +// metadata-driven, so nothing had to ship before their renderers could. +// Registered here, not in `@object-ui/components`, because they read this +// package's providers (the metadata app registry, the expression / permission / +// capability guards) and `@object-ui/components` depends on neither +// `@object-ui/layout`, `@object-ui/permissions` nor `react-router-dom`. Without +// these two imports an authored page draws the "Component Placeholder" scaffold +// for `nav:menu` and a red unknown-type panel for `app:launcher` (which, unlike +// `nav:menu`, is not in the eager `PALETTE_PLACEHOLDER_BLOCKS` set). +import './views/app-launcher-renderer.js'; +import './views/nav-menu-renderer.js'; // The metadata-admin engine's five load-time registrations (built-in anchors, // default JSONSchemas, the datasource resource, built-in previews, built-in // inspectors). objectui#6776 moved them OUT of `views/metadata-admin/index.ts` diff --git a/packages/app-shell/src/views/__tests__/phase1-page-blocks.render.test.tsx b/packages/app-shell/src/views/__tests__/phase1-page-blocks.render.test.tsx new file mode 100644 index 0000000000..98e3d3fa06 --- /dev/null +++ b/packages/app-shell/src/views/__tests__/phase1-page-blocks.render.test.tsx @@ -0,0 +1,290 @@ +/** + * 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#6661 — a page that declares `app:launcher` or `nav:menu` renders a + * WORKING block, not the "Component Placeholder" scaffold. + * + * Phase 1 of the 2026-08-26 maintainer ruling on objectstack#12183. The sibling + * file `global-page-blocks.render.test.tsx` is the Phase 2 (objectui#6757) + * equivalent and this one deliberately follows its shape. + * + * ## Why "not the placeholder" is not the assertion + * + * An empty render is also not the placeholder, and so is a red unknown-type + * panel with the wrong text. Each case below therefore asserts CONTENT that + * only the real renderer can produce, and content that had to travel through + * the block's data path to get there: + * + * - `app:launcher` — a tile per app the metadata app REGISTRY holds, with the + * registry's own `active`/`hidden` filter applied (the deactivated and the + * hidden app are absent), and clicking one routes to that app's segment. + * - `nav:menu` — the active app's navigation tree, with each item's href + * resolved by `@object-ui/layout`'s `resolveHref` (so a `viewName` entry + * lands on `/view/`, not on the bare list), and with the three + * item-level guards applied: `visible`, `requiredPermissions` and the + * `requiresObject` runtime-capability gate. + * + * The placeholder assertion is kept as a second, weaker line in each case, + * because it is the literal symptom the card reported. + * + * ## The two members are NOT symmetric before the fix — measured, not assumed + * + * `placeholders.tsx` puts `nav:menu` in `PALETTE_PLACEHOLDER_BLOCKS` (registered + * EAGERLY on import of `@object-ui/components`) but `app:launcher` only in + * `PROTOCOL_COMPONENTS` (registered solely when a host opts in via + * `registerPlaceholders()`, which only `apps/console` does). So before this + * change, in THIS harness, `nav:menu` drew the dashed scaffold and + * `app:launcher` drew `SchemaRenderer`'s red unknown-type panel — the same + * asymmetry `global:search` / `global:notifications` had in the Phase 2 file. + * Both failure texts are asserted absent below so either regression is caught. + * + * ## Ablation (per member) + * + * Comment out the `ComponentRegistry.register(...)` call in the renderer under + * test and the matching case goes red: `nav:menu` falls back to the eager + * palette placeholder ("Component Placeholder"), `app:launcher` to the red + * unknown-type panel. + * + * ## Harness notes + * + * Real `@object-ui/components`, real `SchemaRenderer`, real registry — the + * ORDER this file's imports produce is the production order (app-shell depends + * on components, so `placeholders.tsx` registers before these two overwrite + * it), and asserting through `SchemaRenderer` is what makes this a page-render + * test rather than a component unit test. + */ +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent, within } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom'; + +// Module scope, never a `beforeAll`: the cold transform of these graphs is +// billed to the import phase, which has no test/hook timeout (AGENTS.md +// §测试纪律, objectui#3010). +import '@object-ui/components'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRenderer, MetadataCtx } from '@object-ui/react'; +import '../app-launcher-renderer'; +import '../nav-menu-renderer'; + +/* ── Fixtures ─────────────────────────────────────────────────────────────── */ + +/** + * The app registry, in the shape `MetadataProvider` publishes it (it fetches + * `GET /api/v1/meta/app` eagerly — `EAGER_TYPES`). Two openable apps, one + * deactivated and one hidden: the launcher must show exactly the first two. + */ +const APPS = [ + { + name: 'crm', + label: 'CRM', + icon: 'Building2', + navigation: [ + { id: 'accounts', type: 'object', label: 'Accounts', objectName: 'crm_account', icon: 'Building2' }, + { id: 'pipeline', type: 'object', label: 'Pipeline', objectName: 'crm_deal', viewName: 'kanban' }, + { id: 'handbook', type: 'url', label: 'Handbook', url: 'https://example.com/handbook', target: '_blank' }, + { + id: 'insights', + type: 'group', + label: 'Insights', + children: [{ id: 'win_rate', type: 'report', label: 'Win rate', reportName: 'win_rate' }], + }, + // Guard 1 — `visible: false` is honoured by the expression evaluator. + { id: 'draft_area', type: 'object', label: 'Draft area', objectName: 'crm_account', visible: false }, + // Guard 2 — `requiresObject` names an object the runtime has not + // registered, so the runtime-capability gate drops it. + { + id: 'billing', + type: 'object', + label: 'Billing', + objectName: 'sys_invoice', + requiresObject: 'sys_invoice', + }, + { id: 'divider_1', type: 'separator', label: '' }, + ], + }, + { name: 'ops', label: 'Operations', icon: 'Wrench', navigation: [] }, + { name: 'legacy_hr', label: 'Legacy HR', active: false, navigation: [] }, + { name: 'account', label: 'Account', hidden: true, navigation: [] }, +]; + +/** + * Stable module-level value: `MetadataCtx` consumers list the context value in + * effect deps, and a fresh object per render re-runs them forever. + * + * `objects` is what the runtime-capability gate probes. `sys_invoice` is + * deliberately absent so the `requiresObject` guard has something to do — and + * the set is non-empty, which is what takes the "metadata still loading, show + * everything" short-circuit out of the picture. + */ +const METADATA = { + apps: APPS, + objects: [ + { name: 'crm_account', label: 'Account', icon: 'Building2' }, + { name: 'crm_deal', label: 'Deal', icon: 'Handshake' }, + ], + dashboards: [], + reports: [], + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async () => null, + getItemsByType: () => [], + getTypeStatus: () => 'ready' as const, +}; + +/** Publishes the current pathname so a click-through can be asserted. */ +function LocationProbe() { + const { pathname } = useLocation(); + return
{pathname}
; +} + +/** A page that DECLARES the member, rendered through the normal recursion. */ +const page = (type: string) => ({ + type: 'page:section', + id: 'section_1', + children: [{ type, id: `blk_${type}` }], +}); + +function renderPage(type: string) { + return render( + + + + + } + /> + navigated away} /> + + + , + ); +} + +/* ── The two members ──────────────────────────────────────────────────────── */ + +describe('objectui#6661 — spec `PageComponentType` members that had no renderer', () => { + it('registers both members under their namespaces, not the bare names', () => { + // A registration under bare `launcher` / `menu` would claim two far more + // generic tags; `skipFallback: true` is what prevents it. + expect(ComponentRegistry.get('app:launcher')).toBeTruthy(); + expect(ComponentRegistry.get('nav:menu')).toBeTruthy(); + expect(ComponentRegistry.get('launcher')).toBeFalsy(); + expect(ComponentRegistry.get('menu')).toBeFalsy(); + }); + + it('overwrites the protocol placeholder rather than sitting behind it', () => { + // `registerPlaceholder` refuses to overwrite a real implementation, and the + // eager `PALETTE_PLACEHOLDER_BLOCKS` pass for `nav:menu` runs FIRST (this + // file imports `@object-ui/components` above). So the namespace on the live + // registration is the proof that the real renderer won the key. + expect(ComponentRegistry.getConfig('app:launcher')?.namespace).toBe('app'); + expect(ComponentRegistry.getConfig('nav:menu')?.namespace).toBe('nav'); + }); + + it('publishes NO `inputs` for either — both spec shapes are empty', () => { + // `ComponentPropsMap['app:launcher'|'nav:menu']` declare no props at all. + // Declaring one here would advertise an authoring key the contract rejects + // by name (the forward direction of + // `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`). + expect(ComponentRegistry.getConfig('app:launcher')?.inputs ?? []).toEqual([]); + expect(ComponentRegistry.getConfig('nav:menu')?.inputs ?? []).toEqual([]); + }); + + describe('app:launcher', () => { + it('renders a tile per openable app from the metadata app registry', () => { + renderPage('app:launcher'); + + // 1. Real content: the launcher grid, with a tile per app. + const launcher = screen.getByRole('navigation', { name: 'App launcher' }); + expect(within(launcher).getByTestId('app-tile-crm')).toBeInTheDocument(); + expect(within(launcher).getByTestId('app-tile-ops')).toBeInTheDocument(); + expect(within(launcher).getByText('CRM')).toBeInTheDocument(); + expect(within(launcher).getByText('Operations')).toBeInTheDocument(); + + // 2. Real content that had to travel the data path: the registry's own + // `active`/`hidden` filter was applied to the list it read. A static + // or unfiltered render would show these two. + expect(screen.queryByTestId('app-tile-legacy_hr')).toBeNull(); + expect(screen.queryByTestId('app-tile-account')).toBeNull(); + + // 3. The literal symptom the card reported, plus the OTHER failure shape: + // `app:launcher` is NOT in the eager placeholder set, so with no + // registration at all it draws SchemaRenderer's red unknown-type panel. + expect(screen.queryByText('Component Placeholder')).toBeNull(); + expect(screen.queryByText(/Unknown component type/i)).toBeNull(); + }); + + it('opens the app it was clicked on, by route segment', () => { + renderPage('app:launcher'); + + expect(screen.getByTestId('pathname')).toHaveTextContent('/apps/crm'); + fireEvent.click(screen.getByTestId('app-tile-ops')); + expect(screen.getByTestId('pathname')).toHaveTextContent('/apps/ops'); + }); + }); + + describe('nav:menu', () => { + it('renders the active app’s navigation tree with hrefs from `resolveHref`', () => { + renderPage('nav:menu'); + + // 1. Real content: the menu itself, with its accessible name. + const menu = screen.getByRole('navigation', { name: 'App navigation' }); + expect(menu).toBeInTheDocument(); + + // 2. Real content that had to travel the data path: the items are the + // ACTIVE app's own navigation, and each href is what + // `@object-ui/layout`'s `resolveHref` produces for that item type — + // note `/view/kanban`, which only the shared resolver produces. + expect(within(menu).getByRole('link', { name: 'Accounts' })).toHaveAttribute( + 'href', + '/apps/crm/crm_account', + ); + expect(within(menu).getByRole('link', { name: 'Pipeline' })).toHaveAttribute( + 'href', + '/apps/crm/crm_deal/view/kanban', + ); + expect(within(menu).getByRole('link', { name: 'Win rate' })).toHaveAttribute( + 'href', + '/apps/crm/report/win_rate', + ); + // A `url` item keeps its absolute target and opens out of the SPA. + const handbook = within(menu).getByRole('link', { name: 'Handbook' }); + expect(handbook).toHaveAttribute('href', 'https://example.com/handbook'); + expect(handbook).toHaveAttribute('target', '_blank'); + // Group labels render, so the tree is a tree and not a flattened list. + expect(within(menu).getByText('Insights')).toBeInTheDocument(); + + // 3. The item-level guards ran. Both entries are in the tree above and + // both are gated away — the `visible` expression and the + // `requiresObject` runtime-capability probe respectively. + expect(screen.queryByText('Draft area')).toBeNull(); + expect(screen.queryByText('Billing')).toBeNull(); + + // 4. The literal symptom the card reported. `nav:menu` IS in the eager + // placeholder set, so this is the text it drew before the fix. + expect(screen.queryByText('Component Placeholder')).toBeNull(); + expect(screen.queryByText(/Unknown component type/i)).toBeNull(); + }); + + it('navigates in-app when a navigation item is clicked', () => { + renderPage('nav:menu'); + + expect(screen.getByTestId('pathname')).toHaveTextContent('/apps/crm'); + fireEvent.click(screen.getByRole('link', { name: 'Accounts' })); + expect(screen.getByTestId('pathname')).toHaveTextContent('/apps/crm/crm_account'); + }); + }); +}); diff --git a/packages/app-shell/src/views/app-launcher-renderer.tsx b/packages/app-shell/src/views/app-launcher-renderer.tsx new file mode 100644 index 0000000000..60b2978bb8 --- /dev/null +++ b/packages/app-shell/src/views/app-launcher-renderer.tsx @@ -0,0 +1,157 @@ +/** + * 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. + */ + +/** + * `app:launcher` — the app launcher grid, addressable from a page schema + * (objectui#6661). + * + * ## Why this exists + * + * `app:launcher` is a first-class member of `@objectstack/spec`'s + * `PageComponentType`. The maintainer ruling of 2026-08-26 (objectstack#12183) + * kept it declared and made it Phase 1 of the decomposition precisely because + * it is PURELY METADATA-DRIVEN: the app list is metadata the shell already + * holds, so nothing had to ship before the renderer could. Nothing rendered it, + * so a page that authored it drew a dashed box. + * + * ⚠️ The two Phase 1 members were NOT symptomatic in the same way, which is + * worth recording because it changes what "before" means for each. Measured on + * `592acafbe`, in a harness that imports `@object-ui/components` and nothing + * else: + * + * - `nav:menu` is in `PALETTE_PLACEHOLDER_BLOCKS`, registered EAGERLY, so it + * drew `PlaceholderRenderer`'s literal "Component Placeholder" scaffold in + * every host; + * - `app:launcher` is only in `PROTOCOL_COMPONENTS`, registered solely when a + * host opts in via `registerPlaceholders()` — which just `apps/console` + * does (`apps/console/src/main.tsx:53`). So it drew the scaffold in the + * console (which is what objectstack#12183 measured in a browser) and + * `SchemaRenderer`'s red OBJUI-001 "Unknown component type" panel + * everywhere else. + * + * ## What backs it — nothing new + * + * The app registry: `useMetadata().apps`, which `MetadataProvider` fetches + * eagerly (`app` is in its `EAGER_TYPES`, i.e. `GET /api/v1/meta/app` on + * mount). That is the SAME read the top-bar `AppSwitcher` and the Home page + * make, and the openable-apps filter is the shared `filterActiveApps` helper + * rather than a second copy of the `active`/`hidden` rule. This is the ruling's + * "no external data-source dependency" claim, discharged: the block issues no + * request of its own and reaches no adapter. + * + * The grid itself is `HomeAppsStrip` — the console's existing launcher, already + * the answer to "how does this product draw a wall of apps" — so an authored + * launcher and the Home launcher cannot drift into two looks for one thing. + * + * ## Declared propless, deliberately + * + * `ComponentPropsMap['app:launcher']` is an EMPTY shape ("declares no props at + * all" is the recorded intent), so this registration publishes NO `inputs`. + * Declaring even `className` here would advertise an authoring key the contract + * rejects by name — the forward direction of + * `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts` is a vice on + * exactly that move. The node-level `className` the SchemaRenderer threads + * through is a NODE key (`PageComponentSchema`), not a prop. + * + * One consequence of propless-ness worth stating rather than leaving implicit: + * `HomeAppsStrip`'s marketplace shortcut is gated on `isAdmin`, and this block + * passes `false`. Installing a template is a Home-page/admin affordance, and + * with an empty prop shape there is no authoring key that could ever turn it on + * — so wiring the admin probe here would publish behaviour no author can + * address, describe or disable. + * + * Registered in app-shell rather than `@object-ui/components` for the same + * reason `global:search` is (objectui#6757): the providers are here. + * `@object-ui/components` depends on neither `@object-ui/layout` nor + * `react-router-dom` (measured against its `package.json` on `592acafbe`), and + * this block needs the router to open an app. The eager palette placeholder in + * `components/renderers/placeholders.tsx` STAYS: it is the fallback for a host + * that embeds `@object-ui/components` without app-shell, and this module + * imports that package, so its registration always runs first and this one + * overwrites it. + * + * This does NOT put the block in the Studio page palette: `PALETTE_EXCLUSIONS` + * still records `app:launcher` as a shell singleton, and that is a palette + * decision about authoring ergonomics, independent of whether a declared type + * renders — exactly as objectui#6757 left `global:notifications`. + */ + +import * as React from 'react'; +import { useCallback, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ComponentRegistry } from '@object-ui/core'; +import { useMetadata } from '@object-ui/react'; +import { useObjectTranslation } from '@object-ui/i18n'; +import { HomeAppsStrip } from '../console/home/HomeAppsStrip.js'; +import { useFavorites } from '../hooks/useFavorites.js'; +import { appRouteSegment, filterActiveApps } from '../utils/index.js'; + +/** Keep the designer's own data attributes on the wrapper, drop the rest. */ +const splitDesigner = (props: Record) => { + const { 'data-obj-id': id, 'data-obj-type': type, style } = props || {}; + return { 'data-obj-id': id, 'data-obj-type': type, style }; +}; + +export interface AppLauncherRendererProps { + schema?: Record; + className?: string; + [k: string]: any; +} + +export const AppLauncherRenderer: React.FC = ({ + className, + schema: _schema, + ...props +}) => { + const { t } = useObjectTranslation(); + const navigate = useNavigate(); + const { apps } = useMetadata(); + const { favorites } = useFavorites(); + + // The openable set, through the SHARED predicate — `active !== false && + // hidden !== true`. Two copies of "an app the user can open" is precisely + // the dialect `filterActiveApps` exists to prevent. + const openable = useMemo(() => filterActiveApps(apps as any[]), [apps]); + + const open = useCallback( + (app: any) => navigate(`/apps/${appRouteSegment(app) ?? app?.name}`), + [navigate], + ); + + return ( + + ); +}; + +// Bare name + namespace (the registry prepends it itself); `skipFallback: true` +// keeps this off the top-level `launcher` key. No `inputs`: the spec shape is +// empty. +ComponentRegistry.register('launcher', AppLauncherRenderer, { + namespace: 'app', + skipFallback: true, + category: 'navigation', + label: 'App Launcher', + icon: 'LayoutGrid', +}); + +export default AppLauncherRenderer; diff --git a/packages/app-shell/src/views/nav-menu-renderer.tsx b/packages/app-shell/src/views/nav-menu-renderer.tsx new file mode 100644 index 0000000000..8dfd7fd8f1 --- /dev/null +++ b/packages/app-shell/src/views/nav-menu-renderer.tsx @@ -0,0 +1,376 @@ +/** + * 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. + */ + +/** + * `nav:menu` — the app's navigation tree as PAGE CONTENT, addressable from a + * page schema (objectui#6661). + * + * ## Why this exists + * + * `nav:menu` is a first-class member of `@objectstack/spec`'s + * `PageComponentType` and Phase 1 of the 2026-08-26 maintainer ruling on + * objectstack#12183, alongside `app:launcher`, because both are purely + * metadata-driven. Nothing rendered it, so a page that authored it drew + * `PlaceholderRenderer`'s literal "Component Placeholder" scaffold — the + * author-time gate accepted the metadata and the screen showed a dashed box. + * + * ## What backs it — nothing new + * + * The app's own navigation tree, which the shell already resolves for its + * chrome: `useMetadata().apps` (fetched eagerly by `MetadataProvider` — + * `GET /api/v1/meta/app`), narrowed to the app in the route, then + * `activeArea.navigation ?? app.navigation`. No request of this block's own and + * no adapter call, which is the ruling's "no external data-source dependency" + * claim discharged. + * + * Every derived fact comes from `@object-ui/layout`, not from a second copy: + * + * - hrefs from `resolveHref` — the documented single source of truth for + * nav → URL, so `recordId` / `filters` / `viewName` / `runAction` + * precedence cannot drift between the sidebar and an authored menu; + * - labels from `resolveNavItemLabel` — the same convention-based object / + * view / dashboard i18n resolution the sidebar gets, so the two surfaces + * cannot show one entry under two names; + * - the active row from `resolveActiveNavItem`, the round-trip inverse of + * `resolveHref`; + * - the item-level guards in the same ORDER `NavigationItemRenderer` applies + * them (`visible` → `requiredPermissions` → `requiresObject` → + * `requiresService`), wired to the same three console providers `AppSidebar` + * wires them to. + * + * ## Why not mount `NavigationRenderer` itself + * + * Measured, not assumed: `NavigationRenderer` renders through + * `SidebarMenuButton`, which calls `useSidebar()`, which THROWS + * ("useSidebar must be used within a SidebarProvider") outside the shell's + * provider (`components/src/ui/sidebar.tsx:56-63`, read point at `:576`). A page + * block has to render standalone — in the Studio preview, in a test, in any + * host — so mounting it would trade a dashed box for a crash. Wrapping the block + * in its own `SidebarProvider` is worse than it looks: that provider renders a + * `min-h-svh` full-viewport flex wrapper and registers a WINDOW-level + * Ctrl/Cmd+B handler, so an authored menu would resize the page around itself + * and fight the real sidebar for the shell's own keyboard shortcut. + * + * So this block reuses every pure helper and none of the sidebar chrome. What + * it deliberately does NOT reproduce is sidebar-only interaction state — + * drag-reorder, pinning, and the nav search box — all of which are + * localStorage-backed personalisation of the SHELL's menu (`useNavOrder`, + * `useNavPins`), not properties of an authored page. + * + * ## Two deliberate narrowings, stated rather than left implicit + * + * 1. **Areas.** When an app declares `areas`, the sidebar lets the user switch + * between them and shows one at a time; the elected default is the first + * area with at least one visible item (objectui#3311's derived visibility, + * via the shared `hasVisibleNavigationItems` predicate). This block renders + * that same default and offers no switcher: which area you are in is shell + * state, and a page block has nowhere to put it. Apps with no `areas` — the + * common case — render `app.navigation` flat, exactly as the sidebar does. + * 2. **App-level context selectors.** `AppSidebar` passes `contextValues` from + * `useAppContextSelectors` into the template context, because it also + * RENDERS those selectors. This block passes only `currentUserId` / + * `currentOrgId`; an entry referencing `{some_selector}` therefore falls + * back to the unscoped URL, which is `applyNavTemplate`'s documented answer + * for an unresolved variable, not a new failure mode. + * + * `action` items DO render here: `useNavActionDispatch` is wired, so + * `hasActionHandler` is true and framework#4509's "renders but dead-clicks" + * shape is not reintroduced. + * + * ## Declared propless, deliberately + * + * `ComponentPropsMap['nav:menu']` is an EMPTY shape, so this registration + * publishes NO `inputs` — declaring even `className` would advertise an + * authoring key the contract rejects by name (the forward direction of + * `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`). The + * node-level `className` the SchemaRenderer threads through is a NODE key of + * `PageComponentSchema`, not a prop, and needs no declaration. `nav:menu` is + * already pinned in that test's `EXPECTED_WITHOUT_INPUTS`, where the eager + * placeholder registration put it, and this registration keeps it there. + * + * Registered in app-shell rather than `@object-ui/components` for the same + * reason `global:search` is (objectui#6757): the providers are here. + * `@object-ui/components` depends on neither `@object-ui/layout` (the resolvers) + * nor `@object-ui/permissions` nor `react-router-dom` — measured against its + * `package.json` on `592acafbe`. The eager palette placeholder in + * `components/renderers/placeholders.tsx` STAYS: it is the fallback for a host + * that embeds `@object-ui/components` without app-shell, and this module + * imports that package, so its registration always runs first and this one + * overwrites it. + */ + +import * as React from 'react'; +import { useCallback, useMemo } from 'react'; +import { Link, useLocation, useParams } from 'react-router-dom'; +import { ComponentRegistry } from '@object-ui/core'; +import { useMetadata } from '@object-ui/react'; +import { useAuth } from '@object-ui/auth'; +import { usePermissions } from '@object-ui/permissions'; +import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; +import { Badge, Separator, cn } from '@object-ui/components'; +import { + hasVisibleNavigationItems, + resolveActiveNavItem, + resolveHref, + resolveNavItemLabel, + type NavTemplateContext, +} from '@object-ui/layout'; +import type { NavigationItem } from '@object-ui/types'; +import { useExpressionContext, evaluateVisibility } from '../providers/ExpressionProvider.js'; +import { useNavActionDispatch } from '../hooks/useNavActionDispatch.js'; +import { useNavigationContext } from '../context/NavigationContext.js'; +import { getIcon } from '../utils/getIcon.js'; +import { appRouteSegment, matchAppBySegment } from '../utils/index.js'; + +/** Keep the designer's own data attributes on the wrapper, drop the rest. */ +const splitDesigner = (props: Record) => { + const { 'data-obj-id': id, 'data-obj-type': type, style } = props || {}; + return { 'data-obj-id': id, 'data-obj-type': type, style }; +}; + +const ROW = + 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-foreground/80 ' + + 'transition-colors hover:bg-accent hover:text-foreground ' + + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'; + +export interface NavMenuRendererProps { + schema?: Record; + className?: string; + [k: string]: any; +} + +export const NavMenuRenderer: React.FC = ({ + className, + schema: _schema, + ...props +}) => { + const { t } = useObjectTranslation(); + const { objectLabel, viewLabel, dashboardLabel } = useObjectLabel(); + const { apps, objects } = useMetadata(); + const { appName } = useParams(); + const { currentAppName } = useNavigationContext(); + const { pathname, search } = useLocation(); + const { user, activeOrganization } = useAuth(); + const dispatchNavAction = useNavActionDispatch(); + + /* ── The three guards, wired to the same providers `AppSidebar` uses ────── */ + + const { evaluator } = useExpressionContext(); + const evalVis = useCallback( + (expr: string | boolean | undefined) => evaluateVisibility(expr, evaluator), + [evaluator], + ); + + // `object:action` → object CRUD gate; a bare name is an ADR-0066 system + // capability, with the legacy "can read " reading kept as fallback. + // Same mapping as `AppSidebar` / `UnifiedSidebar` — one question, one answer. + const { can, hasCapabilities } = usePermissions(); + const checkPerm = 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], + ); + + const registeredObjectNames = useMemo( + () => new Set(((objects as any[]) || []).map((o: any) => o?.name).filter(Boolean)), + [objects], + ); + const checkCap = useCallback( + (kind: 'object' | 'service', name: string): boolean => { + if (kind === 'object') { + // While metadata is still loading the set is empty; show entries by + // default rather than flickering the whole menu away (AppSidebar's + // reasoning, and it must match or the two menus disagree on first paint). + if (registeredObjectNames.size === 0) return true; + return registeredObjectNames.has(name); + } + return true; + }, + [registeredObjectNames], + ); + + /* ── Which app, and which slice of its navigation ──────────────────────── */ + + const activeApp = useMemo(() => { + const list = ((apps as any[]) || []).filter((a: any) => a?.active !== false); + return matchAppBySegment(list, appName ?? currentAppName ?? null); + }, [apps, appName, currentAppName]); + + const guards = useMemo( + () => ({ + evaluateVisibility: evalVis, + checkPermission: checkPerm, + checkCapability: checkCap, + // This block DOES wire `onAction`, so `action` items count towards an + // area's derived visibility here (framework#4509). + hasActionHandler: true, + }), + [evalVis, checkPerm, checkCap], + ); + + const items: NavigationItem[] = useMemo(() => { + const areas = (activeApp?.areas as any[]) || []; + if (areas.length > 0) { + const firstVisible = areas.find((area: any) => + hasVisibleNavigationItems(area?.navigation ?? [], guards), + ); + if (firstVisible) return firstVisible.navigation ?? []; + } + return (activeApp?.navigation as NavigationItem[]) ?? []; + }, [activeApp, guards]); + + const basePath = activeApp ? `/apps/${appRouteSegment(activeApp) ?? activeApp.name}` : ''; + + const templateContext: NavTemplateContext = useMemo( + () => ({ currentUserId: user?.id ?? null, currentOrgId: activeOrganization?.id ?? null }), + [user?.id, activeOrganization?.id], + ); + + const activeId = useMemo( + () => resolveActiveNavItem(items, pathname, search, basePath, templateContext)?.id ?? null, + [items, pathname, search, basePath, templateContext], + ); + + /* ── Rendering ─────────────────────────────────────────────────────────── */ + + const label = useCallback( + (item: NavigationItem) => + resolveNavItemLabel( + item, + (objectName, fallback) => objectLabel({ name: objectName, label: fallback }), + t, + (dashboardName, fallback) => dashboardLabel({ name: dashboardName, label: fallback }), + (objectName, viewName, fallback) => viewLabel(objectName, viewName, fallback), + ), + [objectLabel, dashboardLabel, viewLabel, t], + ); + + const renderItem = useCallback( + (item: NavigationItem, depth: number): React.ReactNode => { + // 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 (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; + + if (item.type === 'separator') { + return ( + + ); + } + + if (item.type === 'group') { + const children = (item.children ?? []) + .map((child) => renderItem(child, depth + 1)) + .filter(Boolean); + // A group whose children are all gated away contributes nothing a user + // can navigate to, so it does not render its heading either. + if (children.length === 0) return null; + return ( +
  • +
    + {label(item)} +
    +
      {children}
    +
  • + ); + } + + const Icon = getIcon(item.icon); + const text = label(item); + const badge = + item.badge !== undefined && item.badge !== null && item.badge !== '' ? ( + + {item.badge} + + ) : null; + const body = ( + <> + + {text} + {badge} + + ); + const isActive = activeId !== null && item.id === activeId; + const rowClass = cn(ROW, isActive && 'bg-accent font-medium text-foreground'); + + if (item.type === 'action') { + return ( +
  • + +
  • + ); + } + + const { href, external } = resolveHref(item, basePath, templateContext); + return ( +
  • + {external ? ( + + {body} + + ) : ( + + {body} + + )} +
  • + ); + }, + [evalVis, checkPerm, checkCap, label, activeId, basePath, templateContext, dispatchNavAction], + ); + + const rows = items.map((item) => renderItem(item, 0)).filter(Boolean); + + return ( + + ); +}; + +// Bare name + namespace (the registry prepends it itself); `skipFallback: true` +// keeps this off the top-level `menu` key. No `inputs`: the spec shape is empty. +ComponentRegistry.register('menu', NavMenuRenderer, { + namespace: 'nav', + skipFallback: true, + category: 'navigation', + label: 'Nav Menu', + icon: 'Menu', +}); + +export default NavMenuRenderer; diff --git a/packages/layout/src/NavigationRenderer.tsx b/packages/layout/src/NavigationRenderer.tsx index 085ce76b78..b375afdca6 100644 --- a/packages/layout/src/NavigationRenderer.tsx +++ b/packages/layout/src/NavigationRenderer.tsx @@ -290,8 +290,18 @@ export function resolveLabel( * (`src/system/i18n-resolver.ts`) rewrites every navigation node's `label` by * id before the metadata reaches this renderer, so `base` is already * localized when it arrives. One owner, not two — localize nav labels there. + * + * EXPORTED since objectui#6661, for the same reason {@link resolveHref} is: a + * second surface now renders the same `NavigationItem[]`. `nav:menu` is the + * app's navigation tree as PAGE CONTENT (`app-shell/src/views/nav-menu-renderer.tsx`), + * and it cannot mount `NavigationRenderer` itself — that renders through + * `SidebarMenuButton`, whose `useSidebar()` throws outside the shell's + * `SidebarProvider`. Re-deriving the label rules there would put one nav entry + * under two names on two surfaces of one app, which is exactly the drift the + * "single source of truth" note on `resolveHref` exists to prevent. Nothing + * about the behaviour changed with the keyword. */ -function resolveNavItemLabel( +export function resolveNavItemLabel( item: NavigationItem, resolver?: (objectName: string, fallbackLabel: string) => string, t?: (key: string, options?: any) => string, From c9bb3a626fd4917b08a78193237c177a9d3a2d16 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 17:30:29 +0000 Subject: [PATCH 2/3] fix(i18n): declare the three new nav-block strings in all ten locale packs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check-i18n-call-site-keys` is explicit that an inline `defaultValue` is not a fix — it renders English at one call site and leaves the string untranslatable everywhere (objectui#3517). The three strings the two new page blocks introduce now live under the existing `console.nav` block in `en.ts` and its nine siblings, which is what `all-locales-key-parity` demands. Also drops the recursive `useCallback` in the nav renderer for a hoisted function declaration: an arrow bound to a `const` cannot be called from inside its own initializer without reading the binding before it is declared (`react-hooks/immutability`), and memoising bought nothing — the row list is recomputed every render regardless. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB --- .../src/views/app-launcher-renderer.tsx | 2 +- .../app-shell/src/views/nav-menu-renderer.tsx | 20 ++++++++++--------- packages/i18n/src/locales/ar.ts | 3 +++ packages/i18n/src/locales/de.ts | 3 +++ packages/i18n/src/locales/en.ts | 3 +++ packages/i18n/src/locales/es.ts | 3 +++ packages/i18n/src/locales/fr.ts | 3 +++ packages/i18n/src/locales/ja.ts | 3 +++ packages/i18n/src/locales/ko.ts | 3 +++ packages/i18n/src/locales/pt.ts | 3 +++ packages/i18n/src/locales/ru.ts | 3 +++ packages/i18n/src/locales/zh.ts | 3 +++ 12 files changed, 42 insertions(+), 10 deletions(-) diff --git a/packages/app-shell/src/views/app-launcher-renderer.tsx b/packages/app-shell/src/views/app-launcher-renderer.tsx index 60b2978bb8..a7057d95c9 100644 --- a/packages/app-shell/src/views/app-launcher-renderer.tsx +++ b/packages/app-shell/src/views/app-launcher-renderer.tsx @@ -126,7 +126,7 @@ export const AppLauncherRenderer: React.FC = ({ return (