Skip to content

Commit a240280

Browse files
committed
feat(codegen-ts-react): view.image -> <ImageUpload> via Controller
The formFile template dispatches a view.image field to a react-hook-form <Controller> wrapping <ImageUpload> (a native <input> can't drive a file-upload control's value/onChange contract). The meta prop threads the view's aspectRatio/maxEdge/store/accept/maxBytes attrs. The Controller (react-hook-form) and ImageUpload (@metaobjectsdev/react) imports are gated on the entity having an image field, mirroring the existing useFieldArrayImport idiom.
1 parent 8f1e84b commit a240280

2 files changed

Lines changed: 157 additions & 1 deletion

File tree

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

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,13 @@ import {
4646
VIEW_SUBTYPE_DROPDOWN,
4747
VIEW_SUBTYPE_CHECKBOX,
4848
VIEW_SUBTYPE_RADIO,
49+
VIEW_SUBTYPE_IMAGE,
4950
VIEW_TEXTAREA_ATTR_ROWS,
51+
VIEW_IMAGE_ATTR_ASPECT_RATIO,
52+
VIEW_IMAGE_ATTR_MAX_EDGE,
53+
VIEW_IMAGE_ATTR_STORE,
54+
VIEW_IMAGE_ATTR_ACCEPT,
55+
VIEW_IMAGE_ATTR_MAX_BYTES,
5056
} from "@metaobjectsdev/metadata";
5157
import { type RenderContext, entityModuleSpecifier, GENERATED_HEADER, tphDiscriminatorPin } from "@metaobjectsdev/codegen-ts";
5258

@@ -386,6 +392,21 @@ ${control}
386392
` <fieldset aria-label={${entityName}.${name}.label} className="metaobjects-field-radios">\n${radios}\n </fieldset>`,
387393
);
388394
}
395+
if (kind === VIEW_SUBTYPE_IMAGE) {
396+
const num = (a: string) => {
397+
const v = view?.attr(a);
398+
return typeof v === "number" ? String(v) : "undefined";
399+
};
400+
const accept = view?.attr(VIEW_IMAGE_ATTR_ACCEPT) as string[] | undefined;
401+
const acceptLit = accept ? JSON.stringify(accept) : "undefined";
402+
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 control = ` <Controller name=${JSON.stringify(name)} control={form.control} render={({ field: f }) => (
406+
<ImageUpload value={f.value as string | null} onChange={f.onChange} meta={${meta}} />
407+
)} />`;
408+
return labelAndError(name, control);
409+
}
389410
return scalarBlock(name);
390411
};
391412

@@ -412,13 +433,21 @@ ${control}
412433
const useFieldArrayImport =
413434
fieldArrayHooks.length > 0 ? `import { useFieldArray } from "react-hook-form";\n` : "";
414435

436+
// Only pull in Controller + ImageUpload when a view.image field needs them —
437+
// a native <input> can't drive a file-upload control's value/onChange
438+
// contract, so image fields are wrapped in react-hook-form's <Controller>.
439+
const hasImage = fields.some((f) => f.views()[0]?.subType === VIEW_SUBTYPE_IMAGE);
440+
const imageImports = hasImage
441+
? `import { Controller } from "react-hook-form";\nimport { ImageUpload } from "@metaobjectsdev/react";\n`
442+
: "";
443+
415444
const literalImports = code`
416445
import {
417446
${entityName},
418447
${entityName}InsertSchema,
419448
} from ${JSON.stringify(entityFileSpec)};
420449
import type { ${entityName} as ${entityName}Row } from ${JSON.stringify(entityFileSpec)};
421-
${useFieldArrayImport}`;
450+
${useFieldArrayImport}${imageImports}`;
422451

423452
const body = code`
424453
export interface ${entityName}FormProps {
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Form controls: renderFormFile dispatches a view.image field to <ImageUpload>
2+
// wrapped in react-hook-form's <Controller> (a bound native <input> can't drive
3+
// a file-upload control's value/onChange contract). The Controller/ImageUpload
4+
// imports are gated — they must appear ONLY when the entity has an image field.
5+
import { describe, test, expect } from "bun:test";
6+
import {
7+
MetaDataLoader,
8+
InMemoryStringSource,
9+
type MetaObject,
10+
type MetaRoot,
11+
} from "@metaobjectsdev/metadata";
12+
import { renderFormFile } from "../src/templates/form-file.js";
13+
import { makeRenderContext, buildPkMap, buildRelationMap } from "@metaobjectsdev/codegen-ts";
14+
15+
async function loadModel(): Promise<{ root: MetaRoot; doc: MetaObject; plain: MetaObject }> {
16+
const loader = new MetaDataLoader();
17+
const { root, errors } = await loader.load([
18+
new InMemoryStringSource(
19+
JSON.stringify({
20+
"metadata.root": {
21+
package: "demo",
22+
children: [
23+
{
24+
"object.entity": {
25+
name: "Doc",
26+
children: [
27+
{ "source.rdb": { "@table": "docs" } },
28+
{ "field.long": { name: "id" } },
29+
{ "identity.primary": { name: "id", "@fields": "id", "@generation": "increment" } },
30+
{
31+
"field.string": {
32+
name: "coverKey",
33+
"@maxLength": 80,
34+
children: [
35+
{
36+
"view.image": {
37+
"@aspectRatio": 1.777,
38+
"@maxEdge": 2000,
39+
"@store": "photos",
40+
"@accept": ["image/jpeg", "image/png"],
41+
"@maxBytes": 10485760,
42+
},
43+
},
44+
],
45+
},
46+
},
47+
],
48+
},
49+
},
50+
{
51+
"object.entity": {
52+
name: "Plain",
53+
children: [
54+
{ "source.rdb": { "@table": "plains" } },
55+
{ "field.long": { name: "id" } },
56+
{ "identity.primary": { name: "id", "@fields": "id", "@generation": "increment" } },
57+
{ "field.string": { name: "name", "@required": true } },
58+
],
59+
},
60+
},
61+
],
62+
},
63+
}),
64+
{ id: "doc.json" },
65+
),
66+
]);
67+
if (errors.length > 0) throw new Error(errors.map((e) => e.message).join("; "));
68+
const doc = root.objects().find((o) => o.name === "Doc")! as MetaObject;
69+
const plain = root.objects().find((o) => o.name === "Plain")! as MetaObject;
70+
return { root, doc, plain };
71+
}
72+
73+
function ctxFor(root: MetaRoot) {
74+
return makeRenderContext({
75+
dialect: "postgres",
76+
loadedRoot: root,
77+
outDir: "/x",
78+
dbImport: "../db",
79+
extStyle: "none",
80+
apiPrefix: "/api",
81+
pkMap: buildPkMap(root),
82+
relationMap: buildRelationMap(root),
83+
});
84+
}
85+
86+
describe("form controls — view.image dispatch", () => {
87+
test("a view.image field renders <ImageUpload> wrapped in <Controller>", async () => {
88+
const { root, doc } = await loadModel();
89+
const out = renderFormFile(doc, ctxFor(root));
90+
expect(out).toContain("<ImageUpload");
91+
expect(out).toContain("Controller");
92+
expect(out).not.toMatch(/form\.input\.coverKey/);
93+
});
94+
95+
test("the gated imports are emitted for an image entity", async () => {
96+
const { root, doc } = await loadModel();
97+
const out = renderFormFile(doc, ctxFor(root));
98+
expect(out).toContain('import { Controller } from "react-hook-form"');
99+
// Specifically the ImageUpload specifier, not just any "@metaobjectsdev/react"
100+
// import — useEntityForm's import from the same package is unconditional.
101+
expect(out).toContain('import { ImageUpload } from "@metaobjectsdev/react"');
102+
expect(out).toMatch(/from "@metaobjectsdev\/react"/);
103+
});
104+
105+
test("the <ImageUpload> meta object carries all five view.image attrs", async () => {
106+
const { root, doc } = await loadModel();
107+
const out = renderFormFile(doc, ctxFor(root));
108+
expect(out).toContain("aspectRatio: 1.777");
109+
expect(out).toContain("maxEdge: 2000");
110+
expect(out).toContain('store: "photos"');
111+
// Format-tolerant: the ts-poet `code` template reformats interpolated
112+
// content (e.g. adds a space after the array-literal comma).
113+
expect(out).toMatch(/accept:\s*\[\s*"image\/jpeg",\s*"image\/png"\s*\]/);
114+
expect(out).toContain("maxBytes: 10485760");
115+
});
116+
117+
test("a non-image entity emits neither the ImageUpload import nor a Controller import (gated)", async () => {
118+
const { root, plain } = await loadModel();
119+
const out = renderFormFile(plain, ctxFor(root));
120+
// NOTE: useEntityForm is imported from "@metaobjectsdev/react" on every
121+
// generated form, image or not — so the gating assertion targets the
122+
// ImageUpload/Controller specifiers specifically, not the bare package.
123+
expect(out).not.toContain("ImageUpload");
124+
expect(out).not.toContain("Controller");
125+
expect(out).not.toContain('import { ImageUpload }');
126+
});
127+
});

0 commit comments

Comments
 (0)