Merge branch 'v2/main' into v2/feat/2018-custom-authorization-params
This commit is contained in:
commit
9c4356fb8c
2 changed files with 2457 additions and 260 deletions
|
|
@ -2,6 +2,7 @@ import { useState } from "react";
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { InspectorFormSchema } from "../../../utils/jsonUtils";
|
||||
import { toFormSchema } from "../../../utils/jsonUtils";
|
||||
import {
|
||||
fireEvent,
|
||||
renderWithMantine,
|
||||
|
|
@ -737,7 +738,52 @@ describe("SchemaForm", () => {
|
|||
expect(lastCall.config).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("falls back to passing raw string to onChange when JSON is invalid in JsonInput", async () => {
|
||||
// The JSON field used to store unparseable text back as the *value*, which
|
||||
// the next render re-stringified — so each keystroke added a layer of
|
||||
// escaping (`[` → `"["` → `"\"[\""`). That compounding escape is #1928's
|
||||
// original symptom, and it lived here rather than in the dispatch. It matters
|
||||
// more since #2007, whose fix deliberately routes object unions to this
|
||||
// editor: a fallback nobody can type into is not a fallback.
|
||||
it("shows an error while the JSON draft does not parse", async () => {
|
||||
const user = userEvent.setup();
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
config: { type: "array", title: "Config" },
|
||||
},
|
||||
};
|
||||
function Harness() {
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
return (
|
||||
<SchemaForm schema={schema} values={values} onChange={setValues} />
|
||||
);
|
||||
}
|
||||
renderWithMantine(<Harness />);
|
||||
|
||||
const jsonInput = screen.getByLabelText(/Config/) as HTMLTextAreaElement;
|
||||
await user.type(jsonInput, "[[1,");
|
||||
// Invalid text yields no value, so without this the field would submit as
|
||||
// absent while the user is still looking at what they typed.
|
||||
expect(screen.getByText(/Not valid JSON/)).toBeInTheDocument();
|
||||
|
||||
await user.type(jsonInput, "2]");
|
||||
expect(screen.queryByText(/Not valid JSON/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows no error for an empty optional field", () => {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
config: { type: "array", title: "Config" },
|
||||
},
|
||||
};
|
||||
renderWithMantine(
|
||||
<SchemaForm schema={schema} values={{}} onChange={vi.fn()} />,
|
||||
);
|
||||
expect(screen.queryByText(/Not valid JSON/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports no value, not raw text, while the JSON is mid-edit", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const schema: InspectorFormSchema = {
|
||||
|
|
@ -751,9 +797,59 @@ describe("SchemaForm", () => {
|
|||
);
|
||||
const jsonInput = screen.getByLabelText(/Config/) as HTMLTextAreaElement;
|
||||
await user.type(jsonInput, "x");
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0];
|
||||
expect(typeof lastCall.config).toBe("string");
|
||||
expect(onChange).toHaveBeenLastCalledWith({ config: undefined });
|
||||
});
|
||||
|
||||
it("lets an array literal be typed one character at a time", async () => {
|
||||
const user = userEvent.setup();
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
config: { type: "array", title: "Config" },
|
||||
},
|
||||
};
|
||||
// Drive the real controlled loop: each onChange feeds straight back in as
|
||||
// `values`, which is what turned the old handler's raw-text write into a
|
||||
// compounding re-escape.
|
||||
function Harness() {
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
return (
|
||||
<SchemaForm schema={schema} values={values} onChange={setValues} />
|
||||
);
|
||||
}
|
||||
renderWithMantine(<Harness />);
|
||||
|
||||
const jsonInput = screen.getByLabelText(/Config/) as HTMLTextAreaElement;
|
||||
// `[` is a userEvent keyboard descriptor, so it is escaped as `[[`.
|
||||
await user.type(jsonInput, '[[1,"a"]');
|
||||
|
||||
// The box shows exactly what was typed — no injected quotes or backslashes.
|
||||
expect(jsonInput.value).toBe('[1,"a"]');
|
||||
});
|
||||
|
||||
it("keeps an in-progress draft visible instead of rewriting it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
config: { type: "array", title: "Config" },
|
||||
},
|
||||
};
|
||||
function Harness() {
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
return (
|
||||
<SchemaForm schema={schema} values={values} onChange={setValues} />
|
||||
);
|
||||
}
|
||||
renderWithMantine(<Harness />);
|
||||
|
||||
const jsonInput = screen.getByLabelText(/Config/) as HTMLTextAreaElement;
|
||||
await user.type(jsonInput, "[[1,");
|
||||
// Unparseable so far, and it must survive the re-render untouched.
|
||||
expect(jsonInput.value).toBe("[1,");
|
||||
|
||||
await user.type(jsonInput, "2]");
|
||||
expect(jsonInput.value).toBe("[1,2]");
|
||||
});
|
||||
|
||||
it("uses default values when value is undefined", () => {
|
||||
|
|
@ -811,3 +907,380 @@ describe("SchemaForm", () => {
|
|||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// #1928: "optional AND explicitly nullable" (Zod's `.nullish()`) compiles to a
|
||||
// nullable union rather than a plain type. Before the normalization step these
|
||||
// matched no branch and fell through to the raw-JSON fallback, where every
|
||||
// keystroke re-escaped the value.
|
||||
describe("SchemaForm nullable unions", () => {
|
||||
it("renders a Select for an anyOf string-enum|null field", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: {
|
||||
title: "Type",
|
||||
anyOf: [
|
||||
{ type: "string", enum: ["envio", "recebimento"] },
|
||||
{ type: "null" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
renderWithMantine(
|
||||
<SchemaForm schema={schema} values={{}} onChange={onChange} />,
|
||||
);
|
||||
await user.click(screen.getByRole("textbox", { name: "Type" }));
|
||||
await user.click(
|
||||
await screen.findByRole("option", { name: "envio", hidden: true }),
|
||||
);
|
||||
expect(onChange).toHaveBeenCalledWith({ type: "envio" });
|
||||
});
|
||||
|
||||
it("clears a nullable enum back to null", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: {
|
||||
title: "Type",
|
||||
anyOf: [{ type: "string", enum: ["envio"] }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
renderWithMantine(
|
||||
<SchemaForm
|
||||
schema={schema}
|
||||
values={{ type: "envio" }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
// Mantine marks its combobox clear button `aria-hidden` (it is mouse-only,
|
||||
// `tabIndex={-1}`), so it is only reachable with `hidden: true`.
|
||||
await user.click(screen.getByRole("button", { hidden: true }));
|
||||
expect(onChange).toHaveBeenCalledWith({ type: null });
|
||||
});
|
||||
|
||||
it("offers no clear affordance on a non-nullable enum", () => {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
format: { type: "string", title: "Format", enum: ["json"] },
|
||||
},
|
||||
};
|
||||
renderWithMantine(
|
||||
<SchemaForm
|
||||
schema={schema}
|
||||
values={{ format: "json" }}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { hidden: true }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The other supported nullable encoding: keywords stay at the top level, so
|
||||
// the null sentinel sits inside the enum list rather than on a branch.
|
||||
it("renders a Select for a type: [string, null] enum, without a null option", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
// Built through `toFormSchema`, the same narrowing boundary every
|
||||
// production call site uses, rather than cast into `InspectorFormSchema`.
|
||||
// A `null` member is valid JSON Schema but outside that type's `string[]`
|
||||
// `enum`, and this fixture *is* wire data — so the honest way to introduce
|
||||
// it is the wire→form narrow, not a double cast that erases the mismatch.
|
||||
const schema = toFormSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
direction: {
|
||||
title: "Direction",
|
||||
type: ["string", "null"],
|
||||
enum: ["envio", "recebimento", null],
|
||||
},
|
||||
},
|
||||
});
|
||||
renderWithMantine(
|
||||
<SchemaForm schema={schema!} values={{}} onChange={onChange} />,
|
||||
);
|
||||
await user.click(screen.getByRole("textbox", { name: "Direction" }));
|
||||
const options = await screen.findAllByRole("option", { hidden: true });
|
||||
expect(options.map((option) => option.textContent)).toEqual([
|
||||
"envio",
|
||||
"recebimento",
|
||||
]);
|
||||
await user.click(options[0]);
|
||||
expect(onChange).toHaveBeenCalledWith({ direction: "envio" });
|
||||
});
|
||||
|
||||
it("renders a TextInput for a type: [string, null] field", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
note: { type: ["string", "null"], title: "Note" },
|
||||
},
|
||||
};
|
||||
renderWithMantine(
|
||||
<SchemaForm schema={schema} values={{}} onChange={onChange} />,
|
||||
);
|
||||
await user.type(screen.getByLabelText(/Note/), "a");
|
||||
expect(onChange).toHaveBeenCalledWith({ note: "a" });
|
||||
});
|
||||
|
||||
it("renders a checkbox for an anyOf boolean|null field", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
enabled: {
|
||||
title: "Enabled",
|
||||
anyOf: [{ type: "boolean" }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
renderWithMantine(
|
||||
<SchemaForm schema={schema} values={{}} onChange={onChange} />,
|
||||
);
|
||||
await user.click(screen.getByLabelText("Enabled"));
|
||||
expect(onChange).toHaveBeenCalledWith({ enabled: true });
|
||||
});
|
||||
|
||||
it("renders a MultiSelect for an anyOf array-of-enum|null field", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
tags: {
|
||||
title: "Tags",
|
||||
anyOf: [
|
||||
{ type: "array", items: { type: "string", enum: ["a", "b"] } },
|
||||
{ type: "null" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
renderWithMantine(
|
||||
<SchemaForm schema={schema} values={{}} onChange={onChange} />,
|
||||
);
|
||||
await user.click(screen.getByRole("textbox", { name: "Tags" }));
|
||||
await user.click(
|
||||
await screen.findByRole("option", { name: "a", hidden: true }),
|
||||
);
|
||||
expect(onChange).toHaveBeenCalledWith({ tags: ["a"] });
|
||||
});
|
||||
|
||||
it("renders nested fields for an anyOf object|null field", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
profile: {
|
||||
title: "Profile",
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { nick: { type: "string", title: "Nick" } },
|
||||
},
|
||||
{ type: "null" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
renderWithMantine(
|
||||
<SchemaForm schema={schema} values={{}} onChange={onChange} />,
|
||||
);
|
||||
await user.type(screen.getByLabelText(/Nick/), "z");
|
||||
expect(onChange).toHaveBeenCalledWith({ profile: { nick: "z" } });
|
||||
});
|
||||
|
||||
// #2007: `z.array(z.union([z.object(…), z.object(…)]))` gives an `items.anyOf`
|
||||
// whose branches carry no top-level `const`, so every MultiSelect option was
|
||||
// the empty string — and Mantine *throws* on duplicate option values, greying
|
||||
// out the whole tool panel. The nullable form below is reachable only because
|
||||
// this PR now collapses it into `type: "array"`, so it has to be safe too.
|
||||
it("falls back to the JSON input for an array of object-union items", () => {
|
||||
const objectBranch = (name: string): InspectorFormSchema => ({
|
||||
type: "object",
|
||||
properties: { type: { type: "string", const: name } },
|
||||
required: ["type"],
|
||||
});
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
items: {
|
||||
title: "Items",
|
||||
type: "array",
|
||||
items: { anyOf: [objectBranch("A"), objectBranch("B")] },
|
||||
},
|
||||
},
|
||||
};
|
||||
renderWithMantine(
|
||||
<SchemaForm schema={schema} values={{}} onChange={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByLabelText(/Items/).tagName).toBe("TEXTAREA");
|
||||
});
|
||||
|
||||
it("falls back to the JSON input for a nullable array of object-union items", () => {
|
||||
const objectBranch = (name: string): InspectorFormSchema => ({
|
||||
type: "object",
|
||||
properties: { type: { type: "string", const: name } },
|
||||
required: ["type"],
|
||||
});
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
items: {
|
||||
title: "Items",
|
||||
anyOf: [
|
||||
{ type: "array", items: { anyOf: [objectBranch("A")] } },
|
||||
{ type: "null" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
renderWithMantine(
|
||||
<SchemaForm schema={schema} values={{}} onChange={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByLabelText(/Items/).tagName).toBe("TEXTAREA");
|
||||
});
|
||||
|
||||
// Mantine's select is string-valued, so a numeric const would submit "1" for
|
||||
// 1 — the same wrong-type-on-the-wire problem that keeps a numeric enum off
|
||||
// the select path.
|
||||
it("falls back to the JSON input for non-string anyOf consts", () => {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
items: {
|
||||
title: "Items",
|
||||
type: "array",
|
||||
items: { anyOf: [{ const: 1 }, { const: 2 }] },
|
||||
},
|
||||
},
|
||||
};
|
||||
renderWithMantine(
|
||||
<SchemaForm schema={schema} values={{}} onChange={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByLabelText(/Items/).tagName).toBe("TEXTAREA");
|
||||
});
|
||||
|
||||
it("falls back to the JSON input when two anyOf branches share a const", () => {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
items: {
|
||||
title: "Items",
|
||||
type: "array",
|
||||
items: { anyOf: [{ const: "dup" }, { const: "dup" }] },
|
||||
},
|
||||
},
|
||||
};
|
||||
renderWithMantine(
|
||||
<SchemaForm schema={schema} values={{}} onChange={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByLabelText(/Items/).tagName).toBe("TEXTAREA");
|
||||
});
|
||||
|
||||
it("falls back to a text input for a string oneOf with no consts", () => {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
mode: {
|
||||
title: "Mode",
|
||||
type: "string",
|
||||
oneOf: [{ type: "string" }, { type: "string" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
renderWithMantine(
|
||||
<SchemaForm schema={schema} values={{}} onChange={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByLabelText(/Mode/).tagName).toBe("INPUT");
|
||||
});
|
||||
|
||||
it("still renders a MultiSelect when every anyOf branch has a distinct const", () => {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
items: {
|
||||
title: "Items",
|
||||
type: "array",
|
||||
items: { anyOf: [{ const: "a", title: "Alpha" }, { const: "b" }] },
|
||||
},
|
||||
},
|
||||
};
|
||||
renderWithMantine(
|
||||
<SchemaForm schema={schema} values={{}} onChange={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByRole("textbox", { name: "Items" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Reachable only since the collapse landed: the nullable wrapper had no
|
||||
// top-level `type` before, so it fell to the JSON editor rather than into a
|
||||
// string-valued MultiSelect that would submit ["1"] for [1].
|
||||
it("falls back to the JSON input for a nullable array of numeric item enums", () => {
|
||||
// Wire data again — a numeric `enum` is valid JSON Schema but outside
|
||||
// `InspectorFormSchema`'s `string[]`, so it comes in through the narrow.
|
||||
const schema = toFormSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
levels: {
|
||||
title: "Levels",
|
||||
anyOf: [{ type: "array", items: { enum: [1, 2] } }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
renderWithMantine(
|
||||
<SchemaForm schema={schema!} values={{}} onChange={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByLabelText(/Levels/).tagName).toBe("TEXTAREA");
|
||||
});
|
||||
|
||||
// The sibling enum rules null out even though the type list names it, so the
|
||||
// field must not get a clear button that emits a value the schema rejects.
|
||||
it("offers no clear button when a sibling enum excludes null", () => {
|
||||
const schema = toFormSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
direction: {
|
||||
title: "Direction",
|
||||
type: ["string", "null"],
|
||||
enum: ["envio", "recebimento"],
|
||||
},
|
||||
},
|
||||
});
|
||||
renderWithMantine(
|
||||
<SchemaForm
|
||||
schema={schema!}
|
||||
values={{ direction: "envio" }}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { hidden: true }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("still falls back to the JSON input for a union of two real types", () => {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
mixed: {
|
||||
title: "Mixed",
|
||||
anyOf: [{ type: "string" }, { type: "number" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
renderWithMantine(
|
||||
<SchemaForm schema={schema} values={{}} onChange={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByLabelText(/Mixed/).tagName).toBe("TEXTAREA");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,7 +11,14 @@ import {
|
|||
import { useState } from "react";
|
||||
import { ClearButton } from "../../elements/ClearButton/ClearButton";
|
||||
import { useValueChange } from "../../../hooks/useValueChange";
|
||||
import type { InspectorFormSchema } from "../../../utils/jsonUtils";
|
||||
import type {
|
||||
InspectorFormSchema,
|
||||
JsonSchemaConst,
|
||||
} from "../../../utils/jsonUtils";
|
||||
import {
|
||||
isStringEnum,
|
||||
normalizeNullableUnion,
|
||||
} from "@inspector/core/json/nullableUnion.js";
|
||||
|
||||
const FieldLabel = Text.withProps({
|
||||
fw: 500,
|
||||
|
|
@ -51,6 +58,58 @@ function toEnumData(
|
|||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build `Select`/`MultiSelect` option data from a list of `oneOf`/`anyOf`
|
||||
* branches that are expected to be constants.
|
||||
*
|
||||
* Returns `null` when the branches are not usable as options, which sends the
|
||||
* field to the JSON fallback instead. Four ways that happens:
|
||||
*
|
||||
* - **A non-string `const`.** Mantine's select is string-valued, so
|
||||
* `anyOf: [{ const: 1 }, { const: 2 }]` would submit `["1"]` where the
|
||||
* schema says `[1]` — the same wrong-type-on-the-wire problem that keeps a
|
||||
* numeric `enum` off the select path. An inspector must not misreport what
|
||||
* it sends, so this stays on the JSON editor, where the value keeps its type.
|
||||
* - **No `const` at all.** An `anyOf` of *object* schemas — what
|
||||
* `z.array(z.union([z.object(…), z.object(…)]))` compiles to — has no
|
||||
* top-level `const` on any branch, so every option would be the empty
|
||||
* string. Mantine **throws** on duplicate option values, which greys out the
|
||||
* whole tool panel rather than degrading (#2007).
|
||||
* - **Duplicate values.** Two branches sharing a `const` throw the same way.
|
||||
* - **An empty option value**, which Mantine cannot render as selectable.
|
||||
*
|
||||
* A union of object shapes has no faithful dropdown anyway, so the JSON editor
|
||||
* is the honest widget for it rather than a lossy or crashing one.
|
||||
*/
|
||||
function toConstOptions(
|
||||
branches: (InspectorFormSchema | JsonSchemaConst)[],
|
||||
): { value: string; label: string }[] | null {
|
||||
const options: { value: string; label: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const branch of branches) {
|
||||
if (typeof branch.const !== "string") {
|
||||
return null;
|
||||
}
|
||||
const value = branch.const;
|
||||
if (value === "" || seen.has(value)) {
|
||||
return null;
|
||||
}
|
||||
seen.add(value);
|
||||
options.push({ value, label: branch.title ?? value });
|
||||
}
|
||||
return options.length > 0 ? options : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an enum `Select` should offer a clear affordance. Only a schema that
|
||||
* admits `null` does: clearing emits `null`, and an enum without a null branch
|
||||
* would reject it. Without this a nullable enum is a one-way door — once a
|
||||
* value is picked there is no way back to "no answer".
|
||||
*/
|
||||
function isClearable(fieldSchema: InspectorFormSchema): boolean {
|
||||
return fieldSchema.nullable === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpret whatever Mantine's `NumberInput` reported as the JSON value for the
|
||||
* field. Anything that is not a finite number becomes `undefined`, which is how
|
||||
|
|
@ -90,6 +149,110 @@ function toNumericValue(raw: string | number): number | undefined {
|
|||
return Number.isSafeInteger(Math.trunc(parsed)) ? parsed : undefined;
|
||||
}
|
||||
|
||||
/** Parse editor text, reporting `undefined` for anything that is not JSON yet. */
|
||||
function parseJsonDraft(text: string): unknown {
|
||||
if (text.trim() === "") {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Structural equality for the draft/value re-sync, via canonical JSON. */
|
||||
function isSameJson(a: unknown, b: unknown): boolean {
|
||||
if (a === undefined || b === undefined) {
|
||||
return a === b;
|
||||
}
|
||||
return serializeJson(a) === serializeJson(b);
|
||||
}
|
||||
|
||||
interface SchemaJsonFieldProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
withAsterisk: boolean;
|
||||
disabled: boolean;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A `JsonInput` that keeps the text the user is typing, not just the value it
|
||||
* currently parses to.
|
||||
*
|
||||
* This field used to drive the box straight off the parent's value and, when
|
||||
* the text failed `JSON.parse`, store the **raw text** back as the value — which
|
||||
* the next render re-`JSON.stringify`d, adding a layer of escaping per
|
||||
* keystroke. Typing `[` gave `"["`, then `"\"[\""`, and the field was unusable
|
||||
* within a few characters. That compounding escape is the original #1928
|
||||
* symptom, and it lived in this fallback rather than in the dispatch.
|
||||
*
|
||||
* Routing nullable fields to real widgets removed the common way of *landing*
|
||||
* here, but it did not fix the editor — and #2007's fix deliberately sends a
|
||||
* union of object shapes to it, so the editor itself has to be typeable. Hence
|
||||
* the same split `SchemaNumberInput` uses: the raw text is the source of truth
|
||||
* for what is *displayed*, while the parent only ever sees parsed JSON.
|
||||
*
|
||||
* While the text is mid-edit and does not parse, the parent is told
|
||||
* `undefined` rather than handed the text. An inspector must not report a value
|
||||
* it cannot send, and `undefined` is how "no value supplied" is spelled
|
||||
* everywhere else in this form.
|
||||
*
|
||||
* That leaves invalid text and an empty field indistinguishable *to the
|
||||
* parent*, so the field says so itself via `error`. A **required** field is
|
||||
* already gated — its value is `undefined`, which `hasMissingRequiredFields`
|
||||
* treats as missing — but an **optional** one would otherwise be dropped from
|
||||
* the submission with no indication. Disabling Execute/Open/Submit on an
|
||||
* invalid optional draft needs a validity channel out of this component and
|
||||
* into all four callers, which is a wider change than this fix; tracked
|
||||
* separately.
|
||||
*/
|
||||
function SchemaJsonField({
|
||||
value,
|
||||
onChange,
|
||||
...inputProps
|
||||
}: SchemaJsonFieldProps) {
|
||||
const [draft, setDraft] = useState(() =>
|
||||
value === undefined ? "" : serializeJson(value),
|
||||
);
|
||||
|
||||
// Re-sync only when the parent's value genuinely diverges from what the draft
|
||||
// parses to, which leaves an external reset (a cleared form, a loaded
|
||||
// example) working while an in-progress `[` — whose parse is `undefined`,
|
||||
// matching the `undefined` we just emitted — is left alone.
|
||||
useValueChange(value, (next) => {
|
||||
if (!isSameJson(parseJsonDraft(draft), next)) {
|
||||
setDraft(next === undefined ? "" : serializeJson(next));
|
||||
}
|
||||
});
|
||||
|
||||
// Text that is present but does not parse yields no value, so the field would
|
||||
// otherwise submit as absent while the user is still looking at what they
|
||||
// typed. Saying so on the field is what keeps that from being silent; see the
|
||||
// note on `SchemaJsonField` for the submit-gating half, which is not local to
|
||||
// this component.
|
||||
const hasInvalidDraft =
|
||||
draft.trim() !== "" && parseJsonDraft(draft) === undefined;
|
||||
|
||||
return (
|
||||
<SchemaJsonInput
|
||||
{...inputProps}
|
||||
value={draft}
|
||||
error={
|
||||
hasInvalidDraft
|
||||
? "Not valid JSON — this field will be omitted"
|
||||
: undefined
|
||||
}
|
||||
onChange={(text) => {
|
||||
setDraft(text);
|
||||
onChange(parseJsonDraft(text));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface SchemaNumberInputProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
|
|
@ -207,7 +370,13 @@ export function SchemaForm({
|
|||
onChange({ ...values, [fieldName]: fieldValue });
|
||||
}
|
||||
|
||||
function renderField(fieldName: string, fieldSchema: InspectorFormSchema) {
|
||||
function renderField(fieldName: string, rawSchema: InspectorFormSchema) {
|
||||
// Flatten a nullable union (`anyOf: [X, {type:"null"}]`, `type: [X,"null"]`)
|
||||
// before dispatching. Every branch below tests a single `type` string, so
|
||||
// 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 = normalizeNullableUnion(rawSchema);
|
||||
const isRequired = requiredFields.includes(fieldName);
|
||||
const label = fieldSchema.title ?? fieldName;
|
||||
const description = fieldSchema.description;
|
||||
|
|
@ -223,6 +392,7 @@ export function SchemaForm({
|
|||
withAsterisk={isRequired}
|
||||
disabled={disabled}
|
||||
data={toEnumData(fieldSchema.enum, fieldSchema.enumNames)}
|
||||
clearable={isClearable(fieldSchema)}
|
||||
value={(rawValue as string) ?? null}
|
||||
onChange={(val) => handleFieldChange(fieldName, val)}
|
||||
/>
|
||||
|
|
@ -230,11 +400,11 @@ export function SchemaForm({
|
|||
}
|
||||
|
||||
// string with oneOf
|
||||
if (fieldSchema.type === "string" && fieldSchema.oneOf) {
|
||||
const data = fieldSchema.oneOf.map((item) => ({
|
||||
value: String(item.const ?? ""),
|
||||
label: item.title ?? String(item.const ?? ""),
|
||||
}));
|
||||
const oneOfData = fieldSchema.oneOf
|
||||
? toConstOptions(fieldSchema.oneOf)
|
||||
: null;
|
||||
if (fieldSchema.type === "string" && oneOfData) {
|
||||
const data = oneOfData;
|
||||
return (
|
||||
<Select
|
||||
key={fieldName}
|
||||
|
|
@ -243,6 +413,7 @@ export function SchemaForm({
|
|||
withAsterisk={isRequired}
|
||||
disabled={disabled}
|
||||
data={data}
|
||||
clearable={isClearable(fieldSchema)}
|
||||
value={(rawValue as string) ?? null}
|
||||
onChange={(val) => handleFieldChange(fieldName, val)}
|
||||
/>
|
||||
|
|
@ -312,12 +483,14 @@ export function SchemaForm({
|
|||
);
|
||||
}
|
||||
|
||||
// array of enum values (multi-select)
|
||||
if (fieldSchema.type === "array" && fieldSchema.items?.enum) {
|
||||
const data = toEnumData(
|
||||
fieldSchema.items.enum,
|
||||
fieldSchema.items.enumNames,
|
||||
);
|
||||
// array of enum values (multi-select). Gated on the members being strings
|
||||
// for the same reason `toConstOptions` is: `MultiSelect` is string-valued,
|
||||
// so `items: { enum: [1, 2] }` would submit `["1"]` where the schema says
|
||||
// `[1]`. Reachable from a nullable array since the collapse landed, which
|
||||
// is what makes the guard load-bearing rather than theoretical.
|
||||
const itemsEnum = fieldSchema.items?.enum;
|
||||
if (fieldSchema.type === "array" && isStringEnum(itemsEnum)) {
|
||||
const data = toEnumData(itemsEnum, fieldSchema.items?.enumNames);
|
||||
return (
|
||||
<MultiSelect
|
||||
key={fieldName}
|
||||
|
|
@ -333,11 +506,11 @@ export function SchemaForm({
|
|||
}
|
||||
|
||||
// array with items having anyOf
|
||||
if (fieldSchema.type === "array" && fieldSchema.items?.anyOf) {
|
||||
const data = fieldSchema.items.anyOf.map((item) => ({
|
||||
value: String(item.const ?? ""),
|
||||
label: item.title ?? String(item.const ?? ""),
|
||||
}));
|
||||
const itemsAnyOfData = fieldSchema.items?.anyOf
|
||||
? toConstOptions(fieldSchema.items.anyOf)
|
||||
: null;
|
||||
if (fieldSchema.type === "array" && itemsAnyOfData) {
|
||||
const data = itemsAnyOfData;
|
||||
return (
|
||||
<MultiSelect
|
||||
key={fieldName}
|
||||
|
|
@ -376,20 +549,16 @@ export function SchemaForm({
|
|||
|
||||
// fallback: JsonInput for complex schemas
|
||||
return (
|
||||
<SchemaJsonInput
|
||||
key={fieldName}
|
||||
<SchemaJsonField
|
||||
// Holds local draft state, so — like the number field — it has to be
|
||||
// remounted when `resetKey` says the form moved to another entity.
|
||||
key={resetKey === undefined ? fieldName : `${resetKey}:${fieldName}`}
|
||||
label={label}
|
||||
description={description}
|
||||
withAsterisk={isRequired}
|
||||
disabled={disabled}
|
||||
value={rawValue !== undefined ? serializeJson(rawValue) : ""}
|
||||
onChange={(val) => {
|
||||
try {
|
||||
handleFieldChange(fieldName, JSON.parse(val));
|
||||
} catch {
|
||||
handleFieldChange(fieldName, val);
|
||||
}
|
||||
}}
|
||||
value={rawValue}
|
||||
onChange={(val) => handleFieldChange(fieldName, val)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue