Skip to content
Merged
20 changes: 20 additions & 0 deletions .changeset/8725-formsection-group-resolves.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@object-ui/app-shell': minor
---

The metadata-admin `SchemaForm` no longer crashes on a form section that references a field group, and the form-section authoring type accepts that shape (objectui#8725).

**Clause-②: yes** — two exported types change shape. `FormSectionSpec.fields` becomes optional, which widens what an author may write to match `@objectstack/spec`. `RichMetadataTypeEntry.form` narrows from `Record<string, unknown>` to `FormViewSpec`.

**BREAKING (types).** A consumer that reads `section.fields` unguarded, or indexes `FormSectionSpec['fields'][number]`, no longer compiles (`TS18048` / `TS2537`), because a `{ group }` section carries no `fields`. Read the element type through `NonNullable`. Do not default the list with `?? []`: a `{ group }` section then renders as a silently empty one. A consumer that treated `RichMetadataTypeEntry.form` as an open record now gets the `FormViewSpec` keys only. Outside `SchemaForm`, whose three reads are what this change fixes, no published source in this repository had to change. The other readers that changed were tests: two type-level pins, one in this package and one in `apps/console` (`FormPage.fieldSpec.test.ts`), and four `mergeServerFields` assertions.

**What was wrong.** `@objectstack/spec` lets a form section declare its members one of two ways: enumerate `fields`, or point `group` at a declared field group. The `/meta/types` registry hands its form document to `SchemaForm` without validation, and a `{ group }` section reached the renderer's first `fields` read. That read threw `TypeError: s.fields is not iterable` out of the component body and blanked the whole form, well-formed sections included, on both the simple and the tabbed layout. The required `fields` on `FormSectionSpec` only stopped a TypeScript author from writing the shape; the server channel carried it anyway, through an `as any` cast.

**What changed, in observable terms.**

- Every read of a section's `fields` in `SchemaForm` now sees a resolved section. A `{ group }` section goes through `@object-ui/plugin-form`'s `resolveSectionGroupReferences`, the resolver `ObjectForm` and the console `FormPage` already use, so `SchemaForm` gives their answer.
- No `SchemaForm` host carries an object definition, and `/meta/types` declares no field groups for a metadata type. So a `{ group }` section in a metadata-type editor names no declared field group. For that case the resolver renders the section empty and reports the dangling key once on `console.error`, naming the group and the form's `schemaId`. The other sections render normally.
- A section that declares neither `fields` nor `group` is still refused out loud. The render throws a `TypeError` that names the section; it does not draw an empty section.
- `RichMetadataTypeEntry.form` is typed `FormViewSpec`, and the `as any` casts where `ResourceEditPage` and `EmbeddedItemEditor` hand it to `SchemaForm` are gone.

No change for any form none of whose sections carries a `group` key: the resolver returns such a list by identity. A section carrying both `fields` and `group`, a shape `@objectstack/spec` refuses at parse, now takes the resolver's answer for its `group` (in a metadata-type editor, an empty section and the same `console.error`) instead of rendering its enumerated `fields`.
2 changes: 2 additions & 0 deletions .changeset/quiet-pans-repeat.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@
---

Test-only change: pins the objectui#8725 reachability measurement for a `{ group }` form section reaching `SchemaForm`. No published behaviour, type or API changes.

⚠️ **Dated note, 2026-09-28 — those pins have since been rewritten onto the ruled behaviour — objectui#8725.** The measurement they recorded (a `{ group }` section making `SchemaForm` throw `s.fields is not iterable`) went red when the fix landed, and the same file now pins that `SchemaForm` resolves such a section through `resolveSectionGroupReferences` and still refuses a section with no member source. The rest of this entry is kept as the reading of this change.
4 changes: 3 additions & 1 deletion apps/console/src/components/FormPage.fieldSpec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ?
*/
type ConsoleFormViewSpec = Parameters<typeof buildSections>[0];
type ConsoleSectionSpec = NonNullable<ConsoleFormViewSpec['sections']>[number];
type ConsoleFieldSpec = Exclude<ConsoleSectionSpec['fields'][number], string>;
// `fields` is OPTIONAL on the section since objectui#8725 (a `{ group }` section
// carries none), so the element type is read through `NonNullable`.
type ConsoleFieldSpec = Exclude<NonNullable<ConsoleSectionSpec['fields']>[number], string>;

/**
* These never run. They are the half of this card a runtime test cannot express
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
} from './SchemaForm.js';
import { getMetadataPreview } from './preview-registry.js';
import { useMetadataClient, useMetadataTypes } from './useMetadata.js';
import type { FormViewSpec } from './form-spec.js';
import { useMetadataLocale, t, tFormat, translateValidationMessage } from './i18n.js';
import { errorCodeIsAnyOf } from '@object-ui/types';

Expand Down Expand Up @@ -73,7 +74,7 @@ export function EmbeddedItemEditor({
// than promised, by `EmbeddedItemEditor.indexFallback.test.tsx`.
const fallback = !subEntry?.schema && editAs ? FALLBACK_SCHEMAS[editAs] : undefined;
const schema = (subEntry?.schema as Record<string, unknown> | undefined) ?? fallback?.schema;
const form = (subEntry?.form as any) ?? fallback?.form;
const form = subEntry?.form ?? fallback?.form;
// Opt-in per sub-type, exactly like the Preview tab on the full page: a type
// with no registered renderer gets no surface at all (never a "preview not
// available" placeholder).
Expand Down Expand Up @@ -336,7 +337,7 @@ function spliceEmbedded(
*/
export const FALLBACK_SCHEMAS: Record<
string,
{ schema: Record<string, unknown>; form?: Record<string, unknown> }
{ schema: Record<string, unknown>; form?: FormViewSpec }
> = {
index: {
schema: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1421,7 +1421,7 @@ function MetadataResourceEditPageImpl({
if (!key) return path;
// Resolve the human label for the HEAD segment from the form/schema.
const headLabel = ((): string => {
const formForLabels = (createMode && config.createSchema ? undefined : (entry?.form as any));
const formForLabels = (createMode && config.createSchema ? undefined : entry?.form);
const sections = Array.isArray(formForLabels?.sections) ? formForLabels.sections : [];
for (const section of sections) {
const fields = Array.isArray(section?.fields) ? section.fields : [];
Expand Down Expand Up @@ -2826,7 +2826,7 @@ function MetadataResourceEditPageImpl({
<SchemaForm
schema={schema}
idPath={formIdPath}
form={createMode && config.createSchema ? undefined : (entry?.form as any)}
form={createMode && config.createSchema ? undefined : entry?.form}
value={draft}
onChange={handleCreateAwareChange}
issues={displayIssues}
Expand All @@ -2850,7 +2850,7 @@ function MetadataResourceEditPageImpl({
<SchemaForm
schema={schema}
idPath={formIdPath}
form={createMode && config.createSchema ? undefined : (entry?.form as any)}
form={createMode && config.createSchema ? undefined : entry?.form}
value={draft}
onChange={handleCreateAwareChange}
issues={displayIssues}
Expand Down
Loading
Loading