Skip to content

Commit 5e0b13f

Browse files
dmealingclaude
andcommitted
fix(codegen-ts-react): the blank-optional list names only fields with a scalar control (#223)
Regenerating the advanced-modeling example put `syllabus` (an array-of-VO jsonb) and `instructorProfile` (a single VO jsonb) into `BLANK_OPTIONAL_FIELDS`. Neither can ever hold `""` — one renders as a `useFieldArray` list and the other as a nested block, so what they submit is an array or an object, and the normalizer's `!== ""` guard skips both. Harmless today, and wrong in the direction that invites a real bug: the list is a statement about which controls can be BLANK, so naming a field that has no scalar control at all is a claim about something that does not exist. The predicate was "optional and not a checkbox/image", which is too coarse. It now also excludes `field.object` and any array field. On the example that takes the list from five entries to the three that actually have a blankable control: an enum `<select>`, a textarea, and a currency input. The example is what caught it — its committed `src/generated/**` is drift-gated, so it regenerates whenever a generator changes and a reviewer sees the real field list for a real model. A fixture would not have; the two VO shapes only exist there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
1 parent eeb0a8b commit 5e0b13f

4 files changed

Lines changed: 95 additions & 11 deletions

File tree

examples/advanced-modeling/src/generated/Program.form.tsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,28 @@ import { useEntityForm } from "@metaobjectsdev/react";
1010
import type { ReactElement } from "react";
1111
import type { SubmitHandler } from "react-hook-form";
1212

13+
const BLANK_OPTIONAL_FIELDS = ["status", "summary", "priceCents"] as const;
14+
15+
/**
16+
* Normalize blank optional inputs on the way out of the form (#223).
17+
* On create a blank field is OMITTED (the column defaults); on edit it is sent as
18+
* explicit `null` (present-null clears, per the FR-035 PATCH tristate).
19+
*/
20+
function normalizeBlankOptionals(values: Record<string, unknown>, isEdit: boolean): Record<string, unknown> {
21+
const out: Record<string, unknown> = { ...values };
22+
for (const key of BLANK_OPTIONAL_FIELDS) {
23+
if (out[key] !== "") {
24+
continue;
25+
}
26+
if (isEdit) {
27+
out[key] = null;
28+
} else {
29+
delete out[key];
30+
}
31+
}
32+
return out;
33+
}
34+
1335
export interface ProgramFormProps {
1436
onSubmit: SubmitHandler<Partial<ProgramRow>>;
1537
defaultValues?: Partial<ProgramRow>;
@@ -38,7 +60,13 @@ export function ProgramForm(props: ProgramFormProps): ReactElement {
3860
<form
3961
className={props.className ?? "metaobjects-form"}
4062
data-entity={Program.$entity}
41-
onSubmit={form.handleSubmit(props.onSubmit as never)}
63+
onSubmit={form.handleSubmit(
64+
((values: Record<string, unknown>, event?: unknown) =>
65+
props.onSubmit(
66+
normalizeBlankOptionals(values, props.defaultValues !== undefined) as never,
67+
event as never,
68+
)) as never,
69+
)}
4270
>
4371
<div className="metaobjects-field" key="title">
4472
<label className="metaobjects-field-label" htmlFor={Program.title.name}>{Program.title.label}</label>

examples/advanced-modeling/src/generated/Purchase.form.tsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,28 @@ import { useEntityForm } from "@metaobjectsdev/react";
77
import type { ReactElement } from "react";
88
import type { SubmitHandler } from "react-hook-form";
99

10+
const BLANK_OPTIONAL_FIELDS = ["amountCents", "status", "purchasedAt"] as const;
11+
12+
/**
13+
* Normalize blank optional inputs on the way out of the form (#223).
14+
* On create a blank field is OMITTED (the column defaults); on edit it is sent as
15+
* explicit `null` (present-null clears, per the FR-035 PATCH tristate).
16+
*/
17+
function normalizeBlankOptionals(values: Record<string, unknown>, isEdit: boolean): Record<string, unknown> {
18+
const out: Record<string, unknown> = { ...values };
19+
for (const key of BLANK_OPTIONAL_FIELDS) {
20+
if (out[key] !== "") {
21+
continue;
22+
}
23+
if (isEdit) {
24+
out[key] = null;
25+
} else {
26+
delete out[key];
27+
}
28+
}
29+
return out;
30+
}
31+
1032
export interface PurchaseFormProps {
1133
onSubmit: SubmitHandler<Partial<PurchaseRow>>;
1234
defaultValues?: Partial<PurchaseRow>;
@@ -34,7 +56,13 @@ export function PurchaseForm(props: PurchaseFormProps): ReactElement {
3456
<form
3557
className={props.className ?? "metaobjects-form"}
3658
data-entity={Purchase.$entity}
37-
onSubmit={form.handleSubmit(props.onSubmit as never)}
59+
onSubmit={form.handleSubmit(
60+
((values: Record<string, unknown>, event?: unknown) =>
61+
props.onSubmit(
62+
normalizeBlankOptionals(values, props.defaultValues !== undefined) as never,
63+
event as never,
64+
)) as never,
65+
)}
3866
>
3967
<div className="metaobjects-field" key="programId">
4068
<label className="metaobjects-field-label" htmlFor={Purchase.programId.name}>{Purchase.programId.label}</label>

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

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -300,25 +300,30 @@ function enumValues(field: MetaField): string[] {
300300
* all (it fails the column's type), and for every other nullable column it makes a
301301
* `!= null` check read a blank field as SET.
302302
*
303-
* Excluded because they cannot produce `""`: a checkbox (always boolean) and a
304-
* `view.image` (Controller-managed, carries a storage key). A `@required` field is
305-
* excluded too — blank there is a validation error the schema already owns, and
306-
* rewriting it would convert a caught error into a silent null.
303+
* Excluded because they cannot produce `""`, and naming one anyway would be a claim about
304+
* a control that does not exist:
305+
* - a checkbox — always a boolean, and `false` is a real answer, never "blank";
306+
* - a `view.image` — Controller-managed, and its value is an opaque storage key;
307+
* - a nested `field.object` and any array field — rendered as a nested block or a
308+
* `useFieldArray` list, so what they submit is an object or an array.
309+
* A `@required` field is excluded too: blank there is a validation error the schema
310+
* already owns, and rewriting it would turn a caught error into a silent null.
307311
*/
308-
/** Names of the two symbols the blank-normalizer emits, kept out of the template string. */
309-
const BLANK_FIELDS_CONST = "BLANK_OPTIONAL_FIELDS";
310-
const BLANK_NORMALIZER = "normalizeBlankOptionals";
311-
312312
function blankableOptionalFields(fields: readonly MetaField[]): string[] {
313313
return fields
314314
.filter((f) => f.attr(FIELD_ATTR_REQUIRED) !== true)
315+
.filter((f) => f.subType !== FIELD_SUBTYPE_OBJECT && !f.resolvedIsArray())
315316
.filter((f) => {
316317
const kind = viewKindFor(f, f.views()[0]); // resolving accessor (ADR-0039)
317318
return kind !== VIEW_SUBTYPE_CHECKBOX && kind !== VIEW_SUBTYPE_IMAGE;
318319
})
319320
.map((f) => f.name);
320321
}
321322

323+
/** Names of the two symbols the blank-normalizer emits, kept out of the template string. */
324+
const BLANK_FIELDS_CONST = "BLANK_OPTIONAL_FIELDS";
325+
const BLANK_NORMALIZER = "normalizeBlankOptionals";
326+
322327
export function renderFormFile(entity: MetaObject, ctx: RenderContext): string {
323328
const entityName = entity.name;
324329
// Import the entity's own file. Same target → relative "./Entity"; cross

server/typescript/packages/codegen-ts-react/test/form-blank-optional-tristate.test.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,16 @@ import { makeRenderContext, buildPkMap, buildRelationMap } from "@metaobjectsdev
2121
import type { GenContext } from "@metaobjectsdev/codegen-ts";
2222
import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata";
2323

24-
async function formFor(entityChildren: unknown[], name = "Booking"): Promise<string> {
24+
async function formFor(
25+
entityChildren: unknown[],
26+
name = "Booking",
27+
extraRoots: unknown[] = [],
28+
): Promise<string> {
2529
const json = JSON.stringify({
2630
"metadata.root": {
2731
package: "acme",
2832
children: [
33+
...extraRoots,
2934
{
3035
"object.entity": {
3136
name,
@@ -145,6 +150,24 @@ describe("generated form — blank optional fields are tristate-aware (#223)", (
145150
});
146151
});
147152

153+
// Caught by regenerating the advanced-modeling example: a jsonb value-object field was
154+
// being listed, because "optional and not a checkbox" was too coarse a test. It could
155+
// never fire (its value is an object, never `""`), but the list is a statement about
156+
// which controls can be blank, and naming one that has no scalar control at all is
157+
// wrong in the direction that invites a real bug later.
158+
test("a nested value object and an array field are excluded — neither has a scalar control", async () => {
159+
const src = await formFor(
160+
[
161+
OPTIONAL_TEXT,
162+
{ "field.string": { name: "tags", isArray: true } },
163+
{ "field.object": { name: "profile", "@objectRef": "Profile", "@storage": "jsonb" } },
164+
],
165+
"Listing",
166+
[{ "object.value": { name: "Profile", children: [{ "field.string": { name: "bio" } }] } }],
167+
);
168+
expect(blankFieldList(src)).toEqual(["note"]);
169+
});
170+
148171
test("an all-required form emits no normalizer at all — output stays byte-identical", async () => {
149172
const src = await formFor([REQUIRED_TEXT]);
150173
expect(src).not.toContain("normalizeBlankOptionals");

0 commit comments

Comments
 (0)