Skip to content

Commit f902cd5

Browse files
dmealingclaude
andcommitted
feat(codegen-ts-react): dispatch form controls on field view kind
renderFormFile now dispatches on each field's view kind instead of emitting a bare <input> for every scalar: enum with no explicit view -> <select>, view.textarea -> <textarea rows={4}>, view.checkbox -> checkbox, view.radio -> radio fieldset; everything else keeps the existing typed <input>. Adds viewKindFor / labelAndError / fieldControlFor; swaps the main-loop scalar dispatch; wraps the submit button in a styled actions container; uses the FIELD_ATTR_FORM_EXCLUDE constant. Configurable @rows deferred -> fixed rows={4}. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGQ7oSuNcjhsMHWwZzhBwr
1 parent ee82f06 commit f902cd5

2 files changed

Lines changed: 203 additions & 5 deletions

File tree

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

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ import {
3434
FIELD_SUBTYPE_DATE,
3535
FIELD_SUBTYPE_TIME,
3636
FIELD_SUBTYPE_TIMESTAMP,
37+
FIELD_ATTR_FORM_EXCLUDE,
38+
FIELD_ATTR_VALUES,
39+
FIELD_ATTR_REQUIRED,
40+
FIELD_SUBTYPE_ENUM,
41+
VIEW_SUBTYPE_TEXTAREA,
42+
VIEW_SUBTYPE_DROPDOWN,
43+
VIEW_SUBTYPE_CHECKBOX,
44+
VIEW_SUBTYPE_RADIO,
3745
} from "@metaobjectsdev/metadata";
3846
import { type RenderContext, entityModuleSpecifier, GENERATED_HEADER, tphDiscriminatorPin } from "@metaobjectsdev/codegen-ts";
3947

@@ -69,7 +77,7 @@ function visibleFields(entity: MetaObject, discField?: string): MetaField[] {
6977
// fields() returns effective fields, so inherited fields (from extends:/super:) are included in forms.
7078
for (const child of entity.fields()) {
7179
// ADR-0039: resolving — a field may inherit @formExclude via extends.
72-
if (child.attr("formExclude") === true) continue;
80+
if (child.attr(FIELD_ATTR_FORM_EXCLUDE) === true) continue;
7381
if (pkNames.has(child.name)) continue;
7482
if (isAutoManaged(child)) continue;
7583
if (discField !== undefined && child.name === discField) continue;
@@ -256,6 +264,15 @@ ${inner.join("\n")}
256264
};
257265
}
258266

267+
/** The view kind that drives control selection: an explicit view child wins;
268+
* an enum with no view defaults to a dropdown; otherwise null (typed <input>). */
269+
function viewKindFor(field: MetaField): string | null {
270+
const view = field.views()[0]; // resolving accessor (ADR-0039)
271+
if (view !== undefined) return view.subType;
272+
if (field.subType === FIELD_SUBTYPE_ENUM) return VIEW_SUBTYPE_DROPDOWN;
273+
return null;
274+
}
275+
259276
export function renderFormFile(entity: MetaObject, ctx: RenderContext): string {
260277
const entityName = entity.name;
261278
// Import the entity's own file. Same target → relative "./Entity"; cross
@@ -295,6 +312,67 @@ export function renderFormFile(entity: MetaObject, ctx: RenderContext): string {
295312
)}
296313
</div>`;
297314

315+
// Shared label + control + error wrapper used by the view-kind dispatch
316+
// branches below (select / textarea / checkbox / radio). `scalarBlock`
317+
// stays untouched as the plain-<input> fallback (bound via `form.input.*`).
318+
const labelAndError = (f: string, control: string) =>
319+
` <div className="metaobjects-field" key=${JSON.stringify(f)}>
320+
<label className="metaobjects-field-label" htmlFor={${entityName}.${f}.name}>
321+
{${entityName}.${f}.label}
322+
</label>
323+
${control}
324+
{form.formState.errors.${f} !== undefined && (
325+
<span className="metaobjects-field-error" role="alert">
326+
{String(form.formState.errors.${f}?.message ?? '')}
327+
</span>
328+
)}
329+
</div>`;
330+
331+
// Dispatch a visible scalar field to its control by declared view kind: an
332+
// explicit view.textarea/checkbox/radio child, or an enum defaulting to a
333+
// dropdown when it carries no explicit view. Everything else falls back to
334+
// scalarBlock's typed <input> bound via `form.input.<field>`.
335+
const fieldControlFor = (field: MetaField): string => {
336+
const name = field.name;
337+
const kind = viewKindFor(field);
338+
// Enum member symbols are validated to /^[A-Za-z_][A-Za-z0-9_]*$/, so raw
339+
// interpolation into JSX attribute/text positions is safe (no escaping).
340+
if (kind === VIEW_SUBTYPE_DROPDOWN) {
341+
const values = (field.attr(FIELD_ATTR_VALUES) as string[] | undefined) ?? [];
342+
const required = field.attr(FIELD_ATTR_REQUIRED) === true;
343+
const empty = required ? "" : ` <option value="">Select…</option>\n`;
344+
const options = values.map((v) => ` <option value="${v}">${v}</option>`).join("\n");
345+
return labelAndError(
346+
name,
347+
` <select className="metaobjects-field-input" {...form.register("${name}")}>\n${empty}${options}\n </select>`,
348+
);
349+
}
350+
if (kind === VIEW_SUBTYPE_TEXTAREA) {
351+
// Configurable @rows is deferred (see the spec's Deferred work); default to 4.
352+
return labelAndError(
353+
name,
354+
` <textarea className="metaobjects-field-input" rows={4} {...form.register("${name}")} />`,
355+
);
356+
}
357+
if (kind === VIEW_SUBTYPE_CHECKBOX) {
358+
return labelAndError(
359+
name,
360+
` <input type="checkbox" className="metaobjects-field-checkbox" {...form.register("${name}")} />`,
361+
);
362+
}
363+
if (kind === VIEW_SUBTYPE_RADIO) {
364+
const values = (field.attr(FIELD_ATTR_VALUES) as string[] | undefined) ?? [];
365+
const radios = values
366+
.map(
367+
(v) =>
368+
` <label className="metaobjects-field-radio"><input type="radio" value="${v}" {...form.register("${name}")} /> ${v}</label>`,
369+
)
370+
.join("\n");
371+
return labelAndError(name, ` <fieldset className="metaobjects-field-radios">\n${radios}\n </fieldset>`);
372+
}
373+
return scalarBlock(name);
374+
};
375+
298376
// For each visible field: scalars keep the flat `form.input.<field>` block; a
299377
// `field.object` with a resolvable `@objectRef` recurses into the referenced
300378
// value object as a nested <fieldset> sub-form (issue #95). useFieldArray
@@ -303,7 +381,7 @@ export function renderFormFile(entity: MetaObject, ctx: RenderContext): string {
303381
const fieldArrayHooks: string[] = [];
304382
for (const f of fields) {
305383
if (resolveValueObject(f, ctx) === undefined) {
306-
blocks.push(scalarBlock(f.name));
384+
blocks.push(fieldControlFor(f));
307385
continue;
308386
}
309387
const r = renderNestedField(f, f.name, false, ctx, new Set<string>(), 0);
@@ -357,9 +435,11 @@ export function ${entityName}Form(props: ${entityName}FormProps): ${ReactElement
357435
onSubmit={form.handleSubmit(props.onSubmit as never)}
358436
>
359437
${fieldBlocks}
360-
<button type="submit" disabled={form.formState.isSubmitting}>
361-
{props.submitLabel ?? 'Submit'}
362-
</button>
438+
<div className="metaobjects-form-actions">
439+
<button className="metaobjects-form-submit" type="submit" disabled={form.formState.isSubmitting}>
440+
{props.submitLabel ?? 'Submit'}
441+
</button>
442+
</div>
363443
</form>
364444
);
365445
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// Form controls: renderFormFile dispatches on each field's view kind.
2+
import { describe, test, expect } from "bun:test";
3+
import {
4+
MetaDataLoader,
5+
InMemoryStringSource,
6+
type MetaObject,
7+
type MetaRoot,
8+
} from "@metaobjectsdev/metadata";
9+
import { renderFormFile } from "../src/templates/form-file.js";
10+
import { makeRenderContext, buildPkMap, buildRelationMap } from "@metaobjectsdev/codegen-ts";
11+
12+
async function loadModel(): Promise<{ root: MetaRoot; report: MetaObject }> {
13+
const loader = new MetaDataLoader();
14+
const { root, errors } = await loader.load([
15+
new InMemoryStringSource(
16+
JSON.stringify({
17+
"metadata.root": {
18+
package: "demo",
19+
children: [
20+
{
21+
"object.entity": {
22+
name: "Report",
23+
children: [
24+
{ "source.rdb": { "@table": "reports" } },
25+
{ "field.long": { name: "id" } },
26+
{ "identity.primary": { name: "id", "@fields": "id", "@generation": "increment" } },
27+
{ "field.string": { name: "name", "@required": true } },
28+
// enum with no explicit view -> dropdown default
29+
{ "field.enum": { name: "status", "@required": true, "@values": ["draft", "active", "closed"] } },
30+
// view.textarea -> <textarea rows={4}> (configurable @rows deferred)
31+
{ "field.string": { name: "notes", children: [{ "view.textarea": {} }] } },
32+
// view.checkbox -> checkbox
33+
{ "field.boolean": { name: "archived", children: [{ "view.checkbox": {} }] } },
34+
// view.radio over enum values -> radio fieldset
35+
{ "field.enum": { name: "tier", "@values": ["free", "pro"], children: [{ "view.radio": {} }] } },
36+
// @formExclude -> absent from the form
37+
{ "field.string": { name: "internalNote", "@formExclude": true } },
38+
],
39+
},
40+
},
41+
],
42+
},
43+
}),
44+
{ id: "report.json" },
45+
),
46+
]);
47+
if (errors.length > 0) throw new Error(errors.map((e) => e.message).join("; "));
48+
const report = root.objects().find((o) => o.name === "Report")! as MetaObject;
49+
return { root, report };
50+
}
51+
52+
function ctxFor(root: MetaRoot) {
53+
return makeRenderContext({
54+
dialect: "postgres",
55+
loadedRoot: root,
56+
outDir: "/x",
57+
dbImport: "../db",
58+
extStyle: "none",
59+
apiPrefix: "/api",
60+
pkMap: buildPkMap(root),
61+
relationMap: buildRelationMap(root),
62+
});
63+
}
64+
65+
describe("form controls — view-kind dispatch", () => {
66+
test("enum with no view renders a <select>, not a bare bound input", async () => {
67+
const { root, report } = await loadModel();
68+
const out = renderFormFile(report, ctxFor(root));
69+
expect(out).toContain("<select");
70+
expect(out).toContain('<option value="draft">');
71+
expect(out).toContain('<option value="active">');
72+
expect(out).toContain('form.register("status")');
73+
expect(out).not.toMatch(/form\.input\.status/);
74+
});
75+
76+
test("view.textarea renders a <textarea> with the default row count", async () => {
77+
const { root, report } = await loadModel();
78+
const out = renderFormFile(report, ctxFor(root));
79+
expect(out).toContain("<textarea");
80+
expect(out).toContain("rows={4}");
81+
expect(out).toContain('form.register("notes")');
82+
});
83+
84+
test("view.checkbox renders a checkbox input", async () => {
85+
const { root, report } = await loadModel();
86+
const out = renderFormFile(report, ctxFor(root));
87+
expect(out).toContain('type="checkbox"');
88+
expect(out).toContain('form.register("archived")');
89+
});
90+
91+
test("view.radio renders a radio fieldset over the enum values", async () => {
92+
const { root, report } = await loadModel();
93+
const out = renderFormFile(report, ctxFor(root));
94+
expect(out).toContain('className="metaobjects-field-radios"');
95+
expect(out).toContain('type="radio"');
96+
expect(out).toContain('value="free"');
97+
expect(out).toContain('value="pro"');
98+
});
99+
100+
test("a scalar string with no view keeps the existing bound <input>", async () => {
101+
const { root, report } = await loadModel();
102+
const out = renderFormFile(report, ctxFor(root));
103+
expect(out).toContain("{...form.input.name}");
104+
});
105+
106+
test("a @formExclude field is absent from the form", async () => {
107+
const { root, report } = await loadModel();
108+
const out = renderFormFile(report, ctxFor(root));
109+
expect(out).not.toContain("internalNote");
110+
});
111+
112+
test("the submit button is wrapped in the styled actions container", async () => {
113+
const { root, report } = await loadModel();
114+
const out = renderFormFile(report, ctxFor(root));
115+
expect(out).toContain('className="metaobjects-form-actions"');
116+
expect(out).toContain('className="metaobjects-form-submit"');
117+
});
118+
});

0 commit comments

Comments
 (0)