Skip to content

Commit a29ae2d

Browse files
os-justinclaude
andauthored
fix(plugin-form,types): resolve form.sections[].group through the single field-group assembler, and bound the section loop that blanked the form (#8644)
* feat(plugin-form,types): resolve `form.sections[].group` through the single field-group assembler The view-level half of objectstack#13855 (objectui#7051); the `record:details` half shipped as objectui#8497. `@objectstack/spec` 17.3.0 declares two ways for a `form.sections[]` entry to give itself members: enumerate `fields`, or point `group` at one of the object's declared `fieldGroups`. No form-section renderer read `group`, and the omission was not a no-op — measured on `origin/main`: simple threw `Cannot read properties of undefined (reading 'map')` out of SimpleObjectForm's own body, ABOVE the JSX it returns, and blanked the whole form (siblings included) tabbed/split/drawer/modal silently rendered nothing, no diagnostic wizard an empty step `ObjectForm` now resolves the reference once, above its routing fork, so all six layouts inherit it. Resolution runs through `deriveFieldGroupLayout` (ADR-0085 §5) via this package's existing single adapter — 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. No assembly rule is re-implemented on this side. The object definition is fetched only when a section actually authors `group`. Unresolvable references render nothing and are reported once; a group-owned key restated beside `group` is ignored (no override semantics) and reported; `group` on a wizard step is refused and reported. All three are shapes the spec door refuses at parse, so they reach the renderer only from programmatic SDUI callers — the door itself is pinned rather than assumed. `ObjectFormSection` gains `group` and makes `fields` optional, so the spec-legal shape compiles for a TypeScript author; the shared adapter now carries the group's `description` and `visibleWhen` onto the derived section. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S * test(plugin-form): pin the memberless-section containment layer separately (objectui#7051) With `group` resolved above the section loop, every group leg stays green with the tolerant `?? []` read reverted — the guard was unpinned by anything else. This leg is the only one that fails then. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S * test(plugin-form): reference the SECOND declared group in the layout rows (objectui#7051) Measured, not predicted: with the first group these rows stayed GREEN under the constant-resolution caricature, because the first group is exactly what that caricature returns. A leg a caricature satisfies by accident tests nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S * fix(scripts): re-key two doc-example ledger rows this branch shifted `UNGATED_EXAMPLES` in `scripts/check-doc-example-types.mjs` is keyed by `path:line symbol`, where the line is the position of the `@example` JSDoc tag. Two rows on this branch named lines that no longer hold their tag, so `scripts/__tests__/check-doc-example-types.test.ts` reddened on "every row names a block that is actually in the compiled tier". Both blocks still exist and are still in the compiled tier; only their position moved, and both example bodies are byte-identical to the merge-base, so each row's recorded diagnostic codes and written reason stay accurate: ObjectForm.tsx 122 -> 123 (one added import above the JSDoc) objectql.ts 1572 -> 1604 (schema additions above the JSDoc) `origin/main` carries both tags at their original lines, so this branch is the sole cause; no merge from `main` is required. The line-number keying that makes an unrelated PR edit a CI gate script is objectui#8614. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent de1af55 commit a29ae2d

10 files changed

Lines changed: 984 additions & 20 deletions
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
'@object-ui/plugin-form': minor
3+
'@object-ui/types': minor
4+
---
5+
6+
A form-view section can reference a declared field group instead of copying its
7+
members (objectstack#13855, objectui#7051 — the view-level half; the
8+
`record:details` half shipped as objectui#8497).
9+
10+
`@objectstack/spec` 17.3.0 declares two ways for a `form.sections[]` entry to give
11+
itself members: enumerate `fields`, or point `group` at one of the object's declared
12+
`fieldGroups` and inherit that group's membership **and** its presentation. Nothing on
13+
this renderer read `group`, and the omission was not a no-op:
14+
15+
- on the default `simple` layout an authored `{ group: 'contact_info' }` threw
16+
`Cannot read properties of undefined (reading 'map')` out of the section loop in
17+
`SimpleObjectForm`'s own body — above the JSX it returns, so no per-section error
18+
boundary could contain it — and **blanked the entire form**, taking every
19+
well-formed sibling section with it;
20+
- on `tabbed` / `split` / `drawer` / `modal` the section silently rendered nothing;
21+
- on `wizard` it rendered an empty step.
22+
23+
`ObjectForm` now resolves the reference **once**, above its routing fork, so all six
24+
layouts inherit it: a `{ group }` section renders the members that group declares, in
25+
the order and with the label, description and collapse state the object declares them
26+
with. Resolution goes through `deriveFieldGroupLayout` (ADR-0085 §5) via this
27+
package's existing single adapter — the same code path the no-sections field-group
28+
fallback already used, so authoring a group by reference and letting the fallback
29+
derive it produce the same section by construction. No assembly rule is
30+
re-implemented here.
31+
32+
The object definition is fetched only when a section actually authors `group`, so a
33+
form that does not use the reference form issues no additional request and takes no
34+
new path.
35+
36+
Diagnostics rather than silence, for the shapes the spec door cannot see (programmatic
37+
SDUI callers): a `group` naming no declared group renders nothing and is reported once
38+
on the console (`@objectstack/lint` owns it as `form-section-group-unknown`); a
39+
group-owned presentation key restated beside `group` is ignored — the spec grants no
40+
override semantics — and reported; `group` on a wizard step is refused and reported,
41+
because a step has no slot for the `collapse` / `visibleWhen` a group carries.
42+
43+
Also in this change: `@object-ui/types`' `ObjectFormSection` declares `group` and makes
44+
`fields` optional, so the spec-legal shape finally compiles for a TypeScript author;
45+
and the shared field-group adapter now carries the group's `description` and
46+
`visibleWhen` onto the section it derives — both are keys the assembler emits and
47+
`ObjectFormSection` declares, and a key-by-key rebuild that drops one is how a declared
48+
group's presentation goes missing.

‎apps/console/src/__tests__/public-block-binding-reach.test.tsx‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -292,17 +292,27 @@ const SUPERSEDES_BINDING = new Set(['data', 'staticData', 'customFields']);
292292
* generic `array` sample is `['name']`, and a bare string is not a section:
293293
* `@objectstack/spec`'s `FormViewSchema.sections` rejects it at parse —
294294
* `safeParse(['name'])` returns *"Invalid input: expected object, received
295-
* string"* — and requires `fields` on every entry (`[{}]` returns *"0.fields:
296-
* Invalid input: expected array, received undefined"*). This repo's own type
297-
* says the same: `ObjectFormSection.fields` is REQUIRED
298-
* (`packages/types/src/objectql.ts`). So `['name']` is not metadata any author
299-
* could publish, while `ObjectForm.tsx:1166` reads `section.fields.map(...)` off
300-
* each entry unguarded and takes the whole block down with *"Cannot read
295+
* string"* — and requires every entry to declare its members (`[{}]` returns
296+
* *"0.fields: A section must declare its members exactly one way, and this form
297+
* section declares neither"*). So `['name']` is not metadata any author could
298+
* publish, while `ObjectForm.tsx`'s section loop read `section.fields.map(...)`
299+
* off each entry unguarded and took the whole block down with *"Cannot read
301300
* properties of undefined (reading 'map')"*. That makes the error card a
302301
* FIXTURE defect, not the product bug the shape suggested — the discriminator
303302
* being the spec shape, not the fact that a different sample stops the crash
304303
* (which is true either way).
305304
*
305+
* ⚠️ Two halves of that reasoning moved with objectui#7051 and are restated
306+
* here rather than left to read as still-true. (1) `ObjectFormSection.fields`
307+
* is no longer REQUIRED on this repo's type: `group` — the field-group
308+
* REFERENCE form, `@objectstack/spec` 17.3.0 / objectstack#13855 — is the other
309+
* way a section declares the same fact, so the spec refuses an entry carrying
310+
* NEITHER rather than one missing `fields`. The fixture reasoning is unchanged
311+
* (a bare string is still not a section, and `{}` is still refused), only its
312+
* quoted grounds. (2) That unguarded `.map` is now spelled `?? []`, so this
313+
* sample's shape no longer decides whether the block survives — which is
314+
* exactly why the sample is justified on the SPEC shape and not on the crash.
315+
*
306316
* `formType` is the SEVENTH, and it is why `object-master-detail-form` read GREEN
307317
* while carrying the identical latent crash. That block declares `formType` as a
308318
* bare `string` — not the enum `object-form` declares — so the default branch

‎packages/plugin-form/src/ObjectForm.tsx‎

Lines changed: 87 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
inferColumns,
4343
} from './autoLayout';
4444
import { deriveFieldGroupSections } from './fieldGroups';
45+
import { hasSectionGroupReference, resolveSectionGroupReferences } from './sectionGroups';
4546
import { sanitizeFormData } from './sanitize';
4647
import { noSubmitTargetError } from './submitTarget';
4748
import {
@@ -137,6 +138,43 @@ export const ObjectForm: React.FC<ObjectFormComponentProps> = ({
137138
dataSource,
138139
}) => {
139140
const perms = usePermissions();
141+
142+
// ── `sections[].group` — the ADR-0085 §5 REFERENCE form (objectui#7051) ───
143+
//
144+
// A section may point `group` at one of the object's declared `fieldGroups`
145+
// instead of enumerating members (`@objectstack/spec` 17.3.0,
146+
// objectstack#13855). Resolving it needs the OBJECT definition, and this
147+
// dispatcher — unlike the six bodies below it — has never loaded one.
148+
//
149+
// It is resolved HERE, above the routing, on purpose: every variant's
150+
// `sections` prop is rebuilt key by key in the maps below, so a `group` left
151+
// in place would be dropped before any container could see it, and each
152+
// container would need its own copy of the resolution. One site above the
153+
// fork is what makes "one assembler" true for all six layouts at once —
154+
// including `simple`, whose section loop read `section.fields.map(...)`
155+
// unguarded and threw `Cannot read properties of undefined (reading 'map')`
156+
// on a spec-legal `{ group }` section, blanking the WHOLE form (measured on
157+
// this file at `origin/main`; the other five silently dropped the section
158+
// instead, because `buildSectionFields` spells the same read `?? []`).
159+
//
160+
// The load is gated on an authored `group` being present, so a form that
161+
// does not use the reference form issues no request and takes no new path.
162+
const needsGroupLayout = hasSectionGroupReference((rawSchema as any)?.sections);
163+
const canResolveGroups = typeof (dataSource as any)?.getObjectSchema === 'function';
164+
const [groupObjectDef, setGroupObjectDef] = useState<any>(null);
165+
useEffect(() => {
166+
if (!needsGroupLayout || !canResolveGroups) return;
167+
let alive = true;
168+
Promise.resolve((dataSource as any).getObjectSchema(rawSchema.objectName))
169+
.then((def: any) => { if (alive) setGroupObjectDef(def ?? null); })
170+
// A failed load leaves the references unresolved (their sections render
171+
// empty). It is deliberately NOT reported as a dangling reference: the
172+
// body below owns the load error for the object, and claiming the group
173+
// does not exist would be a second, wrong diagnosis of one failure.
174+
.catch(() => { if (alive) setGroupObjectDef(null); });
175+
return () => { alive = false; };
176+
}, [needsGroupLayout, canResolveGroups, dataSource, rawSchema.objectName]);
177+
140178
// Apply field-level permissions to the entire schema (sections + flat
141179
// fields) BEFORE dispatching to any variant. This way all variants
142180
// (Tabbed/Wizard/Split/Drawer/Modal/Simple) transparently honour FLS.
@@ -168,7 +206,22 @@ export const ObjectForm: React.FC<ObjectFormComponentProps> = ({
168206
})),
169207
}
170208
: folded;
171-
if (!perms?.isLoaded) return base;
209+
/**
210+
* Resolve every `{ group }` section into the section that group declares.
211+
*
212+
* Returns its input UNCHANGED — same array reference — when no section
213+
* uses the reference form, so this memo cannot perturb any existing form.
214+
*/
215+
const withGroups = (s: ObjectFormComponentProps['schema']) => {
216+
const resolved = resolveSectionGroupReferences(s.sections as any, {
217+
objectName: s.objectName,
218+
formType: s.formType,
219+
objectDef: groupObjectDef,
220+
resolvable: canResolveGroups,
221+
});
222+
return resolved === (s.sections as any) ? s : { ...s, sections: resolved as any };
223+
};
224+
if (!perms?.isLoaded) return withGroups(base);
172225
const gateField = (f: any) => {
173226
if (!f?.name) return f;
174227
const canRead = perms.checkField(base.objectName, f.name, 'read');
@@ -181,15 +234,15 @@ export const ObjectForm: React.FC<ObjectFormComponentProps> = ({
181234
};
182235
const filterArr = (arr?: any[]) =>
183236
Array.isArray(arr) ? arr.map(gateField).filter(Boolean) : arr;
184-
return {
237+
return withGroups({
185238
...base,
186239
fields: filterArr(base.fields as any[]),
187240
sections: base.sections?.map((s: any) => ({
188241
...s,
189242
fields: filterArr(s.fields),
190243
})),
191-
} as ObjectFormComponentProps['schema'];
192-
}, [rawSchema, perms]);
244+
} as ObjectFormComponentProps['schema']);
245+
}, [rawSchema, perms, groupObjectDef, canResolveGroups]);
193246
const { sectionLabel } = useSafeFieldLabel();
194247
const tSec = (s: any) =>
195248
s?.name ? sectionLabel(schema.objectName, s.name, s.label || s.name) : s?.label;
@@ -277,7 +330,16 @@ export const ObjectForm: React.FC<ObjectFormComponentProps> = ({
277330
label: tSec(s),
278331
description: s.description,
279332
columns: s.columns,
280-
fields: s.fields,
333+
// `?? []` because `ObjectFormSection.fields` is OPTIONAL since objectui#7051
334+
// — `group` is the other way a section declares its members. A `{ group }`
335+
// section has already been resolved into one carrying `fields` before this
336+
// map runs (and an unresolvable one into `fields: []`), so this default is
337+
// reached only by a programmatic caller sending a section with neither,
338+
// which the spec door refuses. These per-layout config types stay
339+
// `fields`-required on purpose: they are the programmatic React-mount
340+
// shapes, not the authorable surface, and the reference form is resolved
341+
// above them.
342+
fields: s.fields ?? [],
281343
// ADR-0089 section predicate (objectui#6237) — key-by-key rebuild,
282344
// so an uncopied key is silently dropped before TabbedForm ever
283345
// sees it, exactly as it was on this route until this card. The
@@ -348,7 +410,8 @@ export const ObjectForm: React.FC<ObjectFormComponentProps> = ({
348410
label: tSec(s),
349411
description: s.description,
350412
columns: s.columns,
351-
fields: s.fields,
413+
// `?? []` — see the tabbed arm above (objectui#7051).
414+
fields: s.fields ?? [],
352415
// `className` / `gridClassName`: deliberately not copied — see the
353416
// tabbed arm above (objectstack#13626, ruled 2026-09-01).
354417
})),
@@ -375,7 +438,8 @@ export const ObjectForm: React.FC<ObjectFormComponentProps> = ({
375438
label: tSec(s),
376439
description: s.description,
377440
columns: s.columns,
378-
fields: s.fields,
441+
// `?? []` — see the tabbed arm above (objectui#7051).
442+
fields: s.fields ?? [],
379443
// Explicit pane placement (spec FormSection.pane). This mapping
380444
// rebuilds each section key by key, so a key it doesn't copy is
381445
// silently dropped — exactly how `visibleOn` once vanished here.
@@ -410,7 +474,8 @@ export const ObjectForm: React.FC<ObjectFormComponentProps> = ({
410474
label: tSec(s),
411475
description: s.description,
412476
columns: s.columns,
413-
fields: s.fields,
477+
// `?? []` — see the tabbed arm above (objectui#7051).
478+
fields: s.fields ?? [],
414479
collapsible: (s as any).collapsible,
415480
collapsed: (s as any).collapsed,
416481
// ADR-0089 section predicate (#6111) — key-by-key rebuild, so an
@@ -445,7 +510,8 @@ export const ObjectForm: React.FC<ObjectFormComponentProps> = ({
445510
label: tSec(s),
446511
description: s.description,
447512
columns: s.columns,
448-
fields: s.fields,
513+
// `?? []` — see the tabbed arm above (objectui#7051).
514+
fields: s.fields ?? [],
449515
// ADR-0089 section predicate (#6111) — key-by-key rebuild, so an
450516
// uncopied key is silently dropped before ModalForm ever sees it.
451517
visibleWhen: (s as any).visibleWhen,
@@ -1269,7 +1335,18 @@ const SimpleObjectForm: React.FC<ObjectFormComponentProps> = ({
12691335
// legitimately be the spec identity STRING (the cast is the boundary,
12701336
// not a leak; on runtime FormFields the declared `field` slot is
12711337
// always the metadata object, #3090).
1272-
section.fields.map(f => [typeof f === 'string' ? f : ((f as any).field ?? f.name), f]),
1338+
// ⚠️ `?? []`, not a bare `.map` — the second containment layer for
1339+
// objectui#7051. The group-reference resolution above this component
1340+
// means a `{ group }` section never arrives here carrying no `fields`,
1341+
// but this loop runs in `SimpleObjectForm`'s own body, ABOVE the JSX it
1342+
// returns: a throw here is outside every per-section subtree, so no
1343+
// error boundary that a section could own would contain it. That is
1344+
// exactly how a spec-legal section blanked the entire form — the
1345+
// well-formed siblings with it — before this card. The five container
1346+
// variants have always spelled this read `section.fields ?? []` in
1347+
// `buildSectionFields`; this is the sixth joining them, so no section
1348+
// shape can take the form down again through this line.
1349+
(section.fields ?? []).map(f => [typeof f === 'string' ? f : ((f as any).field ?? f.name), f]),
12731350
);
12741351
const sectionFieldNames = Array.from(sectionDefByName.keys());
12751352
const sectionFields = applyFieldPerms(sourceFields.filter(f => sectionFieldNames.includes(f.name)))

0 commit comments

Comments
 (0)