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
27 changes: 27 additions & 0 deletions .changeset/nav-item-type-component.md
Original file line number Diff line number Diff line change
@@ -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/<ns>/<name>`, `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.
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1071,6 +1071,7 @@ const ar = {
navTypeGroup: "مجموعة",
navTypeSeparator: "فاصل",
navTypeAction: "إجراء",
navTypeComponent: "مكوّن",
navEditIcon: "تعديل الأيقونة",
navToggleVisible: "تبديل الرؤية",
navHidden: "مخفي",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1071,6 +1071,7 @@ const de = {
navTypeGroup: "Gruppe",
navTypeSeparator: "Trenner",
navTypeAction: "Aktion",
navTypeComponent: "Komponente",
navEditIcon: "Symbol bearbeiten",
navToggleVisible: "Sichtbarkeit umschalten",
navHidden: "Ausgeblendet",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1227,6 +1227,7 @@ const en = {
navTypeGroup: 'Group',
navTypeSeparator: 'Separator',
navTypeAction: 'Action',
navTypeComponent: 'Component',
navEditIcon: 'Edit icon',
navToggleVisible: 'Toggle visibility',
navHidden: 'Hidden',
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1071,6 +1071,7 @@ const es = {
navTypeGroup: "Grupo",
navTypeSeparator: "Separador",
navTypeAction: "Acción",
navTypeComponent: "Componente",
navEditIcon: "Editar icono",
navToggleVisible: "Alternar visibilidad",
navHidden: "Oculto",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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é",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1071,6 +1071,7 @@ const ja = {
navTypeGroup: "グループ",
navTypeSeparator: "区切り",
navTypeAction: "アクション",
navTypeComponent: "コンポーネント",
navEditIcon: "アイコンを編集",
navToggleVisible: "表示を切り替え",
navHidden: "非表示",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1071,6 +1071,7 @@ const ko = {
navTypeGroup: "그룹",
navTypeSeparator: "구분선",
navTypeAction: "작업",
navTypeComponent: "컴포넌트",
navEditIcon: "아이콘 편집",
navToggleVisible: "가시성 토글",
navHidden: "숨김",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1071,6 +1071,7 @@ const pt = {
navTypeGroup: "Grupo",
navTypeSeparator: "Separador",
navTypeAction: "Ação",
navTypeComponent: "Componente",
navEditIcon: "Editar ícone",
navToggleVisible: "Alternar visibilidade",
navHidden: "Oculto",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1071,6 +1071,7 @@ const ru = {
navTypeGroup: "Группа",
navTypeSeparator: "Разделитель",
navTypeAction: "Действие",
navTypeComponent: "Компонент",
navEditIcon: "Редактировать значок",
navToggleVisible: "Переключить видимость",
navHidden: "Скрыто",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1235,6 +1235,7 @@ const zh = {
navTypeGroup: '分组',
navTypeSeparator: '分隔线',
navTypeAction: '操作',
navTypeComponent: '组件',
navEditIcon: '编辑图标',
navToggleVisible: '切换可见性',
navHidden: '已隐藏',
Expand Down
1 change: 1 addition & 0 deletions packages/layout/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
},
"scripts": {
"build": "vite build",
"type-check": "tsc --noEmit && tsc -p tsconfig.test.json",
"lint": "eslint ."
},
"dependencies": {
Expand Down
4 changes: 2 additions & 2 deletions packages/layout/src/AppSchemaRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | undefined;
const navParams = item.params;
if (navParams) {
const usp = new URLSearchParams();
for (const [k, v] of Object.entries(navParams)) {
Expand Down
6 changes: 3 additions & 3 deletions packages/layout/src/NavigationRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | undefined;
const pageParams = item.params;
let url = `${basePath}/page/${item.pageName}`;
if (pageParams && typeof pageParams === 'object') {
const usp = new URLSearchParams();
Expand Down Expand Up @@ -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<string, unknown> | 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
Expand Down
22 changes: 22 additions & 0 deletions packages/layout/src/__tests__/AppSchemaRenderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
66 changes: 66 additions & 0 deletions packages/layout/src/__tests__/resolveHref.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): NavigationItem {
return { id: 'nav_comp', type: 'component', label: 'Comp', ...extra };
}

it('componentRef → /component/<ns>/<name>', () => {
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`);
});
});
10 changes: 9 additions & 1 deletion packages/layout/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
15 changes: 15 additions & 0 deletions packages/layout/tsconfig.test.json
Original file line number Diff line number Diff line change
@@ -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"]
}
2 changes: 2 additions & 0 deletions packages/plugin-designer/src/NavigationDesigner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
MousePointerClick,
Pencil,
Plus,
Puzzle,
Trash2,
Upload,
Database,
Expand Down Expand Up @@ -85,6 +86,7 @@ const NAV_TYPE_META: Record<NavigationItemType, { labelKey: string; color: strin
page: { labelKey: 'appDesigner.navTypePage', color: 'bg-teal-100 text-teal-700', Icon: FileText },
report: { labelKey: 'appDesigner.navTypeReport', color: 'bg-rose-100 text-rose-700', Icon: BarChart3 },
url: { labelKey: 'appDesigner.navTypeUrl', color: 'bg-sky-100 text-sky-700', Icon: Link },
component: { labelKey: 'appDesigner.navTypeComponent', color: 'bg-indigo-100 text-indigo-700', Icon: Puzzle },
group: { labelKey: 'appDesigner.navTypeGroup', color: 'bg-purple-100 text-purple-700', Icon: FolderOpen },
separator: { labelKey: 'appDesigner.navTypeSeparator', color: 'bg-gray-100 text-gray-600', Icon: Minus },
action: { labelKey: 'appDesigner.navTypeAction', color: 'bg-orange-100 text-orange-700', Icon: MousePointerClick },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ const DESIGNER_DEFAULT_TRANSLATIONS: Record<string, string> = {
'appDesigner.navTypeGroup': 'Group',
'appDesigner.navTypeSeparator': 'Separator',
'appDesigner.navTypeAction': 'Action',
'appDesigner.navTypeComponent': 'Component',
'appDesigner.navEditIcon': 'Edit icon',
'appDesigner.navToggleVisible': 'Toggle visibility',
'appDesigner.navHidden': 'Hidden',
Expand Down
18 changes: 18 additions & 0 deletions packages/types/src/__tests__/navigation-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
17 changes: 17 additions & 0 deletions packages/types/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export type NavigationItemType =
| 'page'
| 'report'
| 'url'
| 'component'
| 'group'
| 'separator'
| 'action';
Expand Down Expand Up @@ -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/<ns>/<name>`. 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<string, unknown>;

// -- Grouping --

/** Child navigation items (for type: 'group') */
Expand Down
4 changes: 3 additions & 1 deletion packages/types/src/zod/app.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]);

/**
Expand All @@ -50,6 +50,8 @@ export const NavigationItemSchema: z.ZodType<any> = 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/<ns>/<name>'),
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)'),
Expand Down
1 change: 0 additions & 1 deletion scripts/check-type-check-coverage.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ViewType,...> 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
Expand Down
Loading