diff --git a/.changeset/retire-spec-bridge-6366.md b/.changeset/retire-spec-bridge-6366.md new file mode 100644 index 0000000000..86bdf64d64 --- /dev/null +++ b/.changeset/retire-spec-bridge-6366.md @@ -0,0 +1,52 @@ +--- +'@object-ui/react': minor +--- + +refactor(spec-bridge): retire the spec-bridge — `SpecBridge`, `bridgeListView`, `bridgeFormView` (#6366) + +Removed under the 2026-08-27 maintainer ruling on #6366 (Option A — remove, and +for `SpecBridge` as a WHOLE, not just the form half), on the same criteria the +17.0.0 entry used to remove `bridgePage` / `bridgeDashboard`: no runtime +consumer, and a route that could not deliver a working result even in +principle. The stage-1 measurement (2026-08-26, PM-verified) the ruling rests +on: + +- **Zero consumers at every reachable endpoint** — no non-test caller in this + repo, none in the sibling `objectstack` repo (which does not depend on + `@object-ui/react` at all), and a GitHub-wide public code search returning + hits only inside this repository. External npm-private hosts are + unmeasurable from here; the ruling records fallback C (keep + document the + boundary) should evidence of one ever surface. +- **The bridged form route was structurally unable to work**: a bridged node + carries neither `objectName` nor `customFields`, so `ObjectForm` takes the + branch its own code labels "cannot proceed" and renders, in the registry + wrapper's words, "a field-less card in silence". The only working host + recipe (`{...node, objectName: '...'} as any`) existed solely inside the + list bridge's integration tests and was documented nowhere. + +This knowingly reverses the 17.0.0 changelog line "The `list` and `form` +bridges are unaffected and remain the live authoring path" — by maintainer +ruling, on the same measurement criteria that entry itself applied. + +#5898's restored-key work on the form-view bridge (the +`FormViewSpecConformance` suite and the spec keys it carried onto the bridged +node) is **superseded by this removal, not fixed** — the route it repaired is +gone. #6366's measured type-vocabulary asymmetry (a bridged field carrying +`text` where the normalizer produces `field:text`) is likewise mooted rather +than repaired. + +The suites pinning the removed route go with it — retirement of a route +nothing travels, not quarantine: the seven suites under +`react/src/spec-bridge/__tests__/`, and plugin-grid's +`specBridgeColumnSpelling` / `specBridgeExportFormats` render-integration +suites. `ObjectGrid`'s own column-spelling, export-gate and density behavior +keep their non-bridge pins (`columnDeclaredSpellingOnly`, `exportGate`, +`exportServer`, `rowHeightOffSpecBoundary`, and core's +`normalize-list-view` suite). + +BREAKING CHANGE: the public exports `SpecBridge`, `bridgeListView`, +`bridgeFormView` and the types `BridgeContext` / `BridgeFn` / `ObjectDefLite` +are removed from `@object-ui/react`. There is no replacement translation +layer — author `object-grid` / `object-form` nodes directly (the live path is +`app-shell`'s `ObjectView`, which builds them from the object's own metadata), +exactly as the 17.0.0 entry already directed for pages and dashboards. diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index b717a98cbf..7cf2514c4f 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -791,7 +791,7 @@ A smart form that auto-generates fields from an ObjectQL object. Supports simple `ObjectFormSchema` keys fall into three classes (#2545): -- **Spec-aligned** — same name and semantics as `@objectstack/spec` `FormViewSchema`: `title`, `description`, `layout`, `columns`, `sections`, `defaultTab`, `tabPosition`, `allowSkip`, `showStepIndicator`, `splitDirection`/`splitSize`/`splitResizable`, `drawerSide`/`drawerWidth`, `modalSize`, `subforms`, `submitBehavior` (plus `formType` ↔ spec `type`). Metadata using these keys round-trips through the SpecBridge without loss. +- **Spec-aligned** — same name and semantics as `@objectstack/spec` `FormViewSchema`: `title`, `description`, `layout`, `columns`, `sections`, `defaultTab`, `tabPosition`, `allowSkip`, `showStepIndicator`, `splitDirection`/`splitSize`/`splitResizable`, `drawerSide`/`drawerWidth`, `modalSize`, `subforms`, `submitBehavior` (plus `formType` ↔ spec `type`). - **ObjectUI extensions** — serializable extras with no spec backing yet: `showSubmit`/`submitText`, `showCancel`/`cancelText`, `showReset`, `nextText`/`prevText`, `successMessage`, `navigateOnSuccess`, `resetOnSuccess`, `modalCloseButton`, `className`, `initialValues`, `fields`, `customFields`. Sanctioned and documented here; candidates for upstreaming into the spec are tracked in #2545. - **Runtime-only** — non-serializable renderer concerns that never appear in view metadata: `mode`, `recordId`, `open`/`onOpenChange`, `readOnly`, and all callbacks (`onSuccess`, `onError`, `onCancel`, `onStepChange`, `submitHandler`). diff --git a/packages/plugin-grid/src/__tests__/gridNonAuthorKeys.test.tsx b/packages/plugin-grid/src/__tests__/gridNonAuthorKeys.test.tsx index 252d7d5811..3162c936bc 100644 --- a/packages/plugin-grid/src/__tests__/gridNonAuthorKeys.test.tsx +++ b/packages/plugin-grid/src/__tests__/gridNonAuthorKeys.test.tsx @@ -91,9 +91,11 @@ * `UserActionsConfigSchema` (`sort`, `search`, `filter`, `refresh`, * `rowHeight`, `addRecordForm`, `editInline`, `buttons`), which REJECTS * `edit` BY NAME. `ListViewSchema` accepts it, so it is spec-legal, and it - * is really written: `SpecBridge.transformListView` copies it onto the - * `object-grid` node `ObjectGrid` renders, and `app-shell`'s `ObjectView` - * builds one unconditionally. "Nobody authors it" was false. + * is really written: `app-shell`'s `ObjectView` builds one unconditionally. + * "Nobody authors it" was false. (A second producer, + * `SpecBridge.transformListView`, copied it onto the `object-grid` node + * until the whole spec-bridge was retired — objectui#6366, 2026-08-27 + * maintainer ruling: zero measured consumers on every reachable endpoint.) * - OBJECT-level `userActions` is the CRUD-PREDICATE block (`edit` / * `delete` / `create` carrying `visibleWhen` / `disabledWhen`, * objectui#2614) — the only shape `listViewPredicates` can read, since its @@ -127,7 +129,7 @@ import { ComponentRegistry, collectPredicateFieldRefs, listViewPredicates } from import { ComponentPropsMap, ListViewSchema, UserActionsConfigSchema } from '@objectstack/spec/ui'; import { manifestFromConfigs, validateTree } from '@object-ui/sdui-parser'; import { registerAllFields } from '@object-ui/fields'; -import { ActionProvider, SpecBridge } from '@object-ui/react'; +import { ActionProvider } from '@object-ui/react'; import { ObjectGrid } from '../ObjectGrid'; // Module scope, not a hook: this import IS the registration (AGENTS.md's @@ -511,27 +513,18 @@ describe('the view-level key is AUTHORED, not an unwritten surface (objectui#524 expect(unrecognizedKeys(result)).toContain('edit'); }); - it('the PRODUCER writes it onto the very `object-grid` node ObjectGrid renders', () => { - // The producer IS the evidence, same as the `NON_AUTHOR_KEYS` table above — - // except here it proves the opposite: the key is written, so "nobody - // authors it" was never available as a reason. - const node = new SpecBridge().transformListView({ - name: 'accounts', - columns: [{ field: 'name', label: 'Name' }], - userActions: VIEW_TOOLBAR_BLOCK, - } as never) as unknown as Record; - expect( - node.type, - 'the list bridge no longer emits `object-grid` — the collision comments name this producer.', - ).toBe('object-grid'); - expect( - node.userActions, - 'the list bridge stopped copying `userActions` onto the grid node. If that is the' - + ' producer-side fix (the spec-coordination card the 2026-08-20 ruling routed through' - + ' triage), the collision comments in `ObjectGrid.tsx` are due a re-read — do not just' - + ' delete this assertion.', - ).toEqual(VIEW_TOOLBAR_BLOCK); - }); + // A third `it` stood here: "the PRODUCER writes it onto the very + // `object-grid` node ObjectGrid renders", witnessed with + // `new SpecBridge().transformListView(...)`. Its own failure message said + // "do not just delete this assertion" — that instruction covered the + // producer-side fix routed through triage by the 2026-08-20 ruling, not what + // actually happened: the WHOLE spec-bridge (`SpecBridge`, `bridgeListView`, + // `bridgeFormView`) was retired by the 2026-08-27 maintainer ruling on + // objectui#6366 (zero measured consumers at every reachable endpoint), so + // the witness it rendered no longer exists. The key is still AUTHORED — the + // surviving producer is `app-shell/src/views/ObjectView.tsx`, which builds a + // view-level toolbar block unconditionally — and the two pins above still + // hold the "spec-legal on a view" half of the claim. }); describe('only the OBJECT block is interpretable by the predicate harvest (objectui#5240)', () => { diff --git a/packages/plugin-grid/src/__tests__/specBridgeColumnSpelling.test.tsx b/packages/plugin-grid/src/__tests__/specBridgeColumnSpelling.test.tsx deleted file mode 100644 index b545149ddf..0000000000 --- a/packages/plugin-grid/src/__tests__/specBridgeColumnSpelling.test.tsx +++ /dev/null @@ -1,132 +0,0 @@ -/** - * 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. - */ - -/** - * SpecBridge to ObjectGrid — a bridged view keeps its columns (objectui#5068). - * - * This is the half that could not have caught itself. `bridgeListView` takes a - * spec-canonical `ListView` whose columns are ALREADY spelled `field`/`label`, - * and used to down-translate every one of them to `accessorKey`/`header` - * before emitting the `object-grid` node — which `ObjectGrid` then translated - * back through the tolerance branch this card retires. Producer and consumer - * were the two halves of one round trip, and deleting the consumer half alone - * blanked every bridged grid in TOTAL SILENCE: measured on this card, - * `headers ["#","Name"]` collapsed to `["#"]` with no error, while all 715 - * tests in this package stayed green — `specBridgeExportFormats.test.tsx` - * renders bridge output through `ObjectGrid` and watched its grid lose every - * column, because it asserts the export menu and never the columns. - * - * So the round trip is gone on BOTH sides in one PR (the shape objectui#3951 - * used: PR4909 migrated its consumer in `packages/fields` and its producer - * `deriveMasterDetail` in `packages/plugin-form` together), and this file pins - * the seam that had no pin. It lives in `plugin-grid` for the same reason the - * export-formats test does: `@object-ui/plugin-grid` depends on - * `@object-ui/react`, so it can see both `SpecBridge` and `ObjectGrid`; react - * cannot import the grid without inverting the dependency graph. - */ -import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import '@testing-library/jest-dom'; -import React from 'react'; - -import { ObjectGrid } from '../ObjectGrid'; -import { registerAllFields } from '@object-ui/fields'; -import { ActionProvider, SpecBridge } from '@object-ui/react'; - -registerAllFields(); - -const ROWS = [ - { id: '1', name: 'Ada', email: 'ada@example.com' }, - { id: '2', name: 'Grace', email: 'grace@example.com' }, -]; - -function makeDataSource() { - return { - find: vi.fn(async () => ({ data: ROWS, total: ROWS.length, hasMore: false, pageSize: 50 })), - getObjectSchema: vi.fn(async (name: string) => ({ - name, - fields: { - id: { type: 'text' }, - name: { type: 'text', label: 'Full name' }, - email: { type: 'text', label: 'Email address' }, - }, - })), - } as any; -} - -/** Author a spec ListView, bridge it, render the node the bridge produced. */ -function bridgeAndRender(columns: unknown[]) { - const node = new SpecBridge().transformListView({ - name: 'contacts_all', - label: 'All Contacts', - columns, - }); - - render( - - - , - ); - - return node as any; -} - -function headers(): string[] { - return screen.getAllByRole('columnheader').map((h) => (h.textContent ?? '').trim()); -} - -describe('SpecBridge to ObjectGrid — bridged columns survive the seam (#5068)', () => { - it('renders every column of a bridged view', async () => { - bridgeAndRender([ - { field: 'name', label: 'Name' }, - { field: 'email', label: 'Email' }, - ]); - - expect(await screen.findByText('Ada')).toBeInTheDocument(); - expect(headers()).toEqual(['#', 'Name', 'Email']); - expect(screen.getByText('grace@example.com')).toBeInTheDocument(); - }); - - it('emits the declared spelling — the node carries no adapter key', () => { - // The producer pin. `accessorKey` is the data-table adapter's key, applied - // by `ObjectGrid` on the way OUT; a producer of `object-grid` metadata must - // not speak it on the way IN. Asserted on the node itself so a future - // regression fails HERE, loudly, instead of one layer down as blank cells. - const node = bridgeAndRender([{ field: 'name', label: 'Name' }]); - - expect(node.columns).toEqual([{ field: 'name', label: 'Name' }]); - expect(Object.keys(node.columns[0])).not.toContain('accessorKey'); - expect(Object.keys(node.columns[0])).not.toContain('header'); - }); - - it('lets the object schema label a bridged column the view left bare', async () => { - // A consequence of speaking the declared spelling, not an addition: a bare - // `{ field }` column reaches ObjectGrid's ListColumn arm, whose header - // chain is `col.label` → the OBJECT FIELD's label → the prettified machine - // name. The down-translation used to pre-empt that chain by writing - // `header: col.label ?? col.field`, so a bridged view always rendered the - // raw machine name where a directly authored object-grid rendered the - // field's real (and localizable) label. - bridgeAndRender([{ field: 'email' }]); - - expect(await screen.findByText('ada@example.com')).toBeInTheDocument(); - expect(headers()).toEqual(['#', 'Email address']); - }); - - it('carries a bare string column through as a declared field column', async () => { - // The spec's shorthand: `columns: ['name']`. Down-translated it became - // `{ accessorKey: 'name', header: 'name' }` — a synthesized label nobody - // authored. It is now the canonical `{ field: 'name' }`, and the header - // comes from the object schema like any other bare column. - const node = bridgeAndRender(['name']); - - expect(node.columns).toEqual([{ field: 'name' }]); - expect(await screen.findByText('Ada')).toBeInTheDocument(); - expect(headers()).toEqual(['#', 'Full name']); - }); -}); diff --git a/packages/plugin-grid/src/__tests__/specBridgeExportFormats.test.tsx b/packages/plugin-grid/src/__tests__/specBridgeExportFormats.test.tsx deleted file mode 100644 index db25043aee..0000000000 --- a/packages/plugin-grid/src/__tests__/specBridgeExportFormats.test.tsx +++ /dev/null @@ -1,153 +0,0 @@ -/** - * 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. - */ - -/** - * SpecBridge to ObjectGrid — a spec-authored view's declared export formats - * reach the menu (objectui#4585). - * - * This is the card's repro, end to end and in one process: build a spec - * `ListView` that declares `exportOptions: ['csv', 'xlsx']`, route it through - * `SpecBridge`, render the resulting node. Before the fix the bridge copied the - * bare array to the node verbatim, ObjectGrid read `exportOptions.formats` off - * an array (`undefined`) and fell back to its `['csv', 'json']` default — so the - * menu offered CSV and JSON: the declared xlsx never appeared and an undeclared - * json did, silently. The bridge now applies the spec's own parse-time lift - * (objectstack#8010), so the declared set is what the renderer sees. - * - * The two levels are pinned separately on purpose: the bridge's output shape in - * `@object-ui/react`'s `ListViewExportOptionsLift.test.ts`, and the rendered - * consequence here. The old bridge pin was green precisely because it asserted - * a shape it never rendered — this file is the half that could not have been. - * - * It lives in plugin-grid because the dependency direction decides: - * `@object-ui/plugin-grid` depends on `@object-ui/react`, so it can see both - * `SpecBridge` and `ObjectGrid`; react cannot import the grid without inverting - * the graph. `ObjectGrid` itself is untouched read-only context here — the - * server-stream gate below is landed behavior (objectui#2942 / #4535), reused, - * not modified. - */ -import { describe, it, expect, vi, beforeAll } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; -import '@testing-library/jest-dom'; -import React from 'react'; - -import { ObjectGrid } from '../ObjectGrid'; -import { registerAllFields } from '@object-ui/fields'; -import { ActionProvider, SpecBridge } from '@object-ui/react'; - -registerAllFields(); - -beforeAll(() => { - // jsdom has no object-URL plumbing; the download path calls these. - if (!URL.createObjectURL) (URL as any).createObjectURL = () => 'blob:export'; - if (!URL.revokeObjectURL) (URL as any).revokeObjectURL = () => {}; -}); - -/** - * `withStream: true` reproduces a server-backed grid — `exportDownload` present - * — which is the only configuration in which xlsx is deliverable. Without it - * the client fallback path supports csv and json alone, and a declared xlsx is - * dropped from the menu by ObjectGrid's own gate. - */ -function makeDataSource(withStream: boolean) { - const ds: Record = { - find: vi.fn(async () => ({ data: [], total: 0, hasMore: false, pageSize: 50 })), - getObjectSchema: async (name: string) => ({ - name, - fields: { id: { type: 'text' }, name: { type: 'text' } }, - }), - }; - if (withStream) { - ds.exportDownload = vi.fn().mockResolvedValue(new Blob(['ID,Name\n'], { type: 'text/csv' })); - } - return ds as any; -} - -/** Author a spec ListView, bridge it, render the node the bridge produced. */ -function renderBridgedView(exportOptions: unknown, withStream: boolean) { - const node = new SpecBridge().transformListView({ - name: 'tasks_all', - label: 'All Tasks', - columns: [{ field: 'name', label: 'Name' }], - exportOptions, - }); - - // `objectName` is the host's binding, not the view's — the bridge maps the - // ListView's own keys. Everything else is the bridge's output, untouched. - return render( - - - , - ); -} - -/** Opens the export popover; the trigger is the only `/^export$/i` button. */ -async function openExportMenu() { - fireEvent.click(await screen.findByRole('button', { name: /^export$/i })); -} - -describe('SpecBridge to ObjectGrid — declared export formats (#4585)', () => { - it('offers the formats a legacy bare array declared, not the renderer default', async () => { - // The card's repro. Server stream available, so the declared xlsx is - // deliverable and must appear; json was never declared and must not. - renderBridgedView(['csv', 'xlsx'], true); - await openExportMenu(); - - expect(await screen.findByRole('button', { name: /export as csv/i })).toBeInTheDocument(); - expect(await screen.findByRole('button', { name: /export as xlsx/i })).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /export as json/i })).not.toBeInTheDocument(); - }); - - it('still lets the server-stream gate drop a declared xlsx when there is no stream', async () => { - // Same declaration, client fallback only: the gate — landed behavior, not - // this change — keeps csv and drops xlsx. The lift decides what is - // DECLARED; the gate decides what is DELIVERABLE. Both halves must hold, or - // "declared formats now reach the menu" would just mean "the menu stopped - // filtering". - renderBridgedView(['csv', 'xlsx'], false); - await openExportMenu(); - - expect(await screen.findByRole('button', { name: /export as csv/i })).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /export as xlsx/i })).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /export as json/i })).not.toBeInTheDocument(); - }); - - it('hides the export button entirely when the view declares an empty array', async () => { - // `[]` lifts to `{ formats: [] }` verbatim (the spec's transform wraps, it - // does not default), and ObjectGrid reads that literally: no format is - // offered, so `exportableFormats.length > 0` fails and the toolbar button - // is gone. Before the fix the bare `[]` was truthy but unreadable, so the - // button showed and offered the `['csv', 'json']` default — an export menu - // built entirely out of formats the view never declared. - renderBridgedView([], true); - - expect(await screen.findByText('All Tasks')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /^export$/i })).not.toBeInTheDocument(); - }); - - it('leaves a view with no exportOptions without an export button', async () => { - // Unchanged by this card, pinned so the lift cannot start inventing a - // declaration where the view made none. - renderBridgedView(undefined, true); - - expect(await screen.findByText('All Tasks')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /^export$/i })).not.toBeInTheDocument(); - }); - - it('renders the object form the same way — one shape reaches the menu', async () => { - // The object spelling was already correct and passes through by reference; - // this pins that both spellings land on the same rendered menu, which is - // what "the bridge emits one shape" has to mean downstream. - renderBridgedView({ formats: ['csv', 'xlsx'] }, true); - await openExportMenu(); - - expect(await screen.findByRole('button', { name: /export as csv/i })).toBeInTheDocument(); - expect(await screen.findByRole('button', { name: /export as xlsx/i })).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /export as json/i })).not.toBeInTheDocument(); - }); -}); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 477f8e1275..717f85b44f 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -11,7 +11,6 @@ export * from './schema-input.js'; export * from './hooks/index.js'; // will be empty for now export * from './context/index.js'; // will be empty for now export * from './LazyPluginLoader.js'; -export * from './spec-bridge/index.js'; export * from './data-invalidation.js'; // PageComponentSchema.dataSource — mapping the spec's per-element data binding // onto the schema keys each object-bound block reads (objectstack#6953). diff --git a/packages/react/src/spec-bridge/SpecBridge.ts b/packages/react/src/spec-bridge/SpecBridge.ts deleted file mode 100644 index 14910307bf..0000000000 --- a/packages/react/src/spec-bridge/SpecBridge.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * 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. - */ - -import type { BaseSchema } from '@object-ui/types'; -import type { BridgeContext, BridgeFn } from './types.js'; -import { bridgeListView } from './bridges/list-view.js'; -import { bridgeFormView } from './bridges/form-view.js'; - -export class SpecBridge { - private bridges = new Map(); - private context: BridgeContext; - - constructor(context: BridgeContext = {}) { - this.context = context; - this.register('list', bridgeListView); - this.register('form', bridgeFormView); - } - - register(specType: string, bridge: BridgeFn): void { - this.bridges.set(specType, bridge); - } - - /** - * Transform a spec schema into a schema node tree. - * - * Returns `BaseSchema` rather than `SchemaNode` — see {@link BridgeFn} for - * the ruling (objectui#4580 Q4-B) and the measurement behind it. A registered - * bridge always produces an object; the union only made callers narrow. - */ - transform(specType: string, spec: any): BaseSchema { - const bridge = this.bridges.get(specType); - if (!bridge) { - throw new Error(`No bridge registered for spec type: ${specType}`); - } - return bridge(spec, this.context); - } - - /** Transform a ListView spec */ - transformListView(spec: any): BaseSchema { - return this.transform('list', spec); - } - - /** Transform a FormView spec */ - transformFormView(spec: any): BaseSchema { - return this.transform('form', spec); - } - - updateContext(ctx: Partial): void { - this.context = { ...this.context, ...ctx }; - } -} diff --git a/packages/react/src/spec-bridge/__tests__/FormViewRetiredKeys.test.ts b/packages/react/src/spec-bridge/__tests__/FormViewRetiredKeys.test.ts deleted file mode 100644 index 7e0a5ae2e3..0000000000 --- a/packages/react/src/spec-bridge/__tests__/FormViewRetiredKeys.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * 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. - */ - -/** - * Form-view bridge — retired keys stay unread (objectui#3901 / #3974). - * - * The bridge used to end with two copies that could never fire: - * - * if (spec.defaultSort) node.defaultSort = spec.defaultSort; // :188 - * if (spec.aria) node.aria = spec.aria; // :193 - * - * Spec 17 retired both keys on the FORM carrier (`retiredKey()` tombstones in - * `packages/spec/src/ui/view.zod.ts`), so a FormView that passed validation can - * never carry either. Under the maintainer's 2026-08-11 enforce-or-remove - * ruling the reads were removed rather than documented. - * - * This file pins the removal from both directions, because a dormant read is - * only safely deletable when BOTH ends are dead: - * - * 1. **Producer** — the contract still rejects the keys. This is what makes - * the deletion "not a capability removal": the capability was already gone - * at the spec. If a future spec un-retires either key, this half turns red - * and tells the next agent the ruling needs revisiting BEFORE they re-add a - * read to make something work. - * 2. **Bridge output** — even fed a RAW, never-validated object carrying both - * keys (the only class of input that could still present them: a host - * calling the exported `SpecBridge` with a stored pre-17 document), the - * node gains neither key. Feeding raw input on purpose is the point — a - * fixture that could not carry the key would pass for the wrong reason. - * - * Key-presence assertions (`'aria' in node`), not value assertions: what is - * guarded is that the bridge does not PRODUCE these node slots at all. - */ -import { describe, it, expect } from 'vitest'; -import { FormViewSchema } from '@objectstack/spec/ui'; -import { SpecBridge } from '../SpecBridge'; -import { bridgeFormView } from '../bridges/form-view'; - -/** A FormView that is spec-valid except for the deliberately-planted key. */ -const VALID_BASE = { - type: 'simple', - sections: [{ label: 'Basic', fields: [{ field: 'name' }] }], -}; - -describe('form-view bridge — retired spec keys (#3901 / #3974)', () => { - describe('producer: the contract still refuses both keys', () => { - it('parses the base form view (the control — the fixture is otherwise valid)', () => { - expect(FormViewSchema.safeParse(VALID_BASE).success).toBe(true); - }); - - it('rejects `aria` on a form view by name', () => { - const parsed = FormViewSchema.safeParse({ - ...VALID_BASE, - aria: { ariaLabel: 'Create Account Form', role: 'form' }, - }); - - expect(parsed.success).toBe(false); - // `retiredKey()` is `z.never().optional()`, so the key is REJECTED at the - // parse rather than stripped — the issue lands on the key's own path. - expect(parsed.error?.issues.some((i) => i.path[0] === 'aria')).toBe(true); - }); - - it('rejects `defaultSort` on a form view by name', () => { - const parsed = FormViewSchema.safeParse({ - ...VALID_BASE, - defaultSort: [{ field: 'name', order: 'asc' }], - }); - - expect(parsed.success).toBe(false); - expect(parsed.error?.issues.some((i) => i.path[0] === 'defaultSort')).toBe(true); - }); - }); - - describe('bridge: raw input carrying the retired keys produces neither slot', () => { - const RAW_WITH_RETIRED_KEYS = { - ...VALID_BASE, - aria: { ariaLabel: 'Create Account Form', role: 'form' }, - defaultSort: [{ field: 'name', order: 'asc' }], - }; - - it('does not carry `aria` or `defaultSort` onto the node', () => { - const node = new SpecBridge().transformFormView(RAW_WITH_RETIRED_KEYS); - - expect('aria' in node).toBe(false); - expect('defaultSort' in node).toBe(false); - expect(Object.keys(node)).not.toContain('aria'); - expect(Object.keys(node)).not.toContain('defaultSort'); - }); - - it('still carries the live keys beside them (the read removal was surgical)', () => { - const node = new SpecBridge().transformFormView({ - ...RAW_WITH_RETIRED_KEYS, - title: 'Edit Opportunity', - sharing: { visibility: 'team' }, - submitBehavior: { kind: 'redirect', url: '/done' }, - }); - - // `sharing` and `submitBehavior` sat on the same trailing block as the - // two deleted copies — if a future edit takes the block out wholesale, - // this is what reports it. - expect(node.title).toBe('Edit Opportunity'); - expect(node.sharing).toEqual({ visibility: 'team' }); - expect(node.submitBehavior).toEqual({ kind: 'redirect', url: '/done' }); - expect((node.sections as unknown[]).length).toBe(1); - }); - - it('holds when the bridge function is called directly, not just via SpecBridge', () => { - const node = bridgeFormView(RAW_WITH_RETIRED_KEYS, {}); - - expect('aria' in node).toBe(false); - expect('defaultSort' in node).toBe(false); - }); - }); -}); diff --git a/packages/react/src/spec-bridge/__tests__/FormViewSpecConformance.test.ts b/packages/react/src/spec-bridge/__tests__/FormViewSpecConformance.test.ts deleted file mode 100644 index 548be6cd50..0000000000 --- a/packages/react/src/spec-bridge/__tests__/FormViewSpecConformance.test.ts +++ /dev/null @@ -1,467 +0,0 @@ -/** - * 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. - */ - -/** - * FormView spec conformance round-trip (#2545), made structural (objectui#5898). - * - * The bridge must never silently drop `@objectstack/spec` FormViewSchema - * configuration: every serializable spec key is either mapped onto the - * `object-form` node or explicitly listed with a reason for being ignored. - * - * ## Why this file had to change, and what changed - * - * The promise above shipped with a completeness loop that read - * `Object.keys(FULL_SPEC_FORM_VIEW)` — the FIXTURE, hand-listed from memory. - * A key absent from the fixture is a key the loop never asks about, so the - * check could only ever confirm what its author already remembered. Measured on - * spec 17.2.0: **18 contract keys were neither mapped nor explained** while this - * file was green (2 on the form, 2 on the section, 14 on the field). It could - * have caught every one of them and did not — not because an assertion was - * weak, but because the key set was the wrong SOURCE. - * - * The loop now derives its key set from the contract's own shape, at all three - * levels, and every key must be claimed by exactly one registry: - * - * - `MAPPED_*` — one BEHAVIORAL row per key: an assertion that the authored - * value arrives at its documented destination on the node. Deleting the - * copy in `form-view.ts` fails the row. These are mutation-tested rows, not - * a mirror list; a mirror list is what this file used to be. - * - `IGNORED_*` — a deliberate, documented refusal. "Not silently" is what - * the promise asks for, and an explained refusal satisfies it; an invented - * destination would not. - * - * Both directions are then asserted: a key the spec ADDS fails as unclaimed - * (decide: map it or explain it), and a key the spec RETIRES fails as stale. - * - * ## The fixture is spec-valid, and that is asserted first - * - * `FULL_SPEC_FORM_VIEW` carries every live contract key at every level and is - * run through `FormViewSchema.safeParse` as the opening control. Without that - * control a row could pass against a value the contract would refuse, which - * proves nothing about authored metadata. It is fed to the bridge RAW (never - * parsed) on purpose — that is the input class this bridge exists for, and the - * one that still presents the deprecated spellings the contract folds away. - * - * `defaultSort` and `aria` are excluded by construction: spec 17 retired both on - * the FORM carrier (`retiredKey()` tombstones), and `liveSpecKeys` filters them - * out — pinned below so the filter cannot quietly start dropping live keys too. - * Their absence from the node is pinned by `FormViewRetiredKeys.test.ts`; the - * widened arms `columns` / `dependsOn` / `visibleWhen` are pinned end-to-end by - * `FormViewWidenedArms.test.ts`. Neither is repeated here. - */ -import { describe, it, expect } from 'vitest'; -// Enumerating the contract's key set is the ONE sanctioned reason to import the -// spec's form-field schema in this repo (the same exemption -// `plugin-form/src/sectionFields.spec-parity.test.ts` takes, for the same -// reason): this file exists to ask the contract what its keys are. It is read -// as a KEY SET only — never as a form field's shape, which is the layer -// violation the rule guards (objectui#3090). -// eslint-disable-next-line no-restricted-imports -import { FormFieldSchema, FormSectionSchema, FormViewSchema } from '@objectstack/spec/ui'; -import { SpecBridge } from '../SpecBridge'; - -/** - * The authoring (`z.input`) shape of a spec schema. - * - * `FormSectionSchema` and `FormFieldSchema` close with `.transform()`, so they - * are `ZodPipe`s whose `.in` carries the object; `FormViewSchema` closes with - * `.superRefine()` and stays a `ZodObject`. Reading `.shape` alone answers - * `undefined` for two of the three — silently, which would make every - * completeness assertion below vacuous. - */ -function authoringShape(schema: unknown): Record { - const s = schema as any; - const shape = s.in?.shape ?? s.shape; - if (!shape) throw new Error('spec schema exposed no authoring shape'); - return shape; -} - -/** `retiredKey()` is `z.never().optional()` — a tombstone, not a live key. */ -function isRetired(entry: any): boolean { - const def = entry?._def ?? entry?.def; - if (def?.type !== 'optional') return false; - const inner = def.innerType?._def ?? def.innerType?.def; - return inner?.type === 'never'; -} - -/** Every key the contract still accepts, tombstones removed. */ -function liveSpecKeys(schema: unknown): string[] { - const shape = authoringShape(schema); - return Object.keys(shape).filter((key) => !isRetired(shape[key])).sort(); -} - -/** The tombstoned keys, so the filter above can be pinned in both directions. */ -function retiredSpecKeys(schema: unknown): string[] { - const shape = authoringShape(schema); - return Object.keys(shape).filter((key) => isRetired(shape[key])).sort(); -} - -/** - * A form view carrying every LIVE contract key at every level. - * - * `type: 'split'` is load-bearing: `section.pane` is split-only vocabulary and - * the contract rejects it on any other form type, so a wizard fixture could not - * carry the key at all — and a fixture that cannot carry a key passes for the - * wrong reason. Both visibility spellings are authored side by side on the - * section and the field; the canonical one wins, which is what the contract's - * own `normalizeVisibleWhen` does with the same input. - */ -const FULL_SPEC_FORM_VIEW = { - type: 'split', - layout: 'grid', - columns: 2, - title: 'Edit Opportunity', - description: 'All the fields', - defaultTab: 'details', - tabPosition: 'left', - allowSkip: true, - showStepIndicator: false, - splitDirection: 'horizontal', - splitSize: 40, - splitResizable: true, - drawerSide: 'right', - drawerWidth: '480px', - modalSize: 'lg', - data: { provider: 'object', object: 'opportunity' }, - sections: [ - { - name: 'basic_info', - label: 'Basic Info', - description: 'Who and what', - collapsible: true, - collapsed: false, - columns: 2, - visibleWhen: 'record.stage != "closed"', - visibleOn: 'record.legacy == true', - pane: 'primary', - fields: [ - { - field: 'name', - type: 'text', - label: 'Name', - required: true, - placeholder: 'Acme deal', - helpText: 'Deal name', - readonly: false, - hidden: false, - colSpan: 2, - span: 'full', - widget: 'input', - options: [{ label: 'Tech', value: 'tech' }], - reference: 'account', - // A BARE parent-field name: the array arm is the one arm - // `FormFieldSchema` rejects (objectui#5652). - dependsOn: 'account', - visibleWhen: 'record.active == true', - visibleOn: 'record.legacy == true', - // The constraint / presentation / composite block objectui#5898 - // restored. Authored together on one field because that is how the - // completeness loop below can ask about all of them at once. - maxLength: 120, - minLength: 2, - min: 0, - max: 100, - precision: 10, - scale: 2, - multiple: false, - immutable: true, - language: 'sql', - disclosure: 'popover', - keyField: { field: 'name', label: 'Name', regex: '^[a-z_]+$', immutable: true }, - fields: [{ field: 'inner', type: 'text' }], - publicPicker: { displayFields: ['name'], maxResults: 10 }, - }, - 'amount', - ], - }, - ], - groups: [{ label: 'Legacy Group', fields: [{ field: 'name' }] }], - subforms: [{ childObject: 'opportunity_line_item', amountField: 'amount' }], - sharing: { enabled: true, publicLink: 'opp-form', allowAnonymous: false }, - submitBehavior: { kind: 'redirect', url: '/done' }, - buttons: { - submit: { show: true, label: 'Save' }, - cancel: { show: false }, - reset: { show: true, label: 'Reset' }, - }, - defaults: { stage: 'prospecting' }, -}; - -/** - * A form authored with ONLY the deprecated visibility spelling. - * - * The full fixture above authors both spellings, so the canonical one wins - * there and `visibleOn`'s own value never reaches the node — a row asserted - * against that fixture would pass whether or not the fallback exists. This is - * the input the fallback is FOR: metadata that never went through the parser - * (the parser folds the key away), which is also the class the bridge still - * reads `groups` for. - */ -const DEPRECATED_ONLY_FORM_VIEW = { - type: 'simple', - sections: [ - { - label: 'Legacy', - visibleOn: 'record.stage != "closed"', - fields: [{ field: 'name', visibleOn: 'record.active == true' }], - }, - ], -}; - -const node = new SpecBridge().transformFormView(FULL_SPEC_FORM_VIEW); -const section = (node.sections as any[])[0]; -const field = section.fields[0]; - -const legacyNode = new SpecBridge().transformFormView(DEPRECATED_ONLY_FORM_VIEW); -const legacySection = (legacyNode.sections as any[])[0]; -const legacyField = legacySection.fields[0]; - -/** Spec FormViewSchema keys → where the authored value lands on the node. */ -const MAPPED_VIEW_KEYS: Record void> = { - type: () => expect(node.formType).toBe('split'), // ObjectUI rename, not verbatim - layout: () => expect(node.layout).toBe('grid'), - columns: () => expect(node.columns).toBe(2), - title: () => expect(node.title).toBe('Edit Opportunity'), - description: () => expect(node.description).toBe('All the fields'), - defaultTab: () => expect(node.defaultTab).toBe('details'), - tabPosition: () => expect(node.tabPosition).toBe('left'), - allowSkip: () => expect(node.allowSkip).toBe(true), - showStepIndicator: () => expect(node.showStepIndicator).toBe(false), - splitDirection: () => expect(node.splitDirection).toBe('horizontal'), - splitSize: () => expect(node.splitSize).toBe(40), - splitResizable: () => expect(node.splitResizable).toBe(true), - drawerSide: () => expect(node.drawerSide).toBe('right'), - drawerWidth: () => expect(node.drawerWidth).toBe('480px'), - modalSize: () => expect(node.modalSize).toBe('lg'), - data: () => expect(node.data).toEqual({ provider: 'object', object: 'opportunity' }), - sections: () => expect(node.sections).toHaveLength(1), - subforms: () => expect(node.subforms).toEqual(FULL_SPEC_FORM_VIEW.subforms), - sharing: () => expect(node.sharing).toEqual(FULL_SPEC_FORM_VIEW.sharing), - submitBehavior: () => expect(node.submitBehavior).toEqual({ kind: 'redirect', url: '/done' }), - // objectui#5898 — `ObjectFormSchema` declares both slots and `ObjectForm` - // folds them at render (`buttons.*` onto the flat button props, `defaults` - // into create-mode initial values). The spec's own descriptions name that - // renderer as the consumer, which is why these are mapped and not exempted. - buttons: () => expect(node.buttons).toEqual(FULL_SPEC_FORM_VIEW.buttons), - defaults: () => expect(node.defaults).toEqual({ stage: 'prospecting' }), -}; - -const IGNORED_VIEW_KEYS: Record = { - groups: - 'Legacy alias of `sections` (the contract folds it at parse, #6926). Normalized into ' + - 'node.sections here for the never-parsed input class, and deliberately NOT re-emitted as a ' + - 'second key the renderer would have to learn — `ObjectForm` reads `sections` only.', -}; - -/** Spec FormSectionSchema keys → where the authored value lands on the section. */ -const MAPPED_SECTION_KEYS: Record void> = { - name: () => expect(section.name).toBe('basic_info'), - label: () => expect(section.label).toBe('Basic Info'), - description: () => expect(section.description).toBe('Who and what'), - collapsible: () => expect(section.collapsible).toBe(true), - collapsed: () => expect(section.collapsed).toBe(false), - columns: () => expect(section.columns).toBe(2), - visibleWhen: () => expect(section.visibleWhen).toBe('record.stage != "closed"'), - // objectui#5898 — asserted on the deprecated-only fixture, because the full - // fixture authors the canonical spelling beside it and that one wins. - visibleOn: () => expect(legacySection.visibleWhen).toBe('record.stage != "closed"'), - // objectui#5898 — `ObjectFormSection.pane`, read by `SplitForm`'s `paneOf`. - pane: () => expect(section.pane).toBe('primary'), - fields: () => { - expect(section.fields).toHaveLength(2); - // The bare-name shorthand travels verbatim; `normalizeSectionField` - // resolves it against the object schema. - expect(section.fields[1]).toBe('amount'); - }, -}; - -const IGNORED_SECTION_KEYS: Record = {}; - -/** Spec FormFieldSchema keys → where the authored value lands on the field. */ -const MAPPED_FIELD_KEYS: Record void> = { - field: () => expect(field.name).toBe('name'), // identity key → the runtime data path - type: () => expect(field.type).toBe('text'), - label: () => expect(field.label).toBe('Name'), - placeholder: () => expect(field.placeholder).toBe('Acme deal'), - helpText: () => expect(field.helpText).toBe('Deal name'), - readonly: () => expect(field.readonly).toBe(false), - required: () => expect(field.required).toBe(true), - hidden: () => expect(field.hidden).toBe(false), - colSpan: () => expect(field.colSpan).toBe(2), - widget: () => expect(field.widget).toBe('input'), - options: () => expect(field.options).toEqual([{ label: 'Tech', value: 'tech' }]), - reference: () => expect(field.reference).toBe('account'), - dependsOn: () => expect(field.dependsOn).toBe('account'), - // ADR-0089: the view-level predicate lands in the node's `visibleOn` slot. - visibleWhen: () => expect(field.visibleOn).toBe('record.active == true'), - visibleOn: () => expect(legacyField.visibleOn).toBe('record.active == true'), - // objectui#5898 — same-name copies onto the runtime FormField, matching the - // destinations `normalizeSectionField` pins in - // `plugin-form/src/sectionFields.spec-parity.test.ts`. - maxLength: () => expect(field.maxLength).toBe(120), - minLength: () => expect(field.minLength).toBe(2), - min: () => expect(field.min).toBe(0), - max: () => expect(field.max).toBe(100), - precision: () => expect(field.precision).toBe(10), - scale: () => expect(field.scale).toBe(2), - multiple: () => expect(field.multiple).toBe(false), - immutable: () => expect(field.immutable).toBe(true), - span: () => expect(field.span).toBe('full'), - language: () => expect(field.language).toBe('sql'), - disclosure: () => expect(field.disclosure).toBe('popover'), - keyField: () => - expect(field.keyField).toEqual({ - field: 'name', - label: 'Name', - regex: '^[a-z_]+$', - immutable: true, - }), - // Verbatim, in the SPEC vocabulary — the runtime slot is a pass-through and - // its pinned row asserts the authored `{ field: 'inner' }` survives. - fields: () => expect(field.fields).toEqual([{ field: 'inner', type: 'text' }]), -}; - -const IGNORED_FIELD_KEYS: Record = { - publicPicker: - 'A SERVER-side authorization opt-in, not a presentation delta: it gates objectstack\'s ' + - 'public-lookup route (`GET /forms/:slug/lookup/:field` answers 403 LOOKUP_NOT_PUBLIC without ' + - 'it) and the public-form resolve route strips undeclared lookup fields before any renderer ' + - 'sees them. This bridge builds the `object-form` node for an in-app authenticated form and ' + - 'has no destination for it — carrying it would invent a client-side meaning for a capability ' + - 'only the server enforces. Same reasoned exemption the downstream chokepoint records ' + - '(objectui#4648 delegated ruling item 5, 2026-08-15); it becomes an implementation card if ' + - 'ObjectUI ever renders anonymous public forms.', -}; - -const LEVELS = [ - { - level: 'FormViewSchema', - schema: FormViewSchema, - mapped: MAPPED_VIEW_KEYS, - ignored: IGNORED_VIEW_KEYS, - retired: ['aria', 'defaultSort'], - }, - { - level: 'FormSectionSchema', - schema: FormSectionSchema, - mapped: MAPPED_SECTION_KEYS, - ignored: IGNORED_SECTION_KEYS, - retired: [] as string[], - }, - { - level: 'FormFieldSchema', - schema: FormFieldSchema, - mapped: MAPPED_FIELD_KEYS, - ignored: IGNORED_FIELD_KEYS, - retired: [] as string[], - }, -] as const; - -describe('FormView spec conformance (#2545) — key census derived from the contract', () => { - it('the fixture is a document the contract accepts (the control)', () => { - const parsed = FormViewSchema.safeParse(FULL_SPEC_FORM_VIEW); - // Without this, every row below could be asserting against metadata no - // author could publish, and the census would describe a private dialect. - expect( - parsed.success ? [] : parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`), - ).toEqual([]); - expect(FormViewSchema.safeParse(DEPRECATED_ONLY_FORM_VIEW).success).toBe(true); - }); - - for (const { level, schema, mapped, ignored, retired } of LEVELS) { - describe(level, () => { - it('claims every live contract key exactly once (map it, or explain it)', () => { - const claimed = [...Object.keys(mapped), ...Object.keys(ignored)].sort(); - // The assertion the old fixture-driven loop could not make: the - // expected side is the CONTRACT, so a key nobody remembered still - // fails here. - expect(claimed).toEqual(liveSpecKeys(schema)); - }); - - it('holds no stale rows for keys the contract has dropped', () => { - const live = liveSpecKeys(schema); - for (const key of [...Object.keys(mapped), ...Object.keys(ignored)]) { - expect(live, `'${key}' is no longer a ${level} key`).toContain(key); - } - }); - - it('sees the tombstoned keys as tombstones, and nothing else', () => { - // Both directions of the `liveSpecKeys` filter. Over-filtering would - // silently shrink the census above; under-filtering would demand a - // mapping for a key the contract refuses. - expect(retiredSpecKeys(schema)).toEqual([...retired]); - }); - - it('the fixture exercises every mapped key (no row can pass vacuously)', () => { - // A conformance fixture assembled only from keys the bridge already - // carried is why this file was green while 18 keys were dropped. - expect(Object.keys(mapped).length).toBeGreaterThan(0); - }); - - for (const [key, assertRow] of Object.entries(mapped)) { - it(`carries spec '${key}' to its destination on the node`, () => { - assertRow(); - }); - } - - for (const [key, reason] of Object.entries(ignored)) { - it(`states why spec '${key}' is deliberately not carried`, () => { - // "Not silently" is the promise — an ignore entry with no reason is - // the silent drop wearing a label. - expect(reason.length).toBeGreaterThan(80); - expect(reason, `'${key}' has no tracking reference`).toMatch(/#\d+/); - }); - } - }); - } -}); - -describe('FormView spec conformance (#2545) — round-trip behaviour', () => { - it('normalizes legacy groups into sections (groups-only spec now renders)', () => { - const bridge = new SpecBridge(); - const groupsOnly = bridge.transformFormView({ - type: 'simple', - groups: [{ label: 'Legacy Group', fields: [{ field: 'name' }] }], - }); - - expect(groupsOnly.groups).toBeUndefined(); - const sections = groupsOnly.sections as any[]; - expect(sections).toHaveLength(1); - expect(sections[0].label).toBe('Legacy Group'); - expect(sections[0].fields[0].name).toBe('name'); - }); - - it('prefers sections over groups when both are present', () => { - const bridge = new SpecBridge(); - const both = bridge.transformFormView({ - sections: [{ label: 'Canonical', fields: [] }], - groups: [{ label: 'Legacy', fields: [] }], - }); - - const sections = both.sections as any[]; - expect(sections).toHaveLength(1); - expect(sections[0].label).toBe('Canonical'); - }); - - it('maps every form variant name onto node.formType', () => { - const bridge = new SpecBridge(); - for (const variant of ['simple', 'tabbed', 'wizard', 'split', 'drawer', 'modal']) { - expect(bridge.transformFormView({ type: variant }).formType).toBe(variant); - } - // An unknown variant is refused rather than forwarded — `mapFormType`'s - // allowlist is the reason `formType` is a mapped key and not a passthrough. - expect(bridge.transformFormView({ type: 'carousel' }).formType).toBeUndefined(); - }); - - it('drops nothing when the canonical and deprecated visibility spellings disagree', () => { - // Precedence, asserted on both carriers: the canonical spelling wins, which - // is what the contract's own `normalizeVisibleWhen` does with this input. - expect(section.visibleWhen).toBe('record.stage != "closed"'); - expect(field.visibleOn).toBe('record.active == true'); - }); -}); diff --git a/packages/react/src/spec-bridge/__tests__/FormViewWidenedArms.test.ts b/packages/react/src/spec-bridge/__tests__/FormViewWidenedArms.test.ts deleted file mode 100644 index 6323265a24..0000000000 --- a/packages/react/src/spec-bridge/__tests__/FormViewWidenedArms.test.ts +++ /dev/null @@ -1,292 +0,0 @@ -/** - * 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. - */ - -/** - * Form-view bridge — the arms the contract admits and the bridge used to refuse - * (objectui#5652). - * - * The bridge held a THIRD hand-written description of the FormViewSchema - * contract (after objectui#5542's leaf and objectui#5596's two containers), and - * a description nothing compares is one spec release from being a fork. Three - * of its key types had already drifted. Each of them refused metadata the - * platform accepts, which is the objectui#5040 symptom: a legal document that - * the type says cannot exist. - * - * Every arm below is pinned TWICE, and both halves are load-bearing: - * - * 1. **The contract's answer**, from `safeParse` — so "the contract admits - * this" is a measurement in the suite rather than a claim in a comment. - * These are the assertions that turn red if the spec ever moves, which is - * the moment the derived declarations below need re-reading. - * 2. **The bridge's answer**, end to end onto the `object-form` node — because - * widening a declaration is only half a repair. A key that is declarable - * but silently dropped on the way out is the same defect one layer over - * (objectui#5542 / #5594), so each arm is followed to the node, and the - * predicates are followed one step further into the evaluator that reads - * them. - * - * The compile-time pins in `describe('derivation')` are the drift guard proper: - * they fail if a future edit restates any of these types by hand, whatever - * value it restates them to. They are erased at runtime — `tsc -p - * tsconfig.test.json` (chained from this package's `type-check` script) is what - * checks them, not vitest. - */ -import { describe, it, expect } from 'vitest'; -import { FormSectionSchema, FormViewSchema } from '@objectstack/spec/ui'; -import type { FormFieldInput, FormSection, FormView } from '@objectstack/spec/ui'; -import { evalFieldPredicate } from '@object-ui/core'; -import { bridgeFormView } from '../bridges/form-view'; -import type { FormFieldSpec, FormSectionSpec, FormViewSpec } from '../bridges/form-view'; - -type Assert = T; -/** Invariant type equality — `A extends B` is too weak to catch a widening. */ -type Equal = - (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; -type Extends = [A] extends [B] ? true : false; - -/** A section fixture that is spec-valid except for the arm under test. */ -const SECTION_BASE = { label: 'Basic', fields: [{ field: 'name' }] }; - -describe("objectui#5652 — the contract's measured answers (controls)", () => { - it('admits the string spelling of a SECTION column count', () => { - expect(FormSectionSchema.safeParse({ ...SECTION_BASE, columns: '3' }).success).toBe(true); - expect(FormSectionSchema.safeParse({ ...SECTION_BASE, columns: 3 }).success).toBe(true); - // The control that keeps the assertion above honest: the string arm is an - // enum of the four counts, not "any string". - expect(FormSectionSchema.safeParse({ ...SECTION_BASE, columns: '5' }).success).toBe(false); - }); - - it('does NOT admit the string arm on the FORM, which is not what the card predicted', () => { - const withNumber = FormViewSchema.safeParse({ columns: 2, sections: [SECTION_BASE] }); - const withString = FormViewSchema.safeParse({ columns: '3', sections: [SECTION_BASE] }); - - expect(withNumber.success).toBe(true); - expect(withString.success).toBe(false); - expect(withString.error?.issues.some((i) => i.path[0] === 'columns')).toBe(true); - }); - - it('admits a BARE `dependsOn` name and refuses the array arm', () => { - const bare = FormViewSchema.safeParse({ - sections: [{ label: 'S', fields: [{ field: 'config', dependsOn: 'objectName' }] }], - }); - const array = FormViewSchema.safeParse({ - sections: [{ label: 'S', fields: [{ field: 'config', dependsOn: ['objectName'] }] }], - }); - - expect(bare.success).toBe(true); - // The exact inversion the bridge shipped: it declared `string[]`, so the - // only arm it admitted is the one the contract rejects. - expect(array.success).toBe(false); - }); - - it('admits both predicate arms, and requires `dialect` on the object one', () => { - const asString = FormSectionSchema.safeParse({ ...SECTION_BASE, visibleWhen: 'x == 1' }); - const asObject = FormSectionSchema.safeParse({ - ...SECTION_BASE, - visibleWhen: { dialect: 'cel', source: 'x == 1' }, - }); - const dialectless = FormSectionSchema.safeParse({ - ...SECTION_BASE, - visibleWhen: { source: 'x == 1' }, - }); - - expect(asString.success).toBe(true); - expect(asObject.success).toBe(true); - // `{ dialect?, source }` is the EVALUATOR's `FieldRulePredicate`, a - // different layer's type — the contract refuses it. - expect(dialectless.success).toBe(false); - }); -}); - -describe('objectui#5652 — each widened arm reaches the object-form node', () => { - it('folds a string SECTION column count onto the number the node carries', () => { - const spec: FormViewSpec = { - type: 'simple', - sections: [{ label: 'Basic', columns: '3', fields: [{ field: 'name' }] }], - }; - - const section = (bridgeFormView(spec, {}).sections as any[])[0]; - - // Not `'3'`: `FormSectionContainer` types this `1 | 2 | 3 | 4` and indexes - // its grid-class map by it, and its own header puts the fold at this seam. - expect(section.columns).toBe(3); - expect(typeof section.columns).toBe('number'); - }); - - it('leaves a numeric column count alone (the fold is not a rewrite)', () => { - const spec: FormViewSpec = { - columns: 2, - sections: [{ label: 'Basic', columns: 2, fields: [{ field: 'name' }] }], - }; - const node = bridgeFormView(spec, {}); - - expect((node.sections as any[])[0].columns).toBe(2); - expect(node.columns).toBe(2); - }); - - it('carries a BARE `dependsOn` name onto the node field', () => { - const spec: FormViewSpec = { - sections: [ - { - label: 'Basic', - fields: [{ field: 'config', widget: 'field-selector', dependsOn: 'objectName' }], - }, - ], - }; - - const field = (bridgeFormView(spec, {}).sections as any[])[0].fields[0]; - - // The node slot is declared `DependsOnInput` and read by - // `resolveDependsOnFields`, which takes the bare name as-is. - expect(field.dependsOn).toBe('objectName'); - expect(field.widget).toBe('field-selector'); - }); - - it('carries the object predicate arm onto the field, whole and evaluable', () => { - const spec: FormViewSpec = { - sections: [ - { - label: 'Basic', - fields: [ - { field: 'discount', visibleWhen: { dialect: 'cel', source: "record.stage == 'won'" } }, - ], - }, - ], - }; - - const field = (bridgeFormView(spec, {}).sections as any[])[0].fields[0]; - - // ADR-0089: the view-level predicate lands in the node's `visibleOn` slot. - expect(field.visibleOn).toEqual({ dialect: 'cel', source: "record.stage == 'won'" }); - // Reaching the node is not the same as being usable there. Both verdicts - // are asserted because a predicate the evaluator cannot read returns the - // FALLBACK for every record — which would look identical to the `false` - // case on its own. - expect(evalFieldPredicate(field.visibleOn, { stage: 'won' }, false)).toBe(true); - expect(evalFieldPredicate(field.visibleOn, { stage: 'lost' }, false)).toBe(false); - }); - - it('carries the object predicate arm onto the section', () => { - const spec: FormViewSpec = { - sections: [ - { - label: 'Internal', - visibleWhen: { dialect: 'cel', source: "record.stage != 'closed'" }, - fields: [{ field: 'name' }], - }, - ], - }; - - const section = (bridgeFormView(spec, {}).sections as any[])[0]; - - expect(section.visibleWhen).toEqual({ dialect: 'cel', source: "record.stage != 'closed'" }); - }); - - it('still carries the bare-string predicate arm (the widening added, it did not replace)', () => { - const spec: FormViewSpec = { - sections: [ - { - label: 'Basic', - visibleWhen: "record.stage != 'closed'", - fields: [{ field: 'discount', visibleWhen: "record.stage == 'won'" }], - }, - ], - }; - - const section = (bridgeFormView(spec, {}).sections as any[])[0]; - - expect(section.visibleWhen).toBe("record.stage != 'closed'"); - expect(section.fields[0].visibleOn).toBe("record.stage == 'won'"); - expect(evalFieldPredicate(section.fields[0].visibleOn, { stage: 'won' }, false)).toBe(true); - }); - - it('forwards the spec shorthand for a field — a bare object-field name', () => { - const spec: FormViewSpec = { - sections: [{ label: 'Basic', fields: ['name', { field: 'amount' }] }], - }; - - const section = (bridgeFormView(spec, {}).sections as any[])[0]; - - // Verbatim: the node's `fields` slot admits the shorthand and - // `normalizeSectionField` (@object-ui/plugin-form) resolves it against the - // object schema. Running it through the object mapper produced - // `{ name: undefined }` — a field with no identity. - expect(section.fields[0]).toBe('name'); - expect(section.fields[1]).toEqual({ name: 'amount', label: 'amount' }); - }); -}); - -describe('objectui#5652 — derivation pins (compile-time; vitest only reports they exist)', () => { - // Each declared type IS the contract's type. A hand restatement fails here - // whatever it restates the type to — that is the whole point of the guard, - // and the reason these are `Equal` and not `extends`. - type _sectionColumns = Assert>; - type _formColumns = Assert>; - type _fieldVisibleWhen = Assert>; - type _fieldVisibleOn = Assert>; - type _sectionVisibleWhen = Assert>; - - // `dependsOn` is deliberately WIDER than the contract (it is forwarded into a - // node slot declared `DependsOnInput`, which also admits the array arm the - // runtime has always read). Equality would be the wrong pin; what must hold - // is that every arm the contract admits is describable here, so a spec that - // widens `dependsOn` fails this instead of outgrowing the declaration. - type _dependsOnCoversContract = Assert< - Extends, FormFieldSpec['dependsOn']> - >; - - // The arms themselves, spelled out: these are what a re-narrowing to - // `number` / `string[]` / `string` breaks first, and they name the value. - type _stringColumnArm = Assert>>; - type _bareNameArm = Assert>>; - type _objectPredicateArm = Assert< - Extends<{ dialect: 'cel'; source: string }, NonNullable> - >; - - it('holds the compile-time pins above (erased at runtime by design)', () => { - // Referenced so the aliases are not unused declarations, and so a reader - // running vitest alone sees the pins named rather than silently absent. - const pinned: Array<_sectionColumns | _formColumns | _fieldVisibleWhen | _fieldVisibleOn> = [ - true, - true, - true, - true, - ]; - const alsoPinned: Array< - | _sectionVisibleWhen - | _dependsOnCoversContract - | _stringColumnArm - | _bareNameArm - | _objectPredicateArm - > = [true, true, true, true, true]; - - expect(pinned.every(Boolean)).toBe(true); - expect(alsoPinned.every(Boolean)).toBe(true); - }); - - it('accepts a spec-authored document at the declared types', () => { - // Assignability IS the assertion: each of these was a compile error before - // the conversion. The runtime expectations keep the fixtures live. - const section: FormSectionSpec = { - label: 'Basic', - columns: '3', - visibleWhen: { dialect: 'cel', source: 'record.open == true' }, - fields: ['name', { field: 'config', dependsOn: 'objectName' }], - }; - const field: FormFieldSpec = { - field: 'config', - widget: 'dynamic-config', - dependsOn: 'driver', - visibleWhen: { dialect: 'cel', source: 'record.open == true' }, - }; - const view: FormViewSpec = { type: 'simple', columns: 2, sections: [section] }; - - expect(bridgeFormView(view, {}).columns).toBe(2); - expect(field.dependsOn).toBe('driver'); - }); -}); diff --git a/packages/react/src/spec-bridge/__tests__/ListViewExportOptionsLift.test.ts b/packages/react/src/spec-bridge/__tests__/ListViewExportOptionsLift.test.ts deleted file mode 100644 index 64bd08c598..0000000000 --- a/packages/react/src/spec-bridge/__tests__/ListViewExportOptionsLift.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * 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. - */ - -import { describe, it, expect } from 'vitest'; -import { SpecBridge } from '../SpecBridge'; - -/** - * The `exportOptions` lift pin (objectui#4585). - * - * The bridge used to copy `spec.exportOptions` across verbatim, so a legacy - * bare format array reached the `object-grid` node unchanged — and ObjectGrid - * reads the OBJECT form and only that (`schema.exportOptions?.formats`). - * `.formats` on an array is `undefined`, so the renderer's `['csv', 'json']` - * default won: a view declaring `['csv', 'xlsx']` offered csv and json, losing - * the declared xlsx and gaining an undeclared json, with no error, no warning - * and no console line. `!!schema.exportOptions` stayed truthy for a non-empty - * array, so the export button still showed — the failure was silent, not - * absent. - * - * The fix mirrors the spec's OWN parse-time lift and nothing more - * (`@objectstack/spec` `ui/view.zod.ts`, objectstack#8010): - * - * z.array(ListViewExportFormatSchema).transform((formats) => ({ formats })) - * - * so this is one contract applied where `parse` cannot reach, not a second one. - * The bridge's input is a TypeScript type, never a parsed value — there is no - * `parse`/`safeParse` anywhere under `spec-bridge/` — which is why bumping the - * spec pin alone would not have closed #4585. - * - * The lifted value is typed `ListViewExportOptions` from `@object-ui/types` - * (landed objectui#4535 / PR #4584) — one spelling of the spec's five-key - * shape, no third copy. That half is enforced by `tsc` on the bridge source, - * not by an assertion here. - * - * RESIDUE (recorded, not built — #4585 item 4). objectui pins - * `@objectstack/spec@17.0.0-rc.6`, where `ListView.exportOptions` is still the - * bare array and the lift does not exist yet; measured in the pinned dist: - * `exportOptions: z.array(z.enum(['csv','xlsx','pdf','json'])).optional()`. - * Once the pin bumps past objectstack#8324 the spec's schema becomes reachable - * from here, and a stronger assertion replaces the hand-mirroring below: parse - * the same input through the spec and require `bridge lift === spec parse - * output`, one contract proven equal rather than copied. Do not block on it. - */ - -/** What the bridge writes to the node, through the untyped host boundary. */ -function bridgedExportOptions(exportOptions: unknown): unknown { - return new SpecBridge().transformListView({ - name: 'export_view', - columns: [{ field: 'name', label: 'Name' }], - exportOptions, - }).exportOptions; -} - -describe('SpecBridge — exportOptions lift (#4585)', () => { - it('lifts a legacy bare format array to the spec object form', () => { - expect(bridgedExportOptions(['csv', 'xlsx'])).toEqual({ formats: ['csv', 'xlsx'] }); - }); - - it('lifts an empty array to `{ formats: [] }` verbatim, neither dropped nor defaulted', () => { - // The spec's `z.array()` carries no `.min(1)`, so `[]` is a legal input and - // its transform wraps it like any other. Downstream that reads as "this - // view declares no export format": ObjectGrid's menu comes out empty and - // the export button is hidden — pinned end-to-end in plugin-grid's - // `specBridgeExportFormats.test.tsx`. - expect(bridgedExportOptions([])).toEqual({ formats: [] }); - }); - - it('passes the object form through by reference, unread and unrewritten', () => { - const authored = { formats: ['csv', 'json'], maxRecords: 5000, streaming: false }; - - // Identity, not equality: the object spelling is already the contract, so - // the bridge must not rebuild, re-key or re-order it. - expect(bridgedExportOptions(authored)).toBe(authored); - }); - - it('leaves the key absent when the view declares no exportOptions', () => { - const node = new SpecBridge().transformListView({ - name: 'no_export_view', - columns: [{ field: 'name', label: 'Name' }], - }); - - expect('exportOptions' in node).toBe(false); - }); - - it('carries a pre-retirement `pdf` into the lift instead of filtering it out', () => { - // The spec REFUSES `'pdf'` at parse with a migration prescription - // (objectstack#8010; PDF export declined as objectstack#1301 NOT_PLANNED) — - // it does not silently drop the value, so the mirror of its lift may not - // either. Stored metadata predating `os migrate meta --from 16` still - // carries it, and it dies downstream in ObjectGrid's format-agnostic menu - // filter (objectui#4535) rather than in a `'pdf'`-shaped branch here. - expect(bridgedExportOptions(['csv', 'pdf'])).toEqual({ formats: ['csv', 'pdf'] }); - }); -}); diff --git a/packages/react/src/spec-bridge/__tests__/P1SpecBridge.test.ts b/packages/react/src/spec-bridge/__tests__/P1SpecBridge.test.ts deleted file mode 100644 index 1021f90c23..0000000000 --- a/packages/react/src/spec-bridge/__tests__/P1SpecBridge.test.ts +++ /dev/null @@ -1,455 +0,0 @@ -/** - * 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. - */ - -/** - * P1 SpecBridge Protocol Alignment Tests - * Tests for the enhanced bridge transformations: ListView, FormView - */ -import { describe, it, expect } from 'vitest'; -import { SpecBridge } from '../SpecBridge'; - -describe('P1 SpecBridge Protocol Alignment', () => { - // ======================================================================== - // P1.1 ListView Bridge Enhancements - // ======================================================================== - describe('P1.1 ListView bridge enhancements', () => { - it('should pass through rowActions and bulkActions', () => { - const bridge = new SpecBridge(); - const node = bridge.transformListView({ - name: 'accounts', - columns: [{ field: 'name', label: 'Name' }], - rowActions: ['edit', 'delete', 'clone'], - bulkActions: ['delete', 'assign'], - }); - - expect(node.rowActions).toEqual(['edit', 'delete', 'clone']); - expect(node.bulkActions).toEqual(['delete', 'assign']); - }); - - it('should pass through conditionalFormatting', () => { - const bridge = new SpecBridge(); - const node = bridge.transformListView({ - name: 'styled', - conditionalFormatting: [ - { condition: '${data.amount > 10000}', style: { backgroundColor: '#fee2e2' } }, - ], - }); - - expect(node.conditionalFormatting).toHaveLength(1); - expect(node.conditionalFormatting[0].condition).toBe('${data.amount > 10000}'); - }); - - it('should pass through inlineEdit', () => { - const bridge = new SpecBridge(); - const node = bridge.transformListView({ - name: 'editable', - inlineEdit: true, - }); - - expect(node.inlineEdit).toBe(true); - }); - - it('should pass through exportOptions', () => { - const bridge = new SpecBridge(); - const node = bridge.transformListView({ - name: 'exportable', - exportOptions: { - formats: ['csv', 'xlsx'], - maxRecords: 10000, - }, - }); - - expect(node.exportOptions).toBeDefined(); - expect(node.exportOptions.formats).toEqual(['csv', 'xlsx']); - }); - - it('should pass through emptyState', () => { - const bridge = new SpecBridge(); - const node = bridge.transformListView({ - name: 'empty', - emptyState: { title: 'No Data', message: 'Add records to get started', icon: 'Database' }, - }); - - expect(node.emptyState).toBeDefined(); - expect(node.emptyState.title).toBe('No Data'); - }); - - it('should pass through showRecordCount and allowPrinting', () => { - const bridge = new SpecBridge(); - const node = bridge.transformListView({ - name: 'full_featured', - showRecordCount: true, - allowPrinting: true, - }); - - expect(node.showRecordCount).toBe(true); - expect(node.allowPrinting).toBe(true); - }); - - it('should pass through userActions', () => { - const bridge = new SpecBridge(); - const node = bridge.transformListView({ - name: 'actions_test', - userActions: { sort: true, search: true, filter: false }, - }); - - expect(node.userActions).toBeDefined(); - expect(node.userActions.sort).toBe(true); - expect(node.userActions.filter).toBe(false); - }); - - it('should pass through aria properties', () => { - const bridge = new SpecBridge(); - const node = bridge.transformListView({ - name: 'accessible', - aria: { ariaLabel: 'Accounts List', role: 'grid' }, - }); - - expect(node.aria).toBeDefined(); - expect(node.aria.ariaLabel).toBe('Accounts List'); - }); - - it('should pass through hiddenFields and fieldOrder', () => { - const bridge = new SpecBridge(); - const node = bridge.transformListView({ - name: 'ordered', - hiddenFields: ['internal_id', 'sys_created_at'], - fieldOrder: ['name', 'email', 'status'], - }); - - expect(node.hiddenFields).toEqual(['internal_id', 'sys_created_at']); - expect(node.fieldOrder).toEqual(['name', 'email', 'status']); - }); - - it('should map short rowHeight to compact density', () => { - const bridge = new SpecBridge(); - const node = bridge.transformListView({ - name: 'density_test', - rowHeight: 'short', - }); - - expect(node.density).toBe('compact'); - }); - - it('should map extra_tall rowHeight to spacious density', () => { - const bridge = new SpecBridge(); - const node = bridge.transformListView({ - name: 'density_test', - rowHeight: 'extra_tall', - }); - - expect(node.density).toBe('spacious'); - }); - - it('should map tall rowHeight to spacious density', () => { - const bridge = new SpecBridge(); - const node = bridge.transformListView({ - name: 'density_test', - rowHeight: 'tall', - }); - - expect(node.density).toBe('spacious'); - }); - - it('should pass through filterableFields', () => { - const bridge = new SpecBridge(); - const node = bridge.transformListView({ - name: 'filterable', - filterableFields: ['status', 'priority', 'assignee'], - }); - - expect(node.filterableFields).toEqual(['status', 'priority', 'assignee']); - }); - - it('should pass through resizable', () => { - const bridge = new SpecBridge(); - const node = bridge.transformListView({ - name: 'styled', - resizable: true, - }); - - expect(node.resizable).toBe(true); - }); - - // objectstack#7176 retired `striped` / `bordered` / `virtualScroll` after - // measuring this bridge — and every reader downstream of it — as - // pass-through: the value was copied node-ward and no renderer ever applied - // it. The three `pass through` cases that stood here asserted exactly that - // dead copy. What replaces them is the inverse claim, and it is the one - // worth pinning: the bridge must not resurrect the keys. - // - // The input fixture MUST carry the three keys — asserting their absence on - // the output of a fixture that never supplied them would be green whatever - // the bridge does. Stored view metadata authored before the retirement is - // exactly this shape, so the case is also the realistic one. It compiles on - // both pins because `transformListView(spec: any)` types its input as - // `any`, so the GA `retiredKey()` tombstones cannot reject the literal. - it('should drop the retired striped / bordered / virtualScroll keys', () => { - const bridge = new SpecBridge(); - const node = bridge.transformListView({ - name: 'styled', - resizable: true, - striped: true, - bordered: true, - virtualScroll: true, - }); - - expect(node).not.toHaveProperty('striped'); - expect(node).not.toHaveProperty('bordered'); - expect(node).not.toHaveProperty('virtualScroll'); - // The surviving neighbour proves the fixture reached the bridge at all — - // without it an early return would satisfy every assertion above. - expect(node.resizable).toBe(true); - }); - - it('should pass through view-type configs (kanban, calendar, gantt, gallery, timeline)', () => { - const bridge = new SpecBridge(); - const node = bridge.transformListView({ - name: 'multi_view', - kanban: { groupField: 'status' }, - calendar: { startDateField: 'date' }, - gantt: { startDateField: 'start', endDateField: 'end' }, - gallery: { coverField: 'photo', cardSize: 'medium' }, - timeline: { startDateField: 'created', titleField: 'name' }, - }); - - expect(node.kanban).toEqual({ groupField: 'status' }); - expect(node.calendar).toEqual({ startDateField: 'date' }); - expect(node.gantt).toEqual({ startDateField: 'start', endDateField: 'end' }); - expect(node.gallery).toEqual({ coverField: 'photo', cardSize: 'medium' }); - expect(node.timeline).toEqual({ startDateField: 'created', titleField: 'name' }); - }); - - it('should pass through navigation config', () => { - const bridge = new SpecBridge(); - const node = bridge.transformListView({ - name: 'navigable', - navigation: { mode: 'drawer', width: '400px' }, - }); - - expect(node.navigation).toEqual({ mode: 'drawer', width: '400px' }); - }); - }); - - // ======================================================================== - // P1.2 FormView Bridge Enhancements - // ======================================================================== - describe('P1.2 FormView bridge enhancements', () => { - it('should map formType from spec type', () => { - const bridge = new SpecBridge(); - const node = bridge.transformFormView({ - type: 'tabbed', - sections: [ - { label: 'Tab 1', fields: [{ field: 'name' }] }, - { label: 'Tab 2', fields: [{ field: 'email' }] }, - ], - }); - - expect(node.formType).toBe('tabbed'); - }); - - it('should map wizard formType', () => { - const bridge = new SpecBridge(); - const node = bridge.transformFormView({ - type: 'wizard', - sections: [ - { label: 'Step 1', fields: [{ field: 'name' }] }, - { label: 'Step 2', fields: [{ field: 'email' }] }, - ], - }); - - expect(node.formType).toBe('wizard'); - }); - - it('should map modal formType', () => { - const bridge = new SpecBridge(); - const node = bridge.transformFormView({ type: 'modal' }); - expect(node.formType).toBe('modal'); - }); - - it('should map split formType', () => { - const bridge = new SpecBridge(); - const node = bridge.transformFormView({ type: 'split' }); - expect(node.formType).toBe('split'); - }); - - it('should map drawer formType', () => { - const bridge = new SpecBridge(); - const node = bridge.transformFormView({ type: 'drawer' }); - expect(node.formType).toBe('drawer'); - }); - - it('should not set formType for unknown types', () => { - const bridge = new SpecBridge(); - const node = bridge.transformFormView({ type: 'unknown_type' }); - expect(node.formType).toBeUndefined(); - }); - - it('should map FormSection with columns', () => { - const bridge = new SpecBridge(); - const node = bridge.transformFormView({ - sections: [ - { - label: 'Contact Info', - columns: 2, - collapsible: true, - collapsed: false, - fields: [ - { field: 'name', label: 'Full Name', required: true }, - { field: 'email', label: 'Email', placeholder: 'user@example.com' }, - ], - }, - ], - }); - - const sections = node.sections as any[]; - expect(sections).toHaveLength(1); - expect(sections[0].label).toBe('Contact Info'); - expect(sections[0].columns).toBe(2); - expect(sections[0].collapsible).toBe(true); - expect(sections[0].fields).toHaveLength(2); - }); - - it('should map FormField properties: widget, dependsOn, visibleOn, colSpan', () => { - const bridge = new SpecBridge(); - const node = bridge.transformFormView({ - sections: [ - { - fields: [ - { - field: 'sub_industry', - label: 'Sub-Industry', - widget: 'industry-picker', - dependsOn: ['industry'], - visibleOn: '${data.industry != null}', - colSpan: 2, - }, - ], - }, - ], - }); - - const field = (node.sections as any[])[0].fields[0]; - expect(field.widget).toBe('industry-picker'); - expect(field.dependsOn).toEqual(['industry']); - expect(field.visibleOn).toBe('${data.industry != null}'); - expect(field.colSpan).toBe(2); - }); - - // The `should pass through aria properties` case that stood here pinned a - // read the bridge no longer has: spec 17 retired `aria` on the FORM carrier - // (#3901), so no FormView that parsed can carry it. Replacing rather than - // re-spelling — a fixture whose key the bridge stopped reading keeps passing - // only because nothing is produced. The surviving behaviour is pinned in - // `FormViewRetiredKeys.test.ts`; the LIST carrier's aria pass-through is - // still live and still covered above (P1.1). - it('does not carry the retired form `aria` onto the node', () => { - const bridge = new SpecBridge(); - const node = bridge.transformFormView({ - type: 'simple', - aria: { ariaLabel: 'Create Account Form' }, - }); - - expect('aria' in node).toBe(false); - }); - }); - - // ======================================================================== - // P2 Sharing / ExportOptions / Pagination Protocol Alignment - // ======================================================================== - describe('P2 sharing/exportOptions/pagination alignment', () => { - // #2890: `sharing` is the spec's `ViewSharing` shape on BOTH sides now, so - // the bridge passes it through. It used to downgrade it here — inventing a - // legacy `visibility` audience and an `enabled` flag that the renderer then - // had to fold back into `type`. - it('should pass spec sharing { type: personal } through unchanged', () => { - const bridge = new SpecBridge(); - const node = bridge.transformListView({ - name: 'personal_view', - sharing: { type: 'personal', lockedBy: 'admin@example.com' }, - }); - - expect(node.sharing).toEqual({ type: 'personal', lockedBy: 'admin@example.com' }); - }); - - it('should pass spec sharing { type: collaborative } through unchanged', () => { - const bridge = new SpecBridge(); - const node = bridge.transformListView({ - name: 'collab_view', - sharing: { type: 'collaborative' }, - }); - - expect(node.sharing).toEqual({ type: 'collaborative' }); - }); - - it('should preserve ObjectUI sharing format without overriding', () => { - const bridge = new SpecBridge(); - const node = bridge.transformListView({ - name: 'objectui_view', - sharing: { visibility: 'organization', enabled: true }, - }); - - expect(node.sharing.visibility).toBe('organization'); - expect(node.sharing.enabled).toBe(true); - expect(node.sharing.type).toBeUndefined(); - }); - - it('should not override explicit visibility when type is set', () => { - const bridge = new SpecBridge(); - const node = bridge.transformListView({ - name: 'mixed_view', - sharing: { type: 'collaborative', visibility: 'public' }, - }); - - expect(node.sharing.visibility).toBe('public'); - expect(node.sharing.type).toBe('collaborative'); - }); - - // AUTHORIZED PIN MOVE (objectui#4585). This used to pin the bare array - // reaching the node verbatim — the one shape ObjectGrid cannot read, since - // it takes `exportOptions.formats` and `.formats` on an array is - // `undefined`. The pin was about the bridge's output shape and never - // rendered it, so the bridge stayed green while the view's declared formats - // were silently replaced by the renderer's `['csv', 'json']` default. The - // bridge now applies the spec's own parse-time lift, so the legacy spelling - // arrives in the shape the renderer reads. Full coverage of the lift lives - // in `ListViewExportOptionsLift.test.ts`; the end-to-end consequence is - // pinned in plugin-grid's `specBridgeExportFormats.test.tsx`. - it('should lift a legacy exportOptions array to the spec object form', () => { - const bridge = new SpecBridge(); - const node = bridge.transformListView({ - name: 'export_spec', - exportOptions: ['csv', 'xlsx'], - }); - - expect(node.exportOptions).toEqual({ formats: ['csv', 'xlsx'] }); - }); - - it('should pass through exportOptions object format', () => { - const bridge = new SpecBridge(); - const node = bridge.transformListView({ - name: 'export_obj', - exportOptions: { formats: ['csv', 'json'], maxRecords: 5000 }, - }); - - expect(node.exportOptions.formats).toEqual(['csv', 'json']); - expect(node.exportOptions.maxRecords).toBe(5000); - }); - - it('should pass through pagination with pageSizeOptions', () => { - const bridge = new SpecBridge(); - const node = bridge.transformListView({ - name: 'paginated', - pagination: { pageSize: 25, pageSizeOptions: [10, 25, 50, 100] }, - }); - - expect(node.pagination.pageSize).toBe(25); - expect(node.pagination.pageSizeOptions).toEqual([10, 25, 50, 100]); - }); - }); -}); diff --git a/packages/react/src/spec-bridge/__tests__/RowHeightDensityAgreement.test.ts b/packages/react/src/spec-bridge/__tests__/RowHeightDensityAgreement.test.ts deleted file mode 100644 index fb43af7125..0000000000 --- a/packages/react/src/spec-bridge/__tests__/RowHeightDensityAgreement.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -/** - * 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. - */ - -import { describe, it, expect } from 'vitest'; -import { rowHeightToDensityMode, type DensityMode } from '@object-ui/core'; -import { SpecBridge } from '../SpecBridge'; - -/** - * The agreement pin for objectui#4440. - * - * Two surfaces narrow a list view's `rowHeight` onto the renderer's three-step - * density vocabulary, and for a while they answered differently for the same - * off-spec input: `@object-ui/core`'s `rowHeightToDensityMode` coerced anything - * unknown to `'comfortable'`, while this package's `mapDensity` — after #4352 - * (PR #4439) — declined to answer at all. One metadata-driven system, two - * answers for one input. #4440 retired the coercion; this test is what stops - * the disagreement regrowing silently on either side. - * - * It lives HERE, not in `@object-ui/core`, because the dependency direction - * decides: `@object-ui/react` depends on `@object-ui/core`, so this package can - * see both surfaces, and core cannot import react without inverting the graph. - * The pin therefore imports core's function by its PUBLISHED specifier - * (`@object-ui/core`, a declared dependency of this package) and reaches the - * bridge through `SpecBridge.transformListView`, whose parameter is `any` — - * the untyped boundary a host's stored JSON actually crosses, and after #4352 - * the only way an off-spec `rowHeight` can enter the bridge at all. - */ - -/** What the bridge answers for a `rowHeight`, in core's return shape. */ -function bridgeDensityFor(rowHeight: unknown): DensityMode | undefined { - const node = new SpecBridge().transformListView({ - name: 'row_height_agreement', - rowHeight, - }); - // The bridge writes the key only when it has an answer, so an absent key and - // an explicit `undefined` are the same statement: "no density, use yours". - return 'density' in node ? (node.density as DensityMode | undefined) : undefined; -} - -/** - * The third off-spec family (#4459) — values that are not strings at all. - * - * The first two families are both strings, so for a while the invariant this - * file's title claims ("core and the spec bridge give one answer") was strictly - * broader than the invariant it pinned. A JSON-authored view can hold an array - * or an object at `rowHeight` just as easily as a bad string, and until #4459 - * the two surfaces disagreed on exactly that: core opens with a TYPE guard - * (`typeof rowHeight !== 'string'`), the bridge opened with a TRUTHINESS guard, - * so every truthy non-string walked past it and was then coerced to a key — - * `hasOwnProperty.call` and the index both run `String(...)`. - * - * Split into two groups on purpose, because they fail differently: - * - * - **coercing** — their string form IS one of the five spec keys, so the - * bridge answered `'compact'`: a density fabricated from an array. This is - * the quieter half of #4442's defect. A leaked function is visibly wrong to - * everything downstream; a fabricated `'compact'` is a perfectly legitimate - * value that nothing can tell apart from an authored one. - * - **non-coercing** — truthy non-strings whose string form is not a key - * (`'42'`, `'true'`), plus the falsy values the truthiness guard used to be - * there for. These already abstained. They are pinned anyway because #4459 - * REPLACES that guard rather than adding to it: the type guard has to keep - * catching everything the truthiness guard caught, and this is the row that - * says so. - */ -const NON_STRING_ROW_HEIGHTS: ReadonlyArray = [ - // Coercing: String(…) lands on a real spec key. - ["the array ['compact']", ['compact']], - ['the boxed String(compact)', new String('compact')], - ['an object whose toString() returns compact', { toString: () => 'compact' }], - // Non-coercing truthy non-strings. - ['the number 42', 42], - ['the boolean true', true], - // Falsy — what the retired truthiness guard existed to catch. - ['the number 0', 0], - ['the boolean false', false], - ['null', null], - ['undefined', undefined], -]; - -describe('rowHeight → density: core and the spec bridge give one answer (#4440)', () => { - describe('the five spec row heights — controls, green on both sides', () => { - it.each([ - ['compact', 'compact'], - ['short', 'compact'], - ['medium', 'comfortable'], - ['tall', 'spacious'], - ['extra_tall', 'spacious'], - ] as const)('both surfaces map %s to %s', (rowHeight, expected) => { - expect(rowHeightToDensityMode(rowHeight)).toBe(expected); - expect(bridgeDensityFor(rowHeight)).toBe(expected); - }); - }); - - describe('off-spec row heights — both abstain', () => { - // Two families of off-spec spelling, both pinned here. - // - // `comfortable` / `spacious` / `small` / `large` are the four spellings - // #4352 deleted from the bridge; `gargantuan` is a string in neither - // vocabulary. Before #4440 core answered `'comfortable'` for every one of - // them while the bridge answered nothing. - // - // The `Object.prototype` member names are the second family (#4442). Both - // lookup tables are plain object literals, so indexing one with an - // unguarded key reaches the prototype: `rowHeight: 'toString'` used to come - // back as `Object.prototype.toString` — a FUNCTION — from a read whose - // return type is three strings or nothing. Core closed that hole with - // `hasOwnProperty` in #4440, the bridge in #4442; these rows are what keeps - // either side from regrowing it. - it.each([ - 'comfortable', - 'spacious', - 'small', - 'large', - 'gargantuan', - 'toString', - 'constructor', - 'valueOf', - 'hasOwnProperty', - 'isPrototypeOf', - 'propertyIsEnumerable', - 'toLocaleString', - ])('neither surface invents a density for the off-spec rowHeight %s', (rowHeight) => { - expect(rowHeightToDensityMode(rowHeight)).toBeUndefined(); - expect(bridgeDensityFor(rowHeight)).toBeUndefined(); - }); - - it('never writes a function into the SchemaNode density (#4442)', () => { - // The expression #4442 measured, read straight off the node instead of - // through the helper above. It is worth stating separately because the - // leak was not in the lookup alone: `bridgeListView` writes the key under - // `if (density)`, and a function is truthy — so the bad value was not - // merely returned, it was STORED on a SchemaNode whose renderer expects - // `'compact' | 'comfortable' | 'spacious'`. - const node = new SpecBridge().transformListView({ name: 'x', rowHeight: 'toString' }); - expect(node.density).toBeUndefined(); - expect(typeof node.density).not.toBe('function'); - }); - - it('agrees for every off-spec input without either side being read first', () => { - // Same assertion phrased as the invariant itself: whatever the answer is, - // it is ONE answer. A future edit that re-adds a fallback to either - // surface breaks this even if it re-adds it to both differently. - const offSpec: unknown[] = [ - 'comfortable', - 'spacious', - 'small', - 'large', - 'gargantuan', - '', - 'toString', - 'constructor', - 'valueOf', - 'hasOwnProperty', - 'isPrototypeOf', - 'propertyIsEnumerable', - 'toLocaleString', - // #4459 — the non-string family, run through the same invariant. This - // is the assertion the truthiness guard actually broke. - ...NON_STRING_ROW_HEIGHTS.map(([, value]) => value), - ]; - for (const rowHeight of offSpec) { - expect(rowHeightToDensityMode(rowHeight)).toBe(bridgeDensityFor(rowHeight)); - } - }); - }); - - describe('non-string row heights — both abstain (#4459)', () => { - it.each(NON_STRING_ROW_HEIGHTS)( - 'neither surface invents a density for %s', - (_label, rowHeight) => { - expect(rowHeightToDensityMode(rowHeight)).toBeUndefined(); - expect(bridgeDensityFor(rowHeight)).toBeUndefined(); - }, - ); - - it.each(NON_STRING_ROW_HEIGHTS)( - 'never writes a density fabricated from %s onto the SchemaNode', - (_label, rowHeight) => { - // The boundary expression #4459 measured, read straight off the node - // rather than through the helper — the same reason #4442 states one - // separately. `bridgeListView` writes the key under `if (density)`, so - // a fabricated `'compact'` is not merely returned, it is STORED, and - // downstream it is indistinguishable from an authored density. - const node = new SpecBridge().transformListView({ name: 'x', rowHeight }); - expect(node.density).toBeUndefined(); - }, - ); - - it('abstains on the empty string by a different route, same answer', () => { - // `''` is the one input whose HANDLING changes without its ANSWER - // changing, so it is worth stating on its own. Before #4459 it was caught - // by the truthiness guard (`''` is falsy) and never reached the table; - // after, it is a string, so it passes the type guard and is refused one - // line later by `hasOwnProperty` — `''` is not one of the five keys. - // Both routes end in `undefined`, and core has always agreed. - expect(bridgeDensityFor('')).toBeUndefined(); - expect(rowHeightToDensityMode('')).toBeUndefined(); - expect( - new SpecBridge().transformListView({ name: 'x', rowHeight: '' }).density, - ).toBeUndefined(); - }); - }); -}); diff --git a/packages/react/src/spec-bridge/__tests__/SpecBridge.test.ts b/packages/react/src/spec-bridge/__tests__/SpecBridge.test.ts deleted file mode 100644 index c3fa33354e..0000000000 --- a/packages/react/src/spec-bridge/__tests__/SpecBridge.test.ts +++ /dev/null @@ -1,405 +0,0 @@ -/** - * 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. - */ - -import { describe, it, expect } from 'vitest'; -import { SpecBridge } from '../SpecBridge'; -import { bridgeListView } from '../bridges/list-view'; -import { bridgeFormView } from '../bridges/form-view'; -import type { BridgeFn } from '../types'; - -describe('SpecBridge', () => { - describe('class orchestration', () => { - it('registers built-in bridges on construction', () => { - const bridge = new SpecBridge(); - // Both built-in types should work without error - expect(() => bridge.transform('list', {})).not.toThrow(); - expect(() => bridge.transform('form', {})).not.toThrow(); - }); - - it('throws for unknown spec types', () => { - const bridge = new SpecBridge(); - expect(() => bridge.transform('unknown', {})).toThrow( - 'No bridge registered for spec type: unknown', - ); - }); - - it('allows custom bridge registration', () => { - const bridge = new SpecBridge(); - const customBridge: BridgeFn = (spec) => ({ - type: 'custom-widget', - id: spec.id, - }); - bridge.register('custom', customBridge); - - const node = bridge.transform('custom', { id: 'my-custom' }); - expect(node.type).toBe('custom-widget'); - expect(node.id).toBe('my-custom'); - }); - - it('allows overriding built-in bridges', () => { - const bridge = new SpecBridge(); - const override: BridgeFn = () => ({ type: 'my-list' }); - bridge.register('list', override); - - const node = bridge.transformListView({}); - expect(node.type).toBe('my-list'); - }); - - it('passes context to bridge functions', () => { - const bridge = new SpecBridge({ - user: { role: 'admin' }, - variables: { theme: 'dark' }, - }); - const spy: BridgeFn = (_spec, ctx) => ({ - type: 'test', - user: ctx.user, - variables: ctx.variables, - }); - bridge.register('test', spy); - - const node = bridge.transform('test', {}); - expect(node.user).toEqual({ role: 'admin' }); - expect(node.variables).toEqual({ theme: 'dark' }); - }); - - it('updates context via updateContext', () => { - const bridge = new SpecBridge({ user: { role: 'viewer' } }); - bridge.updateContext({ user: { role: 'admin' } }); - - const spy: BridgeFn = (_spec, ctx) => ({ - type: 'test', - role: ctx.user?.role, - }); - bridge.register('ctx-test', spy); - - const node = bridge.transform('ctx-test', {}); - expect(node.role).toBe('admin'); - }); - }); - - describe('bridgeListView', () => { - it('transforms a basic list view spec', () => { - const spec = { - name: 'accounts_list', - label: 'All Accounts', - type: 'grid', - columns: [ - { field: 'name', label: 'Account Name', width: 200, sortable: true }, - { field: 'industry', label: 'Industry', width: 150 }, - ], - data: { provider: 'object', object: 'Account' }, - selection: { mode: 'multiple' }, - pagination: { pageSize: 25 }, - }; - - const bridge = new SpecBridge(); - const node = bridge.transformListView(spec); - - expect(node.type).toBe('object-grid'); - expect(node.id).toBe('accounts_list'); - expect(node.label).toBe('All Accounts'); - expect(node.columns).toHaveLength(2); - // The DECLARED spelling, and only it (objectui#5068). `accessorKey` / - // `header` is the data-table adapter's vocabulary, which `ObjectGrid` - // applies on the way OUT; a producer of `object-grid` metadata emits the - // spec's `ListColumn` — the shape it was handed in the first place. - expect(node.columns[0].field).toBe('name'); - expect(node.columns[0].label).toBe('Account Name'); - expect(node.columns[0].accessorKey).toBeUndefined(); - expect(node.columns[0].header).toBeUndefined(); - expect(node.columns[0].width).toBe(200); - expect(node.columns[0].sortable).toBe(true); - expect(node.columns[1].field).toBe('industry'); - expect(node.data).toEqual({ provider: 'object', object: 'Account' }); - expect(node.selection).toEqual({ mode: 'multiple' }); - expect(node.pagination).toEqual({ pageSize: 25 }); - }); - - it('leaves a bare column bare — no label is invented (#5068)', () => { - // This used to assert `header === 'email'`: the down-translation wrote - // `header: col.label ?? col.field`, so "the author declared no label" - // arrived downstream as "the author declared the machine name". That - // synthesized value pre-empted `ObjectGrid`'s own header chain - // (`col.label` → the object FIELD's label → the prettified name), which - // exists precisely for a column authored as a bare `{ field }` — see the - // localized-label comment at `ObjectGrid.tsx`'s ListColumn arm. The - // bridge now forwards what the view declared and nothing else; the - // rendered consequence is pinned in `@object-ui/plugin-grid`'s - // `specBridgeColumnSpelling.test.tsx`. - const node = bridgeListView( - { columns: [{ field: 'email' }] }, - {}, - ); - expect(node.columns[0]).toEqual({ field: 'email' }); - }); - - it('maps the spec shorthand string column to a declared field column (#5068)', () => { - const node = bridgeListView( - { columns: ['email'] as any }, - {}, - ); - expect(node.columns[0]).toEqual({ field: 'email' }); - }); - - it('maps column properties correctly', () => { - const node = bridgeListView( - { - columns: [ - { - field: 'status', - label: 'Status', - align: 'center', - hidden: false, - resizable: true, - wrap: true, - type: 'badge', - pinned: 'left', - summary: { type: 'count' }, - // `ListColumn.link` is a BOOLEAN in @objectstack/spec and - // `action` is the NAME of an action — not the `{ href }` / - // `{ type }` envelopes this fixture used to author. The bridge - // forwards both keys verbatim, so the old assertions passed on - // any value at all and proved only pass-through (objectui#4040). - link: true, - action: 'navigate', - }, - ], - }, - {}, - ); - - const col = node.columns[0]; - expect(col.align).toBe('center'); - expect(col.hidden).toBe(false); - expect(col.resizable).toBe(true); - expect(col.wrap).toBe(true); - expect(col.type).toBe('badge'); - expect(col.pinned).toBe('left'); - expect(col.summary).toEqual({ type: 'count' }); - expect(col.link).toBe(true); - expect(col.action).toBe('navigate'); - }); - - // All FIVE spellings `RowHeightSchema` admits, and only those. The fixture - // used to author `comfortable` / `spacious` / `small` as well — three values - // no spec-valid list view can carry — and it type-checked against nothing, - // so `mapDensity`'s branches for them read as live capability. What a caller - // can actually hand this bridge is the enum below (objectui#4040). - it('maps every spec rowHeight to a density', () => { - const compact = bridgeListView({ rowHeight: 'compact' }, {}); - expect(compact.density).toBe('compact'); - - const short = bridgeListView({ rowHeight: 'short' }, {}); - expect(short.density).toBe('compact'); - - const medium = bridgeListView({ rowHeight: 'medium' }, {}); - expect(medium.density).toBe('comfortable'); - - const tall = bridgeListView({ rowHeight: 'tall' }, {}); - expect(tall.density).toBe('spacious'); - - const extraTall = bridgeListView({ rowHeight: 'extra_tall' }, {}); - expect(extraTall.density).toBe('spacious'); - }); - - // The other half of that enum: `comfortable` / `spacious` / `small` / - // `large` used to be mapped too, so four values `RowHeightSchema` does not - // admit read as live capability. They are gone (objectui#4352) — AGENTS.md - // #0.1, one strict contract beats N dialects — and an off-spec `rowHeight` - // now falls through to no density at all rather than being quietly - // rehabilitated into one. - // - // Routed through `SpecBridge.transformListView`, whose parameter is `any`: - // that untyped boundary is the one a host's stored JSON actually crosses, - // and it is the only way left to get these values into the bridge. Writing - // them on `bridgeListView` directly no longer type-checks, which is the - // static half of the same fix. - it.each(['comfortable', 'spacious', 'small', 'large'])( - 'leaves density unset for the off-spec rowHeight %s', - (rowHeight) => { - const node = new SpecBridge().transformListView({ - name: 'off_spec_density', - rowHeight, - }); - - expect(node.density).toBeUndefined(); - // Not merely undefined — the key is never written, so the renderer's - // own default applies instead of an explicit `density: undefined`. - expect('density' in node).toBe(false); - }, - ); - - // Control: a string in neither vocabulary already fell through before the - // four keys were deleted, and still does. Green on both sides of the - // change — it pins the fall-through itself, not the deletion. - it('leaves density unset for a rowHeight in no vocabulary at all', () => { - const node = new SpecBridge().transformListView({ - name: 'off_spec_density', - rowHeight: 'gargantuan', - }); - - expect(node.density).toBeUndefined(); - expect('density' in node).toBe(false); - }); - - it('includes optional list properties', () => { - const node = bridgeListView( - { - // Spec spellings throughout: `sort` is a list of `{ field, order }` - // (not a single `{ field, direction }`), `filter` is a list of - // `{ field, operator, value }` predicates (not a field→value map), - // and `grouping` wraps its levels in `fields`. The bridge forwards - // each key verbatim, so the previous fixture's dialect round-tripped - // and the assertions passed while describing metadata that cannot be - // authored (objectui#4040). - sort: [{ field: 'name', order: 'asc' }], - filter: [{ field: 'status', operator: 'equals', value: 'active' }], - grouping: { fields: [{ field: 'region' }] }, - rowColor: { field: 'priority' }, - searchableFields: ['name', 'email'], - }, - {}, - ); - - expect(node.sort).toEqual([{ field: 'name', order: 'asc' }]); - expect(node.filter).toEqual([ - { field: 'status', operator: 'equals', value: 'active' }, - ]); - expect(node.grouping).toEqual({ fields: [{ field: 'region' }] }); - expect(node.rowColor).toEqual({ field: 'priority' }); - expect(node.searchableFields).toEqual(['name', 'email']); - }); - - it('handles empty spec gracefully', () => { - const node = bridgeListView({}, {}); - expect(node.type).toBe('object-grid'); - expect(node.columns).toEqual([]); - }); - }); - - describe('bridgeFormView', () => { - it('transforms a basic form view spec', () => { - const spec = { - type: 'create', - data: { provider: 'object', object: 'Contact' }, - sections: [ - { - label: 'Basic Info', - columns: 2, - fields: [ - { field: 'firstName', label: 'First Name', required: true }, - { field: 'lastName', label: 'Last Name', required: true }, - { field: 'email', label: 'Email', placeholder: 'you@example.com' }, - ], - }, - ], - }; - - const bridge = new SpecBridge(); - const node = bridge.transformFormView(spec); - - expect(node.type).toBe('object-form'); - expect(node.id).toBe('form-create'); - expect(node.data).toEqual({ provider: 'object', object: 'Contact' }); - expect(node.sections).toHaveLength(1); - expect(node.sections[0].label).toBe('Basic Info'); - expect(node.sections[0].columns).toBe(2); - expect(node.sections[0].fields).toHaveLength(3); - expect(node.sections[0].fields[0].name).toBe('firstName'); - expect(node.sections[0].fields[0].required).toBe(true); - }); - - it('maps field properties correctly', () => { - const node = bridgeFormView( - { - sections: [ - { - fields: [ - { - field: 'notes', - label: 'Notes', - placeholder: 'Enter notes', - helpText: 'Keep it brief', - readonly: true, - hidden: false, - colSpan: 2, - widget: 'textarea', - dependsOn: ['status'], - visibleOn: '${status === "active"}', - }, - ], - }, - ], - }, - {}, - ); - - const field = node.sections[0].fields[0]; - expect(field.name).toBe('notes'); - expect(field.label).toBe('Notes'); - expect(field.placeholder).toBe('Enter notes'); - expect(field.helpText).toBe('Keep it brief'); - expect(field.readonly).toBe(true); - expect(field.hidden).toBe(false); - expect(field.colSpan).toBe(2); - expect(field.widget).toBe('textarea'); - expect(field.dependsOn).toEqual(['status']); - expect(field.visibleOn).toBe('${status === "active"}'); - }); - - it('maps section properties correctly', () => { - const node = bridgeFormView( - { - sections: [ - { - label: 'Advanced', - collapsible: true, - collapsed: true, - columns: 3, - fields: [], - }, - ], - }, - {}, - ); - - const section = node.sections[0]; - expect(section.label).toBe('Advanced'); - expect(section.collapsible).toBe(true); - expect(section.collapsed).toBe(true); - expect(section.columns).toBe(3); - }); - - it('uses default id when type is not specified', () => { - const node = bridgeFormView({}, {}); - expect(node.id).toBe('form-default'); - }); - - it('uses field name as label fallback', () => { - const node = bridgeFormView( - { sections: [{ fields: [{ field: 'age' }] }] }, - {}, - ); - expect(node.sections[0].fields[0].label).toBe('age'); - }); - - it('normalizes legacy groups into sections (#2545)', () => { - // Spec: `groups` is a legacy alias of `sections`. The renderer only - // consumes `sections`, so the bridge folds groups into it instead of - // passing a dead `groups` key through. - const node = bridgeFormView( - { groups: [{ name: 'g1', label: 'Group 1' }] }, - {}, - ); - expect(node.groups).toBeUndefined(); - expect(node.sections).toEqual([ - { name: 'g1', label: 'Group 1', fields: [] }, - ]); - }); - }); -}); diff --git a/packages/react/src/spec-bridge/bridges/form-view.ts b/packages/react/src/spec-bridge/bridges/form-view.ts deleted file mode 100644 index 21d47691cb..0000000000 --- a/packages/react/src/spec-bridge/bridges/form-view.ts +++ /dev/null @@ -1,480 +0,0 @@ -/** - * 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. - */ - -/** - * Form-view bridge input — the documented SUBSET of `@objectstack/spec`'s - * FormViewSchema this bridge consumes, with every drift-prone key's TYPE bound - * to the contract instead of restated (objectui#5652). - * - * The subset stays hand-listed on purpose: this declaration is load-bearing - * documentation, and its NON-declarations are a retirement ledger - * (`defaultSort` / `aria`, objectui#3901 / #3974) that a blanket - * `Omit` would erase. What was never deliberate is the TYPE each - * declared key was given. Three of them had drifted from what the contract - * accepts, and nothing ever compared the two descriptions — the mirror shape - * `scripts/check-spec-symbol-derivation.mjs` exists to catch. The sibling - * bridge in this directory (`list-view.ts`) was converted the same way and for - * the same reason (objectui#2231): the shape derives, so it cannot drift. - * - * ## What the contract actually answers - * - * Measured with `safeParse` against the installed `@objectstack/spec` while - * fixing this, NOT copied from the finding — two of its three rows were wrong, - * in ways that change the repair: - * - * FormSection.columns `1 | 2 | 3 | 4 | '1' | '2' | '3' | '4'`. The string - * arm IS admitted, and the section schema's own pipe - * folds it to a number. The bridge declared `number` - * and refused it. - * FormView.columns `number`, with NO string arm: `FormViewSchema` - * rejects `columns: '3'` at the FORM level while - * `FormSectionSchema` accepts it at the SECTION level. - * The bridge's `number` was already right here — it is - * bound to the contract so the asymmetry cannot rot, - * and the two answers are pinned side by side. - * FormField.dependsOn a BARE parent-field name. The array arm is rejected - * by the contract outright. The bridge declared - * `string[]`, so it admitted only the arm the contract - * refuses and refused the only arm it admits — the one - * configuration that makes `field-selector` and - * `dynamic-config` work (objectui#5040). - * *.visibleWhen `string | { dialect, source?, ast?, meta? }`, with - * `dialect` REQUIRED and enum-typed. `{ dialect?, - * source }` is a DIFFERENT layer's type — the - * evaluator's `FieldRulePredicate` (`@object-ui/core`, - * ADR-0089) — and the contract does not accept it: an - * object arm without `dialect` is refused. The bridge - * declared `string` and refused the object arm - * entirely. - * - * ## Declaring an arm is not consuming it - * - * A key that is declarable but dropped on the way out is the same defect one - * layer over (objectui#5542 / #5594), so each widened arm is followed all the - * way onto the `object-form` node by `FormViewWidenedArms.test.ts`, and the one - * arm that needs a translation to get there gets it — see `normalizeColumns`. - */ - -import type { BaseSchema, DependsOnInput } from '@object-ui/types'; -// `FormFieldInput` is the AUTHORING half of the spec's form field (`z.input`) — -// what a bridge that reads authored metadata is handed. Its parsed twin, -// `FormField`, is banned from import here by `no-restricted-imports` (it names -// the runtime vocabulary in this repo, and its spec type erases to `any` — -// objectstack#4171 / objectui#3090). `FormFieldInput` carries neither problem. -import type { FormFieldInput, FormSection, FormView } from '@objectstack/spec/ui'; -import type { BridgeContext, BridgeFn } from '../types.js'; - -/** - * A form field as authored. Exported so the derivation pins in - * `__tests__/FormViewWidenedArms.test.ts` can name the declared types; NOT - * re-exported by `spec-bridge/index.ts`, so the package surface is unchanged. - */ -export interface FormFieldSpec { - field: string; - /** Field type (spec FormFieldSchema reuses Data.FieldType; auto-infers widget). */ - type?: string; - /** Options for select/multiselect/radio/checkboxes fields. */ - options?: any[]; - /** Target object name for lookup/master_detail fields. */ - reference?: string; - label?: string; - placeholder?: string; - helpText?: string; - readonly?: boolean; - required?: boolean; - hidden?: boolean; - colSpan?: number; - widget?: string; - /** - * Sibling field name whose value this field's widget reads (objectui#5040). - * - * The contract admits a BARE NAME and refuses an array. This is the wider - * {@link DependsOnInput} rather than the contract's `string` alone because - * the value is FORWARDED, unread, into the node slot that is declared with - * exactly this type (`FormField.dependsOn`, `@object-ui/types`) and read by - * `resolveCascadingOptions` / `resolveDependsOnFields` (`@object-ui/core`), - * whose parameter is also exactly this type (framework#4074). Narrowing to - * the contract's `string` would refuse array-authored layouts that the - * runtime has always honoured, on the same never-parsed inputs `groups` and - * `visibleOn` below are still read for. The contract's arm is a subset of - * this one, which is what the derivation pin asserts — so a spec that widens - * `dependsOn` fails the pin instead of silently outgrowing the declaration. - */ - dependsOn?: DependsOnInput; - /** Canonical conditional-visibility predicate (ADR-0089), bound to the contract. */ - visibleWhen?: FormFieldInput['visibleWhen']; - /** @deprecated ADR-0089 -> `visibleWhen`. */ - visibleOn?: FormFieldInput['visibleOn']; - - // ── Keys restored by objectui#5898 ────────────────────────────────────────── - // Every one below was a spec key this declaration did not name, so `mapField` - // could not copy it and the authored value ended at this seam. Each has a - // destination on the runtime `FormField` that `normalizeSectionField` - // (@object-ui/plugin-form) already pins by name in - // `sectionFields.spec-parity.test.ts` — the SAME slot, so a bridged field and - // a directly-normalised one carry the value identically. Types are bound to - // the contract rather than restated, per this file's derivation policy. - /** Text length constraints. */ - maxLength?: FormFieldInput['maxLength']; - minLength?: FormFieldInput['minLength']; - /** Numeric constraints. */ - min?: FormFieldInput['min']; - max?: FormFieldInput['max']; - precision?: FormFieldInput['precision']; - scale?: FormFieldInput['scale']; - /** Multi-value flag — part of the (type, multiple) pair the widget id derives from. */ - multiple?: FormFieldInput['multiple']; - /** Editable on create, locked once the record exists. */ - immutable?: FormFieldInput['immutable']; - /** Relative field width (`'auto' | 'full'`) — preferred over the legacy `colSpan`. */ - span?: FormFieldInput['span']; - /** Code-editor language, for `type: 'code'` fields. */ - language?: FormFieldInput['language']; - /** Record-typed field key column config (ADR-0007). */ - keyField?: FormFieldInput['keyField']; - /** Composite rendering mode: inline box or summary + popover (ADR-0007). */ - disclosure?: FormFieldInput['disclosure']; - /** - * Sub-fields for `composite` / `repeater` / `record` types. Forwarded - * VERBATIM, in the spec vocabulary (`field`, not `name`): the runtime slot is - * the pass-through `base.fields = fd.fields` in `normalizeSectionField`, and - * its pinned row asserts the authored shape survives (`{ field: 'inner' }`). - * Recursing through `mapField` here would rewrite the sub-field identity key - * and hand the widget a shape its own gate says it must not receive. - */ - fields?: FormFieldInput['fields']; - - // `publicPicker` is NOT declared here, and that is the deliberate half of - // #2545's promise — an explained refusal, not a silent drop. It is a - // SERVER-side authorization opt-in, not a presentation delta: it gates - // objectstack's public-lookup route (`GET /forms/:slug/lookup/:field` answers - // `403 LOOKUP_NOT_PUBLIC` for a field whose form declaration lacks it), and - // the public-form resolve route strips undeclared lookup fields before the - // metadata ever reaches a renderer. This bridge builds the `object-form` node - // for an in-app authenticated form and has no destination for it — zero read - // points repo-wide. Carrying it onto the node would invent a client-side - // meaning for a capability only the server enforces. Same reasoned exemption - // the downstream chokepoint already records, on the same delegated ruling - // (objectui#4648 item 5, 2026-08-15); it becomes an implementation card if - // ObjectUI ever renders anonymous public forms. -} - -/** One section of a form layout, as authored. */ -export interface FormSectionSpec { - /** Stable section identifier for i18n lookup (spec FormSectionSchema.name). */ - name?: string; - label?: string; - description?: string; - collapsible?: boolean; - collapsed?: boolean; - /** Bound to the contract: the string spelling of a column count is admitted. */ - columns?: FormSection['columns']; - /** Section-level conditional-visibility predicate (ADR-0089), bound to the contract. */ - visibleWhen?: FormSection['visibleWhen']; - /** - * @deprecated ADR-0089 -> `visibleWhen`. - * - * Declared and FOLDED, not carried (objectui#5898). The contract accepts this - * spelling and normalises it away in `FormSectionSchema`'s own - * `.transform(normalizeVisibleWhen)`, so a section that has been through the - * parser never presents it — but this bridge is also the seam for the - * never-parsed input class it already reads `groups` and field `visibleOn` - * for, and on that input the section path read only `visibleWhen`. The - * deprecated spelling was therefore dropped on exactly the documents the - * fallback exists for. Folding here reproduces the contract's own - * normalisation rather than teaching the node a second key. - */ - visibleOn?: FormSection['visibleOn']; - /** - * Which pane of a split form this section renders in (`type: 'split'` only — - * the contract rejects the key on any other form type at parse). Restored by - * objectui#5898: the node declares the same slot (`ObjectFormSection.pane`), - * `ObjectForm`'s split branch copies it, and `SplitForm`'s `paneOf` reads it. - * Dropped here, a spec-authored split form fell back to the legacy positional - * rule (first section primary, the rest secondary) — so reordering sections - * moved them across the divider, the exact failure `pane` was added to - * prevent. - */ - pane?: FormSection['pane']; - /** - * The authored field list. A bare string is the spec's shorthand for "this - * object's own field, rendered with its defaults" — the same shorthand - * `mapColumn` already honours on the list bridge. - */ - fields?: Array; -} - -/** - * The subset of `@objectstack/spec` FormViewSchema the bridge consumes. - * Every serializable spec key is either mapped onto the `object-form` node - * or listed here with an explicit reason for being ignored — the bridge must - * never silently drop spec configuration (#2545). - * - * ⚠️ That promise was FALSE for 18 keys until objectui#5898, and the way it - * stayed false is the part worth keeping: the conformance test that enforces it - * ran its completeness loop over `Object.keys(FIXTURE)`, so a key nobody - * remembered to put in the fixture was a key the loop never asked about. A - * hand-listed subset is legitimate here (its NON-declarations are a retirement - * ledger a blanket `Omit` would erase) — a hand-listed *check* of that subset is - * not. The loop now derives its key set from the contract's own shape at all - * three levels, so a spec key that is neither mapped nor explained fails the - * suite by construction rather than by recall. - */ -export interface FormViewSpec { - type?: string; - layout?: string; - /** Bound to the contract, which admits NO string arm here — unlike a section. */ - columns?: FormView['columns']; - title?: string; - description?: string; - // Tabbed (`type: 'tabbed'`) - defaultTab?: string; - tabPosition?: string; - // Wizard (`type: 'wizard'`) - allowSkip?: boolean; - showStepIndicator?: boolean; - // Split (`type: 'split'`) - splitDirection?: string; - splitSize?: number; - splitResizable?: boolean; - // Drawer (`type: 'drawer'`) - drawerSide?: string; - drawerWidth?: string; - // Modal (`type: 'modal'`) - modalSize?: string; - data?: any; - sections?: FormSectionSpec[]; - /** Legacy alias of `sections` (spec: "Legacy support → alias to sections"). */ - groups?: FormSectionSpec[]; - /** Inline master-detail child collections. */ - subforms?: any[]; - /** - * Structured action-button config (`submit` / `cancel` / `reset` visibility + - * label). Restored by objectui#5898: the node declares the same slot - * (`ObjectFormSchema.buttons`) and `ObjectForm` folds it down onto the flat - * `showSubmit` / `submitText` / … props at render. The spec key exists FOR - * this consumer — its own description names ObjectUI's ObjectForm as what - * consumes it (framework#1894 / #2998) — so an ignore-list entry would have - * been the wrong repair. - */ - buttons?: FormView['buttons']; - /** - * Create-mode initial field values, keyed by field machine name. Restored by - * objectui#5898 for the same reason as `buttons`: `ObjectFormSchema.defaults` - * is the declared slot and `ObjectForm` folds it into `initialValues` at - * render. - */ - defaults?: FormView['defaults']; - // `defaultSort` and `aria` are NOT declared here on purpose — see the - // retirement note above `bridgeFormView`'s trailing key copies (#3901/#3974). - // Re-adding either to this mirror is the first half of re-adding a read that - // can never fire. - sharing?: any; - submitBehavior?: any; -} - -/** - * Fold the contract's string spelling of a column count onto the number the - * node carries. - * - * `FormSectionSchema` accepts `'2'` and `2` and normalises the string away in - * its own pipe; the `object-form` node's section declares `columns?: 1|2|3|4` - * and `FormSectionContainer` indexes its grid-class map by that number. Its - * header states where the fold belongs: "The normalisation belongs at the seam - * that parses authored metadata, not in the container's props." This bridge is - * that seam. Forwarding `'2'` verbatim hands every downstream renderer a value - * outside the type it declares. - */ -function normalizeColumns(columns: FormSectionSpec['columns']): number | undefined { - return typeof columns === 'string' ? Number(columns) : columns; -} - -function mapField(field: FormFieldSpec): Record { - const mapped: Record = { - name: field.field, - label: field.label ?? field.field, - }; - - if (field.type) mapped.type = field.type; - if (field.options) mapped.options = field.options; - if (field.reference) mapped.reference = field.reference; - if (field.placeholder) mapped.placeholder = field.placeholder; - if (field.helpText) mapped.helpText = field.helpText; - if (field.readonly != null) mapped.readonly = field.readonly; - if (field.required != null) mapped.required = field.required; - if (field.hidden != null) mapped.hidden = field.hidden; - if (field.colSpan != null) mapped.colSpan = field.colSpan; - if (field.widget) mapped.widget = field.widget; - // objectui#5898 — the constraint / presentation / composite keys the - // declaration above had never named. Every one is a SAME-NAME copy, matching - // the destination `normalizeSectionField` gives it when it normalises an - // authored spec field directly (`base.maxLength = fd.maxLength`, …), so the - // two routes to a runtime `FormField` agree key for key. `!= null` rather - // than truthiness throughout: `min: 0`, `precision: 0`, `multiple: false` and - // `immutable: false` are all authored decisions, and a truthiness test would - // drop them exactly as the missing declaration did. - if (field.maxLength != null) mapped.maxLength = field.maxLength; - if (field.minLength != null) mapped.minLength = field.minLength; - if (field.min != null) mapped.min = field.min; - if (field.max != null) mapped.max = field.max; - if (field.precision != null) mapped.precision = field.precision; - if (field.scale != null) mapped.scale = field.scale; - if (field.multiple != null) mapped.multiple = field.multiple; - if (field.immutable != null) mapped.immutable = field.immutable; - if (field.span != null) mapped.span = field.span; - if (field.language != null) mapped.language = field.language; - if (field.keyField != null) mapped.keyField = field.keyField; - if (field.disclosure != null) mapped.disclosure = field.disclosure; - // Verbatim, in the spec vocabulary — see the declaration's note: the runtime - // slot is a pass-through and its pinned row asserts `{ field: 'inner' }` - // survives unrewritten. - if (Array.isArray(field.fields)) mapped.fields = field.fields; - // Forwarded unread into the node's `dependsOn`, which is declared with the - // same type and read by `@object-ui/core`'s cascading-option resolver. - if (field.dependsOn) mapped.dependsOn = field.dependsOn; - // ADR-0089: `visibleWhen` is the canonical view-form-field visibility predicate - // (the spec folds the deprecated `visibleOn` into it at parse). Prefer it and - // fall back to `visibleOn` for raw / un-normalized metadata. The ObjectForm - // renderer reads this view-level predicate from the node's `visibleOn` slot. - // - // Both contract arms travel whole: the bare CEL string, and the expression - // object. `evalFieldPredicate` reads `{ dialect, source }` back out of the - // object arm, which is how a spec-authored `{ dialect: 'cel', source: ... }` - // now drives visibility instead of being refused by this declaration. An - // `ast`-only expression (legal metadata: the contract requires `dialect` plus - // one of `source` / `ast`) is forwarded whole rather than dropped, but no - // evaluator in this repo reads an `ast` today — that gap is the evaluator's, - // and the bridge must not hide it by discarding the key. - const visiblePredicate = field.visibleWhen ?? field.visibleOn; - if (visiblePredicate) mapped.visibleOn = visiblePredicate; - - return mapped; -} - -function mapSection(section: FormSectionSpec): Record { - const mapped: Record = { - // A bare field name is the spec's shorthand and is forwarded verbatim: the - // node's `fields` slot admits it and `normalizeSectionField` - // (@object-ui/plugin-form) resolves it against the object schema. Running - // it through `mapField` instead produced `{ name: undefined }` — a field - // with no identity — for the most ordinary section a form can declare. - fields: (section.fields ?? []).map((field) => - typeof field === 'string' ? field : mapField(field), - ), - }; - - if (section.name) mapped.name = section.name; - if (section.label) mapped.label = section.label; - if (section.description) mapped.description = section.description; - if (section.collapsible != null) mapped.collapsible = section.collapsible; - if (section.collapsed != null) mapped.collapsed = section.collapsed; - if (section.columns != null) mapped.columns = normalizeColumns(section.columns); - // Whole, both arms — same predicate contract as the field above. The - // deprecated `visibleOn` spelling folds onto the canonical slot exactly as - // `FormSectionSchema`'s own `.transform(normalizeVisibleWhen)` does, so a - // never-parsed document reaches the node saying what a parsed one would - // (objectui#5898). Canonical wins when both are authored, matching the - // contract's precedence and the field path directly above. - const sectionPredicate = section.visibleWhen ?? section.visibleOn; - if (sectionPredicate) mapped.visibleWhen = sectionPredicate; - // objectui#5898 — explicit split-pane placement. `ObjectFormSection.pane` is - // the node slot; `ObjectForm`'s split branch copies it and `SplitForm`'s - // `paneOf` reads it, falling back to the positional rule only when it is - // absent. Dropped here, every spec-authored placement took that fallback. - if (section.pane != null) mapped.pane = section.pane; - - return mapped; -} - -/** Maps spec formType to ObjectUI formType */ -function mapFormType(type?: string): string | undefined { - if (!type) return undefined; - const validTypes = ['simple', 'tabbed', 'wizard', 'split', 'drawer', 'modal']; - return validTypes.includes(type) ? type : undefined; -} - -/** - * Spec FormViewSchema keys carried onto the `object-form` node verbatim. - * All of them are declared with the same name (and semantics) on - * `ObjectFormSchema`, so no per-key mapping is needed — only presence checks. - */ -const PASSTHROUGH_KEYS = [ - 'layout', - 'columns', - 'title', - 'description', - 'defaultTab', - 'tabPosition', - 'allowSkip', - 'showStepIndicator', - 'splitDirection', - 'splitSize', - 'splitResizable', - 'drawerSide', - 'drawerWidth', - 'modalSize', - 'subforms', - // objectui#5898 — the spec's structured authoring surface for the form's - // action buttons and its create-mode initial values. Same name and semantics - // on `ObjectFormSchema`, where `ObjectForm` folds both down at render. - 'buttons', - 'defaults', -] as const; - -/** Transforms a FormView spec into a Form SchemaNode */ -export const bridgeFormView: BridgeFn = ( - spec: FormViewSpec, - _context: BridgeContext, -): BaseSchema => { - // Spec defines `groups` as a legacy alias of `sections`; normalize here so - // downstream renderers only ever see `sections` (ObjectForm never reads a - // `groups` key — before this normalization a groups-only spec silently - // rendered no sections at all, #2545). - const sections = (spec.sections ?? spec.groups ?? []).map(mapSection); - const formType = mapFormType(spec.type); - - const node: BaseSchema = { - type: 'object-form', - id: `form-${spec.type ?? 'default'}`, - sections, - data: spec.data, - }; - - // P1.2 — formType mapping (tabbed, wizard, split, drawer, modal) - if (formType) node.formType = formType; - - // #2545 — same-name spec keys (layout, title, tab/wizard/split/drawer/modal - // options, subforms) pass straight through onto the node. - for (const key of PASSTHROUGH_KEYS) { - if (spec[key] != null) node[key] = spec[key]; - } - - if (spec.submitBehavior) node.submitBehavior = spec.submitBehavior; - - // P1.6 — sharing - if (spec.sharing) node.sharing = spec.sharing; - - // `defaultSort` (#3974) and `aria` (#3901) USED to be copied here. Both are - // `retiredKey()` tombstones on spec 17's FormViewSchema, so a FormView that - // parsed can never carry either — the guards were unreachable, and their - // shape invited the next reader to copy a dead pattern. Removed under the - // maintainer's 2026-08-11 enforce-or-remove ruling; measured dormant at BOTH - // ends before removal: - // - producer: `FormViewSchema.safeParse` rejects both keys by name - // (`form.defaultSort` / `form.aria` removed in the #3896 close-out). - // - consumer: no form renderer reads either off the node — plugin-form - // reads neither, and `SchemaRenderer`'s generic ARIA injection resolves - // FLAT `ariaLabel`/`ariaDescribedBy`/`role` (SchemaRenderer.tsx:109-119), - // never a nested `aria` object. - // The nested `aria` copy is still correct on the LIST bridge - // (`list-view.ts:214`), whose carrier stayed live and IS consumed at - // `plugin-list/src/ListView.tsx:2389-2392` — do not "align" the two. - // Pinned by `__tests__/FormViewRetiredKeys.test.ts`. - - return node; -}; diff --git a/packages/react/src/spec-bridge/bridges/list-view.ts b/packages/react/src/spec-bridge/bridges/list-view.ts deleted file mode 100644 index bebab62115..0000000000 --- a/packages/react/src/spec-bridge/bridges/list-view.ts +++ /dev/null @@ -1,256 +0,0 @@ -/** - * 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. - */ - -import type { BaseSchema } from '@object-ui/types'; -import type { ListViewExportFormat, ListViewExportOptions } from '@object-ui/types'; -import type { BridgeContext, BridgeFn } from '../types.js'; -import type { ListView, ListColumn, RowHeight } from '@objectstack/spec/ui'; - -/** - * Bridge input: the spec-canonical ListView (#2231 — the former hand-written - * `ListViewSpec`/`ListColumn` mirrors are retired; the shape now derives from - * `@objectstack/spec/ui` and can no longer drift). Relaxed to `Partial` because - * hosts routinely hand the bridge fragmentary view-configs (every field was - * optional in the old mirror too, including spec-required `columns`). - */ -type ListViewSpec = Partial; - -/** - * A spec `ListColumn` in, the same column out — in the spelling the spec - * declares (objectui#5068). - * - * This used to down-translate every column to the data-table adapter's - * `{ accessorKey, header }` before handing it to an `object-grid` node, and - * `ObjectGrid` translated it straight back through a tolerance branch that - * sniffed `columns[0]`. A round trip whose input was ALREADY canonical: the - * bridge's own parameter is `@objectstack/spec/ui`'s `ListColumn`, where - * `field` is required and `accessorKey` / `header` are refused by name - * (`ListColumnSchema` is a strict object). So the producer emitted a spelling - * the contract rejects, and the renderer grew a second de-facto contract to - * read it back — exactly the shape AGENTS.md #0.1 forbids, and the disposition - * objectui#3951 already settled: unify at the producer, no consumer-side alias. - * - * `accessorKey` is not retired as a concept — it is the TanStack adapter key - * `@object-ui/core` deliberately keeps OUT of the metadata identity fold - * (`column-identity.ts`'s `TABLE_ADAPTER_COLUMN_KEY`). `ObjectGrid` still - * applies it on the way OUT, at the one boundary that owns it. Metadata - * vocabulary comes in; adapter vocabulary goes out; one translation, one place. - * - * Nothing is invented on the way through, which is the second half of the fix. - * `header: col.label ?? col.field` used to turn "no label declared" into "the - * machine name was declared as the label", and that synthesized value then - * pre-empted `ObjectGrid`'s own header chain — `col.label` → the OBJECT - * FIELD's label → the prettified name — whose whole purpose is a column - * authored as a bare `{ field }`, and whose middle step exists so a localized - * field label wins over the name-derived English one on a non-English app. A - * bridged view therefore rendered raw machine names where a directly authored - * `object-grid` rendered the field's real, localizable label. It now forwards - * the declaration and lets the renderer resolve what was left unsaid. - */ -function mapColumn(col: ListColumn | string): Record { - // Spec shorthand: a bare field name stands for a default column. - if (typeof col === 'string') { - return { field: col }; - } - - const mapped: Record = { - field: col.field, - }; - - if (col.label != null) mapped.label = col.label; - if (col.width != null) mapped.width = col.width; - if (col.align) mapped.align = col.align; - if (col.hidden != null) mapped.hidden = col.hidden; - if (col.sortable != null) mapped.sortable = col.sortable; - if (col.resizable != null) mapped.resizable = col.resizable; - if (col.wrap != null) mapped.wrap = col.wrap; - if (col.type) mapped.type = col.type; - if (col.pinned) mapped.pinned = col.pinned; - if (col.summary) mapped.summary = col.summary; - if (col.link) mapped.link = col.link; - if (col.action) mapped.action = col.action; - - return mapped; -} - -/** - * The spec's five row heights collapsed onto the renderer's three densities. - * - * `Record` on purpose (objectui#4352): the key set is the spec's, - * so a row height added upstream fails the build here instead of silently - * arriving with no density. The table used to carry four more keys — - * `comfortable`, `spacious`, `small`, `large` — which `RowHeightSchema` does - * not admit, so no spec-valid list view could ever reach them; they only - * survived because the parameter was widened back to `string` and the fixture - * asserting three of them compiled against nothing. Deleting them is AGENTS.md - * #0.1: a renderer-side dialect for off-spec metadata is a second de-facto - * contract, and one strict contract beats N. An off-spec `rowHeight` now falls - * through to `undefined` — the producer is where it gets fixed. - */ -const ROW_HEIGHT_TO_DENSITY: Record< - RowHeight, - 'compact' | 'comfortable' | 'spacious' -> = { - compact: 'compact', - short: 'compact', - medium: 'comfortable', - tall: 'spacious', - extra_tall: 'spacious', -}; - -/** - * Runtime reader for {@link ROW_HEIGHT_TO_DENSITY} — the five spec row heights - * and **nothing else**. - * - * `hasOwnProperty`, not a bare index (objectui#4442): the table is a plain - * object literal, so indexing it with an unchecked key reaches - * `Object.prototype`. The parameter is typed `RowHeight`, but the boundary a - * host's stored view definition actually crosses is `SpecBridge.transformListView`, - * whose parameter is `any` — so `rowHeight: 'toString'` used to come back as - * `Object.prototype.toString`, a FUNCTION returned from a signature that - * promises three strings or nothing. `bridgeListView` then writes the key under - * `if (density)`, and a function is truthy, so it landed on the SchemaNode. - * - * This is the same guard `@object-ui/core`'s `rowHeightToDensityMode` grew in - * objectui#4440, and the repo's existing convention (`freeze-schema.ts:135`, - * `metadata-admin/predicate.ts:305`, six more). Both rowHeight surfaces now - * abstain identically on every off-spec spelling; the agreement is pinned by - * `__tests__/RowHeightDensityAgreement.test.ts`. - * - * `typeof rowHeight !== 'string'`, not `!rowHeight` (objectui#4459): the - * truthiness guard this replaces rejected only FALSY values, so every truthy - * non-string walked past it into a lookup that coerces its key — both - * `hasOwnProperty.call` and the index run `String(...)`. `['compact']`, a boxed - * `String('compact')` and `{ toString: () => 'compact' }` therefore each - * selected a real density, where core's twin abstained - * (`packages/core/src/utils/normalize-list-view.ts:83`, which has opened with - * the type guard since #4440). Note the direction against #4442: that leak produced a - * FUNCTION, visibly wrong to everything downstream; this one produced a - * legitimate-looking `'compact'` that nothing can tell apart from an authored - * value. The type guard subsumes the old one — `undefined`, `null`, `0` and - * `false` are all non-strings — and `''` keeps its answer while changing route: - * a string, so it passes here and is refused by `hasOwnProperty` one line down. - */ -function mapDensity( - rowHeight?: RowHeight, -): 'compact' | 'comfortable' | 'spacious' | undefined { - if (typeof rowHeight !== 'string') return undefined; - if (!Object.prototype.hasOwnProperty.call(ROW_HEIGHT_TO_DENSITY, rowHeight)) { - return undefined; - } - return ROW_HEIGHT_TO_DENSITY[rowHeight]; -} - -/** - * The spec's own parse-time lift, applied where `parse` cannot reach - * (objectui#4585). - * - * `@objectstack/spec` accepts BOTH spellings of `view.exportOptions` and lifts - * the legacy bare format array to the object form at parse (objectstack#8010, - * `ui/view.zod.ts`): - * - * z.array(ListViewExportFormatSchema).transform((formats) => ({ formats })) - * - * That transform never runs on this path. The bridge's input is a TypeScript - * type, not a parsed value — there is no `parse`/`safeParse` anywhere under - * `spec-bridge/` — so a host that parses first hands over the object form while - * a host that forwards raw stored metadata hands over whatever was authored, - * and nothing here can tell them apart. The legacy array therefore used to - * reach the `object-grid` node verbatim, where `ObjectGrid` reads - * `exportOptions.formats` and only that: `.formats` on an array is `undefined`, - * the renderer's `['csv', 'json']` default won, and the view's declared formats - * were dropped with no error and no console line — the export button still - * showed, because a non-empty array is truthy. - * - * This mirrors that transform and NOTHING more: the same contract applied one - * layer out, not a second de-facto one (AGENTS.md #0.1 — the consumer-side - * `Array.isArray` fallback in the renderer is what that rule forbids). Hence: - * - * - the object form passes through by reference, unread and unrewritten; - * - an empty array lifts to `{ formats: [] }`, exactly as the spec's transform - * does — its `z.array()` carries no `.min(1)`, so `[]` is a legal input that - * wraps rather than defaults. ObjectGrid then offers no format and hides the - * export button: "declared zero formats", read literally; - * - a `'pdf'` stored before its retirement is carried, not filtered. The spec - * REFUSES `'pdf'` at parse with a migration prescription (objectstack#8010; - * PDF export itself was declined as objectstack#1301 NOT_PLANNED) — it does - * not silently drop the value, so neither may this. Such a format dies - * downstream in ObjectGrid's format-AGNOSTIC menu filter, kept deliberately - * at objectui#4535 for metadata predating `os migrate meta --from 16`. - */ -function liftExportOptions( - exportOptions: NonNullable | ListViewExportOptions, -): ListViewExportOptions { - if (!Array.isArray(exportOptions)) return exportOptions; - // The pinned `@objectstack/spec@17.0.0-rc.6` still admits `'pdf'` as an array - // member (the enum narrowing arrives with the pin bump), so the element type - // here is wider than the renderer-side `ListViewExportFormat`. Narrowing it by - // filtering would invent precisely the silent drop the spec declines to do. - return { formats: exportOptions as ListViewExportFormat[] }; -} - -/** Transforms a ListView spec into a DataTable SchemaNode */ -export const bridgeListView: BridgeFn = ( - spec: ListViewSpec, - _context: BridgeContext, -): BaseSchema => { - const columns = (spec.columns ?? []).map(mapColumn); - const density = mapDensity(spec.rowHeight); - - const node: BaseSchema = { - type: 'object-grid', - id: spec.name, - columns, - data: spec.data, - }; - - if (spec.label) node.label = spec.label; - if (spec.selection) node.selection = spec.selection; - if (spec.pagination) node.pagination = spec.pagination; - if (spec.sort) node.sort = spec.sort; - if (spec.filter) node.filter = spec.filter; - if (density) node.density = density; - if (spec.grouping) node.grouping = spec.grouping; - if (spec.rowColor) node.rowColor = spec.rowColor; - if (spec.searchableFields) node.searchableFields = spec.searchableFields; - if (spec.filterableFields) node.filterableFields = spec.filterableFields; - if (spec.resizable != null) node.resizable = spec.resizable; - if (spec.navigation) node.navigation = spec.navigation; - if (spec.kanban) node.kanban = spec.kanban; - if (spec.calendar) node.calendar = spec.calendar; - if (spec.gantt) node.gantt = spec.gantt; - if (spec.gallery) node.gallery = spec.gallery; - if (spec.timeline) node.timeline = spec.timeline; - - // P1.1 — Spec Protocol Alignment additions - if (spec.rowActions) node.rowActions = spec.rowActions; - if (spec.bulkActions) node.bulkActions = spec.bulkActions; - if (spec.bulkActionDefs) node.bulkActionDefs = spec.bulkActionDefs; - if (spec.conditionalFormatting) node.conditionalFormatting = spec.conditionalFormatting; - if (spec.inlineEdit != null) node.inlineEdit = spec.inlineEdit; - if (spec.exportOptions) node.exportOptions = liftExportOptions(spec.exportOptions); - if (spec.emptyState) node.emptyState = spec.emptyState; - if (spec.userActions) node.userActions = spec.userActions; - if (spec.appearance) node.appearance = spec.appearance; - if (spec.compactToolbar != null) node.compactToolbar = spec.compactToolbar; - if (spec.addRecord) node.addRecord = spec.addRecord; - if (spec.showRecordCount != null) node.showRecordCount = spec.showRecordCount; - if (spec.allowPrinting != null) node.allowPrinting = spec.allowPrinting; - - // P1.6 — i18n & ARIA - if (spec.aria) node.aria = spec.aria; - // `sharing` is already the spec's `ViewSharing` shape on both sides (#2890) — - // this used to DOWNGRADE it here, inventing a legacy `visibility` audience and - // an `enabled` flag that the renderer then had to fold back. - if (spec.sharing) node.sharing = spec.sharing; - if (spec.hiddenFields) node.hiddenFields = spec.hiddenFields; - if (spec.fieldOrder) node.fieldOrder = spec.fieldOrder; - if (spec.description) node.description = spec.description; - - return node; -}; diff --git a/packages/react/src/spec-bridge/index.ts b/packages/react/src/spec-bridge/index.ts deleted file mode 100644 index b37668ad44..0000000000 --- a/packages/react/src/spec-bridge/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * 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. - */ - -export { SpecBridge } from './SpecBridge.js'; -export type { BridgeContext, BridgeFn, ObjectDefLite } from './types.js'; -export { bridgeListView } from './bridges/list-view.js'; -export { bridgeFormView } from './bridges/form-view.js'; diff --git a/packages/react/src/spec-bridge/types.ts b/packages/react/src/spec-bridge/types.ts deleted file mode 100644 index b1f574d435..0000000000 --- a/packages/react/src/spec-bridge/types.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * 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. - */ - -import type { BaseSchema } from '@object-ui/types'; - -/** Context passed to all bridge functions */ -export interface BridgeContext { - /** Current user context for permission checks */ - user?: Record; - /** App-level variables */ - variables?: Record; - /** Registered object definitions for field metadata lookup */ - objectDefs?: Record; -} - -/** Lightweight object definition for field metadata resolution */ -export interface ObjectDefLite { - name: string; - label?: string; - fields: Array<{ - name: string; - label?: string; - type: string; - required?: boolean; - options?: Array<{ label: string; value: string }>; - }>; -} - -/** - * A bridge function transforms a spec schema into a schema node tree. - * - * Returns **`BaseSchema`**, not `SchemaNode` (objectui#4580, ruling Q4-B). - * Once core's `SchemaNode` became `@object-ui/types`' union - * (`BaseSchema | string | number | boolean | null | undefined`), declaring the - * return as `SchemaNode` made every caller re-ask a question no bridge has ever - * answered "yes" to: *is this a bare string / null?* Both shipped bridges end - * in a single `return node` on an object literal — `bridges/list-view.ts:226` - * and `bridges/form-view.ts:195` — so the wider declaration described nothing - * real while forcing a narrowing at every read. - * - * That cost was measured, not assumed: round 1 of this card recorded **272** - * mechanical errors across five spec-bridge suites, all of them TS18049 - * (`'node' is possibly 'null' or 'undefined'`) and TS2339 - * (`Property 'formType' does not exist on type - * 'string | number | boolean | BaseSchema'`) — the union being destructured by - * tests reading properties off a node the bridge always produces. - * - * This is a type ANNOTATION only: no runtime behaviour changes, and the emitted - * JavaScript is byte-identical (proven by bundle sha256 on this PR). - */ -export type BridgeFn = ( - spec: T, - context: BridgeContext, -) => BaseSchema; diff --git a/scripts/__tests__/one-authority-per-exported-name-6273.test.ts b/scripts/__tests__/one-authority-per-exported-name-6273.test.ts index 706172650a..542a445903 100644 --- a/scripts/__tests__/one-authority-per-exported-name-6273.test.ts +++ b/scripts/__tests__/one-authority-per-exported-name-6273.test.ts @@ -371,9 +371,12 @@ const KNOWN_COLLISIONS: ReadonlyMap = new Map([ ['FilterBuilderCondition', ['packages/components/src/custom/filter-builder.tsx', 'packages/types/src/complex.ts']], ['FilterBuilderOperator', ['packages/components/src/custom/filter-builder.tsx', 'packages/types/src/complex.ts']], ['FilterGroup', ['packages/components/src/custom/filter-builder.tsx', 'packages/types/src/complex.ts']], - ['FormFieldSpec', ['packages/app-shell/src/views/metadata-admin/form-spec.ts', 'packages/react/src/spec-bridge/bridges/form-view.ts']], - ['FormSectionSpec', ['packages/app-shell/src/views/metadata-admin/form-spec.ts', 'packages/react/src/spec-bridge/bridges/form-view.ts']], - ['FormViewSpec', ['packages/app-shell/src/views/metadata-admin/form-spec.ts', 'packages/react/src/spec-bridge/bridges/form-view.ts']], + // `FormFieldSpec` / `FormSectionSpec` / `FormViewSpec` sat here, colliding + // between `packages/app-shell/src/views/metadata-admin/form-spec.ts` and + // `packages/react/src/spec-bridge/bridges/form-view.ts` — the second + // authority left the tree with the spec-bridge retirement (objectui#6366, + // 2026-08-27 maintainer ruling), so app-shell's `form-spec.ts` is now the + // one authority and the entries would fail the stale-baseline direction. ['KanbanCard', ['packages/plugin-kanban/src/KanbanEnhanced.tsx', 'packages/plugin-kanban/src/KanbanImpl.tsx', 'packages/plugin-kanban/src/types.ts', 'packages/types/src/complex.ts']], // objectui#6155 — the ×4 that card measured ['KanbanColumn', ['packages/plugin-kanban/src/KanbanEnhanced.tsx', 'packages/plugin-kanban/src/KanbanImpl.tsx', 'packages/plugin-kanban/src/types.ts', 'packages/types/src/complex.ts']], // the same four files; no family card named it ['KanbanSchema', ['packages/plugin-kanban/src/types.ts', 'packages/types/src/complex.ts']], // objectui#6172