fix: centralize the nullable-union collapse in core and apply it in the TUI

Closes #2015, folded into #1928 rather than left as a follow-up.

The TUI's `schemaToForm` has the same defect the web `SchemaForm` had: its
local `JsonSchemaProperty` declares only `type?: string` — no `anyOf`, no
array-valued `type` — so a `.nullish()` argument has no top-level `type` or
`enum` for the dispatcher to read and falls to the `switch` default, losing
its select. Milder than the web bug (a plain text field, not a corrupting
JSON textarea), but the same class.

Fixing it in a second place is the argument for not having the logic in a
first place. So the collapse moves to `core/json/nullableUnion.ts` as
`normalizeNullableUnion`, generic over a minimal `NullableUnionSchema`
shape — the least a caller must expose to be *recognized* as a nullable
union — so neither client has to adopt the other's schema type. Both form
builders now share one copy and cannot drift on which schemas they render.

- `core/json/nullableUnion.ts` — the shared collapse, plus tests at
  `clients/web/src/test/core/nullableUnion.test.ts` (the web suite is what
  gates `core/`).
- `clients/web`: `schemaUtils.normalizeUnionType` is gone; `SchemaForm` and
  `resolveRefsInMessage` call the core function directly.
- `clients/tui`: `schemaToForm` normalizes each property before dispatch,
  and `JsonSchemaProperty` grows the `anyOf` / array-`type` fields the
  encoding uses.

Verified by driving the built TUI under a pty against the
`nullable-fields-http.json` showcase server. Before, every argument of
`record_shipment` rendered as a plain text field; after, `direction` is a
select ("No value"), `quantity` an integer, `express` a boolean
("[Not set]").

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-15 16:59:37 -04:00
commit 17a4c78dd3

View file

@ -12,7 +12,7 @@ import { useState } from "react";
import { ClearButton } from "../../elements/ClearButton/ClearButton";
import { useValueChange } from "../../../hooks/useValueChange";
import type { InspectorFormSchema } from "../../../utils/jsonUtils";
import { normalizeUnionType } from "../../../utils/schemaUtils";
import { normalizeNullableUnion } from "@inspector/core/json/nullableUnion.js";
const FieldLabel = Text.withProps({
fw: 500,
@ -224,7 +224,7 @@ export function SchemaForm({
// without this an "optional and explicitly nullable" field — what Zod's
// `.nullish()` emits — matches nothing and falls through to the raw-JSON
// fallback, which is unusable for a value the user has to type (#1928).
const fieldSchema = normalizeUnionType(rawSchema);
const fieldSchema = normalizeNullableUnion(rawSchema);
const isRequired = requiredFields.includes(fieldName);
const label = fieldSchema.title ?? fieldName;
const description = fieldSchema.description;