diff --git a/src/components/SchemaEditor/SchemaFieldList.tsx b/src/components/SchemaEditor/SchemaFieldList.tsx index 19ef8c5..a0803ac 100644 --- a/src/components/SchemaEditor/SchemaFieldList.tsx +++ b/src/components/SchemaEditor/SchemaFieldList.tsx @@ -49,12 +49,6 @@ const SchemaFieldList: FC = ({ const getValidSchemaType = (propSchema: JsonSchemaType): SchemaEditorType => { if (typeof propSchema === "boolean") return "object"; - // Handle array of types by picking the first one - const type = propSchema.type; - if (Array.isArray(type)) { - return type[0] || "object"; - } - return getEditorType(propSchema); }; @@ -107,12 +101,8 @@ const SchemaFieldList: FC = ({ property: Property, updatedSchema: ObjectJsonSchema, ): NewField => { - const type = updatedSchema.type || "object"; - // Ensure we're using a single type, not an array of types - const validType = Array.isArray(type) ? type[0] || "object" : type; - return createUpdatedField(property, { - type: getEditorType(updatedSchema) || validType, + type: getEditorType(updatedSchema), description: updatedSchema.description || "", validation: updatedSchema, }); diff --git a/src/components/SchemaEditor/SchemaPropertyEditor.tsx b/src/components/SchemaEditor/SchemaPropertyEditor.tsx index 53f511c..87470e4 100644 --- a/src/components/SchemaEditor/SchemaPropertyEditor.tsx +++ b/src/components/SchemaEditor/SchemaPropertyEditor.tsx @@ -15,6 +15,8 @@ import { asObjectSchema, getEditorType, getSchemaDescription, + isNullableSchema, + preserveNullableSchemaType, } from "../../types/jsonSchema.ts"; import type { ValidationTreeNode } from "../../types/validation.ts"; import TypeDropdown from "./TypeDropdown.tsx"; @@ -205,6 +207,7 @@ const SchemaPropertyEditorFrame: React.FC = ({
{ if ( @@ -236,7 +239,12 @@ const SchemaPropertyEditorFrame: React.FC = ({ allOf: _al, ...rest } = asObjectSchema(schema); - onSchemaChange({ ...rest, type: newType }); + onSchemaChange( + preserveNullableSchemaType(schema, { + ...rest, + type: newType, + }), + ); } }} /> diff --git a/src/components/SchemaEditor/TypeDropdown.tsx b/src/components/SchemaEditor/TypeDropdown.tsx index eebb272..6dfc6b2 100644 --- a/src/components/SchemaEditor/TypeDropdown.tsx +++ b/src/components/SchemaEditor/TypeDropdown.tsx @@ -9,6 +9,7 @@ export interface TypeDropdownProps { onChange: (value: SchemaEditorType) => void; className?: string; readOnly: boolean; + nullable?: boolean; } const typeOptions: SchemaEditorType[] = [ @@ -28,6 +29,7 @@ export const TypeDropdown: React.FC = ({ onChange, className, readOnly, + nullable = false, }) => { const t = useTranslation(); const [isOpen, setIsOpen] = useState(false); @@ -58,12 +60,15 @@ export const TypeDropdown: React.FC = ({ "text-xs px-3.5 py-1.5 rounded-md font-medium text-center flex items-center justify-between", getTypeColor(value), "hover:shadow-xs hover:ring-1 hover:ring-ring/30 active:scale-95 transition-all", - readOnly ? "" : "w-[92px]", + readOnly ? "" : nullable ? "w-[124px]" : "w-[92px]", className, )} onClick={() => !readOnly && setIsOpen(!isOpen)} > - {getTypeLabel(t, value)} + + {getTypeLabel(t, value)} + {nullable ? ` | ${getTypeLabel(t, "null")}` : ""} + {!readOnly && } diff --git a/src/components/SchemaEditor/TypeEditor.tsx b/src/components/SchemaEditor/TypeEditor.tsx index 8ae99a5..96a364d 100644 --- a/src/components/SchemaEditor/TypeEditor.tsx +++ b/src/components/SchemaEditor/TypeEditor.tsx @@ -1,7 +1,10 @@ import { lazy, Suspense } from "react"; import { useTranslation } from "../../hooks/use-translation.ts"; import type { JsonSchema, ObjectJsonSchema } from "../../types/jsonSchema.ts"; -import { getEditorType } from "../../types/jsonSchema.ts"; +import { + getEditorType, + preserveNullableSchemaType, +} from "../../types/jsonSchema.ts"; import type { ValidationTreeNode } from "../../types/validation.ts"; // Lazy load specific type editors to avoid circular dependencies @@ -41,6 +44,9 @@ const TypeEditor: React.FC = ({ }) => { const t = useTranslation(); const type = getEditorType(schema); + const handleChange = (updatedSchema: ObjectJsonSchema) => { + onChange(preserveNullableSchemaType(schema, updatedSchema)); + }; return ( {t.schemaEditorLoading}
}> @@ -48,7 +54,7 @@ const TypeEditor: React.FC = ({ = ({ = ({ = ({ = ({ = ({ = ({ = ({ const itemSchemaKey = schemaKey ? `${schemaKey}[]` : undefined; // Get the type of the array items - const itemType = withObjectSchema( - itemsSchema, - (s) => (s.type || "string") as SchemaType, - "string" as SchemaType, - ); + const itemType = getEditorType(itemsSchema); // Handle validation settings change const handleValidationChange = () => { @@ -225,6 +223,7 @@ const ArrayEditor: React.FC = ({ { if ( newType === "anyOf" || @@ -255,7 +254,12 @@ const ArrayEditor: React.FC = ({ allOf: _al, ...rest } = asObjectSchema(itemsSchema); - handleItemSchemaChange({ ...rest, type: newType }); + handleItemSchemaChange( + preserveNullableSchemaType(itemsSchema, { + ...rest, + type: newType, + }), + ); } }} /> diff --git a/src/components/SchemaEditor/types/CombinatorEditor.tsx b/src/components/SchemaEditor/types/CombinatorEditor.tsx index 8af0e76..059958c 100644 --- a/src/components/SchemaEditor/types/CombinatorEditor.tsx +++ b/src/components/SchemaEditor/types/CombinatorEditor.tsx @@ -14,6 +14,8 @@ import { getEditorType, getSchemaDescription, isBooleanSchema, + isNullableSchema, + preserveNullableSchemaType, } from "../../../types/jsonSchema.ts"; import TypeDropdown from "../TypeDropdown.tsx"; import type { TypeEditorProps } from "../TypeEditor.tsx"; @@ -158,7 +160,7 @@ const CombinatorEditor: React.FC = ({ if (prevDesc !== "") { next = { ...next, description: prevDesc }; } - newOptions[index] = next; + newOptions[index] = preserveNullableSchemaType(options[index], next); updateOptions(newOptions); }; @@ -296,6 +298,7 @@ const CombinatorEditor: React.FC = ({
handleOptionTypeChange(index, newType) diff --git a/src/lib/schemaEditor.ts b/src/lib/schemaEditor.ts index f2974e2..9be2bda 100644 --- a/src/lib/schemaEditor.ts +++ b/src/lib/schemaEditor.ts @@ -3,7 +3,11 @@ import type { NewField, ObjectJsonSchema, } from "../types/jsonSchema.ts"; -import { isBooleanSchema, isObjectSchema } from "../types/jsonSchema.ts"; +import { + getEditorType, + isBooleanSchema, + isObjectSchema, +} from "../types/jsonSchema.ts"; export type Property = { name: string; @@ -143,7 +147,7 @@ export function updateArrayItems( schema: JsonSchema, itemsSchema: JsonSchema, ): JsonSchema { - if (isObjectSchema(schema) && schema.type === "array") { + if (isObjectSchema(schema) && getEditorType(schema) === "array") { return { ...schema, items: itemsSchema, @@ -236,7 +240,7 @@ function propertyFromEntry( */ export function getArrayItemsSchema(schema: JsonSchema): JsonSchema | null { if (isBooleanSchema(schema)) return null; - if (schema.type !== "array") return null; + if (getEditorType(schema) !== "array") return null; return schema.items || null; } @@ -304,12 +308,18 @@ function renameObjectSchemaEntry( export function hasChildren(schema: JsonSchema): boolean { if (!isObjectSchema(schema)) return false; - if (schema.type === "object" && schema.properties) { + if (getEditorType(schema) === "object" && schema.properties) { return Object.keys(schema.properties).length > 0; } - if (schema.type === "array" && schema.items && isObjectSchema(schema.items)) { - return schema.items.type === "object" && !!schema.items.properties; + if ( + getEditorType(schema) === "array" && + schema.items && + isObjectSchema(schema.items) + ) { + return ( + getEditorType(schema.items) === "object" && !!schema.items.properties + ); } return false; diff --git a/src/types/jsonSchema.ts b/src/types/jsonSchema.ts index c498a5c..1917309 100644 --- a/src/types/jsonSchema.ts +++ b/src/types/jsonSchema.ts @@ -192,10 +192,59 @@ export function isAllOfSchema(schema: JsonSchema): boolean { return isObjectSchema(schema) && Array.isArray(schema.allOf); } +/** + * Returns the editable type for the common nullable union form `T | null`. + * Other type arrays are left unsupported by the visual editor so they are not + * accidentally simplified to a single type. + */ +export function getNullableSchemaType( + schema: JsonSchema, +): Exclude | undefined { + if (!isObjectSchema(schema) || !Array.isArray(schema.type)) return undefined; + if (schema.type.length !== 2 || !schema.type.includes("null")) { + return undefined; + } + + return schema.type.find( + (type): type is Exclude => type !== "null", + ); +} + +export function isNullableSchema(schema: JsonSchema): boolean { + return getNullableSchemaType(schema) !== undefined; +} + +/** + * Re-applies an existing `T | null` union after a type-specific editor emits a + * scalar type. The original array order is preserved so JSON source edits can + * round-trip without unrelated formatting changes. + */ +export function preserveNullableSchemaType( + source: JsonSchema, + updated: ObjectJsonSchema, +): ObjectJsonSchema { + const nullableType = getNullableSchemaType(source); + if (!nullableType || !isObjectSchema(source)) return updated; + const sourceTypes = source.type; + if (!Array.isArray(sourceTypes)) return updated; + if (typeof updated.type !== "string" || updated.type === "null") { + return updated; + } + + return { + ...updated, + type: sourceTypes.map((type) => + type === "null" ? "null" : (updated.type as SchemaType), + ), + }; +} + export function getEditorType(schema: JsonSchema): SchemaEditorType { if (isAnyOfSchema(schema)) return "anyOf"; if (isOneOfSchema(schema)) return "oneOf"; if (isAllOfSchema(schema)) return "allOf"; + const nullableType = getNullableSchemaType(schema); + if (nullableType) return nullableType; return withObjectSchema( schema, (s) => (s.type || "object") as SchemaType, diff --git a/src/types/validation.ts b/src/types/validation.ts index a4337f5..37182c9 100644 --- a/src/types/validation.ts +++ b/src/types/validation.ts @@ -277,9 +277,13 @@ export function buildValidationTree( if ( Array.isArray(declared) && declared.length > 0 && - typeof declared[0] === "string" - ) + declared.every((type) => typeof type === "string") + ) { + if (declared.length === 2 && declared.includes("null")) { + return declared.find((type) => type !== "null"); + } return declared[0]; + } return undefined; }; diff --git a/test/components/SchemaEditor/SchemaFieldsEditor.test.tsx b/test/components/SchemaEditor/SchemaFieldsEditor.test.tsx index 9fc598b..28e47a3 100644 --- a/test/components/SchemaEditor/SchemaFieldsEditor.test.tsx +++ b/test/components/SchemaEditor/SchemaFieldsEditor.test.tsx @@ -1,4 +1,4 @@ -import { render } from "@testing-library/react"; +import { fireEvent, render, within } from "@testing-library/react"; import "global-jsdom/register"; import { describe, test } from "node:test"; import React from "react"; @@ -35,4 +35,29 @@ describe("SchemaFieldsEditor", () => { }); t.assert.snapshot(render(element).container.innerHTML); }); + + test("nullable objects render their base type and nested properties", async () => { + const element = React.createElement(SchemaFieldsEditor, { + readOnly: false, + onChange: () => {}, + value: { + type: "object", + properties: { + profile: { + type: ["object", "null"], + properties: { + nickname: { type: ["null", "string"] }, + }, + }, + }, + }, + }); + const view = render(element); + const editor = within(view.container); + + editor.getByText("Object | Empty"); + fireEvent.click(editor.getByRole("button", { name: "Expand" })); + await editor.findByText("nickname"); + editor.getByText("Text | Empty"); + }); }); diff --git a/test/jsonSchema.test.js b/test/jsonSchema.test.js index 075bd6e..7152b09 100644 --- a/test/jsonSchema.test.js +++ b/test/jsonSchema.test.js @@ -2,9 +2,13 @@ import assert from "node:assert"; import { describe, test } from "node:test"; import metaschema from "../metaschema.schema.json" with { type: "json" }; import { + getEditorType, + getNullableSchemaType, isBooleanSchema, + isNullableSchema, isObjectSchema, jsonSchemaType, + preserveNullableSchemaType, } from "../src/types/jsonSchema.ts"; describe("JSON Schema", () => { @@ -26,4 +30,43 @@ describe("JSON Schema", () => { assert.strictEqual(isObjectSchema(booleanSchema), false); assert.strictEqual(isBooleanSchema(booleanSchema), true); }); + + test("nullable type arrays use their non-null type in the visual editor", () => { + const nullableObject = { type: ["object", "null"] }; + const nullFirstString = { type: ["null", "string"] }; + + assert.strictEqual(getEditorType(nullableObject), "object"); + assert.strictEqual(getEditorType(nullFirstString), "string"); + assert.strictEqual(getNullableSchemaType(nullableObject), "object"); + assert.strictEqual(isNullableSchema(nullFirstString), true); + }); + + test("nullable type arrays preserve null and source order after edits", () => { + assert.deepStrictEqual( + preserveNullableSchemaType( + { type: ["null", "string"] }, + { type: "string", minLength: 2 }, + ), + { type: ["null", "string"], minLength: 2 }, + ); + + assert.deepStrictEqual( + preserveNullableSchemaType( + { type: ["object", "null"] }, + { type: "array", items: { type: "string" } }, + ), + { + type: ["array", "null"], + items: { type: "string" }, + }, + ); + }); + + test("multi-type unions are not treated as nullable", () => { + assert.strictEqual( + isNullableSchema({ type: ["string", "number", "null"] }), + false, + ); + assert.strictEqual(isNullableSchema({ type: ["string", "number"] }), false); + }); }); diff --git a/test/lib/schemaEditor.test.ts b/test/lib/schemaEditor.test.ts index f816c6e..4f9a71f 100644 --- a/test/lib/schemaEditor.test.ts +++ b/test/lib/schemaEditor.test.ts @@ -1,6 +1,11 @@ import assert from "node:assert"; import { describe, test } from "node:test"; -import { renameObjectProperty } from "../../src/lib/schemaEditor.ts"; +import { + getArrayItemsSchema, + hasChildren, + renameObjectProperty, + updateArrayItems, +} from "../../src/lib/schemaEditor.ts"; describe("renameObjectProperty", () => { test("preserves property order when renaming", () => { @@ -21,3 +26,38 @@ describe("renameObjectProperty", () => { assert.deepStrictEqual(result.required, ["firstName", "surname", "email"]); }); }); + +describe("nullable container helpers", () => { + test("reads and updates items on nullable arrays", () => { + const schema = { + type: ["null", "array"] as const, + items: { type: "string" as const }, + }; + + assert.deepStrictEqual(getArrayItemsSchema(schema), { type: "string" }); + assert.deepStrictEqual(updateArrayItems(schema, { type: "number" }), { + type: ["null", "array"], + items: { type: "number" }, + }); + }); + + test("detects children on nullable objects and array items", () => { + assert.strictEqual( + hasChildren({ + type: ["object", "null"], + properties: { name: { type: "string" } }, + }), + true, + ); + assert.strictEqual( + hasChildren({ + type: ["array", "null"], + items: { + type: ["null", "object"], + properties: { name: { type: "string" } }, + }, + }), + true, + ); + }); +});