Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 1 addition & 11 deletions src/components/SchemaEditor/SchemaFieldList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,6 @@ const SchemaFieldList: FC<SchemaFieldListProps> = ({
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);
};

Expand Down Expand Up @@ -107,12 +101,8 @@ const SchemaFieldList: FC<SchemaFieldListProps> = ({
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,
});
Expand Down
10 changes: 9 additions & 1 deletion src/components/SchemaEditor/SchemaPropertyEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -205,6 +207,7 @@ const SchemaPropertyEditorFrame: React.FC<SchemaPropertyEditorFrameProps> = ({
<div className="flex items-center gap-2 justify-end shrink-0">
<TypeDropdown
value={type}
nullable={isNullableSchema(schema)}
readOnly={readOnly}
onChange={(newType: SchemaEditorType) => {
if (
Expand Down Expand Up @@ -236,7 +239,12 @@ const SchemaPropertyEditorFrame: React.FC<SchemaPropertyEditorFrameProps> = ({
allOf: _al,
...rest
} = asObjectSchema(schema);
onSchemaChange({ ...rest, type: newType });
onSchemaChange(
preserveNullableSchemaType(schema, {
...rest,
type: newType,
}),
);
}
}}
/>
Expand Down
9 changes: 7 additions & 2 deletions src/components/SchemaEditor/TypeDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface TypeDropdownProps {
onChange: (value: SchemaEditorType) => void;
className?: string;
readOnly: boolean;
nullable?: boolean;
}

const typeOptions: SchemaEditorType[] = [
Expand All @@ -28,6 +29,7 @@ export const TypeDropdown: React.FC<TypeDropdownProps> = ({
onChange,
className,
readOnly,
nullable = false,
}) => {
const t = useTranslation();
const [isOpen, setIsOpen] = useState(false);
Expand Down Expand Up @@ -58,12 +60,15 @@ export const TypeDropdown: React.FC<TypeDropdownProps> = ({
"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)}
>
<span>{getTypeLabel(t, value)}</span>
<span>
{getTypeLabel(t, value)}
{nullable ? ` | ${getTypeLabel(t, "null")}` : ""}
</span>
{!readOnly && <ChevronDown size={14} className="ml-1" />}
</button>

Expand Down
22 changes: 14 additions & 8 deletions src/components/SchemaEditor/TypeEditor.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -41,14 +44,17 @@ const TypeEditor: React.FC<TypeEditorProps> = ({
}) => {
const t = useTranslation();
const type = getEditorType(schema);
const handleChange = (updatedSchema: ObjectJsonSchema) => {
onChange(preserveNullableSchemaType(schema, updatedSchema));
};

return (
<Suspense fallback={<div>{t.schemaEditorLoading}</div>}>
{type === "string" && (
<StringEditor
readOnly={readOnly}
schema={schema}
onChange={onChange}
onChange={handleChange}
schemaKey={schemaKey}
onAddEnum={onAddEnum}
onDeleteEnum={onDeleteEnum}
Expand All @@ -60,7 +66,7 @@ const TypeEditor: React.FC<TypeEditorProps> = ({
<NumberEditor
readOnly={readOnly}
schema={schema}
onChange={onChange}
onChange={handleChange}
schemaKey={schemaKey}
onAddEnum={onAddEnum}
onDeleteEnum={onDeleteEnum}
Expand All @@ -72,7 +78,7 @@ const TypeEditor: React.FC<TypeEditorProps> = ({
<NumberEditor
readOnly={readOnly}
schema={schema}
onChange={onChange}
onChange={handleChange}
schemaKey={schemaKey}
onAddEnum={onAddEnum}
onDeleteEnum={onDeleteEnum}
Expand All @@ -85,7 +91,7 @@ const TypeEditor: React.FC<TypeEditorProps> = ({
<BooleanEditor
readOnly={readOnly}
schema={schema}
onChange={onChange}
onChange={handleChange}
schemaKey={schemaKey}
onAddEnum={onAddEnum}
onDeleteEnum={onDeleteEnum}
Expand All @@ -97,7 +103,7 @@ const TypeEditor: React.FC<TypeEditorProps> = ({
<ObjectEditor
readOnly={readOnly}
schema={schema}
onChange={onChange}
onChange={handleChange}
schemaKey={schemaKey}
onAddEnum={onAddEnum}
onDeleteEnum={onDeleteEnum}
Expand All @@ -109,7 +115,7 @@ const TypeEditor: React.FC<TypeEditorProps> = ({
<ArrayEditor
readOnly={readOnly}
schema={schema}
onChange={onChange}
onChange={handleChange}
schemaKey={schemaKey}
onAddEnum={onAddEnum}
onDeleteEnum={onDeleteEnum}
Expand All @@ -121,7 +127,7 @@ const TypeEditor: React.FC<TypeEditorProps> = ({
<CombinatorEditor
readOnly={readOnly}
schema={schema}
onChange={onChange}
onChange={handleChange}
schemaKey={schemaKey}
onAddEnum={onAddEnum}
onDeleteEnum={onDeleteEnum}
Expand Down
18 changes: 11 additions & 7 deletions src/components/SchemaEditor/types/ArrayEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import { useComponent } from "../../../registry/SchemaBuilderRegistryContext.tsx
import type {
ObjectJsonSchema,
SchemaEditorType,
SchemaType,
} from "../../../types/jsonSchema.ts";
import {
asObjectSchema,
getEditorType,
isBooleanSchema,
isNullableSchema,
preserveNullableSchemaType,
withObjectSchema,
} from "../../../types/jsonSchema.ts";
import TypeDropdown from "../TypeDropdown.tsx";
Expand Down Expand Up @@ -50,11 +52,7 @@ const ArrayEditor: React.FC<TypeEditorProps> = ({
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 = () => {
Expand Down Expand Up @@ -225,6 +223,7 @@ const ArrayEditor: React.FC<TypeEditorProps> = ({
<TypeDropdown
readOnly={readOnly}
value={itemType}
nullable={isNullableSchema(itemsSchema)}
onChange={(newType: SchemaEditorType) => {
if (
newType === "anyOf" ||
Expand Down Expand Up @@ -255,7 +254,12 @@ const ArrayEditor: React.FC<TypeEditorProps> = ({
allOf: _al,
...rest
} = asObjectSchema(itemsSchema);
handleItemSchemaChange({ ...rest, type: newType });
handleItemSchemaChange(
preserveNullableSchemaType(itemsSchema, {
...rest,
type: newType,
}),
);
}
}}
/>
Expand Down
5 changes: 4 additions & 1 deletion src/components/SchemaEditor/types/CombinatorEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -158,7 +160,7 @@ const CombinatorEditor: React.FC<CombinatorEditorProps> = ({
if (prevDesc !== "") {
next = { ...next, description: prevDesc };
}
newOptions[index] = next;
newOptions[index] = preserveNullableSchemaType(options[index], next);
updateOptions(newOptions);
};

Expand Down Expand Up @@ -296,6 +298,7 @@ const CombinatorEditor: React.FC<CombinatorEditorProps> = ({
<div className="flex shrink-0 items-center gap-2 sm:ml-auto">
<TypeDropdown
value={optionType}
nullable={isNullableSchema(option)}
readOnly={readOnly}
onChange={(newType) =>
handleOptionTypeChange(index, newType)
Expand Down
22 changes: 16 additions & 6 deletions src/lib/schemaEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
49 changes: 49 additions & 0 deletions src/types/jsonSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SchemaType, "null"> | 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<SchemaType, "null"> => 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,
Expand Down
8 changes: 6 additions & 2 deletions src/types/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down
Loading