diff --git a/.changeset/console-form-container-specs-one-declaration-5596.md b/.changeset/console-form-container-specs-one-declaration-5596.md new file mode 100644 index 000000000..6f199b8c8 --- /dev/null +++ b/.changeset/console-form-container-specs-one-declaration-5596.md @@ -0,0 +1,68 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/console': patch +--- + +The two form CONTAINER contracts now have ONE declaration each, derived from +`@objectstack/spec`, and the console reads them instead of its own copies. + +objectui#5542 converged the LEAF of this contract — the field spec — and left the +two containers above it untouched, because converging them was a bigger call than a +mechanical import. `FormSectionSpec` and `FormViewSpec` were each hand-declared +twice under the same names, once in `packages/app-shell`'s `SchemaForm.tsx` and once +in `apps/console`'s `FormPage.tsx`. Unlike the leaf — whose console copy was a clean +subset — these two had **already drifted, in both directions**, so neither copy was a +subset of the other and there were two live answers to "what may an author write": + +- `FormSectionSpec` — app-shell declared `description` / `visibleWhen` / `visibleOn`; + the console declared none of them. The console's `columns` admitted the string arm + (`'1' | '2' | '3' | '4'`); app-shell's took numbers only. +- `FormViewSpec` — the console declared `label` / `groups` / `sharing` / + `submitBehavior`; app-shell stopped at `type` plus `sections`. + +The drift is decided by asking the **contract**, not by picking a side. `columns` +does admit the string arm (`FormSectionSchema.columns` unions `z.enum(['1','2','3','4'])` +with the four numeric literals, folded to a number by its own transform), so +app-shell's numbers-only declaration was rejecting metadata the platform accepts — +objectui#5040's own symptom, not a deliberate narrowing. `label` on the form view is +the opposite answer: `FormViewSchema` **rejects** it (`unrecognized_keys`, measured +against the installed `@objectstack/spec` 17.0.0), because a form config is titled, +not labelled. The value that read actually finds is the VIEW's identity label, which +arrives on the `ExpandedViewItem` envelope or beside the config on a flattened +runtime overlay — so it is declared on `FormPage.tsx`'s own `FormViewBody`, next to +the body it unwraps, rather than smuggled onto the form contract. + +Both types are therefore **derived from the spec's own `FormSection` / `FormView` +with named narrowings** — the repo's sanctioned form for a spec-shaped local type +(`scripts/check-spec-symbol-derivation.mjs`) — rather than restated. Every key the +two layers agree on comes from the spec and cannot fall behind it; the four positions +where this layer is deliberately narrower are each named in an `Omit` list and +restated once next to its reason: `fields` keeps the converged 26-key leaf (deriving +it would silently re-open #5542), and `label` / `description` / `visibleWhen` / +`visibleOn` keep the shapes this repo's renderers and evaluators actually consume +rather than the spec's `I18nLabel` and `ExpressionInput`. `apps/console`'s +`submitBehavior` union — previously hand-written under the comment "Mirrors the spec +FormView.submitBehavior union" — is now read back off the shared type, making the +mirror structural. `@object-ui/app-shell` re-exports both names from its package root +(type-only, erased at build — nothing is added to the bundle), because a type that +cannot be imported is a type that gets retyped. + +The pins are what make future drift loud, and each half is pinned on both sides. +`form-spec.containers.test.tsx` and `FormPage.viewSpec.test.ts` compare the +non-narrowed half of each type against the spec's own symbol, so re-hand-writing +either declaration fails `type-check` the day the spec moves rather than years later +when someone reads two files side by side — and the console's pins read both types +back out of the **exported** `buildSections` signature rather than naming them, so a +re-inlined local copy fails even if it agrees on every key on the day it is written. +Their liveness controls are what stop them being phantom checks: the removed copies +are pinned NOT equal to the shared types (proving the `Equal` helper still +discriminates), the renderer's honoured `RenderableSection` is pinned not equal +either (so the authored-document and honoured-row types cannot be collapsed again), +and an undeclared key is still rejected (so the derivation smuggled in no index +signature or `any`). Every narrowing carries a matching negative pin, so "derived" +cannot quietly become "widened to whatever the spec says". + +Behaviour is unchanged — the runtime always accepted these keys. The vitest halves +prove it: a section spelling its column count as the string `'3'` lays out identically +to the numeric `3` on both sides, and a section carrying the keys only one side used +to declare builds the same rows. diff --git a/apps/console/src/components/FormPage.tsx b/apps/console/src/components/FormPage.tsx index 8a7e2b4d3..825156f37 100644 --- a/apps/console/src/components/FormPage.tsx +++ b/apps/console/src/components/FormPage.tsx @@ -72,7 +72,7 @@ import { useEffect, useMemo, useState, type FormEvent } from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { toast } from 'sonner'; import { evalFieldPredicate } from '@object-ui/core'; -import type { FormFieldSpec } from '@object-ui/app-shell'; +import type { FormFieldSpec, FormViewSpec } from '@object-ui/app-shell'; import { resolveSubmitRedirect } from './submitRedirect'; const API_BASE = (import.meta.env.VITE_SERVER_URL || '') + '/api/v1'; @@ -82,7 +82,7 @@ interface PublicFormPayload { slug: string; object: string; label?: string; - form: FormViewSpec; + form: FormViewBody; objectSchema: ObjectSchemaPayload | null; } @@ -106,30 +106,51 @@ interface ObjectFieldDef { /** Visualization types the form renderer understands (FormViewSpec.type). */ const FORM_SPEC_TYPES = new Set(['simple', 'tabbed', 'wizard', 'split', 'drawer', 'modal']); -interface FormViewSpec { - type?: 'simple' | 'tabbed' | 'wizard' | 'split' | 'drawer' | 'modal'; - label?: string; - sections?: FormSectionSpec[]; - groups?: FormSectionSpec[]; - sharing?: { allowAnonymous?: boolean; publicLink?: string }; - /** Behaviour after a successful submit. */ - submitBehavior?: SubmitBehavior; -} +/** + * The form CONFIG plus the view identity that travels beside it (objectui#5596). + * + * {@link FormViewSpec} is the converged form contract and carries no `label`: + * `@objectstack/spec`'s `FormViewSchema` REJECTS that key outright + * (`unrecognized_keys`, measured against the installed 17.0.0) — a form config + * says `title`, not `label`. Until #5596 this file's hand copy declared `label` + * on the form type, which read as "a FormView may be labelled" and is not true. + * + * The value those reads actually find is the VIEW's identity label, and both + * bodies this renderer accepts carry it one way or another: + * + * - the `ExpandedViewItem` envelope (#2208) puts it beside `config`, and + * {@link resolveInternalForm} already reads it from there; + * - a FLATTENED runtime overlay has no envelope at all — the config and the + * identity share one object, which is exactly what the spec publishes as + * `VIEW_METADATA_MEMBERS.formOverlay` (`FormViewSchema` extended with + * `label` / `object` / `viewKind` / ...). On that branch `form === body`, so + * the label is reachable through the form variable. + * + * So the key is declared HERE, on the body this renderer unwraps, and not on the + * form contract shared with `packages/app-shell`. Narrowed to `string` because + * every read below assigns it into a `string` slot; the spec's own overlay types + * it `I18nLabel`, whose inline locale-map arm no form renderer in this repo + * resolves. + */ +type FormViewBody = FormViewSpec & { label?: string }; /** - * Mirrors the spec FormView.submitBehavior union (added in Step 4). + * Post-submit behaviour — DERIVED from the converged form contract, not + * restated (objectui#5596). * - * `redirect.url` stays a plain string here because that is what the contract + * This was a hand-written four-member union carrying the comment "Mirrors the + * spec FormView.submitBehavior union", which is the claim shape + * `scripts/check-spec-symbol-derivation.mjs` exists to catch: a mirror that + * nothing checks is one spec release from being a fork. Reading it back off + * {@link FormViewSpec} makes the mirror structural. + * + * `redirect.url` is still a plain string, because that is what the contract * ships: the ruled shape (objectstack#7496) is a refinement ON a string, so the * key arrives as the author wrote it. What it is ALLOWED to say is not restated - * in this type — `resolveSubmitRedirect` asks the spec's own schema at the + * here either — `resolveSubmitRedirect` asks the spec's own schema at the * moment of use (`submitRedirect.ts`). */ -type SubmitBehavior = - | { kind: 'thank-you'; title?: string; message?: string } - | { kind: 'redirect'; url: string; delayMs?: number } - | { kind: 'continue' } - | { kind: 'next-record' }; +type SubmitBehavior = NonNullable; /** Which surface is rendering the form — see {@link FormPageProps.mode}. */ export type FormPageMode = 'public' | 'internal'; @@ -289,35 +310,6 @@ export function readFormRecordTarget( */ type EffectiveSubmitBehavior = SubmitBehavior | { kind: 'created-record' }; -interface FormSectionSpec { - label?: string; - collapsible?: boolean; - collapsed?: boolean; - columns?: 1 | 2 | 3 | 4 | '1' | '2' | '3' | '4'; - /** - * `FormFieldSpec` here is the app-shell declaration, imported — NOT a local - * copy of it (objectui#5542). - * - * This position describes what an AUTHOR wrote: `sec.fields` is read straight - * off the `/meta/view/:name` payload, the same `FormView` document - * metadata-admin authors and renders. Until #5542 this file declared its own - * nine-key `interface FormFieldSpec` in that position — a second description - * of one contract, and the description was wrong about the document: the - * shared surface has 26 keys, so legal metadata (`visibleWhen`, `dependsOn`, - * `type`, `options`, `immutable`, the recursive `fields`, …) was undeclared - * here. That is the exact failure mode objectui#5040 recorded — "the type - * rejects the configuration the runtime accepts" — and nothing could notice, - * because each copy was only ever checked against itself. - * - * The narrow shape this renderer actually honours is a DIFFERENT type and - * already exists: {@link RenderableField}, what {@link buildSections} emits. - * Keeping the incoming-document type wide and the honoured-row type narrow is - * the distinction the old declaration collapsed. `FormFieldSpec.contract.test.ts` - * pins this element type to the app-shell one, so re-inlining a local copy - * fails `type-check` even if it agrees on every key on the day it is written. - */ - fields: Array; -} /** Normalized field row used by the renderer. */ interface RenderableField { @@ -722,7 +714,7 @@ async function apiFetch(path: string, init?: RequestInit): Promise { interface LoadedForm { label: string; object: string; - form: FormViewSpec; + form: FormViewBody; objectSchema: ObjectSchemaPayload | null; /** * The stored record this form is editing, or null in create mode @@ -751,7 +743,7 @@ async function loadPublicForm(slug: string): Promise { } /** - * Unwrap a `/meta/view/:name` response into the FormViewSpec the renderer + * Unwrap a `/meta/view/:name` response into the {@link FormViewBody} the renderer * consumes. Since the ADR-0017 registrar the server returns the flattened * ExpandedViewItem envelope — `{ name, object, viewKind, label, config: * { type, sections, … } }` — with the actual form spec nested under @@ -768,7 +760,7 @@ async function loadPublicForm(slug: string): Promise { export function resolveInternalForm( name: string, viewBody: unknown, -): { label: string; object?: string; form: FormViewSpec } { +): { label: string; object?: string; form: FormViewBody } { const body = viewBody as Record | null; const item = body?.item ?? body; const spec = item?.spec ?? item; @@ -786,7 +778,7 @@ export function resolveInternalForm( `View "${name}" is a ${viewKind} view, not a form view — check the action or link that targets it.`, ); } - const form: FormViewSpec = isEnvelope ? spec.config : spec; + const form: FormViewBody = isEnvelope ? spec.config : spec; // A flattened list config carries no viewKind at all but declares a grid/ // kanban/… visualization type no form renderer understands — same false // positive, same loud failure. @@ -798,7 +790,13 @@ export function resolveInternalForm( } return { label: (isEnvelope ? spec.label : undefined) ?? form?.label ?? name, - object: (isEnvelope ? spec.object : undefined) ?? (form as any)?.data?.object ?? spec?.object, + // `data.object` is a declared FormView key since objectui#5596, so this no + // longer needs `as any` — only a narrowing to the one arm that carries an + // object name (`ViewDataSchema`'s `provider: 'object'`), read defensively + // because the body itself is untrusted. + object: (isEnvelope ? spec.object : undefined) + ?? (form?.data as { object?: string } | undefined)?.object + ?? spec?.object, form, }; } diff --git a/apps/console/src/components/FormPage.viewSpec.test.ts b/apps/console/src/components/FormPage.viewSpec.test.ts new file mode 100644 index 000000000..aa1b2eab9 --- /dev/null +++ b/apps/console/src/components/FormPage.viewSpec.test.ts @@ -0,0 +1,221 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ONE description of the form CONTAINER contracts, in this app — objectui#5596. + * + * ## The class this pins shut + * + * `FormPage.fieldSpec.test.ts` next door pins the LEAF: the element type of this + * app's authored-field array is `@object-ui/app-shell`'s `FormFieldSpec`, not a + * local twin of it (objectui#5542). The two containers ABOVE that leaf were + * still hand-declared here — `FormViewSpec` and `FormSectionSpec`, the same + * names app-shell declares — and unlike the leaf they had already drifted in + * both directions, so neither copy was a subset of the other: + * + * FormSectionSpec app-shell declared `description` / `visibleWhen` / + * `visibleOn`; this file declared none of them. This file's + * `columns` admitted the string arm; app-shell's did not. + * FormViewSpec this file declared `label` / `groups` / `sharing` / + * `submitBehavior`; app-shell stopped at `type` + `sections`. + * + * Both are now the shared declaration, and the shared declaration is derived + * from `@objectstack/spec` rather than restated — so the question "what may an + * author write in a form section?" has one answer, and that answer is the + * contract's. + * + * ## What is pinned, and by which tool + * + * • **`tsc`** — {@link formContainerContractPins}. Type assertions are erased + * at runtime, so vitest proves nothing about them; the app's `type-check` + * script (`tsc --noEmit`, whose `include` is `["src", "dev"]` and therefore + * compiles this file) is what judges them. PIN A and PIN B are the ones that + * close the class: they read both container types back out of the + * **exported** `buildSections` signature, so a re-inlined local copy fails + * here even if it agrees on every key on the day it is written. PIN C is + * their liveness control. + * • **vitest** — the `buildSections` block. It proves the widening is inert at + * runtime: a section carrying the keys this app could not previously declare + * builds the same rows, and the string `columns` arm — which this app's type + * always admitted and app-shell's always refused — still normalises to the + * same number. + * + * `RenderableSection`, what `buildSections` EMITS, is deliberately still a + * different and narrower type: see PIN F. Keeping the incoming-document type + * wide and the honoured-row type narrow is objectui#5542's distinction, and + * declaring a key here is not honouring it — objectui#5627 tracks the section + * predicates this convergence makes declarable but does not evaluate. + */ + +import { describe, expect, it } from 'vitest'; +import type { FormSectionSpec, FormViewSpec } from '@object-ui/app-shell'; +import { buildSections, resolveInternalForm } from './FormPage'; + +type Assert = T; +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false; + +/** + * This app's own view of the container types, read back out of the public + * surface rather than re-stated. `buildSections` is exported and takes the + * FormView it renders, so this walks spec -> sections -> the element type. + * Deriving them this way is the point: nothing in this file names either type + * the way `FormPage.tsx` names it, so the pins follow whatever that file + * actually uses in those positions. + */ +type ConsoleFormViewSpec = Parameters[0]; +type ConsoleSectionSpec = NonNullable[number]; + +/** + * These never run. They are the half of this card a runtime test cannot express + * — "there is only one declaration of this contract" is a statement about `tsc`. + */ +export function formContainerContractPins(): void { + // ── PIN A — the class, view level. The type this app hands its own renderer + // IS the app-shell declaration. Re-inlining a local `interface FormViewSpec` + // here turns this line red on the day it is written, which is exactly what did + // NOT happen to the copy #5596 removed. + const viewIsOneContract: Assert> = true; + void viewIsOneContract; + + // ── PIN B — the class, section level. + const sectionIsOneContract: Assert> = true; + void sectionIsOneContract; + + // ── PIN C — liveness control for A and B. `Equal` is a conditional-type trick + // and a broken one would answer `true` for everything, which would make both + // pins phantom checks that no drift could ever fail. Pin the negative answer + // too: the five-key shape this file removed is NOT the shared type. + type _removedCopy = { + label?: string; + collapsible?: boolean; + collapsed?: boolean; + columns?: 1 | 2 | 3 | 4 | '1' | '2' | '3' | '4'; + fields: ConsoleSectionSpec['fields']; + }; + const equalStillDiscriminates: Assert, false>> = true; + void equalStillDiscriminates; + + // ── PIN D — the measurement, made executable. Every key below was + // `TS2353: … does not exist in type 'FormSectionSpec'` in this app before + // #5596, while `@objectstack/spec`'s `FormSectionSchema` accepted all of them + // and app-shell's copy declared three of them. + const wasUndeclarableHere: ConsoleSectionSpec = { + name: 'advanced_options', + description: 'Rarely-used options', + pane: 'secondary', + visibleWhen: '${record.kind == "deal"}', + visibleOn: { dialect: 'cel', source: 'record.kind == "deal"' }, + columns: '3', + fields: ['name'], + }; + void wasUndeclarableHere; + + // Same for the view: `title`, `description`, `data`, `subforms` and the + // per-variant presentation keys were all undeclarable here. + const viewWasUndeclarableHere: ConsoleFormViewSpec = { + type: 'wizard', + title: 'Contact us', + description: 'We reply within a day', + allowSkip: true, + showStepIndicator: true, + data: { provider: 'schema', schemaId: 'contact' }, + sections: [{ fields: ['name'] }], + }; + void viewWasUndeclarableHere; + + // ── PIN E — negative control on the KEY. PIN D means "these keys are + // declared", not "these positions stopped checking". Excess-property checking + // is still live, so the shared type did not smuggle in an index signature or + // `any` — the failure mode a widening is most likely to reach for. + const undeclaredKey: ConsoleSectionSpec = { + fields: [], + // @ts-expect-error objectui#5596 — an undeclared key is still rejected + thisKeyIsNotPartOfTheAuthoringSurface: true, + }; + void undeclaredKey; + + // ── PIN F — the renderer's honoured shape is a DIFFERENT type, and stays that + // way. Collapsing the two is how the removed copy came to describe an authored + // document with only the four keys this file happens to read. + type ConsoleRenderableSection = ReturnType[number]; + const notTheSameThing: Assert, false>> = true; + void notTheSameThing; + + // ── PIN G — `label` did not follow the form contract. It was on this file's + // removed `FormViewSpec`, and `@objectstack/spec`'s `FormViewSchema` REJECTS + // it (`unrecognized_keys` — a form config is titled, not labelled). It is view + // IDENTITY: it arrives on the `ExpandedViewItem` envelope, or beside the + // config on a flattened runtime overlay. So it lives on `FormPage.tsx`'s own + // `FormViewBody`, which is what `resolveInternalForm` returns — NOT on the + // contract shared with `packages/app-shell`. + type ResolvedBody = ReturnType['form']; + const bodyCarriesIdentityLabel: Assert> = true; + void bodyCarriesIdentityLabel; + const formContractDoesNot: Assert> = true; + void formContractDoesNot; +} + +describe('objectui#5596 — the console renders the shared container specs', () => { + it('builds the same rows from a section carrying the previously-undeclarable keys', () => { + // Typed through `buildSections`' own parameter, so this literal is checked + // against whatever `FormPage.tsx` declares in that position. + const sections = buildSections( + { + type: 'simple', + sections: [ + { + name: 'details', + label: 'Details', + description: 'The bits that matter', + pane: 'primary', + columns: 2, + visibleWhen: '${record.kind == "deal"}', + fields: [{ field: 'stage', label: 'Stage', colSpan: 2 }], + }, + ], + }, + null, + ); + + expect(sections).toHaveLength(1); + expect(sections[0].label).toBe('Details'); + expect(sections[0].columns).toBe(2); + expect(sections[0].fields.map((f) => f.name)).toEqual(['stage']); + }); + + it("normalises the string columns arm, which app-shell's copy used to refuse", () => { + const asString = buildSections({ sections: [{ columns: '3', fields: ['a'] }] }, null); + const asNumber = buildSections({ sections: [{ columns: 3, fields: ['a'] }] }, null); + // Not just "equal to each other" — pin the THREE, so this cannot go green on + // two identically-wrong answers (e.g. both falling back to the default 2). + expect(asNumber[0].columns).toBe(3); + expect(asString[0].columns).toBe(3); + }); + + it('reads the view label off the body, not off the form config', () => { + // Envelope branch: identity sits beside `config` (objectui#2208). + const envelope = resolveInternalForm('contact_form', { + name: 'contact_form', + object: 'contact', + viewKind: 'form', + label: 'Contact us', + config: { type: 'simple', sections: [{ fields: ['name'] }] }, + }); + expect(envelope.label).toBe('Contact us'); + expect(envelope.object).toBe('contact'); + + // Flattened-overlay branch: no envelope at all, so the identity keys and the + // form config share one object — the shape `@objectstack/spec` publishes as + // `VIEW_METADATA_MEMBERS.formOverlay`. This is the branch the removed + // `FormViewSpec.label` key was really describing. + const flattened = resolveInternalForm('contact_form', { + type: 'simple', + label: 'Contact us', + data: { provider: 'object', object: 'contact' }, + sections: [{ fields: ['name'] }], + }); + expect(flattened.label).toBe('Contact us'); + expect(flattened.object).toBe('contact'); + }); +}); diff --git a/packages/app-shell/src/index.ts b/packages/app-shell/src/index.ts index 91975ee72..9523a8fd0 100644 --- a/packages/app-shell/src/index.ts +++ b/packages/app-shell/src/index.ts @@ -308,12 +308,15 @@ export type { MetadataSelection, MetadataInspector, MetadataInspectorProps, - // The form-field authoring surface, in ONE declaration (objectui#5040 / - // #5542). `apps/console` renders the same authored `FormView` documents this - // package's metadata-admin does; before it could import this name it kept a - // third hand-written copy of the shape. See the note on the re-export in - // `views/metadata-admin/index.ts`. + // The form authoring surface, in ONE declaration per layer: the field + // (objectui#5040 / #5542) and the two containers above it (objectui#5596). + // `apps/console` renders the same authored `FormView` documents this + // package's metadata-admin does; before it could import these names it kept + // its own hand-written copies of all three shapes. See the note on the + // re-export in `views/metadata-admin/index.ts`. FormFieldSpec, + FormSectionSpec, + FormViewSpec, } from './views/metadata-admin/index.js'; // Studio WYSIWYG design surface (ADR-0080) — the open-source design surface. diff --git a/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx b/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx index 073089b87..f88f222b2 100644 --- a/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx +++ b/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx @@ -56,7 +56,7 @@ import { CollapsibleContent, } from '@object-ui/components'; import { evaluatePredicate } from './predicate.js'; -import type { FormFieldSpec, VisibilityPredicate } from './form-spec.js'; +import type { FormFieldSpec, FormSectionSpec, FormViewSpec, VisibilityPredicate } from './form-spec.js'; import { WIDGETS, widgetLabelling, @@ -75,7 +75,7 @@ import { useMetadataLocale, t, tFormat, translateValidationMessage, translateEnu * (objectui#5040). Re-exported here because this is the module every importer * already reaches for. */ -export type { FormFieldSpec, VisibilityPredicate } from './form-spec.js'; +export type { FormFieldSpec, FormSectionSpec, FormViewSpec, VisibilityPredicate } from './form-spec.js'; type JsonSchema = Record; @@ -618,20 +618,6 @@ function detectSecretWidget(name: string, schema: JsonSchema | undefined): strin return undefined; } -/* -------------------------------------------------------------------------- */ -/* FormView spec (subset) */ -/* -------------------------------------------------------------------------- */ - -/** - * Lightweight shape of the spec `FormView` we consume. We deliberately - * accept `any` for forward compatibility — the spec evolves faster than - * we want this admin engine to break. - */ -export interface FormViewSpec { - type?: 'simple' | 'tabbed' | 'wizard' | 'split' | 'drawer' | 'modal'; - sections?: FormSectionSpec[]; -} - /** * Read a visibility predicate off a spec node — **canonical key first**. * @@ -657,19 +643,6 @@ function readVisibility( return node?.visibleWhen ?? node?.visibleOn; } -export interface FormSectionSpec { - label?: string; - description?: string; - collapsible?: boolean; - collapsed?: boolean; - columns?: 1 | 2 | 3 | 4; - /** Canonical section visibility predicate (ADR-0089). */ - visibleWhen?: VisibilityPredicate; - /** @deprecated ADR-0089 alias of `visibleWhen`; still read for legacy layouts. */ - visibleOn?: VisibilityPredicate; - fields: Array; -} - export interface SchemaFormIssue { path: string; message: string; diff --git a/packages/app-shell/src/views/metadata-admin/form-spec.containers.test.tsx b/packages/app-shell/src/views/metadata-admin/form-spec.containers.test.tsx new file mode 100644 index 000000000..5ad2b9fb8 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/form-spec.containers.test.tsx @@ -0,0 +1,274 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The two form CONTAINERS are one declaration each, derived from the spec — + * objectui#5596. + * + * ## The defect this closes + * + * objectui#5040 / #5542 are one contract described several times, each + * description only ever checked against itself. #5542 converged the LEAF + * (`FormFieldSpec`). The two containers above it — `FormSectionSpec` and + * `FormViewSpec` — were still hand-declared twice, once here and once in + * `apps/console`'s `FormPage.tsx`, and unlike the leaf they had ALREADY drifted + * in both directions, so neither was a subset of the other: + * + * FormSectionSpec this side had `description` / `visibleWhen` / `visibleOn`, + * the console had none of them; the console's `columns` + * admitted the string arm, this side's did not. + * FormViewSpec the console had `label` / `groups` / `sharing` / + * `submitBehavior`, this side stopped at `type` + `sections`. + * + * Two live answers to "what may an author write", one nesting level above a + * contract that had just been converged. + * + * ## What is pinned, and by which tool + * + * • **`tsc`** — {@link formContainerContractPins}. Type assertions are erased + * at runtime, so vitest proves nothing about them; this package's + * `type-check` script (`tsc -p tsconfig.test.json`, the only project that + * compiles this directory's tests) is what judges them. The DERIVATION pins + * are the ones that close the class: they compare the non-narrowed half of + * each type against `@objectstack/spec`'s own `FormSection` / `FormView`, so + * re-hand-writing either declaration fails here the day the spec moves, + * rather than years later when someone reads two files side by side. Each + * narrowing has a matching negative pin, so "derived" can never quietly + * become "widened to whatever the spec says". + * • **vitest** — the render block. The widening this convergence performs is + * asserted INERT and, for `columns`, load-bearing: a section that spells its + * column count as the string `'3'` — legal metadata `FormSectionSchema` + * accepts and this renderer used to refuse at the type level — lays out + * identically to the numeric `3`. A pin that only proved the key + * type-checks would go green against a value the renderer mangles. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import type { FormSection, FormView } from '@objectstack/spec/ui'; +import { SchemaForm } from './SchemaForm'; +import type { FormFieldSpec, FormSectionSpec, FormViewSpec } from './form-spec'; + +afterEach(cleanup); + +type Assert = T; +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false; + +/** The positions {@link FormSectionSpec} deliberately overrides. */ +type SectionNarrowed = 'fields' | 'label' | 'description' | 'visibleWhen' | 'visibleOn'; +/** The positions {@link FormViewSpec} deliberately overrides. */ +type ViewNarrowed = 'sections' | 'groups'; + +/** + * These never run. They are the half of this card a runtime test cannot express + * — "there is only one declaration of this contract, and it is the spec's" is a + * statement about `tsc`. + */ +export function formContainerContractPins(): void { + // ── PIN A — DERIVATION, section. Every key this layer does not deliberately + // narrow is the spec's key with the spec's type. A hand-written copy that + // agrees on the day it is written passes this line and then fails the first + // time `FormSectionSchema` gains, drops or retypes a key — which is the drift + // nothing could see before. + const sectionIsDerived: Assert< + Equal, Omit> + > = true; + void sectionIsDerived; + + // ── PIN B — DERIVATION, view. Same, and stricter: `sections`/`groups` are the + // ONLY overridden positions, so `type`, `data`, `sharing`, `submitBehavior`, + // `subforms`, `buttons` and every per-variant presentation key are the spec's. + const viewIsDerived: Assert< + Equal, Omit> + > = true; + void viewIsDerived; + + // ── PIN C — liveness control for A and B. `Equal` is a conditional-type trick + // and a broken one would answer `true` for everything, which would make both + // derivation pins phantom checks that no drift could ever fail. Pin the + // negative answer too: the five-key shape `apps/console` removed in #5596 is + // NOT this type, and `Equal` must still say so. + type _removedConsoleCopy = { + label?: string; + collapsible?: boolean; + collapsed?: boolean; + columns?: 1 | 2 | 3 | 4 | '1' | '2' | '3' | '4'; + fields: Array; + }; + const equalStillDiscriminates: Assert< + Equal, false> + > = true; + void equalStillDiscriminates; + + // ── PIN D — the narrowing that keeps #5542 shut. The element type of the + // authored field array is the converged 26-key leaf, NOT the spec's own field + // arm (which adds `publicPicker`, `span`, `keyField`). Deriving `fields` too + // would silently re-open #5542 by swapping the element type out from under the + // console's landed pin — and the spec's `FormField` type cannot even be named + // here: `no-restricted-imports` bans it from `@objectstack/spec/ui`, because + // that name is also the runtime field contract in `@object-ui/types` + // (objectui#3090). So the arm is reached through `FormSection` instead. + type SectionFieldArm = Exclude; + const fieldsAreTheLeaf: Assert> = true; + void fieldsAreTheLeaf; + + type SpecSectionFieldArm = Exclude[number], string>; + const leafIsNotTheSpecArm: Assert, false>> = true; + void leafIsNotTheSpecArm; + // ...and the spec arm is a REAL checked type, not `any` — otherwise the line + // above would pass for the empty reason and PIN A's `Omit` would be hiding a + // hole rather than naming a narrowing. + const specArmStillChecks: SpecSectionFieldArm = { + field: 'amount', + // @ts-expect-error objectui#5596 — the spec's own field arm rejects unknown keys + thisKeyIsNotPartOfTheAuthoringSurface: true, + }; + void specArmStillChecks; + + // ── PIN E — the derived half really did widen. Each of these was a + // `TS2353: … does not exist in type 'FormSectionSpec'` on ONE of the two + // sides before #5596, while `FormSectionSchema` accepted all of them. + const wasUndeclarableOnOneSide: FormSectionSpec = { + name: 'advanced_options', + label: 'Advanced', + description: 'Rarely-used options', + collapsible: true, + collapsed: true, + columns: '3', + pane: 'secondary', + visibleWhen: '${data.kind == "deal"}', + visibleOn: { dialect: 'cel', source: 'data.kind == "deal"' }, + fields: ['name', { field: 'amount', colSpan: 2 }], + }; + void wasUndeclarableOnOneSide; + + const viewWasUndeclarableHere: FormViewSpec = { + type: 'tabbed', + title: 'Contact us', + description: 'We reply within a day', + sharing: { allowAnonymous: true }, + submitBehavior: { kind: 'redirect', url: '/thanks', delayMs: 500 }, + groups: [{ fields: ['name'] }], + sections: [{ fields: ['email'] }], + }; + void viewWasUndeclarableHere; + + // ── PIN F — negative control on the KEY. PIN E means "these keys are + // declared", not "these positions stopped checking". Excess-property checking + // is still live, so the derivation did not smuggle in an index signature or + // `any` — the failure mode a widening is most likely to reach for. + const undeclaredSectionKey: FormSectionSpec = { + fields: [], + // @ts-expect-error objectui#5596 — an undeclared key is still rejected + thisKeyIsNotPartOfTheAuthoringSurface: true, + }; + void undeclaredSectionKey; + const undeclaredViewKey: FormViewSpec = { + // @ts-expect-error objectui#5596 — an undeclared key is still rejected + thisKeyIsNotPartOfTheAuthoringSurface: true, + }; + void undeclaredViewKey; + + // ── PIN G — negative control on the NARROWINGS. "Derived" must not drift into + // "whatever the spec says": each of the four narrowed positions still refuses + // the arm this layer cannot consume. Every `@ts-expect-error` is a two-way pin + // — the directive is itself an error (TS2578) once the line below it starts + // compiling, so a LOOSENING turns this file red rather than passing quietly. + const narrowedLabel: FormSectionSpec = { + // @ts-expect-error narrowed to `string`: neither renderer resolves the + // spec's `I18nLabel` inline locale-map arm, and `FormFieldSpec.label` is + // already `string` (objectui#5542). + label: { en: 'Advanced', 'zh-CN': 'gao ji' }, + fields: [], + }; + void narrowedLabel; + + const narrowedDescription: FormSectionSpec = { + // @ts-expect-error narrowed to `string`, same reason as `label`. + description: { en: 'Rarely used' }, + fields: [], + }; + void narrowedDescription; + + const narrowedPredicate: FormSectionSpec = { + // @ts-expect-error narrowed to `VisibilityPredicate`: `source` is REQUIRED + // here, because that is what `evalFieldPredicate` / this package's + // `evaluatePredicate` read. The spec's `ExpressionInput` also admits an + // `ast`-only envelope, which no evaluator in this repo consumes. + visibleWhen: { dialect: 'cel' }, + fields: [], + }; + void narrowedPredicate; + + // ── PIN H — `label` is NOT a form-config key, measured. `FormViewSchema` + // answers `unrecognized_keys` for it (the form config says `title`), so the + // console's removed copy was describing view IDENTITY as form configuration. + // That key now lives on `FormPage.tsx`'s own `FormViewBody`, next to the + // envelope/overlay it actually arrives on. + const viewHasNoLabel: FormViewSpec = { + // @ts-expect-error objectui#5596 — a FormView is titled, not labelled + label: 'Contact us', + }; + void viewHasNoLabel; +} + +const schema = { + type: 'object', + properties: { + a: { type: 'string', title: 'Field A' }, + b: { type: 'string', title: 'Field B' }, + c: { type: 'string', title: 'Field C' }, + }, +}; + +function gridStyleFor(columns: FormSectionSpec['columns']): string | null { + const form: FormViewSpec = { + type: 'simple', + sections: [{ label: 'Sec', columns, fields: [{ field: 'a' }, { field: 'b' }, { field: 'c' }] }], + }; + const { container } = render( + {}} />, + ); + const grid = container.querySelector('div.grid'); + return grid ? (grid as HTMLElement).getAttribute('style') : null; +} + +describe('objectui#5596 — the converged containers render what the spec accepts', () => { + it("lays out columns: '3' exactly as columns: 3 — the string arm was legal metadata this renderer refused", () => { + const asString = gridStyleFor('3'); + cleanup(); + const asNumber = gridStyleFor(3); + + // Not just "equal to each other" — both must be the THREE-column grid, so + // this cannot go green on two identically-wrong renders (e.g. if `columns` + // stopped being read at all and both fell back to 1). + expect(asNumber).toContain('repeat(3'); + expect(asString).toBe(asNumber); + }); + + it('renders a section carrying the keys only ONE side used to declare', () => { + const form: FormViewSpec = { + type: 'simple', + sections: [ + { + name: 'advanced_options', + label: 'Advanced', + description: 'Rarely-used options', + pane: 'secondary', + columns: 2, + fields: [{ field: 'a' }], + }, + ], + }; + render( {}} />); + + expect(screen.getByText('Advanced')).toBeInTheDocument(); + expect(screen.getByText('Field A')).toBeInTheDocument(); + // `name` and `pane` are authored-document keys with no reader in this repo + // yet — declared because the document carries them, honoured by nobody. + // Asserted inert so a future reader has to arrive deliberately. + expect(screen.queryByText('advanced_options')).not.toBeInTheDocument(); + expect(screen.queryByText('secondary')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/form-spec.ts b/packages/app-shell/src/views/metadata-admin/form-spec.ts index fd1ce6e99..abaae11f0 100644 --- a/packages/app-shell/src/views/metadata-admin/form-spec.ts +++ b/packages/app-shell/src/views/metadata-admin/form-spec.ts @@ -15,12 +15,82 @@ * * It lives in a leaf rather than in `SchemaForm.tsx` because the import runs * the other way: `SchemaForm.tsx` imports `./widgets.js`, so a back-edge from - * `widgets.tsx` would close a cycle. A module that imports nothing is - * importable from both sides. `SchemaForm.tsx` re-exports both names, so the + * `widgets.tsx` would close a cycle. A module that imports nothing at RUNTIME is + * importable from both sides. `SchemaForm.tsx` re-exports all three names, so the * surface every existing importer reaches for is unchanged. + * + * ## The two containers above the leaf (objectui#5596) + * + * `FormSectionSpec` and `FormViewSpec` were hand-declared TWICE for the same + * reason and with the same result: once in `SchemaForm.tsx`, once in + * `apps/console`'s `FormPage.tsx`. Unlike the field spec, whose console copy was + * a clean subset, these two had already drifted in BOTH directions -- so neither + * was a subset of the other and there were two live answers to what an author + * may write: + * + * FormSectionSpec app-shell had `description`/`visibleWhen`/`visibleOn`; the + * console had none of them. The console's `columns` admitted + * the string arm (`'1'|'2'|'3'|'4'`); app-shell's took + * numbers only. + * FormViewSpec the console had `label`/`groups`/`sharing`/`submitBehavior`; + * app-shell stopped at `type` plus `sections`. + * + * Both are now DERIVED from `@objectstack/spec`'s own `FormSection` / `FormView` + * rather than restated, which is the repo's sanctioned form for a spec-shaped + * local type (`scripts/check-spec-symbol-derivation.mjs`). The import is + * `import type`, so this module still pulls in nothing at runtime and the leaf + * property above is unchanged. + * + * Deriving decides the drift by asking the contract instead of a reader: + * `columns` DOES admit the string arm (`FormSectionSchema.columns` is a union of + * `z.enum(['1','2','3','4'])` with the four numeric literals, folded to a number + * by its own transform), so app-shell's numbers-only declaration was rejecting + * metadata the platform accepts -- objectui#5040's symptom, not a deliberate + * narrowing. `description`, `visibleWhen`, `visibleOn`, and also `name` and + * `pane`, are likewise spec keys that simply went undeclared on one side or the + * other. + * + * ## Why DERIVED-WITH-NAMED-NARROWINGS and not a bare re-export + * + * A bare `export type FormSectionSpec = FormSection` would put two different + * answers INSIDE one document, one nesting level apart, because the spec writes + * its containers in vocabularies the converged leaf above deliberately does not + * use: + * + * - `FormSection.label` / `.description` are the spec's `I18nLabel` + * (`string | InlineLocaleMap`). `FormFieldSpec.label` -- already converged, + * already landed -- is `string`, because both renderers put the value + * straight into a text slot and neither resolves a locale map. A section + * admitting `{ en: 'x' }` above a field refusing it is the same defect class + * this file exists to close. + * - `FormSection.visibleWhen` / `.visibleOn` are the spec's `ExpressionInput` + * (`dialect` REQUIRED and enum-typed, `source` optional, plus `ast`/`meta`). + * The predicate that actually reaches an evaluator here is + * {@link VisibilityPredicate} -- `dialect` optional, `source` required -- + * which is the shape `@object-ui/core`'s `evalFieldPredicate` takes. + * - `FormSection.fields` elements are the spec's `FormField` (29 keys), not + * the 26-key {@link FormFieldSpec} objectui#5542 converged and pinned. + * Re-pointing that position is what would silently re-open #5542. + * + * So every key the two layers agree on comes FROM the spec and cannot fall + * behind it, and each of the four positions where this layer is deliberately + * narrower is named in an `Omit` list and restated once, next to its reason. + * `form-spec.containers.test.tsx` pins both halves: that the derived keys really + * are the spec's, and that each narrowing still refuses the arm it means to + * refuse. Its console twin, `FormPage.viewSpec.test.ts`, pins the same two + * types back out of that app's own renderer signature. */ -/** Wire shapes a visibility predicate arrives in: bare CEL, or `{dialect, source}`. */ +import type { FormSection, FormView } from '@objectstack/spec/ui'; + +/** + * Wire shapes a visibility predicate arrives in: bare CEL, or `{dialect, source}`. + * + * NOT the spec's `ExpressionInput`, deliberately -- see the narrowing note in the + * file header. This is the shape `@object-ui/core`'s `evalFieldPredicate` accepts + * (`FieldRulePredicate`, `evaluator/fieldRules.ts`), which is the engine every + * predicate here is ultimately handed to. + */ export type VisibilityPredicate = string | { dialect?: string; source: string }; export interface FormFieldSpec { @@ -94,3 +164,84 @@ export interface FormFieldSpec { * (array of embedded objects) types. Recursive. */ fields?: Array; } + +/** + * One section of a form layout, in ONE declaration (objectui#5596). + * + * Derived from `@objectstack/spec`'s own `FormSection`, so `name`, `collapsible`, + * `collapsed`, `columns` and `pane` are the contract's keys with the contract's + * types -- including the `columns` string arm, which app-shell's hand copy used + * to refuse. Four positions are narrowed, each for a reason recorded in the file + * header: + * + * fields the element type is {@link FormFieldSpec}, the leaf + * objectui#5542 converged and pinned -- NOT the spec's 29-key + * `FormField`. + * label `string`, not the spec's `I18nLabel`. Both renderers put this + * straight into a text slot; neither resolves the inline + * locale-map arm, and `FormFieldSpec.label` is already `string`. + * description same, for the same reason. + * visibleWhen {@link VisibilityPredicate}, the shape the evaluators take, not + * visibleOn the spec's `ExpressionInput`. + * + * This type describes what an AUTHOR WROTE, so it stays as wide as the document + * -- `pane` and `name` are declared here even though no renderer in this repo + * reads them yet. Keeping the incoming-document type wide and the honoured-row + * type narrow is objectui#5542's distinction; the narrow types are this file's + * `SchemaForm` sections and `FormPage`'s `RenderableSection`. + * + * Declaring a key is not honouring it: objectui#5627 tracks the console renderer + * still rendering every section unconditionally, which this type makes + * *declarable* but does not evaluate. + */ +export type FormSectionSpec = + & Omit + & { + /** Section heading. Narrowed to `string` -- see the note above. */ + label?: string; + /** Optional description under the section header. Narrowed to `string`. */ + description?: string; + /** Canonical section visibility predicate (ADR-0089). */ + visibleWhen?: VisibilityPredicate; + /** @deprecated ADR-0089 alias of `visibleWhen`; still read for legacy layouts. */ + visibleOn?: VisibilityPredicate; + /** The authored field list. Element type is the converged leaf (objectui#5542). */ + fields: Array; + }; + +/** + * A form-layout view, in ONE declaration (objectui#5596). + * + * Derived from `@objectstack/spec`'s own `FormView` with exactly ONE position + * overridden -- `sections` and `groups`, whose element type is + * {@link FormSectionSpec} rather than the spec's `FormSection`, for the same + * reason that type overrides `fields`. Everything else is the contract's: the + * six-member `type` union both hand copies happened to spell identically, the + * per-variant presentation keys, `data`, `sharing`, `submitBehavior`, `subforms`, + * `buttons`. + * + * `groups` is kept even though `@objectstack/spec` folds it onto `sections` at + * parse: the fold happens in the NORMALISER, and both renderers also receive + * documents that never pass through one (hand-written layouts, and this package's + * own create schemas -- the same reason `visibleOn` is still read). + * + * ## `label` is NOT a key of this type, and that is a measurement + * + * `apps/console`'s hand copy declared `label`. `FormViewSchema` REJECTS it -- + * `unrecognized_keys`, measured against the installed `@objectstack/spec` 17.0.0 + * -- because the form config has `title`/`description` instead. The value that + * read actually finds is the VIEW's identity label, which lives on the envelope + * (`ExpandedViewItem.label`) or, on a flattened runtime overlay, alongside the + * config on the same object (`VIEW_METADATA_MEMBERS.formOverlay` is + * `FormViewSchema` extended with `label`/`object`/`viewKind`/...). That is view + * identity, not form configuration, so it is declared where the console unwraps + * the body rather than smuggled onto the form contract. + */ +export type FormViewSpec = + & Omit + & { + /** Section list. Element type is {@link FormSectionSpec} -- see above. */ + sections?: FormSectionSpec[]; + /** Legacy alias of `sections`, folded onto it by the spec's normaliser. */ + groups?: FormSectionSpec[]; + }; diff --git a/packages/app-shell/src/views/metadata-admin/index.ts b/packages/app-shell/src/views/metadata-admin/index.ts index ca3dbfc14..d0a2697f8 100644 --- a/packages/app-shell/src/views/metadata-admin/index.ts +++ b/packages/app-shell/src/views/metadata-admin/index.ts @@ -28,18 +28,20 @@ export { MetadataQuickFind } from './QuickFind.js'; export { PageShell as MetadataPageShell } from './PageShell.js'; export { SchemaForm } from './SchemaForm.js'; /** - * The ONE declaration of the metadata-admin form-field authoring surface - * (objectui#5040, converged by PR #5537 into the `./form-spec.js` leaf). + * The ONE declaration of the metadata-admin form authoring surface -- the + * field (objectui#5040, converged by PR #5537 into the `./form-spec.js` leaf) + * and, since objectui#5596, the two containers above it. * - * Re-exported through the package root because it has an out-of-package + * Re-exported through the package root because they have an out-of-package * consumer: `apps/console`'s `FormPage.tsx` reads the same authored `FormView` - * documents and, until objectui#5542, held a THIRD hand-written description of - * this shape under the same name. A type that cannot be imported is a type + * documents and, until objectui#5542 for the field and objectui#5596 for the + * two containers, held its own hand-written descriptions of these shapes under + * the same names. A type that cannot be imported is a type * that gets retyped, and retyped copies drift — which is the defect #5040 * recorded. Reachability is what makes the convergence hold outside this * directory. Type-only: erased at build, so nothing is added to the bundle. */ -export type { FormFieldSpec } from './form-spec.js'; +export type { FormFieldSpec, FormSectionSpec, FormViewSpec } from './form-spec.js'; export { LayeredDiff } from './LayeredDiff.js'; export { PermissionMatrixEditPage } from './PermissionMatrixEditor.js'; export {