fix: align enumNames when stripping null, and make the JSON fallback typeable

Copilot round 5: one inline comment, two suppressed. All three valid, and two
were defects from round 4's own fix.

1. Stripping the `null` sentinel from an `enum` left the parallel `enumNames`
   untouched. Both renderers discard labels outright when the two lengths
   disagree, so `{ enum: ["a", null], enumNames: ["Alpha", "None"] }` lost the
   `Alpha` label entirely — a worse outcome than the sentinel it was fixing.
   `enumNames` is now filtered by the same indices, and only when it was
   positionally parallel to begin with (a mismatched list is already ignored
   by both renderers, so re-indexing it would invent an alignment the server
   never declared).

2. `enum: [null]` became `enum: undefined`, which widened a schema permitting
   *only* `null` into a plain string field accepting arbitrary text — the form
   would then invite values the schema forbids. Trading a cosmetic problem for
   a correctness one, which was the wrong call. The collapse now declines
   entirely for that case and the field renders through the JSON editor.

3. That JSON editor was still carrying #1928's original mechanism: it stored
   unparseable text back as the *value*, which the next render
   re-`JSON.stringify`d, so typing `[` gave `"["` then `"\"[\""`. Routing
   nullable fields to real widgets removed the common way of landing there but
   never fixed the editor — and #2007's fix deliberately sends object unions to
   it, so a fallback nobody can type into is not a fallback. `SchemaJsonField`
   now holds the draft text locally (the split `SchemaNumberInput` already
   uses) and reports parsed JSON, or `undefined` while the text is mid-edit —
   an inspector must not report a value it cannot send. Keyed by `resetKey`
   like the number field, since it now holds state.

Also replaces an `as unknown as string[]` in the round-4 test, which violated
the repo's TypeScript rules and papered over `InspectorFormSchema.enum` being
unable to model a valid `["a", null]`. The fixture is wire data, so it now goes
through `toFormSchema` — the same wire→form narrowing boundary every production
call site uses.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-15 18:54:58 -04:00
commit 32d6f80bc1

View file

@ -277,14 +277,52 @@ describe("normalizeNullableUnion", () => {
});
});
it("drops an enum that held nothing but null", () => {
// No value a dropdown could offer, so the field is better served by its
// plain widget than by an empty select.
// Declining is the point: emitting `enum: undefined` would turn a schema that
// permits *only* `null` into a plain string field accepting arbitrary text,
// inviting values the schema forbids. Left uncollapsed it renders through the
// JSON editor, which represents it honestly.
it("declines to collapse an enum that held nothing but null", () => {
const schema = { type: ["string", "null"], enum: [null] };
expect(normalizeNullableUnion(schema)).toBe(schema);
});
it("declines the same way on the anyOf encoding", () => {
const schema = {
anyOf: [{ type: "string", enum: [null] }, { type: "null" as const }],
};
expect(normalizeNullableUnion(schema)).toBe(schema);
});
// The parallel `enumNames` is filtered by the same indices. Both renderers
// discard labels outright on a length mismatch, so stripping one without the
// other would silently lose every label, not just the dropped one's.
it("keeps enumNames aligned when stripping the null member", () => {
expect(
normalizeNullableUnion({ type: ["string", "null"], enum: [null] }),
normalizeNullableUnion({
type: ["string", "null"],
enum: ["a", null, "b"],
enumNames: ["Alpha", "None", "Beta"],
}),
).toEqual({
type: "string",
enum: undefined,
enum: ["a", "b"],
enumNames: ["Alpha", "Beta"],
anyOf: undefined,
nullable: true,
});
});
it("leaves a mismatched enumNames alone rather than inventing an alignment", () => {
expect(
normalizeNullableUnion({
type: ["string", "null"],
enum: ["a", null],
enumNames: ["Alpha"],
}),
).toEqual({
type: "string",
enum: ["a"],
enumNames: ["Alpha"],
anyOf: undefined,
nullable: true,
});