diff --git a/.changeset/nav-item-type-component.md b/.changeset/nav-item-type-component.md new file mode 100644 index 0000000000..e2cee99748 --- /dev/null +++ b/.changeset/nav-item-type-component.md @@ -0,0 +1,27 @@ +--- +"@object-ui/types": minor +"@object-ui/layout": patch +"@object-ui/plugin-designer": patch +"@object-ui/i18n": patch +--- + +fix(types,layout): nav item type `component` joins `NavigationItemType` and its zod enum — objectui#2918 + +The renderers have carried a full `type: 'component'` implementation (Phase 3b: +`componentRef` colon-split to `/component//`, `params` serialised as +querystring, `metadata:*` special-cases) — but the vocabulary never gained the +member, and `@objectstack/spec` has had `ComponentNavItem` all along. The zod +enum was the part that bit: `NavigationItemTypeSchema` rejected +`type: 'component'` at validation time, so authors could not declare one and +the renderer half was unreachable — dead on arrival rather than dead code. + +- `NavigationItemType` and `NavigationItemTypeSchema` gain `'component'`; + `NavigationItem` gains the fields the renderer consumes, `componentRef` and + `params` (also used by `type: 'page'`), mirroring spec's `ComponentNavItem` — + declared in zod too, so parse no longer strips them. +- The `(item as any).componentRef` / `params` casts in `NavigationRenderer` + and `AppSchemaRenderer` become typed access. +- `NavigationDesigner`'s exhaustive type-meta map gains a `component` badge + (new `appDesigner.navTypeComponent` key in all 10 locales). +- `@object-ui/layout` gains `type-check` (src + tests) with the #2915 `paths` + override; its DEBT entry in `check-type-check-coverage.mjs` is deleted. diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 6c7714912c..fc653e753e 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -1071,6 +1071,7 @@ const ar = { navTypeGroup: "مجموعة", navTypeSeparator: "فاصل", navTypeAction: "إجراء", + navTypeComponent: "مكوّن", navEditIcon: "تعديل الأيقونة", navToggleVisible: "تبديل الرؤية", navHidden: "مخفي", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index f587c54825..b8bdaf915c 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -1071,6 +1071,7 @@ const de = { navTypeGroup: "Gruppe", navTypeSeparator: "Trenner", navTypeAction: "Aktion", + navTypeComponent: "Komponente", navEditIcon: "Symbol bearbeiten", navToggleVisible: "Sichtbarkeit umschalten", navHidden: "Ausgeblendet", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 26be375cd4..a7c1ef0159 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -1227,6 +1227,7 @@ const en = { navTypeGroup: 'Group', navTypeSeparator: 'Separator', navTypeAction: 'Action', + navTypeComponent: 'Component', navEditIcon: 'Edit icon', navToggleVisible: 'Toggle visibility', navHidden: 'Hidden', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 87c7470781..80b58eb93f 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -1071,6 +1071,7 @@ const es = { navTypeGroup: "Grupo", navTypeSeparator: "Separador", navTypeAction: "Acción", + navTypeComponent: "Componente", navEditIcon: "Editar icono", navToggleVisible: "Alternar visibilidad", navHidden: "Oculto", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 30c1fa5aff..50718a10d4 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -1071,6 +1071,7 @@ const fr = { navTypeGroup: "Groupe", navTypeSeparator: "Séparateur", navTypeAction: "Action", + navTypeComponent: "Composant", navEditIcon: "Modifier l'icône", navToggleVisible: "Basculer la visibilité", navHidden: "Masqué", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index b8f6de9eae..51f634ce16 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -1071,6 +1071,7 @@ const ja = { navTypeGroup: "グループ", navTypeSeparator: "区切り", navTypeAction: "アクション", + navTypeComponent: "コンポーネント", navEditIcon: "アイコンを編集", navToggleVisible: "表示を切り替え", navHidden: "非表示", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 1f63bf93df..962b3d4ac7 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -1071,6 +1071,7 @@ const ko = { navTypeGroup: "그룹", navTypeSeparator: "구분선", navTypeAction: "작업", + navTypeComponent: "컴포넌트", navEditIcon: "아이콘 편집", navToggleVisible: "가시성 토글", navHidden: "숨김", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 013d57d7a7..233cc82ea6 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -1071,6 +1071,7 @@ const pt = { navTypeGroup: "Grupo", navTypeSeparator: "Separador", navTypeAction: "Ação", + navTypeComponent: "Componente", navEditIcon: "Editar ícone", navToggleVisible: "Alternar visibilidade", navHidden: "Oculto", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 05c4686f79..48b35a2998 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -1071,6 +1071,7 @@ const ru = { navTypeGroup: "Группа", navTypeSeparator: "Разделитель", navTypeAction: "Действие", + navTypeComponent: "Компонент", navEditIcon: "Редактировать значок", navToggleVisible: "Переключить видимость", navHidden: "Скрыто", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 36cb892bf7..a4a2136cf6 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -1235,6 +1235,7 @@ const zh = { navTypeGroup: '分组', navTypeSeparator: '分隔线', navTypeAction: '操作', + navTypeComponent: '组件', navEditIcon: '编辑图标', navToggleVisible: '切换可见性', navHidden: '已隐藏', diff --git a/packages/layout/package.json b/packages/layout/package.json index 40b6847921..ad458ded63 100644 --- a/packages/layout/package.json +++ b/packages/layout/package.json @@ -15,6 +15,7 @@ }, "scripts": { "build": "vite build", + "type-check": "tsc --noEmit && tsc -p tsconfig.test.json", "lint": "eslint ." }, "dependencies": { diff --git a/packages/layout/src/AppSchemaRenderer.tsx b/packages/layout/src/AppSchemaRenderer.tsx index 40b6906e8e..9ae869ac89 100644 --- a/packages/layout/src/AppSchemaRenderer.tsx +++ b/packages/layout/src/AppSchemaRenderer.tsx @@ -220,11 +220,11 @@ function MobileBottomNav({ else if (item.type === 'report') href = item.reportName ? `${basePath}/report/${item.reportName}` : '#'; else if (item.type === 'url') href = item.url ?? '#'; else if (item.type === 'component') { - const ref = (item as any).componentRef as string | undefined; + const ref = item.componentRef; if (ref) { const segs = ref.split(':').filter(Boolean); href = `${basePath}/component/${segs.join('/')}`; - const navParams = (item as any).params as Record | undefined; + const navParams = item.params; if (navParams) { const usp = new URLSearchParams(); for (const [k, v] of Object.entries(navParams)) { diff --git a/packages/layout/src/NavigationRenderer.tsx b/packages/layout/src/NavigationRenderer.tsx index ebf5a97c02..a98e74e14c 100644 --- a/packages/layout/src/NavigationRenderer.tsx +++ b/packages/layout/src/NavigationRenderer.tsx @@ -438,7 +438,7 @@ export function resolveHref( // additionally pass through `applyNavTemplate` so nav entries can // refer to `{current_user_id}` / `{current_org_id}` — exactly like // the `recordId` substitution above for object-typed nav items. - const pageParams = (item as any).params as Record | undefined; + const pageParams = item.params; let url = `${basePath}/page/${item.pageName}`; if (pageParams && typeof pageParams === 'object') { const usp = new URLSearchParams(); @@ -468,11 +468,11 @@ export function resolveHref( // the same component can be reused across many nav entries with // different inputs (e.g. `params: { type: 'object' }` vs // `params: { type: 'field' }`). - const ref = (item as any).componentRef as string | undefined; + const ref = item.componentRef; if (!ref) return { href: '#', external: false }; const segs = ref.split(':').filter(Boolean); if (segs.length === 0) return { href: '#', external: false }; - const navParams = (item as any).params as Record | undefined; + const navParams = item.params; // Special-case metadata refs: route to nested REST-style /metadata paths. // metadata:directory → /metadata // metadata:resource (+ params.type) → /metadata/:type diff --git a/packages/layout/src/__tests__/AppSchemaRenderer.test.tsx b/packages/layout/src/__tests__/AppSchemaRenderer.test.tsx index 270f860dee..1f794d9d9c 100644 --- a/packages/layout/src/__tests__/AppSchemaRenderer.test.tsx +++ b/packages/layout/src/__tests__/AppSchemaRenderer.test.tsx @@ -120,6 +120,28 @@ describe('AppSchemaRenderer', () => { expect(pageLink?.getAttribute('href')).toBe('/apps/crm/page/settings'); }); + // #2918 — `type: 'component'` is part of the nav vocabulary; the sidebar + // renders it as a link to the ComponentRegistry route. + it('renders a component navigation item with its /component href', () => { + const schema: AppSchema = { + type: 'app', + name: 'crm', + title: 'Sales CRM', + navigation: [ + { + id: 'nc', + type: 'component', + label: 'Permissions', + icon: 'Puzzle', + componentRef: 'setup:permission_matrix', + }, + ], + }; + renderApp(schema); + const link = screen.getByText('Permissions').closest('a'); + expect(link?.getAttribute('href')).toBe('/apps/crm/component/setup/permission_matrix'); + }); + // --- Legacy menu migration --- it('renders legacy menu items converted to NavigationItem', () => { diff --git a/packages/layout/src/__tests__/resolveHref.test.ts b/packages/layout/src/__tests__/resolveHref.test.ts index e1dd9d352e..a96c18ccd1 100644 --- a/packages/layout/src/__tests__/resolveHref.test.ts +++ b/packages/layout/src/__tests__/resolveHref.test.ts @@ -212,3 +212,69 @@ describe('resolveActiveNavItem — single winner across the tree', () => { } }); }); + +// ============================================================================ +// component targets (#2918) — the renderer half predates the vocabulary; these +// items now typecheck as NavigationItem without casts. +// ============================================================================ + +describe('resolveHref — component targets', () => { + function componentItem(extra: Partial = {}): NavigationItem { + return { id: 'nav_comp', type: 'component', label: 'Comp', ...extra }; + } + + it('componentRef → /component//', () => { + const { href, external } = resolveHref( + componentItem({ componentRef: 'setup:permission_matrix' }), + BASE, + ); + expect(href).toBe(`${BASE}/component/setup/permission_matrix`); + expect(external).toBe(false); + }); + + it('params are serialised as querystring (non-strings as JSON)', () => { + const { href } = resolveHref( + componentItem({ componentRef: 'foo:bar', params: { mode: 'compact', page: 2 } }), + BASE, + ); + expect(href.startsWith(`${BASE}/component/foo/bar?`)).toBe(true); + const q = queryOf(href); + expect(q.get('mode')).toBe('compact'); + expect(q.get('page')).toBe('2'); + }); + + it('missing componentRef → dead link', () => { + expect(resolveHref(componentItem(), BASE).href).toBe('#'); + }); + + it('metadata:directory → /metadata', () => { + const { href } = resolveHref( + componentItem({ componentRef: 'metadata:directory' }), + BASE, + ); + expect(href).toBe(`${BASE}/metadata`); + }); + + it('metadata:resource + params.type/name → nested /metadata path', () => { + expect( + resolveHref(componentItem({ componentRef: 'metadata:resource', params: { type: 'object' } }), BASE).href, + ).toBe(`${BASE}/metadata/object`); + expect( + resolveHref( + componentItem({ componentRef: 'metadata:resource', params: { type: 'object', name: 'task' } }), + BASE, + ).href, + ).toBe(`${BASE}/metadata/object/task`); + }); + + it('metadata extra params: templates resolve via context, unresolved are dropped', () => { + const item = componentItem({ + componentRef: 'metadata:resource', + params: { type: 'object', package: '{active_package}' }, + }); + const scoped = resolveHref(item, BASE, { contextValues: { active_package: 'crm_core' } }); + expect(scoped.href).toBe(`${BASE}/metadata/object?package=crm_core`); + const unscoped = resolveHref(item, BASE); + expect(unscoped.href).toBe(`${BASE}/metadata/object`); + }); +}); diff --git a/packages/layout/tsconfig.json b/packages/layout/tsconfig.json index b0ad829848..b81f80b3c3 100644 --- a/packages/layout/tsconfig.json +++ b/packages/layout/tsconfig.json @@ -2,7 +2,15 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "dist", - "jsx": "react-jsx" + "jsx": "react-jsx", + "baseUrl": ".", + // Overriding `paths` drops the root config's `@object-ui/* -> packages/*/src` + // mappings, so workspace deps resolve through their built `.d.ts` instead of + // pulling sibling sources into this program (which would be outside rootDir). + "paths": { + "@/*": ["src/*"] + }, + "skipLibCheck": true }, "include": ["src"], "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"] diff --git a/packages/layout/tsconfig.test.json b/packages/layout/tsconfig.test.json new file mode 100644 index 0000000000..0d12e19d10 --- /dev/null +++ b/packages/layout/tsconfig.test.json @@ -0,0 +1,15 @@ +{ + // Type-checks this package's TESTS, which `tsconfig.json` excludes. + // See `packages/types/tsconfig.test.json` for why that exclusion was a hole: + // the build correctly keeps tests out of `dist`, but nothing else compiled + // them, so a test could assert a contract the compiler never checked. + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + // Drop the root tsconfig's source-tree `paths` so `@object-ui/*` resolves + // through the workspace dependency's built `.d.ts` instead of pulling + // sibling sources in as program inputs (TS6059). + "paths": {} + }, + "include": ["src/**/*.test.ts", "src/**/*.test.tsx"] +} diff --git a/packages/plugin-designer/src/NavigationDesigner.tsx b/packages/plugin-designer/src/NavigationDesigner.tsx index adfb4a852c..2e7ff7ba83 100644 --- a/packages/plugin-designer/src/NavigationDesigner.tsx +++ b/packages/plugin-designer/src/NavigationDesigner.tsx @@ -34,6 +34,7 @@ import { MousePointerClick, Pencil, Plus, + Puzzle, Trash2, Upload, Database, @@ -85,6 +86,7 @@ const NAV_TYPE_META: Record = { 'appDesigner.navTypeGroup': 'Group', 'appDesigner.navTypeSeparator': 'Separator', 'appDesigner.navTypeAction': 'Action', + 'appDesigner.navTypeComponent': 'Component', 'appDesigner.navEditIcon': 'Edit icon', 'appDesigner.navToggleVisible': 'Toggle visibility', 'appDesigner.navHidden': 'Hidden', diff --git a/packages/types/src/__tests__/navigation-model.test.ts b/packages/types/src/__tests__/navigation-model.test.ts index 1de0184dd7..bd3a82f4ea 100644 --- a/packages/types/src/__tests__/navigation-model.test.ts +++ b/packages/types/src/__tests__/navigation-model.test.ts @@ -76,6 +76,24 @@ describe('NavigationItem Zod Schema', () => { expect(result.success).toBe(true); }); + // #2918 — `type: 'component'` was implemented in the renderers but rejected + // by this schema, so authors could not declare it at all. + it('should validate a component navigation item and keep componentRef/params', () => { + const item = { + id: 'nav_objects', + type: 'component', + label: 'Objects', + componentRef: 'metadata:resource', + params: { type: 'object' }, + }; + const result = NavigationItemSchema.safeParse(item); + expect(result.success).toBe(true); + // The fields the renderer consumes must survive parse (zod strips + // undeclared keys) — otherwise the item validates but renders `#`. + expect(result.data.componentRef).toBe('metadata:resource'); + expect(result.data.params).toEqual({ type: 'object' }); + }); + it('should validate a group navigation item with children', () => { const item = { id: 'nav_sales_group', diff --git a/packages/types/src/app.ts b/packages/types/src/app.ts index de12b28086..7df8eeb40f 100644 --- a/packages/types/src/app.ts +++ b/packages/types/src/app.ts @@ -34,6 +34,7 @@ export type NavigationItemType = | 'page' | 'report' | 'url' + | 'component' | 'group' | 'separator' | 'action'; @@ -121,6 +122,22 @@ export interface NavigationItem { /** Link target (for type: 'url') */ target?: '_blank' | '_self'; + /** + * Target component reference (for type: 'component') — a colon-joined + * `ComponentRegistry` key (e.g. `metadata:resource`, `setup:permission_matrix`) + * identifying a first-party UI shipped with the platform. Routed to + * `/component//`. Mirrors `@objectstack/spec` `ComponentNavItem`. + */ + componentRef?: string; + + /** + * Extra parameters (for type: 'component' | 'page') — serialised as + * querystring so the same component/page can be reused across nav entries + * with different inputs (e.g. `params: { type: 'object' }`). String values + * support the same template variables as `recordId`. + */ + params?: Record; + // -- Grouping -- /** Child navigation items (for type: 'group') */ diff --git a/packages/types/src/zod/app.zod.ts b/packages/types/src/zod/app.zod.ts index 8e42d101ae..d000ba2d30 100644 --- a/packages/types/src/zod/app.zod.ts +++ b/packages/types/src/zod/app.zod.ts @@ -27,7 +27,7 @@ import { BaseSchema } from './base.zod.js'; * Navigation Item Type enum */ export const NavigationItemTypeSchema = z.enum([ - 'object', 'dashboard', 'page', 'report', 'url', 'group', 'separator', 'action', + 'object', 'dashboard', 'page', 'report', 'url', 'component', 'group', 'separator', 'action', ]); /** @@ -50,6 +50,8 @@ export const NavigationItemSchema: z.ZodType = z.lazy(() => z.object({ reportName: z.string().optional().describe('Target report name (type: report)'), url: z.string().optional().describe('Target URL (type: url)'), target: z.enum(['_blank', '_self']).optional().describe('Link target (type: url)'), + componentRef: z.string().optional().describe('Target component reference (type: component) — colon-joined ComponentRegistry key e.g. metadata:resource, routed to /component//'), + params: z.record(z.string(), z.unknown()).optional().describe('Extra parameters (type: component | page) — serialised as querystring; string values support {current_user_id}/{current_org_id}'), // Grouping children: z.array(z.lazy(() => NavigationItemSchema)).optional().describe('Child items (type: group)'), diff --git a/scripts/check-type-check-coverage.mjs b/scripts/check-type-check-coverage.mjs index cf58755df5..c14bbdef29 100644 --- a/scripts/check-type-check-coverage.mjs +++ b/scripts/check-type-check-coverage.mjs @@ -41,7 +41,6 @@ const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); // peers already carry, so the TS6059 rootDir noise is excluded). const DEBT = { "@object-ui/plugin-view": { errors: 3, issue: 2916, note: "Record missing the 'chart' key" }, - "@object-ui/layout": { errors: 2, issue: 2918, note: "nav type 'component' is implemented but absent from NavigationItemType and its zod enum" }, }; // Packages that are not compiled at all: documentation snippets with no build