fix: honor sibling constraints on nullability, guard numeric item enums
Copilot round 6: no visible comments, three suppressed. All valid.
1. JSON Schema keywords at one level are conjunctive, so a syntactic null does
not by itself mean the schema accepts null. `{ type: ["string", "null"],
enum: ["envio"] }` names "null" and still rejects it — yet the collapse
marked it `nullable: true`, giving the field a clear button that emits a
value the schema forbids, and `admitsNull` let required-field gating accept
it. Both now consult `nullExcludedBySiblings`.
The distinction that makes this safe: a *branch's* `enum` is not a sibling.
In `anyOf: [{ type: "string", enum: [...] }, { type: "null" }]` — the #1928
shape — the enum constrains that branch alone and the union still permits
null, so `nullable` is read off the ORIGINAL schema rather than the hoisted
merge. Reading the merge would have called the headline case non-nullable.
Flattening is unaffected either way; only the nullability claim is withheld.
2. The collapse newly routed a nullable array of numeric item enums into the
string-only `MultiSelect` path, which would submit `["1"]` for `[1]`. Gated
on `isStringEnum`, matching `toConstOptions`; `isStringEnum` is now a type
predicate so the narrowing is real rather than asserted.
3. Invalid JSON and an empty field both report `undefined`, so an optional
complex argument could be dropped from the submission while invalid text sat
visible in the box. The field now says so via `error`. Note a *required*
field was already gated — its value is `undefined`, which
`hasMissingRequiredFields` treats as missing — so this is specifically the
optional case. Disabling Execute/Open/Submit on an invalid optional draft
needs a validity channel out of `SchemaForm` into all four call sites, which
is wider than this fix; filed separately.
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
32d6f80bc1
commit
86ba42633c
2 changed files with 214 additions and 18 deletions
|
|
@ -744,6 +744,45 @@ describe("SchemaForm", () => {
|
|||
// 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();
|
||||
|
|
@ -1183,6 +1222,52 @@ describe("SchemaForm nullable unions", () => {
|
|||
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",
|
||||
|
|
|
|||
|
|
@ -15,7 +15,10 @@ import type {
|
|||
InspectorFormSchema,
|
||||
JsonSchemaConst,
|
||||
} from "../../../utils/jsonUtils";
|
||||
import { normalizeNullableUnion } from "@inspector/core/json/nullableUnion.js";
|
||||
import {
|
||||
isStringEnum,
|
||||
normalizeNullableUnion,
|
||||
} from "@inspector/core/json/nullableUnion.js";
|
||||
|
||||
const FieldLabel = Text.withProps({
|
||||
fw: 500,
|
||||
|
|
@ -196,6 +199,15 @@ interface SchemaJsonFieldProps {
|
|||
* `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,
|
||||
|
|
@ -216,10 +228,23 @@ function SchemaJsonField({
|
|||
}
|
||||
});
|
||||
|
||||
// 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));
|
||||
|
|
@ -458,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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue