From 666717c69e0ae15b79fbc877c5acf20135ae5b35 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 01:10:03 +0000 Subject: [PATCH 1/2] fix(console,plugin-form): render a form section that references a field group (#8641) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@objectstack/spec` 17.3.0 lets a `form.sections[]` entry declare its members either way — enumerate `fields`, or point `group` at one of the object's declared `fieldGroups` (objectstack#13855, ADR-0085 §5). `apps/console`'s `FormPage` has its own section builder, on none of `@object-ui/plugin-form`'s code path, and it read `sec.fields ?? []` and `sec.label` — neither of which a `{ group }` section carries. Reproduced in the DOM before repairing, on both routes: the `
` was emitted with its border and padding and then stood empty — no heading, no inputs, no diagnostic. "Renders nothing" was one word off; a submitter saw a blank card where the group's fields belong. `buildSections` now resolves the reference through `resolveSectionGroupReferences`, newly published from `@object-ui/plugin-form` — the same function `ObjectForm` resolves through, so all three consumers share one assembler. No assembly rule is re-implemented on the console side: declared order, the empty-group drop, the ungrouped trailing bucket and the collapse / `visibleWhen` passthrough all come from `deriveFieldGroupLayout` via that package's single adapter. Reaching for the derivation directly would have meant re-spelling the `collapse` enum onto this renderer's boolean pair, which is that constraint's exact prohibition. `ObjectSchemaPayload` carries `fieldGroups` and the internal `/meta/object/:name` loader copies it: that rebuild is key by key, so an uncopied key is gone before the builder can see it. The public `/f/:slug` payload forwards the server's object schema whole and needed no such line. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S --- .../8641-console-formpage-section-group.md | 51 +++ .../components/FormPage.sectionGroup.test.tsx | 393 ++++++++++++++++++ apps/console/src/components/FormPage.tsx | 73 +++- packages/plugin-form/README.md | 52 ++- ...sectionGroupResolverPublished-8641.test.ts | 78 ++++ packages/plugin-form/src/index.tsx | 42 ++ 6 files changed, 685 insertions(+), 4 deletions(-) create mode 100644 .changeset/8641-console-formpage-section-group.md create mode 100644 apps/console/src/components/FormPage.sectionGroup.test.tsx create mode 100644 packages/plugin-form/src/__tests__/sectionGroupResolverPublished-8641.test.ts diff --git a/.changeset/8641-console-formpage-section-group.md b/.changeset/8641-console-formpage-section-group.md new file mode 100644 index 0000000000..64872f9945 --- /dev/null +++ b/.changeset/8641-console-formpage-section-group.md @@ -0,0 +1,51 @@ +--- +'@object-ui/plugin-form': minor +'@object-ui/console': patch +--- + +Render a form section that REFERENCES a field group on the console's form page, +and publish the resolver that does it (objectui#8641). + +`@objectstack/spec` 17.3.0 lets a `form.sections[]` entry declare its members +either way — enumerate `fields`, or point `group` at one of the object's declared +`fieldGroups` (objectstack#13855, ADR-0085 §5). `apps/console`'s `FormPage` has +its own section builder, on none of `@object-ui/plugin-form`'s code path, and it +read `sec.fields ?? []` and `sec.label` — neither of which a `{ group }` section +carries. Measured in the DOM on both routes before the fix: the `
` was +emitted with its border and padding and then stood **empty** — no heading, no +inputs, no diagnostic — so a submitter saw a blank card where the group's fields +belong. The same silent-drop class objectui#7051 closed on the `plugin-form` +chain, at the third consumer. + +Newly importable from `@object-ui/plugin-form` — one function and the options +type its signature requires, nothing else: + +```typescript +import { + resolveSectionGroupReferences, + type ResolveSectionGroupsOptions, +} from '@object-ui/plugin-form'; +``` + +- `resolveSectionGroupReferences(sections, { objectName, formType, objectDef })` + — replace every `{ group: 'x' }` section with the section that group declares + (label, members, description, collapse state), leaving everything else + untouched. With no reference in the list it returns its input **by identity**, + so it cannot perturb an existing form and is safe inside a `useMemo`. An + unresolvable reference yields an empty section, never a dropped one, and is + reported once naming the object and the key. + +`hasSectionGroupReference`, `resetSectionGroupReports`, `GROUP_OWNED_SECTION_KEYS` +and `SECTION_LAYOUT_KEYS` stay module-private, pinned as the withheld set. + +⛔ No assembly rule is re-implemented on the console side: declared order, the +empty-group drop, the ungrouped trailing bucket and the collapse / `visibleWhen` +passthrough all reach it from `deriveFieldGroupLayout` through this package's one +adapter — the same code path `ObjectForm` resolves through — which is why the +resolver is exported rather than the derivation being read a second time. + +`ObjectSchemaPayload` in the console now carries `fieldGroups`, and its internal +`/meta/object/:name` loader copies the key: that rebuild is key by key, so an +uncopied key is gone before the builder can see it. + +No behaviour change for any form that does not author `group`. diff --git a/apps/console/src/components/FormPage.sectionGroup.test.tsx b/apps/console/src/components/FormPage.sectionGroup.test.tsx new file mode 100644 index 0000000000..8f29489ae8 --- /dev/null +++ b/apps/console/src/components/FormPage.sectionGroup.test.tsx @@ -0,0 +1,393 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#8641 — `FormPage.buildSections` honours a spec-legal + * `form.sections[].group`. + * + * ## What was MEASURED before the fix, in the DOM (not read off the source) + * + * The card said, about itself, that its finding came from reading + * `sec.fields ?? []` rather than from rendering anything. It was reproduced + * first. `origin/main` at `a29ae2d66`, this file's own fixture, both routes: + * + * buildSections -> [ {label:'Anchor', fields:['anchor_note']}, + * {columns:2, fields:[]}, + * {columns:2, fields:[]} ] + * + * DOM (internal /forms/:name AND public /f/:slug, identical): + * SECTION heading="Anchor" inputs=[anchor_note] + * SECTION heading=NONE inputs=[] + * SECTION heading=NONE inputs=[] + * + * So "renders nothing" is one word off: the `
` element IS emitted, + * with its border, background and padding — the submitter sees two EMPTY CARDS + * where the contact and billing inputs belong, and no diagnostic is printed on + * any channel. The members the group declares are simply absent. + * + * ## ⭐ Which leg discriminates, said out loud + * + * `RENDERS_THE_ANCHOR` and "the group section is non-empty" are NOT the axis: + * both are satisfied by today's builder, which drops every group-referenced + * section while rendering the rest of the form perfectly. The load-bearing leg + * is `EACH_GROUP_SECTION_RENDERS_ITS_OWN_MEMBERS` — two sections referencing + * two DIFFERENT groups, each asserted against concrete field identifiers in + * order. + * + * ⚠️ And the section that carries that leg references `billing`, the SECOND + * declared group, deliberately. A constant-resolution caricature — a resolver + * answering with the same derived section for every reference — returns the + * FIRST declared group, so a leg written against `contact_info` alone stays + * GREEN under it and proves nothing. That is not hypothetical: PR #8644's + * author hit exactly it. Measured here (leg 3 below): with `byKey.get(group)` + * replaced by "the first derived section", the `contact_info` assertions stay + * green and only the `billing` ones turn red. + * + * ## ⛔ What this file does NOT pin, on purpose + * + * Declared order, the empty-group drop, the ungrouped trailing bucket and the + * collapse / `visibleWhen` passthrough are `deriveFieldGroupLayout`'s + * (ADR-0085 §5), reached through `@object-ui/plugin-form`'s one adapter. This + * app re-implements none of them and therefore asserts none of them as its + * own; what it asserts is that the section this app draws is the one that + * assembler produced — which is why the label, the member list, the member + * ORDER and the collapse booleans are all read back from the object's + * `fieldGroups` rather than from anything written here. + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { FormSectionSchema } from '@objectstack/spec/ui'; +import { buildSections, FormPage } from './FormPage'; +import type { FormViewSpec } from '@object-ui/app-shell'; + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +// ─── Fixture ────────────────────────────────────────────────────────────── + +/** + * Three declared groups with DISTINCT members, an ungrouped field, and the + * harness anchor. Distinct membership is what makes the discriminating leg + * discriminate: one constant member list cannot satisfy two group sections. + * + * `contact_info` is declared FIRST and `billing` SECOND — see the header for + * why that ordering is load-bearing rather than incidental. + */ +const TICKET = { + name: 'ticket', + label: 'Ticket', + fieldGroups: [ + { key: 'contact_info', label: 'Contact Info' }, + { key: 'billing', label: 'Billing Details' }, + { key: 'archive', label: 'Archive', description: 'Retired references', collapse: 'collapsed' }, + ], + fields: { + email: { type: 'text', label: 'Email Address', group: 'contact_info' }, + phone: { type: 'text', label: 'Phone Number', group: 'contact_info' }, + invoice_no: { type: 'text', label: 'Invoice No', group: 'billing' }, + po_number: { type: 'text', label: 'PO Number', group: 'billing' }, + old_ref: { type: 'text', label: 'Old Ref', group: 'archive' }, + channel: { type: 'text', label: 'Channel' }, + anchor_note: { type: 'text', label: 'Anchor Note' }, + }, +}; + +/** The anchor every rendered form carries — no content leg asserts anything about it. */ +const ANCHOR = { label: 'Anchor', fields: ['anchor_note'] }; + +/** + * Build a `FormViewSpec` from raw authored sections. + * + * The cast is the finding this card did NOT take on: `FormSectionSpec` (the + * app-shell authoring type this renderer reads) re-declares `fields` as + * REQUIRED, so the spec-legal `{ group }` shape — which carries `fields` + * neither at parse nor by construction — does not type-check for a TypeScript + * author. Filed separately rather than widened here, because the same + * declaration is `SchemaForm`'s (a fourth form-section renderer) and widening + * it reaches into that renderer's unguarded `s.fields` reads. + */ +const formOf = (sections: unknown[], type = 'simple'): FormViewSpec => + ({ type, sections } as unknown as FormViewSpec); + +interface Route_ { method?: string; match: string; body?: unknown } + +function stubFetch(routes: Route_[]) { + return vi.fn(async (url: string, init?: RequestInit) => { + const method = (init?.method ?? 'GET').toUpperCase(); + const route = routes.find( + (r) => (r.method ?? 'GET').toUpperCase() === method && String(url).includes(r.match), + ); + if (!route) throw new Error(`unstubbed fetch: ${method} ${url}`); + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => route.body, + text: async () => JSON.stringify(route.body), + } as unknown as Response; + }); +} + +/** Render the INTERNAL route (`/forms/:name`) over a raw authored section list. */ +function renderInternal(sections: unknown[], type = 'simple') { + vi.stubGlobal( + 'fetch', + stubFetch([ + { + match: '/meta/view/', + body: { + name: 'ticket.edit', + object: 'ticket', + viewKind: 'form', + label: 'Ticket', + config: { type, sections }, + }, + }, + { match: '/meta/object/', body: TICKET }, + ]), + ); + return render( + + + } /> + + , + ); +} + +/** Render the PUBLIC route (`/f/:slug`) over a raw authored section list. */ +function renderPublic(sections: unknown[]) { + vi.stubGlobal( + 'fetch', + stubFetch([ + { + match: '/forms/ticket-intake', + body: { + slug: 'ticket-intake', + object: 'ticket', + label: 'Ticket intake', + form: { type: 'simple', sections }, + objectSchema: TICKET, + }, + }, + ]), + ); + return render( + + + } /> + + , + ); +} + +/** + * Harness-kill leg. Fires in BOTH directions — zero anchors and duplicated + * anchors — so neither a form that failed to load nor one drawn twice can be + * read as a content result. Its message is unlike every content assertion in + * this file on purpose: a harness death must never be counted as a defect + * detection when the ablation legs below are classified. + */ +async function liveForm(): Promise { + await waitFor(() => expect(screen.getByLabelText('Anchor Note')).toBeInTheDocument()); + const forms = document.body.querySelectorAll('form'); + if (forms.length !== 1) { + throw new Error(`HARNESS DEAD 8641: expected exactly 1 form, found ${forms.length}`); + } + const anchors = forms[0].querySelectorAll('input[name="anchor_note"]'); + if (anchors.length !== 1) { + throw new Error(`HARNESS DEAD 8641: expected exactly 1 anchor input, found ${anchors.length}`); + } + return forms[0] as HTMLFormElement; +} + +/** + * The rendered form's structure in document order: `H:` per section + * (`H:-` when it has none) and `F:` per control. Structural rather than + * presence-based, because presence alone cannot see a resolver that gave every + * section the same members. + */ +function outline(form: HTMLElement): string[] { + const out: string[] = []; + form.querySelectorAll('section').forEach((sec) => { + const h = sec.querySelector('h2'); + out.push(`H:${h?.textContent ?? '-'}`); + sec.querySelectorAll('input,textarea,select').forEach((el) => { + out.push(`F:${(el as HTMLInputElement).name}`); + }); + }); + return out; +} + +beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +// ─── The premise ────────────────────────────────────────────────────────── + +describe('objectui#8641 — the shape the console must accept', () => { + it('PREMISE: `@objectstack/spec` ACCEPTS a bare { group } form section', () => { + // Re-derived against the INSTALLED package rather than quoted from the + // card: if the spec ever stopped accepting this shape, every leg below + // would be pinning a shape no producer can author. + const accepted = FormSectionSchema.safeParse({ group: 'contact_info' }); + expect(accepted.success).toBe(true); + expect(accepted.data).toMatchObject({ group: 'contact_info' }); + + // And it refuses the presentation keys the GROUP owns, which is why the + // section arrives with no `label` for this renderer to draw. + expect(FormSectionSchema.safeParse({ group: 'contact_info', label: 'X' }).success).toBe(false); + }); +}); + +// ─── The discriminating axis ────────────────────────────────────────────── + +describe('objectui#8641 — a group-referencing section renders the members that group declares', () => { + it('EACH_GROUP_SECTION_RENDERS_ITS_OWN_MEMBERS — buildSections, two DIFFERENT groups', () => { + const sections = buildSections( + formOf([ANCHOR, { group: 'billing' }, { group: 'contact_info' }]), + TICKET as never, + ); + + expect(sections).toHaveLength(3); + + // ⭐ The SECOND declared group, asserted first — the leg a constant + // resolution cannot satisfy. Concrete identifiers, in declared order. + expect(sections[1].label).toBe('Billing Details'); + expect(sections[1].fields.map((f) => f.name)).toEqual(['invoice_no', 'po_number']); + + // The first declared group. Green under the caricature too, and kept + // because "billing rendered SOMETHING" would otherwise be satisfied by a + // resolver that hands every section the same list. + expect(sections[2].label).toBe('Contact Info'); + expect(sections[2].fields.map((f) => f.name)).toEqual(['email', 'phone']); + + // The labels are the GROUP's, and the member rows carry the object's own + // field metadata — so what is on screen is the derived section, not a + // shell named after it. + expect(sections[1].fields.map((f) => f.label)).toEqual(['Invoice No', 'PO Number']); + }); + + it('EACH_GROUP_SECTION_RENDERS_ITS_OWN_MEMBERS — internal /forms/:name route, in the DOM', async () => { + renderInternal([ANCHOR, { group: 'billing' }, { group: 'contact_info' }]); + const form = await liveForm(); + + expect(outline(form)).toEqual([ + 'H:Anchor', 'F:anchor_note', + 'H:Billing Details', 'F:invoice_no', 'F:po_number', + 'H:Contact Info', 'F:email', 'F:phone', + ]); + + // The pre-fix DOM measurement, inverted: these controls were absent. + expect(screen.getByLabelText('Invoice No')).toBeInTheDocument(); + expect(screen.getByLabelText('Email Address')).toBeInTheDocument(); + }); + + it('EACH_GROUP_SECTION_RENDERS_ITS_OWN_MEMBERS — public /f/:slug route, in the DOM', async () => { + // The second loader, and NOT a duplicate of the one above: the internal + // route rebuilds the object schema key by key (so `fieldGroups` has to be + // copied there explicitly), while the public route forwards the server's + // payload whole. One of them can regress without the other. + renderPublic([ANCHOR, { group: 'billing' }, { group: 'contact_info' }]); + const form = await liveForm(); + + expect(outline(form)).toEqual([ + 'H:Anchor', 'F:anchor_note', + 'H:Billing Details', 'F:invoice_no', 'F:po_number', + 'H:Contact Info', 'F:email', 'F:phone', + ]); + }); + + it('carries the GROUP\'s presentation — collapse state comes from the derivation, not from here', () => { + const sections = buildSections(formOf([ANCHOR, { group: 'archive' }]), TICKET as never); + + expect(sections[1].label).toBe('Archive'); + expect(sections[1].fields.map((f) => f.name)).toEqual(['old_ref']); + // `collapse: 'collapsed'` on the object's `fieldGroups` entry, mapped onto + // this renderer's boolean pair by the SHARED adapter. Nothing in this app + // knows the `collapse` vocabulary. + expect(sections[1].collapsible).toBe(true); + expect(sections[1].collapsed).toBe(true); + }); + + it('the FORM keeps its own layout key beside `group`', () => { + // `columns` is what THIS form does with the section, not what the group + // declares, so the authored value wins — the spec's own precedence. + const sections = buildSections( + formOf([ANCHOR, { group: 'billing', columns: 3 }]), + TICKET as never, + ); + expect(sections[1].columns).toBe(3); + expect(sections[1].fields.map((f) => f.name)).toEqual(['invoice_no', 'po_number']); + }); +}); + +// ─── Non-regression controls ────────────────────────────────────────────── + +describe('objectui#8641 — what must NOT change', () => { + it('CONTROL: a form with no group reference is built exactly as before', async () => { + renderInternal([ + { label: 'Details', fields: ['channel', { field: 'email', label: 'Contact Email' }] }, + ANCHOR, + ]); + const form = await liveForm(); + + expect(outline(form)).toEqual([ + 'H:Details', 'F:channel', 'F:email', + 'H:Anchor', 'F:anchor_note', + ]); + // The field-level override still wins over the object's label. + expect(screen.getByLabelText('Contact Email')).toBeInTheDocument(); + }); + + it('CONTROL: enumerated and referenced sections coexist in AUTHORED order', () => { + const sections = buildSections( + formOf([{ label: 'Top', fields: ['channel'] }, { group: 'billing' }, ANCHOR]), + TICKET as never, + ); + expect(sections.map((s) => s.label)).toEqual(['Top', 'Billing Details', 'Anchor']); + expect(sections.map((s) => s.fields.map((f) => f.name))).toEqual([ + ['channel'], ['invoice_no', 'po_number'], ['anchor_note'], + ]); + }); +}); + +// ─── The other half of the defect: it reported nothing ──────────────────── + +describe('objectui#8641 — an unresolvable reference is REPORTED, not silent', () => { + it('a group nothing declares renders empty and names itself, the object and the fix', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const sections = buildSections( + formOf([ANCHOR, { group: 'no_such_group_8641' }]), + TICKET as never, + ); + + expect(sections[1].fields).toEqual([]); + const said = spy.mock.calls.map((c) => String(c[0])).join('\n'); + expect(said).toContain('no_such_group_8641'); + expect(said).toContain('ticket'); + expect(said).toContain('fieldGroups'); + }); + + it('`group` on a wizard section is refused out loud — the same answer the spec door gives', () => { + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const sections = buildSections( + formOf([ANCHOR, { group: 'billing' }], 'wizard'), + TICKET as never, + ); + + // Rendered empty rather than honoured: a field group carries `collapse` + // and `visibleWhen`, and a wizard step has a slot for neither, so + // `@objectstack/spec` refuses the combination at parse. This renderer + // gives the refused shape the same answer instead of inventing semantics + // the spec declined to give it. + expect(sections[1].fields).toEqual([]); + expect(spy.mock.calls.map((c) => String(c[0])).join('\n')).toContain('wizard'); + }); +}); diff --git a/apps/console/src/components/FormPage.tsx b/apps/console/src/components/FormPage.tsx index 2de9ac8193..c31f2ca265 100644 --- a/apps/console/src/components/FormPage.tsx +++ b/apps/console/src/components/FormPage.tsx @@ -113,7 +113,7 @@ import { resolveFieldRuleState, type FieldRulePredicate, } from '@object-ui/core'; -import { omitServerResolvedDefaults } from '@object-ui/plugin-form'; +import { omitServerResolvedDefaults, resolveSectionGroupReferences } from '@object-ui/plugin-form'; import { usePredicateScope } from '@object-ui/react'; import type { FormFieldSpec, FormSectionSpec, FormViewSpec } from '@object-ui/app-shell'; import { resolveSubmitRedirect } from './submitRedirect'; @@ -133,11 +133,32 @@ interface ObjectSchemaPayload { name: string; label?: string; fields: Record; + /** + * The object's declared field groups (ADR-0085 §5), carried so a form + * section may REFERENCE one instead of enumerating members (objectui#8641). + * + * Untyped on purpose: this key is never read HERE. It travels straight to + * `resolveSectionGroupReferences`, whose derivation + * (`deriveFieldGroupLayout`) owns what a `fieldGroups` entry may say — and + * `@objectstack/spec` owns THAT. Restating the entry shape in this app would + * put a second description of one contract in the place least likely to be + * updated when the contract moves. + */ + fieldGroups?: unknown; } interface ObjectFieldDef { type: string; label?: string; + /** + * Which declared `fieldGroups` entry this field belongs to (ADR-0085 §5). + * + * Membership is the ONLY input the group derivation needs per field, and + * this app never reads the key itself — see {@link ObjectSchemaPayload.fieldGroups}. + * Declared so the group half of the object schema is describable rather than + * smuggled through as an untyped bag (objectui#8641). + */ + group?: string; required?: boolean; defaultValue?: unknown; maxLength?: number; @@ -502,8 +523,49 @@ export function buildSections( form: FormViewSpec, objectSchema: ObjectSchemaPayload | null, ): RenderableSection[] { - const sections = form.sections ?? form.groups ?? []; const objFields = objectSchema?.fields ?? {}; + /** + * A section may declare its members EITHER way (objectui#8641): enumerate + * `fields`, or point `group` at one of the object's declared `fieldGroups` + * (`@objectstack/spec` 17.3.0, objectstack#13855, ADR-0085 §5). The loop + * below reads `sec.fields ?? []` and `sec.label`, and a `{ group }` section + * carries neither — measured in the DOM on both routes before this call + * existed: an empty bordered card, no heading, zero inputs, no diagnostic. + * + * ⛔ Nothing about the assembly is decided here. Declared order, the + * empty-group drop, the ungrouped trailing bucket and the collapse / + * `visibleWhen` passthrough all come from `deriveFieldGroupLayout` through + * `@object-ui/plugin-form`'s ONE adapter onto the section shape — the same + * code path `ObjectForm` resolves through, published for this call site + * (objectui#7051's standing constraint, and the reason this is an import + * rather than a second reader of the derivation). A group authored by + * reference renders the same section in this app and in the plugin because + * one of them IS the other's code path. + * + * `formType` is forwarded so a shape `@objectstack/spec` REFUSES gets the + * same answer here as there: `group` on a `formType: 'wizard'` section + * renders nothing and says why, rather than this renderer honouring what the + * spec door declines to accept. + * + * `resolvable` is left at its default. This builder only ever runs after the + * load settled, so a null `objectSchema` means the object metadata is + * genuinely absent — a failure the load path owns — and reporting a dangling + * group reference for it would be a second, wrong diagnosis of one failure. + */ + const authored = form.sections ?? form.groups ?? []; + const sections = (resolveSectionGroupReferences( + // Two packages describing ONE authored document: `FormSectionSpec` is the + // app-shell authoring type this app reads, `ObjectFormSection` the plugin's + // name for the same section. The parameter type is taken FROM the published + // signature rather than restated, so a change to it lands here as a compile + // error instead of a silent mismatch. + authored as unknown as NonNullable[0]>, + { + objectName: objectSchema?.name ?? '', + formType: form.type, + objectDef: objectSchema, + }, + ) ?? []) as unknown as FormSectionSpec[]; return sections.map((sec) => { const cols = normalizeColumns(sec.columns); const fields: RenderableField[] = []; @@ -1244,6 +1306,13 @@ async function loadInternalForm( name: objSpec.name ?? objectName, label: objSpec.label, fields: objSpec.fields, + // Copied because this rebuild is key by key: a key it does not copy + // is gone before `buildSections` can see it, which is exactly how a + // `{ group }` section reached the builder with nothing to resolve + // against (objectui#8641). The public `/f/:slug` payload needs no + // such line — `loadPublicForm` forwards the server's `objectSchema` + // object whole. + fieldGroups: objSpec.fieldGroups, }; } } diff --git a/packages/plugin-form/README.md b/packages/plugin-form/README.md index c3320b451a..18a020ad9f 100644 --- a/packages/plugin-form/README.md +++ b/packages/plugin-form/README.md @@ -60,8 +60,10 @@ whoever else claims it. ### Public exports The package entry exports these — components, their prop/schema types, the -layout helpers, and the two create-payload rules a second form renderer needs -(see [`required` + a runtime default](#required-or-requiredwhen--a-runtime-default)). +layout helpers, the two create-payload rules a second form renderer needs +(see [`required` + a runtime default](#required-or-requiredwhen--a-runtime-default)) +and the section-group resolver a third one needs +(see [Resolving `sections[].group` outside this package](#resolving-sectionsgroup-outside-this-package)). There is no aggregate map among them: ```typescript @@ -94,6 +96,7 @@ import { resolveInlineMode, omitServerResolvedDefaults, isRequiredInForm, + resolveSectionGroupReferences, } from '@object-ui/plugin-form'; import type { @@ -124,6 +127,7 @@ import type { InlineMode, ChildObjectSchemaLike, FieldDefaultsSchemaLike, + ResolveSectionGroupsOptions, } from '@object-ui/plugin-form'; ``` @@ -371,6 +375,50 @@ evaluator that resolves it — `resolveFieldRuleState`, reading `isServerOwnedValue`. Display and validation share that single verdict, so a field can never lose its asterisk while still refusing the submit. +### Resolving `sections[].group` outside this package + +A form section declares its members exactly one way: it enumerates `fields`, or +it points `group` at one of the object's declared `fieldGroups` and inherits that +group's members **and** its presentation (`@objectstack/spec` 17.3.0, +objectstack#13855, ADR-0085 §5). `ObjectForm` resolves the reference once, above +its routing fork, so all six layouts inherit it. + +A host with its **own** section builder resolves it with the same function +instead of deriving sections itself (objectui#8641): + +```typescript +declare const objectDef: unknown; // `{ fields, fieldGroups }`, or null while loading + +import { + resolveSectionGroupReferences, + type ResolveSectionGroupsOptions, +} from '@object-ui/plugin-form'; + +const sections = resolveSectionGroupReferences(authoredSections, { + objectName: 'ticket', + formType: 'simple', + objectDef, +}); +``` + +Every `{ group: 'x' }` section comes back as the section that group declares — +label, members, description and collapse state — and everything else comes back +**untouched**: with no reference in the list the input array is returned by +identity, so the call cannot perturb an existing form and is safe inside a +`useMemo`. A reference that resolves to nothing yields an empty section (never a +dropped one — a `sections` array that empties out stops being a sectioned form at +all) and is reported once, naming the object and the key. + +⛔ Nothing about the assembly belongs to the caller. Declared order, the +empty-group drop, the ungrouped trailing bucket and the collapse / `visibleWhen` +passthrough all come from `deriveFieldGroupLayout` through this package's single +adapter onto the section shape — the same code path the no-sections field-group +fallback uses, so a group authored by reference and one derived by the fallback +are the same section by construction. A host reaching for `deriveFieldGroupLayout` +directly would have to re-spell the `collapse` enum onto its own boolean pair and +pass `visibleWhen` through by hand, which is the duplication this export exists to +prevent. + ### Column width of a sectioned form A sectioned form renders as ONE grid, and two keys decide its shape: diff --git a/packages/plugin-form/src/__tests__/sectionGroupResolverPublished-8641.test.ts b/packages/plugin-form/src/__tests__/sectionGroupResolverPublished-8641.test.ts new file mode 100644 index 0000000000..3feecb76e6 --- /dev/null +++ b/packages/plugin-form/src/__tests__/sectionGroupResolverPublished-8641.test.ts @@ -0,0 +1,78 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#8641 — what `sectionGroups.ts` publishes from the package ENTRY, + * and what it deliberately does not. + * + * The card is a surface decision (its arm A), so the surface is what this + * pins, in the shape objectui#6059 established for the same barrel: the + * positive half is proved by anything that imports the name — `apps/console`'s + * `FormPage` does, and its suite reds without it — while the NEGATIVE half is + * what nothing else would notice. `sectionGroups.ts` holds four more names, + * and `export * from './sectionGroups'` would have published every one of them + * in a diff that reads tidier than the explicit list. + * + * Each withheld name has its own reason, not "nobody asked": + * + * hasSectionGroupReference the resolver already answers it — it returns its + * input array UNCHANGED when no section uses the + * reference form, so a consumer needs no separate + * predicate to avoid perturbing an existing form. + * Publishing both would invite a caller to gate on + * one and resolve with the other, which is two + * readers of one question. + * resetSectionGroupReports this package's own test seam for the once-per-key + * diagnostic set. Published, it becomes a supported + * way to re-fire warnings. + * GROUP_OWNED_SECTION_KEYS both are `@objectstack/spec`'s facts about what + * SECTION_LAYOUT_KEYS `FormSectionSchema` refuses and permits beside + * `group`. This module reads them to REPORT; a + * consumer wanting the rule wants the spec, and a + * second published spelling of one contract is + * exactly what the reference form exists to avoid. + */ + +import { describe, expect, it } from 'vitest'; +import * as entry from '../index'; +import * as sectionGroups from '../sectionGroups'; + +/** Published by the entry as of #8641 — the resolver, and only the resolver. */ +const PUBLISHED = ['resolveSectionGroupReferences'] as const; + +/** Present in `sectionGroups.ts` and deliberately NOT on the entry. */ +const WITHHELD = [ + 'hasSectionGroupReference', + 'resetSectionGroupReports', + 'GROUP_OWNED_SECTION_KEYS', + 'SECTION_LAYOUT_KEYS', +] as const; + +describe('@object-ui/plugin-form entry — the #8641 surface addition', () => { + it('publishes the resolver, as the very function `sectionGroups` defines', () => { + for (const name of PUBLISHED) { + expect(typeof (entry as Record)[name]).toBe('function'); + // Identity, not just presence: a re-export must not quietly become a + // second implementation free to disagree with the one this package's own + // `ObjectForm` calls. "One assembler, one behaviour" is the whole reason + // the console imports this instead of deriving sections itself. + expect((entry as Record)[name]).toBe( + (sectionGroups as Record)[name], + ); + } + }); + + it('withholds the rest of the module', () => { + for (const name of WITHHELD) { + // Defined next door, so the assertion is about the ENTRY and not about a + // name that does not exist anywhere. + expect((sectionGroups as Record)[name]).toBeDefined(); + expect(entry as Record).not.toHaveProperty(name); + } + }); +}); diff --git a/packages/plugin-form/src/index.tsx b/packages/plugin-form/src/index.tsx index efbd717e74..7e4e304066 100644 --- a/packages/plugin-form/src/index.tsx +++ b/packages/plugin-form/src/index.tsx @@ -101,6 +101,48 @@ export type { DerivedDetail, InlineMode, ChildObjectSchemaLike } from './deriveM */ export { omitServerResolvedDefaults, isRequiredInForm } from './schemaDefaults'; +/** + * The `form.sections[].group` RESOLVER, published for the third form-section + * consumer (objectui#8641). + * + * `resolveSectionGroupReferences` replaces every `{ group: 'x' }` section with + * the section that group declares, and returns its input UNCHANGED — the same + * array reference — when no section uses the reference form. It is a pure + * function over plain data: no React, no registry, no container. + * + * ## Why it is published rather than copied + * + * `apps/console`'s `FormPage` has its OWN section builder (`buildSections`), + * on none of this package's code path, and it read `sec.fields ?? []` — so a + * spec-legal `{ group }` section produced a section with no label and no + * fields, measured in the DOM as an empty bordered card with zero inputs on + * both the internal `/forms/:name` and public `/f/:slug` routes. That is the + * silent-drop class objectui#7051 closed HERE, arriving at a renderer #7051's + * fix could not reach. + * + * The standing constraint of objectui#7051 / objectstack#13855 is that no + * assembly rule may be re-implemented on the objectui side — not declared + * order, not the empty-group drop, not the ungrouped trailing bucket, not the + * collapse / `visibleWhen` passthrough. Every one of those comes from + * `deriveFieldGroupLayout` (ADR-0085 §5) through this package's ONE adapter, + * `deriveFieldGroupSections`, which this resolver calls. A consumer that + * reached for `deriveFieldGroupLayout` directly would have to re-spell the + * `collapse` enum -> `{ collapsible, collapsed }` mapping and the `visibleWhen` + * passthrough to land on a section shape, which is that constraint's exact + * prohibition. So the importable resolver is the only arm that keeps one + * assembler and one behaviour for all three consumers. + * + * This is the same surface decision as objectui#6059's create-payload pair, + * for the same file, and it is deliberately as small: the resolver and the + * options type its signature requires (objectui#7324's rule — a consumer must + * be able to NAME what it passes; a type adds no runtime surface). + * `hasSectionGroupReference` stays module-private because the resolver already + * answers it by returning its input unchanged, and `resetSectionGroupReports` + * stays private because it is this package's own test seam. + */ +export { resolveSectionGroupReferences } from './sectionGroups'; +export type { ResolveSectionGroupsOptions } from './sectionGroups'; + /** * The parameter types those published signatures require, so a consumer can * NAME what it must pass (objectui#7324). From bd686c2f12c150809e30b8ffd10fa315efdded94 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 01:59:00 +0000 Subject: [PATCH 2/2] docs(plugin-form): give the section-group snippet its input, and stop freezing a spec version (#8641) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two red checks on this PR, both in the README block this PR added. `Doc Snippet Type Check` — the block called `resolveSectionGroupReferences` with an `authoredSections` nothing declared (TS2304). It is now declared at the type the function actually takes: `ObjectFormSection[]`, imported from `@object-ui/types`, which is what `packages/plugin-form/dist/sectionGroups.d.ts` spells for that parameter. That type declares `group` and leaves `fields` optional, so it is also the shape the surrounding prose is about. Not `any`: a snippet that type-checks by erasing its own types teaches nothing and leaves the gate asserting nothing. `doc-version-claims` — the paragraph froze "`@objectstack/spec` 17.3.0" into prose no gate can re-measure. Deleted rather than inventoried: the sentence keeps its meaning from objectstack#13855 and ADR-0085 §5, which do not go stale, and the version floor now points at this package's own `package.json` entry (`^17.0.0`), which moves when the dependency moves. The literal was TRUE at this commit — the installed spec is 17.3.0 — and that is exactly the condition under which it would have gone silently false later. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S --- packages/plugin-form/README.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/plugin-form/README.md b/packages/plugin-form/README.md index 18a020ad9f..8d0e7dc091 100644 --- a/packages/plugin-form/README.md +++ b/packages/plugin-form/README.md @@ -379,20 +379,26 @@ field can never lose its asterisk while still refusing the submit. A form section declares its members exactly one way: it enumerates `fields`, or it points `group` at one of the object's declared `fieldGroups` and inherits that -group's members **and** its presentation (`@objectstack/spec` 17.3.0, -objectstack#13855, ADR-0085 §5). `ObjectForm` resolves the reference once, above -its routing fork, so all six layouts inherit it. +group's members **and** its presentation (objectstack#13855, ADR-0085 §5 — the +spec range that carries it is the `@objectstack/spec` entry in this package's own +`package.json`). `ObjectForm` resolves the reference once, above its routing +fork, so all six layouts inherit it. A host with its **own** section builder resolves it with the same function instead of deriving sections itself (objectui#8641): ```typescript -declare const objectDef: unknown; // `{ fields, fieldGroups }`, or null while loading - import { resolveSectionGroupReferences, type ResolveSectionGroupsOptions, } from '@object-ui/plugin-form'; +import type { ObjectFormSection } from '@object-ui/types'; + +// What the host's own builder produced. A `{ group: 'x' }` section is still +// unresolved here — `ObjectFormSection` declares `group` and leaves `fields` +// optional, which is the whole shape this call takes in. +declare const authoredSections: ObjectFormSection[]; +declare const objectDef: unknown; // `{ fields, fieldGroups }`, or null while loading const sections = resolveSectionGroupReferences(authoredSections, { objectName: 'ticket',