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
1 change: 1 addition & 0 deletions .claude/worktrees/elastic-lewin
Submodule elastic-lewin added at ad1473
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ A modern, React-based visual JSON Schema editor for creating and manipulating JS
- **Real-time JSON Preview**: See your schema in JSON format as you build it visually
- **Schema Inference**: Generate schemas automatically from existing JSON data
- **JSON Validation**: Test JSON data against your schema with detailed validation feedback
- **Combinator Types**: Full support for `anyOf`, `oneOf`, and `allOf` schema composition
- **Additional Properties**: Control whether objects allow extra properties beyond the defined ones
- **Responsive Design**: Fully responsive interface that works on desktop and mobile devices

## Getting Started
Expand Down Expand Up @@ -148,6 +150,26 @@ The built files will be available in the `dist` directory.
- **SchemaInferencer**: Dialog component for generating schemas from JSON data
- **JsonValidator**: Dialog component for validating JSON against the current schema

### Component Props

#### `JsonSchemaVisualizer`

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `schema` | `JSONSchema` | — | The JSON Schema to display and edit |
| `className` | `string` | — | Additional CSS class name |
| `onChange` | `(schema: JSONSchema) => void` | — | Called when the schema is edited |
| `autoFocus` | `boolean` | `true` | Whether the editor should be focused when mounted |

#### `SchemaInferencer`

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `open` | `boolean` | — | Whether the dialog is open |
| `onOpenChange` | `(open: boolean) => void` | — | Called when the dialog open state changes |
| `onSchemaInferred` | `(schema: JSONSchema) => void` | — | Called when a schema is generated from JSON input |
| `autoFocus` | `boolean` | `true` | Whether the editor should be focused when mounted |

### Key Features

#### Schema Inference
Expand All @@ -160,6 +182,10 @@ The `SchemaInferencer` component can automatically generate JSON Schema definiti
- Numeric types (integers vs. floats)
- Required fields

#### Combinator Schemas

The editor supports composing schemas with `anyOf`, `oneOf`, and `allOf` combinators. Each combinator option can be edited independently with its own type and constraints.

#### JSON Validation

Validate any JSON document against your schema with:
Expand Down Expand Up @@ -188,7 +214,7 @@ Validate any JSON document against your schema with:
| `npm run build:dev` | Build with development settings |
| `npm run lint` | Run linter |
| `npm run format` | Format code |
| `npm run check` | Type check the project |
| `npm run check` | Lint and format check (Biome) |
| `npm run fix` | Fix linting issues |
| `npm run typecheck` | Type check with TypeScript |
| `npm run preview` | Preview production build |
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/components/SchemaEditor/JsonSchemaEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export interface JsonSchemaEditorProps {
readOnly: boolean;
setSchema?: (schema: JSONSchema) => void;
className?: string;
/** Whether JSON editor should be focused when mounted. Defaults to `true`. */
autoFocus?: boolean;
}

/** @public */
Expand All @@ -31,6 +33,7 @@ const JsonSchemaEditor: FC<JsonSchemaEditorProps> = ({
readOnly = false,
setSchema,
className,
autoFocus = true,
}) => {
// Handle schema changes and propagate to parent if needed
const handleSchemaChange = (newSchema: JSONSchema) => {
Expand Down Expand Up @@ -137,6 +140,7 @@ const JsonSchemaEditor: FC<JsonSchemaEditorProps> = ({
<JsonSchemaVisualizer
schema={schema}
onChange={handleSchemaChange}
autoFocus={autoFocus}
/>
</TabsContent>
</Tabs>
Expand Down Expand Up @@ -185,6 +189,7 @@ const JsonSchemaEditor: FC<JsonSchemaEditorProps> = ({
<JsonSchemaVisualizer
schema={schema}
onChange={handleSchemaChange}
autoFocus={autoFocus}
/>
</div>
</div>
Expand Down
5 changes: 4 additions & 1 deletion src/components/SchemaEditor/JsonSchemaVisualizer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@ export interface JsonSchemaVisualizerProps {
schema: JSONSchema;
className?: string;
onChange?: (schema: JSONSchema) => void;
/** Whether the editor should be focused when mounted. Defaults to `true`. */
autoFocus?: boolean;
}

/** @public */
const JsonSchemaVisualizer: FC<JsonSchemaVisualizerProps> = ({
schema,
className,
onChange,
autoFocus = true,
}) => {
const editorRef = useRef<Parameters<OnMount>[0] | null>(null);
const {
Expand All @@ -36,7 +39,7 @@ const JsonSchemaVisualizer: FC<JsonSchemaVisualizerProps> = ({

const handleEditorDidMount: OnMount = (editor) => {
editorRef.current = editor;
editor.focus();
if (autoFocus) editor.focus();
};

const handleEditorChange = (value: string | undefined) => {
Expand Down
29 changes: 29 additions & 0 deletions src/components/SchemaEditor/SchemaFieldList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import type {
ObjectJSONSchema,
SchemaType,
} from "../../types/jsonSchema.ts";
import {
isAllOfSchema,
isAnyOfSchema,
isOneOfSchema,
} from "../../types/jsonSchema.ts";
import { buildValidationTree } from "../../types/validation.ts";
import SchemaPropertyEditor from "./SchemaPropertyEditor.tsx";

Expand All @@ -33,6 +38,14 @@ const SchemaFieldList: FC<SchemaFieldListProps> = ({
const getValidSchemaType = (propSchema: JSONSchemaType): SchemaType => {
if (typeof propSchema === "boolean") return "object";

// combinator schemas don't have a direct type — default to object for NewField purposes
if (
isAnyOfSchema(propSchema) ||
isOneOfSchema(propSchema) ||
isAllOfSchema(propSchema)
)
return "object";

// Handle array of types by picking the first one
const type = propSchema.type;
if (Array.isArray(type)) {
Expand Down Expand Up @@ -90,6 +103,22 @@ const SchemaFieldList: FC<SchemaFieldListProps> = ({
const property = properties.find((prop) => prop.name === name);
if (!property) return;

// combinator schemas have no direct type field
if (
isAnyOfSchema(updatedSchema) ||
isOneOfSchema(updatedSchema) ||
isAllOfSchema(updatedSchema)
) {
onEditField(name, {
name,
type: "object",
description: updatedSchema.description || "",
required: property.required,
validation: updatedSchema,
});
return;
}

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;
Expand Down
47 changes: 35 additions & 12 deletions src/components/SchemaEditor/SchemaPropertyEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ import { cn } from "../../lib/utils.ts";
import type {
JSONSchema,
ObjectJSONSchema,
SchemaType,
SchemaEditorType,
} from "../../types/jsonSchema.ts";
import {
asObjectSchema,
getEditorType,
getSchemaDescription,
withObjectSchema,
} from "../../types/jsonSchema.ts";
import type { ValidationTreeNode } from "../../types/validation.ts";
import { Badge } from "../ui/badge.tsx";
Expand Down Expand Up @@ -49,11 +49,7 @@ export const SchemaPropertyEditor: React.FC<SchemaPropertyEditorProps> = ({
const [isEditingDesc, setIsEditingDesc] = useState(false);
const [tempName, setTempName] = useState(name);
const [tempDesc, setTempDesc] = useState(getSchemaDescription(schema));
const type = withObjectSchema(
schema,
(s) => (s.type || "object") as SchemaType,
"object" as SchemaType,
);
const type = getEditorType(schema);

// Update temp values when props change
useEffect(() => {
Expand Down Expand Up @@ -174,11 +170,38 @@ export const SchemaPropertyEditor: React.FC<SchemaPropertyEditorProps> = ({
<TypeDropdown
value={type}
readOnly={readOnly}
onChange={(newType) => {
onSchemaChange({
...asObjectSchema(schema),
type: newType,
});
onChange={(newType: SchemaEditorType) => {
if (
newType === "anyOf" ||
newType === "oneOf" ||
newType === "allOf"
) {
const {
type: _type,
anyOf: _a,
oneOf: _o,
allOf: _al,
...rest
} = asObjectSchema(schema);
const initial =
newType === "allOf"
? { allOf: [{ type: "object" as const }] }
: {
[newType]: [
{ type: "string" as const },
{ type: "number" as const },
],
};
onSchemaChange({ ...rest, ...initial });
} else {
const {
anyOf: _a,
oneOf: _o,
allOf: _al,
...rest
} = asObjectSchema(schema);
onSchemaChange({ ...rest, type: newType });
}
}}
/>

Expand Down
11 changes: 7 additions & 4 deletions src/components/SchemaEditor/TypeDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,25 @@ import { Check, ChevronDown } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "../../hooks/use-translation.ts";
import { cn, getTypeColor, getTypeLabel } from "../../lib/utils.ts";
import type { SchemaType } from "../../types/jsonSchema.ts";
import type { SchemaEditorType } from "../../types/jsonSchema.ts";

export interface TypeDropdownProps {
value: SchemaType;
onChange: (value: SchemaType) => void;
value: SchemaEditorType;
onChange: (value: SchemaEditorType) => void;
className?: string;
readOnly: boolean;
}

const typeOptions: SchemaType[] = [
const typeOptions: SchemaEditorType[] = [
"string",
"number",
"boolean",
"object",
"array",
"null",
"anyOf",
"oneOf",
"allOf",
];

export const TypeDropdown: React.FC<TypeDropdownProps> = ({
Expand Down
25 changes: 14 additions & 11 deletions src/components/SchemaEditor/TypeEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import { lazy, Suspense } from "react";
import { useTranslation } from "../../hooks/use-translation.ts";
import type {
JSONSchema,
ObjectJSONSchema,
SchemaType,
} from "../../types/jsonSchema.ts";
import { withObjectSchema } from "../../types/jsonSchema.ts";
import type { JSONSchema, ObjectJSONSchema } from "../../types/jsonSchema.ts";
import { getEditorType } from "../../types/jsonSchema.ts";
import type { ValidationTreeNode } from "../../types/validation.ts";

// Lazy load specific type editors to avoid circular dependencies
Expand All @@ -14,6 +10,7 @@ const NumberEditor = lazy(() => import("./types/NumberEditor.tsx"));
const BooleanEditor = lazy(() => import("./types/BooleanEditor.tsx"));
const ObjectEditor = lazy(() => import("./types/ObjectEditor.tsx"));
const ArrayEditor = lazy(() => import("./types/ArrayEditor.tsx"));
const CombinatorEditor = lazy(() => import("./types/CombinatorEditor.tsx"));

export interface TypeEditorProps {
schema: JSONSchema;
Expand All @@ -31,11 +28,7 @@ const TypeEditor: React.FC<TypeEditorProps> = ({
readOnly = false,
}) => {
const t = useTranslation();
const type = withObjectSchema(
schema,
(s) => (s.type || "object") as SchemaType,
"string" as SchemaType,
);
const type = getEditorType(schema);

return (
<Suspense fallback={<div>{t.schemaEditorLoading}</div>}>
Expand Down Expand Up @@ -94,6 +87,16 @@ const TypeEditor: React.FC<TypeEditorProps> = ({
validationNode={validationNode}
/>
)}
{(type === "anyOf" || type === "oneOf" || type === "allOf") && (
<CombinatorEditor
readOnly={readOnly}
schema={schema}
onChange={onChange}
depth={depth}
validationNode={validationNode}
combinator={type}
/>
)}
</Suspense>
);
};
Expand Down
8 changes: 8 additions & 0 deletions src/components/SchemaEditor/types/AnyOfEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { TypeEditorProps } from "../TypeEditor.tsx";
import CombinatorEditor from "./CombinatorEditor.tsx";

const AnyOfEditor: React.FC<TypeEditorProps> = (props) => (
<CombinatorEditor {...props} combinator="anyOf" />
);

export default AnyOfEditor;
Loading