Skip to content

Commit b5b28f6

Browse files
dmealingclaude
andcommitted
fix(codegen-ts-react): omit absent view.image meta keys (exactOptionalPropertyTypes-safe)
Explicit `key: undefined` literals in the generated <ImageUpload meta={...}> object trip TS2375 under exactOptionalPropertyTypes, since ImageMeta declares each field optional (not `T | undefined`). The image-branch of fieldControlFor now emits a fragment only for attrs actually present on view.image, so a partially-attributed image field (e.g. only @store + @aspectratio) no longer emits `maxEdge: undefined` etc. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGQ7oSuNcjhsMHWwZzhBwr
1 parent a0db1e1 commit b5b28f6

2 files changed

Lines changed: 69 additions & 9 deletions

File tree

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

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -393,15 +393,26 @@ ${control}
393393
);
394394
}
395395
if (kind === VIEW_SUBTYPE_IMAGE) {
396-
const num = (a: string) => {
397-
const v = view?.attr(a);
398-
return typeof v === "number" ? String(v) : "undefined";
396+
// ImageMeta declares every field optional (not `| undefined`), so under
397+
// exactOptionalPropertyTypes an explicit `key: undefined` literal is a
398+
// TS2375 error for a consumer's tsconfig. Emit a fragment ONLY for an
399+
// attr that is actually present; an absent attr contributes nothing.
400+
const numFragment = (attrName: string, key: string): string | undefined => {
401+
const v = view?.attr(attrName);
402+
return typeof v === "number" ? `${key}: ${v}` : undefined;
399403
};
400-
const accept = view?.attr(VIEW_IMAGE_ATTR_ACCEPT) as string[] | undefined;
401-
const acceptLit = accept ? JSON.stringify(accept) : "undefined";
402404
const store = view?.attr(VIEW_IMAGE_ATTR_STORE);
403-
const storeLit = typeof store === "string" ? JSON.stringify(store) : "undefined";
404-
const meta = `{ aspectRatio: ${num(VIEW_IMAGE_ATTR_ASPECT_RATIO)}, maxEdge: ${num(VIEW_IMAGE_ATTR_MAX_EDGE)}, store: ${storeLit}, accept: ${acceptLit}, maxBytes: ${num(VIEW_IMAGE_ATTR_MAX_BYTES)} }`;
405+
const storeFragment = typeof store === "string" ? `store: ${JSON.stringify(store)}` : undefined;
406+
const accept = view?.attr(VIEW_IMAGE_ATTR_ACCEPT);
407+
const acceptFragment = Array.isArray(accept) ? `accept: ${JSON.stringify(accept)}` : undefined;
408+
const fragments = [
409+
numFragment(VIEW_IMAGE_ATTR_ASPECT_RATIO, "aspectRatio"),
410+
numFragment(VIEW_IMAGE_ATTR_MAX_EDGE, "maxEdge"),
411+
storeFragment,
412+
acceptFragment,
413+
numFragment(VIEW_IMAGE_ATTR_MAX_BYTES, "maxBytes"),
414+
].filter((f): f is string => f !== undefined);
415+
const meta = fragments.length > 0 ? `{ ${fragments.join(", ")} }` : "{}";
405416
const control = ` <Controller name=${JSON.stringify(name)} control={form.control} render={({ field: f }) => (
406417
<ImageUpload value={f.value as string | null} onChange={f.onChange} meta={${meta}} />
407418
)} />`;

server/typescript/packages/codegen-ts-react/test/form-image.test.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@ import {
1212
import { renderFormFile } from "../src/templates/form-file.js";
1313
import { makeRenderContext, buildPkMap, buildRelationMap } from "@metaobjectsdev/codegen-ts";
1414

15-
async function loadModel(): Promise<{ root: MetaRoot; doc: MetaObject; plain: MetaObject }> {
15+
async function loadModel(): Promise<{
16+
root: MetaRoot;
17+
doc: MetaObject;
18+
plain: MetaObject;
19+
partialDoc: MetaObject;
20+
}> {
1621
const loader = new MetaDataLoader();
1722
const { root, errors } = await loader.load([
1823
new InMemoryStringSource(
@@ -58,6 +63,32 @@ async function loadModel(): Promise<{ root: MetaRoot; doc: MetaObject; plain: Me
5863
],
5964
},
6065
},
66+
{
67+
"object.entity": {
68+
name: "PartialDoc",
69+
children: [
70+
{ "source.rdb": { "@table": "partial_docs" } },
71+
{ "field.long": { name: "id" } },
72+
{ "identity.primary": { name: "id", "@fields": "id", "@generation": "increment" } },
73+
{
74+
"field.string": {
75+
name: "coverKey",
76+
"@maxLength": 80,
77+
children: [
78+
{
79+
// Only two of the five view.image attrs present —
80+
// exercises the omit-absent-keys path.
81+
"view.image": {
82+
"@store": "photos",
83+
"@aspectRatio": 1.777,
84+
},
85+
},
86+
],
87+
},
88+
},
89+
],
90+
},
91+
},
6192
],
6293
},
6394
}),
@@ -67,7 +98,8 @@ async function loadModel(): Promise<{ root: MetaRoot; doc: MetaObject; plain: Me
6798
if (errors.length > 0) throw new Error(errors.map((e) => e.message).join("; "));
6899
const doc = root.objects().find((o) => o.name === "Doc")! as MetaObject;
69100
const plain = root.objects().find((o) => o.name === "Plain")! as MetaObject;
70-
return { root, doc, plain };
101+
const partialDoc = root.objects().find((o) => o.name === "PartialDoc")! as MetaObject;
102+
return { root, doc, plain, partialDoc };
71103
}
72104

73105
function ctxFor(root: MetaRoot) {
@@ -114,6 +146,23 @@ describe("form controls — view.image dispatch", () => {
114146
expect(out).toContain("maxBytes: 10485760");
115147
});
116148

149+
test("a view.image with only SOME attrs present omits the absent keys (exactOptionalPropertyTypes-safe)", async () => {
150+
const { root, partialDoc } = await loadModel();
151+
const out = renderFormFile(partialDoc, ctxFor(root));
152+
// Present attrs still emit their values.
153+
expect(out).toContain('store: "photos"');
154+
expect(out).toContain("aspectRatio: 1.777");
155+
// Absent attrs contribute NOTHING — no explicit `: undefined` literal
156+
// anywhere in the generated form (ImageMeta's fields are optional, not
157+
// `T | undefined`, so an explicit undefined is a TS2375 error under
158+
// exactOptionalPropertyTypes).
159+
expect(out).not.toMatch(/:\s*undefined/);
160+
// Absent keys don't appear as keys at all.
161+
expect(out).not.toMatch(/\bmaxEdge:/);
162+
expect(out).not.toMatch(/\baccept:/);
163+
expect(out).not.toMatch(/\bmaxBytes:/);
164+
});
165+
117166
test("a non-image entity emits neither the ImageUpload import nor a Controller import (gated)", async () => {
118167
const { root, plain } = await loadModel();
119168
const out = renderFormFile(plain, ctxFor(root));

0 commit comments

Comments
 (0)