Skip to content

Commit 1cb09a4

Browse files
dmealingclaude
andauthored
feat(codegen-ts-react): nested value-object sub-forms in formFile (#95) (#100)
formFile previously emitted one flat <input> per visible field with no field-subtype branching, so a `field.object` with an `@objectRef` to a value object (a nested sub-shape, commonly jsonb-stored) rendered as a single text <input> bound to a nested object — unusable. The form template now branches on field subtype, reusing the same objectRef resolution the codegen-ts zod/inferred-type templates use: - A `field.object` with a resolvable `@objectRef` recurses into the referenced value object's fields and emits a labeled <fieldset> sub-form, binding each sub-field via a react-hook-form nested path (form.register("parent.sub")). - Arrays of value objects emit a useFieldArray-based repeatable group (add/remove + per-element register via the indexed path). - Scalars keep the existing flat form.input.<field> block (byte-identical; plain entities unchanged). - Recursion handles deeper nesting, guarded by a visited-set + max-depth. Arrays-of-value-objects nested inside another array (dynamic RHF path) degrade to an element-shape fieldset + a single TODO rather than a flat input. Claude-Session: https://claude.ai/code/session_01LuZWKnWzYGVnESijL7uuky Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f1b3bd4 commit 1cb09a4

2 files changed

Lines changed: 338 additions & 15 deletions

File tree

server/typescript/packages/codegen-ts-react/src/templates/form-file.ts

Lines changed: 224 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,23 @@
1717
// object metadata. Default off. Most projects don't need stock forms.
1818

1919
import { code, imp } from "ts-poet";
20-
import { MetaField, MetaObject } from "@metaobjectsdev/metadata";
20+
import { MetaField, MetaObject, stripPackage } from "@metaobjectsdev/metadata";
2121
import {
2222
IDENTITY_SUBTYPE_PRIMARY,
2323
IDENTITY_ATTR_FIELDS,
2424
FIELD_ATTR_DEFAULT,
25+
FIELD_SUBTYPE_OBJECT,
26+
FIELD_ATTR_OBJECT_REF,
27+
FIELD_SUBTYPE_INT,
28+
FIELD_SUBTYPE_LONG,
29+
FIELD_SUBTYPE_DOUBLE,
30+
FIELD_SUBTYPE_FLOAT,
31+
FIELD_SUBTYPE_DECIMAL,
32+
FIELD_SUBTYPE_CURRENCY,
33+
FIELD_SUBTYPE_BOOLEAN,
34+
FIELD_SUBTYPE_DATE,
35+
FIELD_SUBTYPE_TIME,
36+
FIELD_SUBTYPE_TIMESTAMP,
2537
} from "@metaobjectsdev/metadata";
2638
import { type RenderContext, entityModuleSpecifier, GENERATED_HEADER, tphDiscriminatorPin } from "@metaobjectsdev/codegen-ts";
2739

@@ -49,18 +61,196 @@ function isAutoManaged(field: MetaField): boolean {
4961
/** Visible form fields = all fields minus PK, DB-auto-defaulted, and (for a TPH
5062
* subtype) the discriminator — which is implicit (the form is subtype-specific)
5163
* and injected server-side, never rendered. */
52-
function visibleFields(entity: MetaObject, discField?: string): string[] {
64+
function visibleFields(entity: MetaObject, discField?: string): MetaField[] {
5365
const pkNames = primaryFieldNames(entity);
54-
const names: string[] = [];
66+
const out: MetaField[] = [];
5567
// fields() returns effective fields, so inherited fields (from extends:/super:) are included in forms.
5668
for (const child of entity.fields()) {
5769
if (child.ownAttr("formExclude") === true) continue;
5870
if (pkNames.has(child.name)) continue;
5971
if (isAutoManaged(child)) continue;
6072
if (discField !== undefined && child.name === discField) continue;
61-
names.push(child.name);
73+
out.push(child);
6274
}
63-
return names;
75+
return out;
76+
}
77+
78+
// ---------------------------------------------------------------------------
79+
// Nested value-object sub-forms (issue #95)
80+
// ---------------------------------------------------------------------------
81+
82+
/** Max recursion depth for nested value objects — a backstop alongside the
83+
* per-branch visited-set cycle guard. */
84+
const MAX_VO_DEPTH = 5;
85+
86+
/** Resolve a `field.object`'s `@objectRef` to the referenced value object from
87+
* the loaded root. Mirrors the resolution the Zod / inferred-type templates use
88+
* (read `@objectRef`, strip the package, look the bare name up on the root). */
89+
function resolveValueObject(field: MetaField, ctx: RenderContext): MetaObject | undefined {
90+
if (field.subType !== FIELD_SUBTYPE_OBJECT) return undefined;
91+
const ref = field.attr(FIELD_ATTR_OBJECT_REF);
92+
if (typeof ref !== "string" || ref.length === 0) return undefined;
93+
const base = stripPackage(ref);
94+
return ctx.loadedRoot.objects().find((o) => o.name === base) as MetaObject | undefined;
95+
}
96+
97+
/** camelCase / PascalCase → human label ("llmConfig" → "Llm Config"). */
98+
function humanize(s: string): string {
99+
return s
100+
.replace(/([a-z])([A-Z])/g, "$1 $2")
101+
.replace(/_/g, " ")
102+
.replace(/\b\w/g, (c) => c.toUpperCase())
103+
.trim();
104+
}
105+
106+
/** A real HTML <input type=...> for a scalar field subtype, or undefined (text). */
107+
function htmlTypeForSubType(subType: string): string | undefined {
108+
switch (subType) {
109+
case FIELD_SUBTYPE_INT:
110+
case FIELD_SUBTYPE_LONG:
111+
case FIELD_SUBTYPE_DOUBLE:
112+
case FIELD_SUBTYPE_FLOAT:
113+
case FIELD_SUBTYPE_DECIMAL:
114+
case FIELD_SUBTYPE_CURRENCY:
115+
return "number";
116+
case FIELD_SUBTYPE_BOOLEAN:
117+
return "checkbox";
118+
case FIELD_SUBTYPE_DATE:
119+
return "date";
120+
case FIELD_SUBTYPE_TIME:
121+
return "time";
122+
case FIELD_SUBTYPE_TIMESTAMP:
123+
return "datetime-local";
124+
default:
125+
return undefined;
126+
}
127+
}
128+
129+
/** The react-hook-form `register(...)` argument for a nested field path. A
130+
* static path is a plain string literal; a path threaded through an array
131+
* element carries a runtime `${index}` and must be a template literal. */
132+
function registerArg(path: string, dynamic: boolean): string {
133+
return dynamic ? `\`${path}\`` : JSON.stringify(path);
134+
}
135+
136+
/** A stable, identifier-safe useFieldArray hook variable for a static path. */
137+
function arrayHookVar(path: string): string {
138+
return `${path.replace(/[^A-Za-z0-9]+/g, "_")}Array`;
139+
}
140+
141+
interface FieldRender {
142+
jsx: string;
143+
/** useFieldArray declarations to hoist to the top of the component. */
144+
hooks: string[];
145+
}
146+
147+
/**
148+
* Render one NESTED field inside a value-object sub-form. Dispatches on subtype:
149+
* - a resolvable `field.object` (single) → a nested <fieldset> recursing into
150+
* the value object's fields;
151+
* - a resolvable `field.object` array → a useFieldArray repeatable group (when
152+
* the path is static) or a degraded element-shape fieldset + TODO (when the
153+
* path is already inside an array element, i.e. dynamic);
154+
* - anything else → a single <input> bound via a nested react-hook-form path.
155+
*/
156+
function renderNestedField(
157+
field: MetaField,
158+
path: string,
159+
dynamic: boolean,
160+
ctx: RenderContext,
161+
visited: Set<string>,
162+
depth: number,
163+
): FieldRender {
164+
const vo = resolveValueObject(field, ctx);
165+
if (vo === undefined) {
166+
// Scalar / enum / unresolved object → a single bound input.
167+
const htmlType = htmlTypeForSubType(field.subType);
168+
const typeAttr = htmlType !== undefined ? ` type=${JSON.stringify(htmlType)}` : "";
169+
return {
170+
jsx: `<div className="metaobjects-field" key=${JSON.stringify(path)}>
171+
<label className="metaobjects-field-label">${humanize(field.name)}</label>
172+
<input className="metaobjects-field-input"${typeAttr} {...form.register(${registerArg(path, dynamic)})} />
173+
</div>`,
174+
hooks: [],
175+
};
176+
}
177+
178+
const voKey = vo.fqn();
179+
if (visited.has(voKey) || depth >= MAX_VO_DEPTH) {
180+
// Cycle or too deep — emit a labeled shell + TODO instead of a flat input.
181+
return {
182+
jsx: `<fieldset className="metaobjects-fieldset" key=${JSON.stringify(path)}>
183+
<legend className="metaobjects-fieldset-legend">${humanize(field.name)}</legend>
184+
{/* TODO(#95): nested value object ${vo.name} not expanded (recursion guard). Author this sub-form by hand. */}
185+
</fieldset>`,
186+
hooks: [],
187+
};
188+
}
189+
const nextVisited = new Set(visited);
190+
nextVisited.add(voKey);
191+
192+
if (!field.isArray) {
193+
// Single nested value object → a fieldset recursing into its fields.
194+
const inner: string[] = [];
195+
const hooks: string[] = [];
196+
for (const sub of vo.fields()) {
197+
const r = renderNestedField(sub, `${path}.${sub.name}`, dynamic, ctx, nextVisited, depth + 1);
198+
inner.push(r.jsx);
199+
hooks.push(...r.hooks);
200+
}
201+
return {
202+
jsx: `<fieldset className="metaobjects-fieldset" key=${JSON.stringify(path)}>
203+
<legend className="metaobjects-fieldset-legend">${humanize(field.name)}</legend>
204+
${inner.join("\n")}
205+
</fieldset>`,
206+
hooks,
207+
};
208+
}
209+
210+
// Array of value objects.
211+
if (dynamic) {
212+
// Already inside an array element — a static useFieldArray name is impossible.
213+
// Degrade to the element shape + a single clear TODO (never a flat input).
214+
return {
215+
jsx: `<fieldset className="metaobjects-fieldset" key=${JSON.stringify(path)}>
216+
<legend className="metaobjects-fieldset-legend">${humanize(field.name)}</legend>
217+
{/* TODO(#95): arrays of value objects nested inside another array are not auto-wired
218+
(the react-hook-form path is dynamic). Render this repeatable group by hand. */}
219+
</fieldset>`,
220+
hooks: [],
221+
};
222+
}
223+
224+
// Static path → a useFieldArray-based repeatable group.
225+
const hookVar = arrayHookVar(path);
226+
const elementPath = `${path}.\${index}`;
227+
const inner: string[] = [];
228+
const hooks: string[] = [`const ${hookVar} = useFieldArray({ control: form.control, name: ${JSON.stringify(path)} as never });`];
229+
for (const sub of vo.fields()) {
230+
const r = renderNestedField(sub, `${elementPath}.${sub.name}`, true, ctx, nextVisited, depth + 1);
231+
inner.push(r.jsx);
232+
hooks.push(...r.hooks);
233+
}
234+
const label = humanize(field.name);
235+
const itemLabel = humanize(vo.name);
236+
return {
237+
jsx: `<div className="metaobjects-field-array" key=${JSON.stringify(path)}>
238+
<span className="metaobjects-field-array-label">${label}</span>
239+
{${hookVar}.fields.map((item, index) => (
240+
<fieldset className="metaobjects-fieldset" key={item.id}>
241+
<legend className="metaobjects-fieldset-legend">${itemLabel} {index + 1}</legend>
242+
${inner.join("\n")}
243+
<button type="button" className="metaobjects-field-array-remove" onClick={() => ${hookVar}.remove(index)}>
244+
Remove
245+
</button>
246+
</fieldset>
247+
))}
248+
<button type="button" className="metaobjects-field-array-add" onClick={() => ${hookVar}.append({} as never)}>
249+
Add ${itemLabel}
250+
</button>
251+
</div>`,
252+
hooks,
253+
};
64254
}
65255

66256
export function renderFormFile(entity: MetaObject, ctx: RenderContext): string {
@@ -88,11 +278,9 @@ export function renderFormFile(entity: MetaObject, ctx: RenderContext): string {
88278
const SubmitHandlerSym = imp("t:SubmitHandler@react-hook-form");
89279
const useEntityFormSym = imp("useEntityForm@@metaobjectsdev/react");
90280

91-
// For each visible field, emit a label + input + error block.
92-
// The input gets every metadata-derived attr via {...form.input.<field>}.
93-
const fieldBlocks = fields
94-
.map(
95-
(f) => ` <div className="metaobjects-field" key=${JSON.stringify(f)}>
281+
// The flat scalar block: a label + pre-bound input + error span, driven
282+
// entirely by the entity constants via `form.input.<field>`. Unchanged.
283+
const scalarBlock = (f: string) => ` <div className="metaobjects-field" key=${JSON.stringify(f)}>
96284
<label className="metaobjects-field-label" htmlFor={${entityName}.${f}.name}>
97285
{${entityName}.${f}.label}
98286
</label>
@@ -102,17 +290,38 @@ export function renderFormFile(entity: MetaObject, ctx: RenderContext): string {
102290
{String(form.formState.errors.${f}?.message ?? '')}
103291
</span>
104292
)}
105-
</div>`,
106-
)
107-
.join("\n");
293+
</div>`;
294+
295+
// For each visible field: scalars keep the flat `form.input.<field>` block; a
296+
// `field.object` with a resolvable `@objectRef` recurses into the referenced
297+
// value object as a nested <fieldset> sub-form (issue #95). useFieldArray
298+
// declarations for array-of-value-object fields hoist to the component top.
299+
const blocks: string[] = [];
300+
const fieldArrayHooks: string[] = [];
301+
for (const f of fields) {
302+
if (resolveValueObject(f, ctx) === undefined) {
303+
blocks.push(scalarBlock(f.name));
304+
continue;
305+
}
306+
const r = renderNestedField(f, f.name, false, ctx, new Set<string>(), 0);
307+
blocks.push(r.jsx);
308+
fieldArrayHooks.push(...r.hooks);
309+
}
310+
const fieldBlocks = blocks.join("\n");
311+
const hookSection =
312+
fieldArrayHooks.length > 0 ? `\n ${fieldArrayHooks.join("\n ")}` : "";
313+
314+
// Only pull in useFieldArray when an array-of-value-object field needs it.
315+
const useFieldArrayImport =
316+
fieldArrayHooks.length > 0 ? `import { useFieldArray } from "react-hook-form";\n` : "";
108317

109318
const literalImports = code`
110319
import {
111320
${entityName},
112321
${entityName}InsertSchema,
113322
} from ${JSON.stringify(entityFileSpec)};
114323
import type { ${entityName} as ${entityName}Row } from ${JSON.stringify(entityFileSpec)};
115-
`;
324+
${useFieldArrayImport}`;
116325

117326
const body = code`
118327
export interface ${entityName}FormProps {
@@ -137,7 +346,7 @@ export function ${entityName}Form(props: ${entityName}FormProps): ${ReactElement
137346
${entityName},
138347
${formSchema},
139348
props.defaultValues !== undefined ? { defaultValues: props.defaultValues } : {},
140-
);
349+
);${hookSection}
141350
return (
142351
<form
143352
className={props.className ?? 'metaobjects-form'}

0 commit comments

Comments
 (0)