Skip to content
21 changes: 21 additions & 0 deletions .changeset/10287-navigation-designer-doc-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@object-ui/plugin-designer': patch
'@object-ui/i18n': patch
---

fix(plugin-designer): the Navigation Designer has an entry for the spec's `doc` navigation item type

objectstack#19789 added a `doc` member to the spec's navigation item union (an
item that targets a book and/or a doc). `NAV_TYPE_META` in `NavigationDesigner`
is keyed by that spec-derived union, so objectui stopped compiling against
`@objectstack/spec` built from objectstack `main`, and every row reads its badge,
colour and icon from that map. The map now has a `doc` entry (a `BookOpen` icon,
its own colour and the `appDesigner.navTypeDoc` label key). The key has an
English fallback in the designer's defaults and a translation in all ten locale
packs.

The map is typed `Record<NavigationItemType | 'doc', ...>` so it compiles both
against the pinned `@objectstack/spec`, which predates `doc`, and against
objectstack `main`. The `| 'doc'` goes away at the pin bump that ships `doc`.
`doc` is not added to the quick-add buttons: an empty `doc` item fails the spec's
book-or-doc requirement, and authoring one is objectui#10188.
40 changes: 40 additions & 0 deletions .changeset/9736-twins-spec-by-reference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
'@object-ui/types': minor
---

feat(types): `AppComponentSchema`, `DashboardComponentSchema` and `PageNodeSchema` take the spec by reference, like their zod mirrors

The three published zod mirrors have long taken `@objectstack/spec`'s `App` / `Dashboard` /
`Page` surface by reference (`BaseSchema.extend(SpecXFields.shape)`), while their hand-written
TypeScript twins restated only the members the renderers read. So the published validator
admitted keys the published type did not declare: the package-lock envelope (`_lock`,
`_lockReason`, `_lockSource`, `_provenance`, `_packageId`, `_packageVersion`, `_lockDocsUrl`,
written by the packaging pipeline), `protection`, the app's `isDefault` / `_unpublished` /
`defaultAgent` / `contextSelectors`, and the page's `source` / `interfaceConfig` / `requires`.
The spec's retirement tombstones reached the type only as `any`, through `BaseSchema`'s index
signature.

Each twin now extends `Omit` over the spec's exported input type, and that `Omit` reads the same
`as const` exclusion array the mirror's `specFieldsExcept` call reads. The two faces therefore
project one spec surface, and a later spec bump moves both of them together:

- **Admitted keys are declared** with the spec's own member types. A reader of a served document
can read `app.protection`, `page.requires` or the envelope without a cast.
- **Tombstones refuse at compile time.** `version`, `homePageId`, `objects`, `apis`, `sharing`,
`embed`, `mobileNavigation` and `aria` on an app, and `aria`, `refreshInterval` and
`performance` on a dashboard, are now optional members typed `undefined`. Authoring a value is
a TypeScript error, which is the verdict the validator already gave at parse. ⚠️ This is the
one breaking edge: code that wrote one of these keys used to compile against the index
signature and was refused at parse. It is now refused by `tsc`, before it runs.
- `DashboardComponentSchema.header` and `PageNodeSchema.slots` keep their own hand-written types.
Both are withheld from the spec projection on the type side only, because neither type is
assignable to the spec's member. The drift is already recorded in the parity ledgers.
`PageNodeSchema.assignedProfiles` is withheld the same way, but for forward compatibility:
objectstack `main` retires the key, and the hand-written `string[]` member (unchanged) would
otherwise stop compiling at the next spec bump. Its retirement is tracked separately.

Why this is a minor bump: the published type surface gains members, and one authoring
spelling moves from a parse-time refusal to a compile-time refusal. This repo marks breaking
semantics `minor` rather than `major`.

This change affects types only. It changes no runtime code and narrows no mirror.
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1405,6 +1405,7 @@ const ar = {
navTypeSeparator: "فاصل",
navTypeAction: "إجراء",
navTypeComponent: "مكوّن",
navTypeDoc: "مستند",
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 @@ -1398,6 +1398,7 @@ const de = {
navTypeSeparator: "Trenner",
navTypeAction: "Aktion",
navTypeComponent: "Komponente",
navTypeDoc: "Dokument",
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 @@ -1647,6 +1647,7 @@ const en = {
navTypeSeparator: 'Separator',
navTypeAction: 'Action',
navTypeComponent: 'Component',
navTypeDoc: 'Doc',
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 @@ -1402,6 +1402,7 @@ const es = {
navTypeSeparator: "Separador",
navTypeAction: "Acción",
navTypeComponent: "Componente",
navTypeDoc: "Documento",
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 @@ -1400,6 +1400,7 @@ const fr = {
navTypeSeparator: "Séparateur",
navTypeAction: "Action",
navTypeComponent: "Composant",
navTypeDoc: "Document",
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 @@ -1398,6 +1398,7 @@ const ja = {
navTypeSeparator: "区切り",
navTypeAction: "アクション",
navTypeComponent: "コンポーネント",
navTypeDoc: "ドキュメント",
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 @@ -1398,6 +1398,7 @@ const ko = {
navTypeSeparator: "구분선",
navTypeAction: "작업",
navTypeComponent: "컴포넌트",
navTypeDoc: "문서",
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 @@ -1397,6 +1397,7 @@ const pt = {
navTypeSeparator: "Separador",
navTypeAction: "Ação",
navTypeComponent: "Componente",
navTypeDoc: "Documento",
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 @@ -1408,6 +1408,7 @@ const ru = {
navTypeSeparator: "Разделитель",
navTypeAction: "Действие",
navTypeComponent: "Компонент",
navTypeDoc: "Документ",
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 @@ -1463,6 +1463,7 @@ const zh = {
navTypeSeparator: '分隔线',
navTypeAction: '操作',
navTypeComponent: '组件',
navTypeDoc: '文档',
navEditIcon: '编辑图标',
navToggleVisible: '切换可见性',
navHidden: '已隐藏',
Expand Down
13 changes: 12 additions & 1 deletion packages/plugin-designer/src/NavigationDesigner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import React, { useState, useCallback, useRef } from 'react';
import type { NavigationItem, NavigationItemType } from '@object-ui/types';
import {
BookOpen,
ChevronDown,
ChevronRight,
ChevronUp,
Expand Down Expand Up @@ -79,7 +80,16 @@ function createId(prefix: string): string {
return `${prefix}_${Date.now()}_${ndCounter}`;
}

const NAV_TYPE_META: Record<NavigationItemType, { labelKey: string; color: string; Icon: React.FC<{ className?: string }> }> = {
// Keyed by the spec-derived union, so a nav type the spec adds stops this file
// compiling until it has an entry -- keep it a `Record`, never `Partial` or
// `Record<string, ...>`.
//
// `| 'doc'` is the published pin lagging the spec: objectstack#19789 added the
// `doc` nav item, and the pinned `@objectstack/spec` predates it, so without
// the extra key a `doc:` entry is an excess property against the pin while its
// absence fails the compile against objectstack `main` (Spec Main Shape Gate).
// Drop `| 'doc'` at the pin bump that ships `doc`; the entry itself stays.
const NAV_TYPE_META: Record<NavigationItemType | 'doc', { labelKey: string; color: string; Icon: React.FC<{ className?: string }> }> = {
object: { labelKey: 'appDesigner.navTypeObject', color: 'bg-green-100 text-green-700', Icon: Database },
dashboard: { labelKey: 'appDesigner.navTypeDashboard', color: 'bg-amber-100 text-amber-700', Icon: LayoutDashboard },
page: { labelKey: 'appDesigner.navTypePage', color: 'bg-teal-100 text-teal-700', Icon: FileText },
Expand All @@ -89,6 +99,7 @@ const NAV_TYPE_META: Record<NavigationItemType, { labelKey: string; color: strin
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 },
doc: { labelKey: 'appDesigner.navTypeDoc', color: 'bg-blue-100 text-blue-700', Icon: BookOpen },
};

const QUICK_ADD_TYPES: Array<{ type: NavigationItemType; labelKey: string }> = [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* 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#10287 — every navigation item type the spec declares gets a row
* the Navigation Designer can draw.
*
* `NAV_TYPE_META` is a `Record` keyed by the spec-derived `NavigationItemType`,
* so the compiler already refuses a missing entry. This pin is the runtime
* half, and it reads the vocabulary from the INSTALLED `@objectstack/spec`'s
* `NavigationItemSchema` discriminants rather than from a list written here,
* so it follows whatever spec it is run against:
*
* - against the pinned release it covers that release's members;
* - against a spec built from objectstack `main` (the Spec Main Shape Gate's
* injected install) it also covers members the pin does not have yet —
* objectstack#19789's `doc` was the one that went unhandled.
*
* The map may carry MORE keys than the spec it is run against (`doc` ahead of
* the pin bump), so this asserts spec ⊆ map, never equality.
*
* A missing entry fails as a render error: every row reads `meta.Icon` from
* the map. An entry whose label key has no English fallback fails the
* raw-key assertion.
*/

import { describe, it, expect } from 'vitest';
import React from 'react';
import { render, screen } from '@testing-library/react';
import { NavigationItemSchema } from '@objectstack/spec/ui';
import type { NavigationItem } from '@object-ui/types';
import { NavigationDesigner } from '../NavigationDesigner';

/**
* Walk `.unwrap()` (the spec wraps its schemas lazily) until the node carries
* `key`. Bounded, and answers `undefined` rather than looping on a shape it
* does not recognise.
*/
function unwrapUntil(node: unknown, key: string): Record<string, unknown> | undefined {
let current = node as Record<string, unknown> | undefined;
for (let depth = 0; depth < 8 && current; depth += 1) {
if (key in current) return current;
const unwrap = current.unwrap;
if (typeof unwrap !== 'function') return undefined;
current = unwrap.call(current) as Record<string, unknown> | undefined;
}
return current && key in current ? current : undefined;
}

/** The discriminant values of the spec's nav-item union. Throws when unreadable. */
function specNavItemTypes(): string[] {
const union = unwrapUntil(NavigationItemSchema, 'options');
const options = union?.options;
if (!Array.isArray(options) || options.length === 0) {
throw new Error('could not read NavigationItemSchema options from @objectstack/spec');
}
return options.flatMap((option, index) => {
const shape = unwrapUntil(option, 'shape')?.shape as Record<string, unknown> | undefined;
const literal = shape?.type as { values?: unknown } | undefined;
if (!(literal?.values instanceof Set) || literal.values.size === 0) {
throw new Error(`could not read the \`type\` literal of NavigationItemSchema option ${index}`);
}
return [...literal.values].map(String);
});
}

const SPEC_NAV_ITEM_TYPES = specNavItemTypes();

describe('NavigationDesigner — a row for every spec navigation item type (#10287)', () => {
it('reads a non-empty vocabulary from the installed spec', () => {
// Non-vacuity: an empty or mis-read list would make every case below pass.
expect(SPEC_NAV_ITEM_TYPES).toContain('object');
expect(SPEC_NAV_ITEM_TYPES).toContain('group');
expect(new Set(SPEC_NAV_ITEM_TYPES).size).toBe(SPEC_NAV_ITEM_TYPES.length);
});

it.each(SPEC_NAV_ITEM_TYPES)('draws the `%s` type in the tree and the live preview', (type) => {
// Cast at the fixture boundary only: the type comes from the installed
// spec at runtime, which can name a member the pinned types do not have.
const item = { id: `probe_${type}`, type, label: 'Probe item' } as unknown as NavigationItem;

render(<NavigationDesigner items={[item]} onChange={() => {}} showPreview />);

const row = screen.getByTestId(`nav-designer-item-probe_${type}`);
// The type badge resolves to a label, not to its raw translation key.
expect(row.textContent ?? '').not.toMatch(/appDesigner\./);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export const DESIGNER_DEFAULT_TRANSLATIONS: Record<string, string> = {
'appDesigner.navTypeSeparator': 'Separator',
'appDesigner.navTypeAction': 'Action',
'appDesigner.navTypeComponent': 'Component',
'appDesigner.navTypeDoc': 'Doc',
'appDesigner.navEditIcon': 'Edit icon',
'appDesigner.navToggleVisible': 'Toggle visibility',
'appDesigner.navHidden': 'Hidden',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,19 @@
* `AriaPropsSchema`, the opposite of the contract: the member-level instance
* of #4631's "declared surfaces disagree".
*
* What the deletion changes at the type level, stated honestly: `BaseSchema`
* carries `[key: string]: any`, so an authored `aria:` on a dashboard literal
* still COMPILES after the removal — it falls to the index signature. A
* `@ts-expect-error` pin on an authored literal therefore cannot stick here
* (unlike `default-children-retired-contract-twins.test.ts`, whose interface
* has no index signature). The pinnable effect is that `aria` stops being a
* DECLARED member: the probe below extracts the interface's literal key set —
* the index signature is filtered out by `string extends K` — and asserts
* `aria` is out while its former neighbours stay in. Real enforcement because
* `packages/types/tsconfig.test.json` is chained from this package's
* `type-check` script (#3009).
* What the deletion changed at the type level, stated honestly: `BaseSchema`
* carries `[key: string]: any`, so after the removal an authored `aria:` on a
* dashboard literal still COMPILED — it fell to the index signature, and a
* `@ts-expect-error` pin on an authored literal could not stick.
*
* objectui#9736 closed that half. The interface now extends the spec's own
* `Dashboard` input type (`Omit< Dashboard, … >` over the exclusion list its
* mirror reads), so it inherits the spec's tombstone as a DECLARED member typed
* `undefined` — `aria` is back in the declared key set, but only as a refusal,
* and an authored value is a compile error: the same verdict the Zod twin gives
* at parse. The pins below assert exactly that, with the former neighbours as
* the control. Real enforcement because `packages/types/tsconfig.test.json` is
* chained from this package's `type-check` script (#3009).
*/

import { describe, it, expect } from 'vitest';
Expand All @@ -43,18 +45,24 @@ import { DashboardComponentSchema as DashboardComponentZodSchema } from '../zod/
type DeclaredKeys<T> = { [K in keyof T as string extends K ? never : K]: T[K] };
type Declared = keyof DeclaredKeys<DashboardComponentSchema>;

describe('the TS interface no longer declares `aria` (objectui#5830)', () => {
it('`aria` is not a declared member; the neighbours it stood beside still are', () => {
// Type-level pin, erased at runtime: if the member came back, the first
// annotation would collapse to `false` and this file would fail
// `type-check`. (Reverse-verified at the PR: with the member restored,
// `tsc -p tsconfig.test.json` goes red on exactly this line.)
const ariaNotDeclared: 'aria' extends Declared ? false : true = true;
describe('the TS interface declares `aria` only as the spec tombstone (objectui#5830, objectui#9736)', () => {
it('`aria` is declared, typed `undefined`; the neighbours it stood beside still are', () => {
// Type-level pins, erased at runtime. `aria` is a DECLARED key again — the
// spec projection carries the tombstone — and its type admits no value.
const ariaDeclared: 'aria' extends Declared ? true : false = true;
const ariaAdmitsNoValue: [DashboardComponentSchema['aria']] extends [undefined] ? true : false = true;
// Positive controls through the same extraction: a probe that saw no
// members at all would also report `aria` absent.
const widgetsDeclared: 'widgets' extends Declared ? true : false = true;
const dateRangeDeclared: 'dateRange' extends Declared ? true : false = true;
expect(ariaNotDeclared && widgetsDeclared && dateRangeDeclared).toBe(true);
expect(ariaDeclared && ariaAdmitsNoValue && widgetsDeclared && dateRangeDeclared).toBe(true);
});

it('an authored `aria` value is a compile error — no longer absorbed by the index signature', () => {
const legal: DashboardComponentSchema = { type: 'dashboard', widgets: [] };
// @ts-expect-error — `aria` is the spec's retirement tombstone (objectui#9736).
const authored: DashboardComponentSchema = { type: 'dashboard', widgets: [], aria: { ariaLabel: 'Ops' } };
expect([legal.type, authored.type]).toEqual(['dashboard', 'dashboard']);
});
});

Expand Down
Loading
Loading