Skip to content

Commit 05fb29c

Browse files
dmealingclaude
andcommitted
fix(codegen-ts): a dropdown descriptor that carries no options
0.25.0 moved a `field.enum`'s FORM view from "text" to "dropdown". The generated `<Entity>` descriptor went on emitting no member list, so it told a consumer to render a `<select>` and gave it nothing to put in one — while `@values` sat in the metadata the whole time. The descriptor made a promise it withheld the means to keep, and every consumer paid for it by restating the members by hand. `UiFieldDescriptor` gains `options`, emitted for any `field.enum`. It reads through the resolving accessor (ADR-0039), so a field inheriting `@values` from an abstract enum via `extends` carries them too — an own-only read would hand back an empty dropdown for exactly the fields a shared enum exists to serve. Found by running the 1.0 candidate against the public reference app, where the same four status symbols appear as literals in five hand-written files. The whole codegen suite — 1529 tests — passed unchanged when `options` was added. Not one fixture asserted an enum field's descriptor output, so the gate for this could not have gone red no matter what the emitter did. A test that asserts it now exists, covering the declared case, the inherited-through-extends case, that a non-enum field grows no options key, and that the emitted const spells exactly two lists for two enums. Confirmed red on three of its four cases before the fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3v6M6uqECZ2Sb5eUv6YY
1 parent e278915 commit 05fb29c

4 files changed

Lines changed: 117 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
77

88
## [Unreleased]
99

10+
### Fixed — a `view: "dropdown"` descriptor carried no options
11+
12+
`0.25.0` moved a `field.enum`'s FORM view from `text` to `dropdown`. The generated
13+
`<Entity>` descriptor kept emitting no member list, so it told a consumer to render a
14+
`<select>` and handed it nothing to fill one with — while `@values` sat in the metadata all
15+
along. Every consumer then restated the members by hand.
16+
17+
`UiFieldDescriptor` gains `options`, emitted for any `field.enum`, read through the
18+
resolving accessor so members inherited from an abstract enum via `extends` are carried too.
19+
20+
Found on the public reference app running the 1.0 candidate, where the same four symbols
21+
appeared as literals across five hand-written files. **Nothing caught it: the entire
22+
codegen suite passed unchanged when `options` was added, because not one fixture asserted
23+
an enum field's descriptor output at all.** That assertion now exists, and was confirmed to
24+
fail before the fix.
25+
1026
### Removed — the deprecated `codegen-ts/generators` export of the four ownable generators
1127

1228
`entityFile`, `queriesFile`, `routesFile` and `barrel` are no longer exported from

server/typescript/packages/codegen-ts/src/templates/entity-constants.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@ function renderFieldEntry(f: UiFieldDescriptor): string {
7676
if (f.htmlType !== undefined) entries.push(`htmlType: ${JSON.stringify(f.htmlType)}`);
7777
if (f.rules.length > 0) entries.push(`rules: { ${f.rules.map(renderRule).join(", ")} }`);
7878

79+
// A `field.enum`'s member symbols. Emitted whenever the field has them, because the
80+
// descriptor reports `view: "dropdown"` for an enum and a dropdown without options is
81+
// a promise the descriptor cannot keep.
82+
if (f.options !== undefined && f.options.length > 0) {
83+
entries.push(`options: [${f.options.map((v) => JSON.stringify(v)).join(", ")}] as const`);
84+
}
85+
7986
// Currency-specific keys: only emitted for currency-subtype fields.
8087
if (f.currency !== undefined) {
8188
entries.push(`currency: ${JSON.stringify(f.currency.currency)}`);

server/typescript/packages/codegen-ts/src/templates/entity-ui-descriptor.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import {
5252
} from "@metaobjectsdev/metadata";
5353
import { inferViewKind, currencyMetaFor, labelFor, humanize, valueObjectFor } from "./field-meta.js";
5454
import { VIEW_CONTEXT_FORM } from "../view-context.js";
55+
import { enumValues } from "../enum-meta.js";
5556
import { isProjection } from "../projection/projection-detector.js";
5657
// `restPath` lives HERE rather than in api-surface.ts, which is where it used to sit and
5758
// which now re-exports it. The descriptor is what emits `$path`, so the composition has to
@@ -81,6 +82,17 @@ export interface UiFieldDescriptor {
8182
readonly rules: readonly UiRule[];
8283
/** Present only for a `field.currency`. */
8384
readonly currency?: { readonly currency: string; readonly locale: string } | undefined;
85+
/**
86+
* The member symbols, present exactly when the field is a `field.enum`.
87+
*
88+
* A descriptor that reports `view: "dropdown"` and carries no options tells a consumer
89+
* to render a `<select>` and gives it nothing to put in one. That was the shape between
90+
* 0.25.0 — which moved an enum field's form view from `text` to `dropdown` — and this,
91+
* and it forced every consumer to restate the member list the metadata already declares.
92+
* Found on the public reference app, where the same four symbols appeared as literals in
93+
* five hand-written files.
94+
*/
95+
readonly options?: readonly string[] | undefined;
8496
/**
8597
* Present when the generated FORM renders this field as a nested value-object
8698
* sub-form rather than an input — a `field.object` whose `@objectRef` resolves (see
@@ -284,6 +296,11 @@ export function buildUiFieldDescriptor(field: MetaField, root?: MetaRoot): UiFie
284296
currency: currencyMeta === null
285297
? undefined
286298
: { currency: currencyMeta.currency, locale: currencyMeta.locale },
299+
// Whatever the field's view resolves to, an enum's members belong on the descriptor:
300+
// they are declared metadata, and withholding them is what makes a consumer hard-code
301+
// them. Read through the RESOLVING accessor (ADR-0039) so a field that inherits
302+
// `@values` from an abstract enum via `extends` carries them too.
303+
options: enumValues(field),
287304
nested: vo === undefined
288305
? undefined
289306
: { objectRef: vo.name, isArray: field.resolvedIsArray() },
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// A descriptor that says `view: "dropdown"` must carry what a dropdown needs.
2+
//
3+
// 0.25.0 moved a `field.enum`'s FORM view from "text" to "dropdown". The descriptor kept
4+
// emitting no member list, so the generated `<Entity>` const told a consumer to render a
5+
// `<select>` and handed it nothing to fill one with — while the metadata had `@values` all
6+
// along. Every consumer then restated the members by hand; on the public reference app the
7+
// same four symbols appeared as literals across five hand-written files.
8+
//
9+
// Nothing caught it: the whole codegen suite passed unchanged when `options` was added,
10+
// because not one fixture asserted an enum field's descriptor output at all. This is that
11+
// assertion.
12+
import { describe, test, expect } from "bun:test";
13+
import type { MetaObject } from "@metaobjectsdev/metadata";
14+
import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata";
15+
import { renderEntityConstants } from "../src/templates/entity-constants.js";
16+
import { buildUiFieldDescriptor } from "../src/templates/entity-ui-descriptor.js";
17+
18+
const MODEL = {
19+
"metadata.root": {
20+
package: "acme",
21+
children: [
22+
// An abstract enum a concrete field inherits its members from, so the resolving
23+
// read (ADR-0039) is exercised rather than an own-only one.
24+
{ "field.enum": { name: "orderState", abstract: true, "@values": ["open", "closed"] } },
25+
{
26+
"object.entity": {
27+
name: "Order",
28+
children: [
29+
{ "source.rdb": { "@kind": "table", "@table": "orders" } },
30+
{ "field.long": { name: "id", "@required": true } },
31+
{ "identity.primary": { name: "pk", "@fields": ["id"], "@generation": "increment" } },
32+
{ "field.enum": { name: "status", "@values": ["pending", "shipped", "cancelled"] } },
33+
{ "field.enum": { name: "state", extends: "orderState" } },
34+
{ "field.string": { name: "note" } },
35+
],
36+
},
37+
},
38+
],
39+
},
40+
};
41+
42+
async function loadOrder(): Promise<MetaObject> {
43+
const result = await new MetaDataLoader().load([new InMemoryStringSource(JSON.stringify(MODEL))]);
44+
if (result.errors.length > 0) throw new Error(result.errors.map((e) => e.message).join("\n"));
45+
return result.root.objects().find((o) => o.name === "Order")!;
46+
}
47+
48+
describe("an enum field's descriptor carries its members", () => {
49+
test("a declared enum reports view dropdown AND its options", async () => {
50+
const order = await loadOrder();
51+
const status = order.fields().find((f) => f.name === "status")!;
52+
const d = buildUiFieldDescriptor(status);
53+
expect(d.view).toBe("dropdown");
54+
expect(d.options).toEqual(["pending", "shipped", "cancelled"]);
55+
});
56+
57+
test("members inherited through extends resolve too", async () => {
58+
const order = await loadOrder();
59+
const state = order.fields().find((f) => f.name === "state")!;
60+
// An own-only read would return undefined here and the dropdown would be empty.
61+
expect(buildUiFieldDescriptor(state).options).toEqual(["open", "closed"]);
62+
});
63+
64+
test("a non-enum field carries no options key", async () => {
65+
const order = await loadOrder();
66+
const note = order.fields().find((f) => f.name === "note")!;
67+
expect(buildUiFieldDescriptor(note).options).toBeUndefined();
68+
});
69+
70+
test("the emitted const spells the members, and only for the enums", async () => {
71+
const out = renderEntityConstants(await loadOrder()).toString();
72+
expect(out).toContain(`options: ["pending", "shipped", "cancelled"] as const`);
73+
expect(out).toContain(`options: ["open", "closed"] as const`);
74+
// Exactly two option lists — the string field must not grow one.
75+
expect(out.match(/options:/g)?.length).toBe(2);
76+
});
77+
});

0 commit comments

Comments
 (0)