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..4d30bd6cba --- /dev/null +++ b/.changeset/6661-app-launcher-nav-menu-renderers.md @@ -0,0 +1,60 @@ +--- +'@object-ui/app-shell': minor +'@object-ui/layout': minor +'@object-ui/i18n': 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. + +Three new strings — the launcher's and the menu's accessible names, and the +menu's empty state — are declared under `console.nav` in `en.ts` and its nine +sibling packs. An inline `defaultValue` alone is not a fix: it renders English +at one call site and leaves the string untranslatable everywhere +(objectui#3517). diff --git a/packages/app-shell/package.json b/packages/app-shell/package.json index f575484850..83c3eb9cc3 100644 --- a/packages/app-shell/package.json +++ b/packages/app-shell/package.json @@ -4,6 +4,7 @@ "type": "module", "sideEffects": [ "./dist/index.js", + "./dist/views/app-launcher-renderer.js", "./dist/console/cloud-connection/CloudConnectionPanel.js", "./dist/console/connect/ConnectAgentWidget.js", "./dist/console/diagnostics/CloudAiModelStatus.js", @@ -13,10 +14,12 @@ "./dist/views/global-notifications-renderer.js", "./dist/views/global-search-renderer.js", "./dist/views/metadata-admin/register-builtins.js", + "./dist/views/nav-menu-renderer.js", "./dist/views/record-approvals-renderer.js", "./dist/views/record-attachments-renderer.js", "./dist/views/studio-design/studio-canvas-preview.js", "./src/index.ts", + "./src/views/app-launcher-renderer.tsx", "./src/console/cloud-connection/CloudConnectionPanel.tsx", "./src/console/connect/ConnectAgentWidget.tsx", "./src/console/diagnostics/CloudAiModelStatus.tsx", @@ -26,6 +29,7 @@ "./src/views/global-notifications-renderer.tsx", "./src/views/global-search-renderer.tsx", "./src/views/metadata-admin/register-builtins.ts", + "./src/views/nav-menu-renderer.tsx", "./src/views/record-approvals-renderer.tsx", "./src/views/record-attachments-renderer.tsx", "./src/views/studio-design/studio-canvas-preview.tsx", 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..a7057d95c9 --- /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..6f04ab571d --- /dev/null +++ b/packages/app-shell/src/views/nav-menu-renderer.tsx @@ -0,0 +1,378 @@ +/** + * 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], + ); + + // A plain (hoisted) function declaration, not a `useCallback`: it recurses + // into itself for `group` children, and a `const` arrow cannot be called from + // inside its own initializer without reading the binding before it is + // declared (`react-hooks/immutability`). Memoising would buy nothing anyway — + // `rows` below is recomputed on every render regardless. + function renderItem(item: NavigationItem): 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)) + .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} + + )} +
  • + ); + } + + const rows = items.map((item) => renderItem(item)).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/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 0fe45d0950..993e8356dc 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -1866,6 +1866,9 @@ const ar = { unpinItem: "إلغاء تثبيت {{name}}", dragToReorder: "اسحب لإعادة الترتيب", favorites: "المفضلة", + launcherLabel: "مشغّل التطبيقات", + menuLabel: "تنقّل التطبيق", + menuEmpty: "لا توجد في هذا التطبيق عناصر تنقّل يمكنك فتحها.", }, }, auth: { diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 77a20f086e..60dce6ad7d 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -1859,6 +1859,9 @@ const de = { unpinItem: "{{name}} loslösen", dragToReorder: "Zum Neuanordnen ziehen", favorites: "Favoriten", + launcherLabel: "App-Launcher", + menuLabel: "App-Navigation", + menuEmpty: "Diese App hat keine Navigationseinträge, die Sie öffnen können.", }, }, auth: { diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 6adf36eef4..7a05cfa800 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -1591,6 +1591,9 @@ const en = { unpinItem: 'Unpin {{name}}', dragToReorder: 'Drag to reorder', favorites: 'Favorites', + launcherLabel: 'App launcher', + menuLabel: 'App navigation', + menuEmpty: 'This app has no navigation entries you can open.', }, settingsHub: { title: 'Settings', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index c97ceaa167..e91be2b5eb 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -1863,6 +1863,9 @@ const es = { unpinItem: "Desfijar {{name}}", dragToReorder: "Arrastrar para reordenar", favorites: "Favoritos", + launcherLabel: "Lanzador de aplicaciones", + menuLabel: "Navegación de la aplicación", + menuEmpty: "Esta aplicación no tiene entradas de navegación que puedas abrir.", }, }, auth: { diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 45b3a5c24a..c06533e513 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -1861,6 +1861,9 @@ const fr = { unpinItem: "Désépingler {{name}}", dragToReorder: "Glisser pour réorganiser", favorites: "Favoris", + launcherLabel: "Lanceur d'applications", + menuLabel: "Navigation de l'application", + menuEmpty: "Cette application n'a aucune entrée de navigation que vous pouvez ouvrir.", }, }, auth: { diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 3417fa379a..61c802a8bd 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -1861,6 +1861,9 @@ const ja = { unpinItem: "{{name}} のピンを外す", dragToReorder: "ドラッグして並べ替え", favorites: "お気に入り", + launcherLabel: "アプリランチャー", + menuLabel: "アプリナビゲーション", + menuEmpty: "このアプリには開けるナビゲーション項目がありません。", }, }, auth: { diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 3fef822442..e9102bca29 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -1859,6 +1859,9 @@ const ko = { unpinItem: "{{name}} 고정 해제", dragToReorder: "드래그하여 순서 변경", favorites: "즐겨찾기", + launcherLabel: "앱 런처", + menuLabel: "앱 탐색", + menuEmpty: "이 앱에는 열 수 있는 탐색 항목이 없습니다.", }, }, auth: { diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 9258def3df..9bc51b8244 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -1858,6 +1858,9 @@ const pt = { unpinItem: "Desafixar {{name}}", dragToReorder: "Arrastar para reordenar", favorites: "Favoritos", + launcherLabel: "Iniciador de aplicativos", + menuLabel: "Navegação do aplicativo", + menuEmpty: "Este aplicativo não tem entradas de navegação que você possa abrir.", }, }, auth: { diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 016124fc84..b41eb49060 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -1871,6 +1871,9 @@ const ru = { unpinItem: "Открепить {{name}}", dragToReorder: "Перетащите для изменения порядка", favorites: "Избранное", + launcherLabel: "Панель приложений", + menuLabel: "Навигация приложения", + menuEmpty: "В этом приложении нет доступных для открытия элементов навигации.", }, }, auth: { diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index b4fb43c26c..5d11042f4a 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -1474,6 +1474,9 @@ const zh = { unpinItem: '取消固定 {{name}}', dragToReorder: '拖动以重新排序', favorites: '收藏', + launcherLabel: '应用启动台', + menuLabel: '应用导航', + menuEmpty: '此应用没有你可以打开的导航项。', }, settingsHub: { title: '设置', 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,