Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .changeset/6661-app-launcher-nav-menu-renderers.md
Original file line numberDiff line numberDiff line change
@@ -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).
4 changes: 4 additions & 0 deletions packages/app-shell/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand All@@ -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",
Expand All@@ -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",
Expand Down
12 changes: 12 additions & 0 deletions packages/app-shell/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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/<name>`, 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 <div data-testid="pathname">{pathname}</div>;
}

/** 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(
<MemoryRouter initialEntries={['/apps/crm']}>
<MetadataCtx.Provider value={METADATA as never}>
<LocationProbe />
<Routes>
<Route
path="/apps/:appName"
element={<SchemaRenderer schema={page(type) as never} />}
/>
<Route path="*" element={<div>navigated away</div>} />
</Routes>
</MetadataCtx.Provider>
</MemoryRouter>,
);
}

/* ── 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');
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .changeset/6661-app-launcher-nav-menu-renderers.md
Original file line numberDiff line numberDiff line change
@@ -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).
4 changes: 4 additions & 0 deletions packages/app-shell/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand All@@ -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",
Expand All@@ -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",
Expand Down
12 changes: 12 additions & 0 deletions packages/app-shell/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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/<name>`, 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 <div data-testid="pathname">{pathname}</div>;
}

/** 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(
<MemoryRouter initialEntries={['/apps/crm']}>
<MetadataCtx.Provider value={METADATA as never}>
<LocationProbe />
<Routes>
<Route
path="/apps/:appName"
element={<SchemaRenderer schema={page(type) as never} />}
/>
<Route path="*" element={<div>navigated away</div>} />
</Routes>
</MetadataCtx.Provider>
</MemoryRouter>,
);
}

/* ── 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');
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .changeset/6661-app-launcher-nav-menu-renderers.md
Original file line numberDiff line numberDiff line change
@@ -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).
4 changes: 4 additions & 0 deletions packages/app-shell/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand All@@ -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",
Expand All@@ -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",
Expand Down
12 changes: 12 additions & 0 deletions packages/app-shell/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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/<name>`, 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 <div data-testid="pathname">{pathname}</div>;
}

/** 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(
<MemoryRouter initialEntries={['/apps/crm']}>
<MetadataCtx.Provider value={METADATA as never}>
<LocationProbe />
<Routes>
<Route
path="/apps/:appName"
element={<SchemaRenderer schema={page(type) as never} />}
/>
<Route path="*" element={<div>navigated away</div>} />
</Routes>
</MetadataCtx.Provider>
</MemoryRouter>,
);
}

/* ── 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');
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .changeset/6661-app-launcher-nav-menu-renderers.md
Original file line numberDiff line numberDiff line change
@@ -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).
4 changes: 4 additions & 0 deletions packages/app-shell/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand All@@ -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",
Expand All@@ -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",
Expand Down
12 changes: 12 additions & 0 deletions packages/app-shell/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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/<name>`, 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 <div data-testid="pathname">{pathname}</div>;
}

/** 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(
<MemoryRouter initialEntries={['/apps/crm']}>
<MetadataCtx.Provider value={METADATA as never}>
<LocationProbe />
<Routes>
<Route
path="/apps/:appName"
element={<SchemaRenderer schema={page(type) as never} />}
/>
<Route path="*" element={<div>navigated away</div>} />
</Routes>
</MetadataCtx.Provider>
</MemoryRouter>,
);
}

/* ── 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');
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .changeset/6661-app-launcher-nav-menu-renderers.md
Original file line numberDiff line numberDiff line change
@@ -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).
4 changes: 4 additions & 0 deletions packages/app-shell/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand All@@ -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",
Expand All@@ -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",
Expand Down
12 changes: 12 additions & 0 deletions packages/app-shell/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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/<name>`, 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 <div data-testid="pathname">{pathname}</div>;
}

/** 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(
<MemoryRouter initialEntries={['/apps/crm']}>
<MetadataCtx.Provider value={METADATA as never}>
<LocationProbe />
<Routes>
<Route
path="/apps/:appName"
element={<SchemaRenderer schema={page(type) as never} />}
/>
<Route path="*" element={<div>navigated away</div>} />
</Routes>
</MetadataCtx.Provider>
</MemoryRouter>,
);
}

/* ── 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');
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .changeset/6661-app-launcher-nav-menu-renderers.md
Original file line numberDiff line numberDiff line change
@@ -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).
4 changes: 4 additions & 0 deletions packages/app-shell/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand All@@ -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",
Expand All@@ -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",
Expand Down
12 changes: 12 additions & 0 deletions packages/app-shell/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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/<name>`, 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 <div data-testid="pathname">{pathname}</div>;
}

/** 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(
<MemoryRouter initialEntries={['/apps/crm']}>
<MetadataCtx.Provider value={METADATA as never}>
<LocationProbe />
<Routes>
<Route
path="/apps/:appName"
element={<SchemaRenderer schema={page(type) as never} />}
/>
<Route path="*" element={<div>navigated away</div>} />
</Routes>
</MetadataCtx.Provider>
</MemoryRouter>,
);
}

/* ── 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');
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .changeset/6661-app-launcher-nav-menu-renderers.md
Original file line numberDiff line numberDiff line change
@@ -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).
4 changes: 4 additions & 0 deletions packages/app-shell/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand All@@ -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",
Expand All@@ -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",
Expand Down
12 changes: 12 additions & 0 deletions packages/app-shell/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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/<name>`, 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 <div data-testid="pathname">{pathname}</div>;
}

/** 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(
<MemoryRouter initialEntries={['/apps/crm']}>
<MetadataCtx.Provider value={METADATA as never}>
<LocationProbe />
<Routes>
<Route
path="/apps/:appName"
element={<SchemaRenderer schema={page(type) as never} />}
/>
<Route path="*" element={<div>navigated away</div>} />
</Routes>
</MetadataCtx.Provider>
</MemoryRouter>,
);
}

/* ── 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');
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .changeset/6661-app-launcher-nav-menu-renderers.md
Original file line numberDiff line numberDiff line change
@@ -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).
4 changes: 4 additions & 0 deletions packages/app-shell/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand All@@ -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",
Expand All@@ -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",
Expand Down
12 changes: 12 additions & 0 deletions packages/app-shell/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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/<name>`, 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 <div data-testid="pathname">{pathname}</div>;
}

/** 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(
<MemoryRouter initialEntries={['/apps/crm']}>
<MetadataCtx.Provider value={METADATA as never}>
<LocationProbe />
<Routes>
<Route
path="/apps/:appName"
element={<SchemaRenderer schema={page(type) as never} />}
/>
<Route path="*" element={<div>navigated away</div>} />
</Routes>
</MetadataCtx.Provider>
</MemoryRouter>,
);
}

/* ── 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');
});
});
});
Loading
Loading