Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions .changeset/console-form-container-specs-one-declaration-5596.md
Original file line numberDiff line numberDiff line change
@@ -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.
104 changes: 51 additions & 53 deletions apps/console/src/components/FormPage.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand All@@ -82,7 +82,7 @@ interface PublicFormPayload {
slug: string;
object: string;
label?: string;
form: FormViewSpec;
form: FormViewBody;
objectSchema: ObjectSchemaPayload | null;
}

Expand All@@ -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<FormViewSpec['submitBehavior']>;

/** Which surface is rendering the form — see {@link FormPageProps.mode}. */
export type FormPageMode = 'public' | 'internal';
Expand DownExpand Up@@ -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<string | FormFieldSpec>;
}

/** Normalized field row used by the renderer. */
interface RenderableField {
Expand DownExpand Up@@ -722,7 +714,7 @@ async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
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
Expand DownExpand Up@@ -751,7 +743,7 @@ async function loadPublicForm(slug: string): Promise<LoadedForm> {
}

/**
* 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
Expand All@@ -768,7 +760,7 @@ async function loadPublicForm(slug: string): Promise<LoadedForm> {
export function resolveInternalForm(
name: string,
viewBody: unknown,
): { label: string; object?: string; form: FormViewSpec } {
): { label: string; object?: string; form: FormViewBody } {
const body = viewBody as Record<string, any> | null;
const item = body?.item ?? body;
const spec = item?.spec ?? item;
Expand All@@ -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.
Expand All@@ -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,
};
}
Expand Down
Loading
Loading