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:
parent
937f1dd51b
commit
17a4c78dd3
1 changed files with 519 additions and 311 deletions
|
|
@ -8,7 +8,6 @@ import {
|
|||
generateDefaultValue,
|
||||
isPropertyRequired,
|
||||
resolveRef,
|
||||
normalizeUnionType,
|
||||
formatFieldLabel,
|
||||
resolveRefsInMessage,
|
||||
} from "./schemaUtils";
|
||||
|
|
@ -243,226 +242,6 @@ describe("resolveRef", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("normalizeUnionType", () => {
|
||||
it("normalizes anyOf string|null", () => {
|
||||
expect(
|
||||
normalizeUnionType({
|
||||
anyOf: [{ type: "string" }, { type: "null" }],
|
||||
}),
|
||||
).toEqual({
|
||||
type: "string",
|
||||
anyOf: undefined,
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes anyOf boolean|null", () => {
|
||||
expect(
|
||||
normalizeUnionType({
|
||||
anyOf: [{ type: "boolean" }, { type: "null" }],
|
||||
}),
|
||||
).toEqual({
|
||||
type: "boolean",
|
||||
anyOf: undefined,
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes anyOf number|null", () => {
|
||||
expect(
|
||||
normalizeUnionType({
|
||||
anyOf: [{ type: "number" }, { type: "null" }],
|
||||
}),
|
||||
).toEqual({
|
||||
type: "number",
|
||||
anyOf: undefined,
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes anyOf integer|null", () => {
|
||||
expect(
|
||||
normalizeUnionType({
|
||||
anyOf: [{ type: "integer" }, { type: "null" }],
|
||||
}),
|
||||
).toEqual({
|
||||
type: "integer",
|
||||
anyOf: undefined,
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes anyOf array|null", () => {
|
||||
expect(
|
||||
normalizeUnionType({
|
||||
anyOf: [{ type: "array" }, { type: "null" }],
|
||||
}),
|
||||
).toEqual({
|
||||
type: "array",
|
||||
anyOf: undefined,
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes type array string|null", () => {
|
||||
expect(normalizeUnionType({ type: ["string", "null"] })).toEqual({
|
||||
type: "string",
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes type array boolean|null", () => {
|
||||
expect(normalizeUnionType({ type: ["boolean", "null"] })).toEqual({
|
||||
type: "boolean",
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes type array number|null", () => {
|
||||
expect(normalizeUnionType({ type: ["number", "null"] })).toEqual({
|
||||
type: "number",
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes type array integer|null", () => {
|
||||
expect(normalizeUnionType({ type: ["integer", "null"] })).toEqual({
|
||||
type: "integer",
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns schema unchanged when no union pattern matches", () => {
|
||||
const schema = { type: "string" as const };
|
||||
expect(normalizeUnionType(schema)).toBe(schema);
|
||||
});
|
||||
|
||||
it("ignores anyOf with more than two members", () => {
|
||||
const schema = {
|
||||
anyOf: [
|
||||
{ type: "string" as const },
|
||||
{ type: "null" as const },
|
||||
{ type: "number" as const },
|
||||
],
|
||||
};
|
||||
expect(normalizeUnionType(schema)).toBe(schema);
|
||||
});
|
||||
|
||||
it("ignores a two-member anyOf with no null branch", () => {
|
||||
const schema = {
|
||||
anyOf: [{ type: "string" as const }, { type: "number" as const }],
|
||||
};
|
||||
expect(normalizeUnionType(schema)).toBe(schema);
|
||||
});
|
||||
|
||||
it("ignores an anyOf whose non-null branch has no renderable type (#1928)", () => {
|
||||
const schema = {
|
||||
anyOf: [{ const: "only" }, { type: "null" as const }],
|
||||
};
|
||||
expect(normalizeUnionType(schema)).toBe(schema);
|
||||
});
|
||||
|
||||
// #1928: the enum lives on the surviving branch, and hoisting it is what makes
|
||||
// the field render as a Select instead of the raw-JSON fallback.
|
||||
it("hoists enum out of an anyOf string-enum|null branch", () => {
|
||||
expect(
|
||||
normalizeUnionType({
|
||||
description: "Direction",
|
||||
anyOf: [
|
||||
{ type: "string", enum: ["envio", "recebimento"] },
|
||||
{ type: "null" },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
type: "string",
|
||||
description: "Direction",
|
||||
enum: ["envio", "recebimento"],
|
||||
anyOf: undefined,
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("infers string for a typeless enum branch", () => {
|
||||
expect(
|
||||
normalizeUnionType({
|
||||
anyOf: [{ enum: ["a", "b"] }, { type: "null" }],
|
||||
}),
|
||||
).toEqual({
|
||||
type: "string",
|
||||
enum: ["a", "b"],
|
||||
anyOf: undefined,
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("hoists items out of an anyOf array|null branch", () => {
|
||||
expect(
|
||||
normalizeUnionType({
|
||||
anyOf: [
|
||||
{ type: "array", items: { type: "string", enum: ["a", "b"] } },
|
||||
{ type: "null" },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
type: "array",
|
||||
items: { type: "string", enum: ["a", "b"] },
|
||||
anyOf: undefined,
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("hoists properties out of an anyOf object|null branch", () => {
|
||||
expect(
|
||||
normalizeUnionType({
|
||||
anyOf: [
|
||||
{ type: "object", properties: { a: { type: "string" } } },
|
||||
{ type: "null" },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
type: "object",
|
||||
properties: { a: { type: "string" } },
|
||||
anyOf: undefined,
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes anyOf object|null regardless of branch order", () => {
|
||||
expect(
|
||||
normalizeUnionType({
|
||||
anyOf: [{ type: "null" }, { type: "object" }],
|
||||
}),
|
||||
).toEqual({ type: "object", anyOf: undefined, nullable: true });
|
||||
});
|
||||
|
||||
it("normalizes type array object|null", () => {
|
||||
expect(
|
||||
normalizeUnionType({
|
||||
type: ["object", "null"],
|
||||
properties: { a: { type: "string" } },
|
||||
}),
|
||||
).toEqual({
|
||||
type: "object",
|
||||
properties: { a: { type: "string" } },
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes type array array|null", () => {
|
||||
expect(normalizeUnionType({ type: ["array", "null"] })).toEqual({
|
||||
type: "array",
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a type array of two non-null members unchanged", () => {
|
||||
const schema = { type: ["string", "number"] as const };
|
||||
expect(normalizeUnionType({ ...schema, type: [...schema.type] })).toEqual({
|
||||
type: ["string", "number"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatFieldLabel", () => {
|
||||
it("inserts spaces before capitals and capitalizes first letter", () => {
|
||||
expect(formatFieldLabel("firstName")).toBe("First Name");
|
||||
|
|
|
|||
Loading…
Reference in a new issue