Skip to content

Commit 60e6880

Browse files
committed
fix(codegen-ts-react): validate edits against UpdateSchema, not InsertSchema (#227)
The generated form takes defaultValues (edit is a first-class mode) but resolved EVERY submit against InsertSchema. InsertSchema optionals are .optional() -- absent ok, null REJECTED -- while an edit is seeded from a real row whose unset optional columns are all null. So editing any row holding a NULL optional was blocked outright: 'Invalid input' appeared on fields the user never touched, handleSubmit never fired, and the save silently did nothing. Since most rows have at least one unset optional, this broke most edits. The resolver now switches on the same defaultValues presence that already distinguishes create from edit everywhere else in the template. This is the semantically right pairing regardless: an edit submits a PATCH, so it validates against the PATCH schema -- UpdateSchema (.optional().nullable()) still enforces min/max/enum on present keys, it just doesn't demand required keys the PATCH isn't sending (FR-035 present-key). TPH forms get the same .omit(discriminator) treatment on both schemas. Generated-output change: regenerate to pick it up; three-way merge preserves hand-edits. TS-only -- no other port generates forms. Rejected: normalizing null -> "" in defaultValues (destroys the was-null vs was-empty distinction #223's tristate needs, and feeds "" to controls like view.image that expect string | null), and loosening InsertSchema optionals to .nullable() (weakens create-path validation for every consumer, including the server route, to paper over a client-side mode-selection bug). Closes #227
1 parent 89b10c3 commit 60e6880

28 files changed

Lines changed: 88 additions & 55 deletions

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// @generated by @metaobjectsdev/codegen-ts — DO NOT EDIT.
22
// Source metadata: Author (Author)
33
// Customize via Author.extra.tsx (custom layouts, per-field components, etc.).
4-
import { Author, AuthorInsertSchema } from "./Author";
4+
import { Author, AuthorInsertSchema, AuthorUpdateSchema } from "./Author";
55
import type { Author as AuthorRow } from "./Author";
66
import { useEntityForm } from "@metaobjectsdev/react";
77
import type { ReactElement } from "react";
@@ -27,7 +27,7 @@ export interface AuthorFormProps {
2727
export function AuthorForm(props: AuthorFormProps): ReactElement {
2828
const form = useEntityForm(
2929
Author,
30-
AuthorInsertSchema,
30+
props.defaultValues !== undefined ? AuthorUpdateSchema : AuthorInsertSchema,
3131
props.defaultValues !== undefined ? { defaultValues: props.defaultValues } : {},
3232
);
3333
return (

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// @generated by @metaobjectsdev/codegen-ts — DO NOT EDIT.
22
// Source metadata: Lesson (Lesson)
33
// Customize via Lesson.extra.tsx (custom layouts, per-field components, etc.).
4-
import { Lesson, LessonInsertSchema } from "./Lesson";
4+
import { Lesson, LessonInsertSchema, LessonUpdateSchema } from "./Lesson";
55
import type { Lesson as LessonRow } from "./Lesson";
66
import { useEntityForm } from "@metaobjectsdev/react";
77
import type { ReactElement } from "react";
@@ -27,7 +27,7 @@ export interface LessonFormProps {
2727
export function LessonForm(props: LessonFormProps): ReactElement {
2828
const form = useEntityForm(
2929
Lesson,
30-
LessonInsertSchema,
30+
props.defaultValues !== undefined ? LessonUpdateSchema : LessonInsertSchema,
3131
props.defaultValues !== undefined ? { defaultValues: props.defaultValues } : {},
3232
);
3333
return (

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import { ImageUpload } from "@metaobjectsdev/react";
55
import { useFieldArray } from "react-hook-form";
66
import { Controller } from "react-hook-form";
7-
import { Program, ProgramInsertSchema } from "./Program";
7+
import { Program, ProgramInsertSchema, ProgramUpdateSchema } from "./Program";
88
import type { Program as ProgramRow } from "./Program";
99
import { useEntityForm } from "@metaobjectsdev/react";
1010
import type { ReactElement } from "react";
@@ -30,7 +30,7 @@ export interface ProgramFormProps {
3030
export function ProgramForm(props: ProgramFormProps): ReactElement {
3131
const form = useEntityForm(
3232
Program,
33-
ProgramInsertSchema,
33+
props.defaultValues !== undefined ? ProgramUpdateSchema : ProgramInsertSchema,
3434
props.defaultValues !== undefined ? { defaultValues: props.defaultValues } : {},
3535
);
3636
const syllabusArray = useFieldArray({ control: form.control, name: "syllabus" as never });

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// @generated by @metaobjectsdev/codegen-ts — DO NOT EDIT.
22
// Source metadata: Purchase (Purchase)
33
// Customize via Purchase.extra.tsx (custom layouts, per-field components, etc.).
4-
import { Purchase, PurchaseInsertSchema } from "./Purchase";
4+
import { Purchase, PurchaseInsertSchema, PurchaseUpdateSchema } from "./Purchase";
55
import type { Purchase as PurchaseRow } from "./Purchase";
66
import { useEntityForm } from "@metaobjectsdev/react";
77
import type { ReactElement } from "react";
@@ -27,7 +27,7 @@ export interface PurchaseFormProps {
2727
export function PurchaseForm(props: PurchaseFormProps): ReactElement {
2828
const form = useEntityForm(
2929
Purchase,
30-
PurchaseInsertSchema,
30+
props.defaultValues !== undefined ? PurchaseUpdateSchema : PurchaseInsertSchema,
3131
props.defaultValues !== undefined ? { defaultValues: props.defaultValues } : {},
3232
);
3333
return (

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

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -308,9 +308,21 @@ export function renderFormFile(entity: MetaObject, ctx: RenderContext): string {
308308
// doesn't require a field the user never fills.
309309
const tphPin = tphDiscriminatorPin(entity);
310310
const fields = visibleFields(entity, tphPin?.fieldName);
311-
const formSchema = tphPin !== undefined
312-
? `${entityName}InsertSchema.omit({ ${tphPin.fieldName}: true })`
313-
: `${entityName}InsertSchema`;
311+
// #227: the resolver must match the MODE. `defaultValues` is what distinguishes
312+
// edit from create everywhere else in this template, so it selects the schema too.
313+
// InsertSchema's optionals are `.optional()` (absent ok, null REJECTED); an edit is
314+
// seeded from a real row where every unset optional column is `null`, so validating
315+
// an edit against InsertSchema fails on untouched fields and handleSubmit never
316+
// fires — the save silently does nothing. UpdateSchema is `.optional().nullable()`,
317+
// and is the semantically right pairing anyway: an edit submits a PATCH, so it
318+
// validates against the PATCH schema (still enforcing min/max/enum on present keys,
319+
// just not demanding required keys the PATCH isn't sending — FR-035 present-key).
320+
const omitTph = (schema: string) => tphPin !== undefined
321+
? `${schema}.omit({ ${tphPin.fieldName}: true })`
322+
: schema;
323+
const insertSchema = omitTph(`${entityName}InsertSchema`);
324+
const updateSchema = omitTph(`${entityName}UpdateSchema`);
325+
const formSchema = `props.defaultValues !== undefined ? ${updateSchema} : ${insertSchema}`;
314326

315327
const ReactElementSym = imp("t:ReactElement@react");
316328
const SubmitHandlerSym = imp("t:SubmitHandler@react-hook-form");
@@ -456,6 +468,7 @@ ${control}
456468
import {
457469
${entityName},
458470
${entityName}InsertSchema,
471+
${entityName}UpdateSchema,
459472
} from ${JSON.stringify(entityFileSpec)};
460473
import type { ${entityName} as ${entityName}Row } from ${JSON.stringify(entityFileSpec)};
461474
${useFieldArrayImport}${imageImports}`;

server/typescript/packages/codegen-ts-react/test/form-view-dispatch.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,23 @@ describe("form controls — view-kind dispatch", () => {
139139
expect(out).toContain('className="metaobjects-form-submit"');
140140
});
141141
});
142+
143+
// #227 — the resolver must match the MODE. A generated form takes `defaultValues`
144+
// (edit mode is first-class), but every submit used to resolve against InsertSchema.
145+
// InsertSchema optionals are `.optional()` — absent ok, null REJECTED — while an edit
146+
// is seeded from a real row whose unset optional columns are all `null`. So editing any
147+
// row with a NULL optional was blocked outright: errors appeared on untouched fields,
148+
// handleSubmit never fired, and the save silently did nothing.
149+
describe("form schema is mode-aware (#227)", () => {
150+
test("selects UpdateSchema when defaultValues is present, InsertSchema otherwise", async () => {
151+
const { root, report } = await loadModel();
152+
const out = renderFormFile(report, ctxFor(root));
153+
154+
// The mode signal is the same `defaultValues` presence used for seeding.
155+
expect(out).toContain("props.defaultValues !== undefined ? ReportUpdateSchema : ReportInsertSchema");
156+
// ...and the update schema must actually be imported, or the emitted file won't compile.
157+
expect(out).toContain("ReportUpdateSchema");
158+
// Regression guard: an unconditional InsertSchema resolver is the bug.
159+
expect(out).not.toMatch(/useEntityForm\(\s*Report,\s*ReportInsertSchema\s*,/);
160+
});
161+
});

server/typescript/packages/codegen-ts/test/golden/__snapshots__/package/Tag.form.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// @generated by @metaobjectsdev/codegen-ts — DO NOT EDIT.
22
// Source metadata: Tag (Tag)
33
// Customize via Tag.extra.tsx (custom layouts, per-field components, etc.).
4-
import { Tag, TagInsertSchema } from "./Tag";
4+
import { Tag, TagInsertSchema, TagUpdateSchema } from "./Tag";
55
import type { Tag as TagRow } from "./Tag";
66
import { useEntityForm } from "@metaobjectsdev/react";
77
import type { ReactElement } from "react";
@@ -27,7 +27,7 @@ export interface TagFormProps {
2727
export function TagForm(props: TagFormProps): ReactElement {
2828
const form = useEntityForm(
2929
Tag,
30-
TagInsertSchema,
30+
props.defaultValues !== undefined ? TagUpdateSchema : TagInsertSchema,
3131
props.defaultValues !== undefined ? { defaultValues: props.defaultValues } : {},
3232
);
3333
return (

server/typescript/packages/codegen-ts/test/golden/__snapshots__/package/shop/commerce/Order.form.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// @generated by @metaobjectsdev/codegen-ts — DO NOT EDIT.
22
// Source metadata: Order (shop::commerce::Order)
33
// Customize via Order.extra.tsx (custom layouts, per-field components, etc.).
4-
import { Order, OrderInsertSchema } from "./Order";
4+
import { Order, OrderInsertSchema, OrderUpdateSchema } from "./Order";
55
import type { Order as OrderRow } from "./Order";
66
import { useEntityForm } from "@metaobjectsdev/react";
77
import type { ReactElement } from "react";
@@ -27,7 +27,7 @@ export interface OrderFormProps {
2727
export function OrderForm(props: OrderFormProps): ReactElement {
2828
const form = useEntityForm(
2929
Order,
30-
OrderInsertSchema,
30+
props.defaultValues !== undefined ? OrderUpdateSchema : OrderInsertSchema,
3131
props.defaultValues !== undefined ? { defaultValues: props.defaultValues } : {},
3232
);
3333
return (

server/typescript/packages/codegen-ts/test/golden/__snapshots__/package/shop/commerce/Product.form.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// @generated by @metaobjectsdev/codegen-ts — DO NOT EDIT.
22
// Source metadata: Product (shop::commerce::Product)
33
// Customize via Product.extra.tsx (custom layouts, per-field components, etc.).
4-
import { Product, ProductInsertSchema } from "./Product";
4+
import { Product, ProductInsertSchema, ProductUpdateSchema } from "./Product";
55
import type { Product as ProductRow } from "./Product";
66
import { useEntityForm } from "@metaobjectsdev/react";
77
import type { ReactElement } from "react";
@@ -27,7 +27,7 @@ export interface ProductFormProps {
2727
export function ProductForm(props: ProductFormProps): ReactElement {
2828
const form = useEntityForm(
2929
Product,
30-
ProductInsertSchema,
30+
props.defaultValues !== undefined ? ProductUpdateSchema : ProductInsertSchema,
3131
props.defaultValues !== undefined ? { defaultValues: props.defaultValues } : {},
3232
);
3333
return (

server/typescript/packages/codegen-ts/test/golden/__snapshots__/package/shop/users/Customer.form.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// @generated by @metaobjectsdev/codegen-ts — DO NOT EDIT.
22
// Source metadata: Customer (shop::users::Customer)
33
// Customize via Customer.extra.tsx (custom layouts, per-field components, etc.).
4-
import { Customer, CustomerInsertSchema } from "./Customer";
4+
import { Customer, CustomerInsertSchema, CustomerUpdateSchema } from "./Customer";
55
import type { Customer as CustomerRow } from "./Customer";
66
import { useEntityForm } from "@metaobjectsdev/react";
77
import type { ReactElement } from "react";
@@ -27,7 +27,7 @@ export interface CustomerFormProps {
2727
export function CustomerForm(props: CustomerFormProps): ReactElement {
2828
const form = useEntityForm(
2929
Customer,
30-
CustomerInsertSchema,
30+
props.defaultValues !== undefined ? CustomerUpdateSchema : CustomerInsertSchema,
3131
props.defaultValues !== undefined ? { defaultValues: props.defaultValues } : {},
3232
);
3333
return (

0 commit comments

Comments
 (0)