diff --git a/.claude/worktrees/elastic-lewin b/.claude/worktrees/elastic-lewin new file mode 160000 index 0000000..ad1473c --- /dev/null +++ b/.claude/worktrees/elastic-lewin @@ -0,0 +1 @@ +Subproject commit ad1473c4c2359b083d95bf28d4251b66d5644f2e diff --git a/README.md b/README.md index 7ccecf7..91dc902 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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: @@ -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 | diff --git a/package-lock.json b/package-lock.json index 4cfac47..158dfe2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "jsonjoy-builder", - "version": "0.3.1", + "version": "0.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jsonjoy-builder", - "version": "0.3.1", + "version": "0.3.2", "license": "MIT", "dependencies": { "@monaco-editor/react": "^4.7.0", diff --git a/src/components/SchemaEditor/JsonSchemaEditor.tsx b/src/components/SchemaEditor/JsonSchemaEditor.tsx index 46b868e..fd5600a 100644 --- a/src/components/SchemaEditor/JsonSchemaEditor.tsx +++ b/src/components/SchemaEditor/JsonSchemaEditor.tsx @@ -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 */ @@ -31,6 +33,7 @@ const JsonSchemaEditor: FC = ({ readOnly = false, setSchema, className, + autoFocus = true, }) => { // Handle schema changes and propagate to parent if needed const handleSchemaChange = (newSchema: JSONSchema) => { @@ -137,6 +140,7 @@ const JsonSchemaEditor: FC = ({ @@ -185,6 +189,7 @@ const JsonSchemaEditor: FC = ({ diff --git a/src/components/SchemaEditor/JsonSchemaVisualizer.tsx b/src/components/SchemaEditor/JsonSchemaVisualizer.tsx index 7d93829..d63907e 100644 --- a/src/components/SchemaEditor/JsonSchemaVisualizer.tsx +++ b/src/components/SchemaEditor/JsonSchemaVisualizer.tsx @@ -11,6 +11,8 @@ 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 */ @@ -18,6 +20,7 @@ const JsonSchemaVisualizer: FC = ({ schema, className, onChange, + autoFocus = true, }) => { const editorRef = useRef[0] | null>(null); const { @@ -36,7 +39,7 @@ const JsonSchemaVisualizer: FC = ({ const handleEditorDidMount: OnMount = (editor) => { editorRef.current = editor; - editor.focus(); + if (autoFocus) editor.focus(); }; const handleEditorChange = (value: string | undefined) => { diff --git a/src/components/SchemaEditor/SchemaFieldList.tsx b/src/components/SchemaEditor/SchemaFieldList.tsx index 518d2d2..f597fdb 100644 --- a/src/components/SchemaEditor/SchemaFieldList.tsx +++ b/src/components/SchemaEditor/SchemaFieldList.tsx @@ -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"; @@ -33,6 +38,14 @@ const SchemaFieldList: FC = ({ 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)) { @@ -90,6 +103,22 @@ const SchemaFieldList: FC = ({ 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; diff --git a/src/components/SchemaEditor/SchemaPropertyEditor.tsx b/src/components/SchemaEditor/SchemaPropertyEditor.tsx index 1e6cf75..9506880 100644 --- a/src/components/SchemaEditor/SchemaPropertyEditor.tsx +++ b/src/components/SchemaEditor/SchemaPropertyEditor.tsx @@ -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"; @@ -49,11 +49,7 @@ export const SchemaPropertyEditor: React.FC = ({ 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(() => { @@ -174,11 +170,38 @@ export const SchemaPropertyEditor: React.FC = ({ { - 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 }); + } }} /> diff --git a/src/components/SchemaEditor/TypeDropdown.tsx b/src/components/SchemaEditor/TypeDropdown.tsx index fb260fe..eebb272 100644 --- a/src/components/SchemaEditor/TypeDropdown.tsx +++ b/src/components/SchemaEditor/TypeDropdown.tsx @@ -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 = ({ diff --git a/src/components/SchemaEditor/TypeEditor.tsx b/src/components/SchemaEditor/TypeEditor.tsx index 9817f0a..49337e3 100644 --- a/src/components/SchemaEditor/TypeEditor.tsx +++ b/src/components/SchemaEditor/TypeEditor.tsx @@ -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 @@ -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; @@ -31,11 +28,7 @@ const TypeEditor: React.FC = ({ readOnly = false, }) => { const t = useTranslation(); - const type = withObjectSchema( - schema, - (s) => (s.type || "object") as SchemaType, - "string" as SchemaType, - ); + const type = getEditorType(schema); return ( {t.schemaEditorLoading}}> @@ -94,6 +87,16 @@ const TypeEditor: React.FC = ({ validationNode={validationNode} /> )} + {(type === "anyOf" || type === "oneOf" || type === "allOf") && ( + + )} ); }; diff --git a/src/components/SchemaEditor/types/AnyOfEditor.tsx b/src/components/SchemaEditor/types/AnyOfEditor.tsx new file mode 100644 index 0000000..5055322 --- /dev/null +++ b/src/components/SchemaEditor/types/AnyOfEditor.tsx @@ -0,0 +1,8 @@ +import type { TypeEditorProps } from "../TypeEditor.tsx"; +import CombinatorEditor from "./CombinatorEditor.tsx"; + +const AnyOfEditor: React.FC = (props) => ( + +); + +export default AnyOfEditor; diff --git a/src/components/SchemaEditor/types/ArrayEditor.tsx b/src/components/SchemaEditor/types/ArrayEditor.tsx index 15953e6..79ca7fa 100644 --- a/src/components/SchemaEditor/types/ArrayEditor.tsx +++ b/src/components/SchemaEditor/types/ArrayEditor.tsx @@ -7,9 +7,11 @@ import { getArrayItemsSchema } from "../../../lib/schemaEditor.ts"; import { cn } from "../../../lib/utils.ts"; import type { ObjectJSONSchema, + SchemaEditorType, SchemaType, } from "../../../types/jsonSchema.ts"; import { + asObjectSchema, isBooleanSchema, withObjectSchema, } from "../../../types/jsonSchema.ts"; @@ -232,11 +234,38 @@ const ArrayEditor: React.FC = ({ { - handleItemSchemaChange({ - ...withObjectSchema(itemsSchema, (s) => s, {}), - type: newType, - }); + onChange={(newType: SchemaEditorType) => { + if ( + newType === "anyOf" || + newType === "oneOf" || + newType === "allOf" + ) { + const { + type: _type, + anyOf: _a, + oneOf: _o, + allOf: _al, + ...rest + } = asObjectSchema(itemsSchema); + const initial = + newType === "allOf" + ? { allOf: [{ type: "object" as const }] } + : { + [newType]: [ + { type: "string" as const }, + { type: "number" as const }, + ], + }; + handleItemSchemaChange({ ...rest, ...initial }); + } else { + const { + anyOf: _a, + oneOf: _o, + allOf: _al, + ...rest + } = asObjectSchema(itemsSchema); + handleItemSchemaChange({ ...rest, type: newType }); + } }} /> diff --git a/src/components/SchemaEditor/types/CombinatorEditor.tsx b/src/components/SchemaEditor/types/CombinatorEditor.tsx new file mode 100644 index 0000000..7a903d6 --- /dev/null +++ b/src/components/SchemaEditor/types/CombinatorEditor.tsx @@ -0,0 +1,246 @@ +import { CirclePlus, X } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "../../../hooks/use-translation.ts"; +import type { Translation } from "../../../i18n/translation-keys.ts"; +import { cn } from "../../../lib/utils.ts"; +import type { + JSONSchema, + ObjectJSONSchema, + SchemaEditorType, + SchemaType, +} from "../../../types/jsonSchema.ts"; +import { getEditorType, isBooleanSchema } from "../../../types/jsonSchema.ts"; +import TypeDropdown from "../TypeDropdown.tsx"; +import type { TypeEditorProps } from "../TypeEditor.tsx"; +import TypeEditor from "../TypeEditor.tsx"; + +export type Combinator = "anyOf" | "oneOf" | "allOf"; + +interface CombinatorStrings { + description: string; + addButton: string; + removeButton: string; + itemLabel: string; + noItems: string; +} + +function getCombinatorStrings( + t: Translation, + combinator: Combinator, +): CombinatorStrings { + switch (combinator) { + case "anyOf": + return { + description: t.anyOfDescription, + addButton: t.anyOfAddOption, + removeButton: t.anyOfRemoveOption, + itemLabel: t.anyOfOptionLabel, + noItems: t.anyOfNoOptions, + }; + case "oneOf": + return { + description: t.oneOfDescription, + addButton: t.oneOfAddOption, + removeButton: t.oneOfRemoveOption, + itemLabel: t.oneOfOptionLabel, + noItems: t.oneOfNoOptions, + }; + case "allOf": + return { + description: t.allOfDescription, + addButton: t.allOfAddSchema, + removeButton: t.allOfRemoveSchema, + itemLabel: t.allOfSchemaLabel, + noItems: t.allOfNoSchemas, + }; + } +} + +const DEFAULT_SCHEMAS: Record = { + string: { type: "string" }, + number: { type: "number" }, + integer: { type: "integer" }, + boolean: { type: "boolean" }, + object: { type: "object" }, + array: { type: "array" }, + null: { type: "null" }, + anyOf: { anyOf: [{ type: "string" }, { type: "number" }] }, + oneOf: { oneOf: [{ type: "string" }, { type: "number" }] }, + allOf: { allOf: [{ type: "object" }] }, +}; + +let idCounter = 0; +const nextId = () => `combinator-${++idCounter}`; + +export interface CombinatorEditorProps extends TypeEditorProps { + combinator: Combinator; +} + +const CombinatorEditor: React.FC = ({ + schema, + readOnly = false, + validationNode, + onChange, + depth = 0, + combinator, +}) => { + const t = useTranslation(); + const strings = getCombinatorStrings(t, combinator); + + const rawOptions: JSONSchema[] = isBooleanSchema(schema) + ? [] + : (schema[combinator] ?? []); + + // Stable IDs for each option to use as React keys + const [ids, setIds] = useState(() => + rawOptions.map(() => nextId()), + ); + + // Keep ids in sync with rawOptions length (e.g. when schema is replaced externally) + const options = useMemo(() => { + if (rawOptions.length !== ids.length) { + setIds(rawOptions.map((_o, i) => ids[i] ?? nextId())); + } + return rawOptions; + }, [rawOptions, ids]); + + const [expandedId, setExpandedId] = useState(null); + + const updateOptions = useCallback( + (newOptions: JSONSchema[]) => { + const base = isBooleanSchema(schema) ? {} : schema; + const { + [combinator]: _old, + type: _type, + ...rest + } = base as ObjectJSONSchema & + Record; + onChange({ ...rest, [combinator]: newOptions }); + }, + [schema, onChange, combinator], + ); + + const handleAddOption = () => { + const newId = nextId(); + setIds((prev) => [...prev, newId]); + updateOptions([...options, { type: "string" }]); + setExpandedId(newId); + }; + + const handleRemoveOption = (index: number) => { + const newOptions = options.filter((_, i) => i !== index); + setIds((prev) => prev.filter((_, i) => i !== index)); + updateOptions(newOptions); + if (expandedId === ids[index]) setExpandedId(null); + }; + + const handleOptionTypeChange = (index: number, newType: SchemaEditorType) => { + const newOptions = [...options]; + newOptions[index] = DEFAULT_SCHEMAS[newType as SchemaType] ?? + DEFAULT_SCHEMAS[newType as Combinator] ?? { type: "string" }; + updateOptions(newOptions); + }; + + const handleOptionSchemaChange = ( + index: number, + updatedSchema: ObjectJSONSchema, + ) => { + const newOptions = [...options]; + newOptions[index] = updatedSchema; + updateOptions(newOptions); + }; + + return ( +
+

+ {strings.description} +

+ + {options.length === 0 ? ( +
+ {strings.noItems} +
+ ) : ( +
+ {options.map((option, index) => { + const id = ids[index]; + const optionType = getEditorType(option); + const isExpanded = expandedId === id; + + return ( +
0 && "ml-0 sm:ml-4 border-l border-l-border/40", + )} + > +
+ + +
+ + handleOptionTypeChange(index, newType) + } + /> + + {!readOnly && ( + + )} +
+
+ + {isExpanded && ( +
+ + handleOptionSchemaChange(index, updatedSchema) + } + depth={depth + 1} + /> +
+ )} +
+ ); + })} +
+ )} + + {!readOnly && ( + + )} +
+ ); +}; + +export default CombinatorEditor; diff --git a/src/components/features/SchemaInferencer.tsx b/src/components/features/SchemaInferencer.tsx index dc11bc8..d226aaa 100644 --- a/src/components/features/SchemaInferencer.tsx +++ b/src/components/features/SchemaInferencer.tsx @@ -20,6 +20,8 @@ export interface SchemaInferencerProps { open: boolean; onOpenChange: (open: boolean) => void; onSchemaInferred: (schema: JSONSchema) => void; + /** Whether the editor should be focused when mounted. Defaults to `true`. */ + autoFocus?: boolean; } /** @public */ @@ -27,6 +29,7 @@ export function SchemaInferencer({ open, onOpenChange, onSchemaInferred, + autoFocus = true, }: SchemaInferencerProps) { const t = useTranslation(); const [jsonInput, setJsonInput] = useState(""); @@ -46,7 +49,7 @@ export function SchemaInferencer({ const handleEditorDidMount: OnMount = (editor) => { editorRef.current = editor; - editor.focus(); + if (autoFocus) editor.focus(); }; const handleEditorChange = (value: string | undefined) => { @@ -77,7 +80,12 @@ export function SchemaInferencer({ return ( - + { + if (!autoFocus) event.preventDefault(); + }} + > {t.inferrerTitle} {t.inferrerDescription} diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 9675295..5822a01 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -127,6 +127,28 @@ export const de: Translation = { stringValidationErrorLengthRange: "'Minimale Länge' darf nicht größer als 'Maximale Länge' sein.", + schemaTypeAnyOf: "Eines von", + anyOfAddOption: "Option hinzufügen", + anyOfRemoveOption: "Option entfernen", + anyOfOptionLabel: "Option", + anyOfDescription: + "Der Wert muss mindestens einem dieser Schemata entsprechen", + anyOfNoOptions: "Keine Optionen definiert", + + schemaTypeOneOf: "Genau eines von", + oneOfAddOption: "Option hinzufügen", + oneOfRemoveOption: "Option entfernen", + oneOfOptionLabel: "Option", + oneOfDescription: "Der Wert muss genau einem dieser Schemata entsprechen", + oneOfNoOptions: "Keine Optionen definiert", + + schemaTypeAllOf: "Alle von", + allOfAddSchema: "Schema hinzufügen", + allOfRemoveSchema: "Schema entfernen", + allOfSchemaLabel: "Schema", + allOfDescription: "Der Wert muss allen diesen Schemata entsprechen", + allOfNoSchemas: "Keine Schemata definiert", + schemaTypeArray: "Liste", schemaTypeBoolean: "Ja/Nein", schemaTypeNumber: "Zahl", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 1b1bb35..367a34b 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -124,6 +124,27 @@ export const en: Translation = { stringValidationErrorLengthRange: "'Minimum Length' cannot be greater than 'Maximum Length'.", + schemaTypeAnyOf: "Any Of", + anyOfAddOption: "Add Option", + anyOfRemoveOption: "Remove option", + anyOfOptionLabel: "Option", + anyOfDescription: "Value must match at least one of these schemas", + anyOfNoOptions: "No options defined", + + schemaTypeOneOf: "One Of", + oneOfAddOption: "Add Option", + oneOfRemoveOption: "Remove option", + oneOfOptionLabel: "Option", + oneOfDescription: "Value must match exactly one of these schemas", + oneOfNoOptions: "No options defined", + + schemaTypeAllOf: "All Of", + allOfAddSchema: "Add Schema", + allOfRemoveSchema: "Remove schema", + allOfSchemaLabel: "Schema", + allOfDescription: "Value must match all of these schemas", + allOfNoSchemas: "No schemas defined", + schemaTypeArray: "List", schemaTypeBoolean: "Yes/No", schemaTypeNumber: "Number", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 209b5f3..129c846 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -126,6 +126,29 @@ export const es: Translation = { stringValidationErrorLengthRange: "'Longitud Mínima' no puede ser mayor que 'Longitud Máxima'.", + schemaTypeAnyOf: "Cualquiera de", + anyOfAddOption: "Agregar opción", + anyOfRemoveOption: "Eliminar opción", + anyOfOptionLabel: "Opción", + anyOfDescription: + "El valor debe coincidir con al menos uno de estos esquemas", + anyOfNoOptions: "No hay opciones definidas", + + schemaTypeOneOf: "Exactamente uno de", + oneOfAddOption: "Agregar opción", + oneOfRemoveOption: "Eliminar opción", + oneOfOptionLabel: "Opción", + oneOfDescription: + "El valor debe coincidir con exactamente uno de estos esquemas", + oneOfNoOptions: "No hay opciones definidas", + + schemaTypeAllOf: "Todos de", + allOfAddSchema: "Agregar esquema", + allOfRemoveSchema: "Eliminar esquema", + allOfSchemaLabel: "Esquema", + allOfDescription: "El valor debe coincidir con todos estos esquemas", + allOfNoSchemas: "No hay esquemas definidos", + schemaTypeArray: "Lista", schemaTypeBoolean: "Sí/No", schemaTypeNumber: "Número", diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 0e65c00..ed9bdbe 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -128,6 +128,28 @@ export const fr: Translation = { stringValidationErrorLengthRange: "'Longueur minimale' ne peut pas être supérieure à 'Longueur maximale'.", + schemaTypeAnyOf: "L'un de", + anyOfAddOption: "Ajouter une option", + anyOfRemoveOption: "Supprimer l'option", + anyOfOptionLabel: "Option", + anyOfDescription: "La valeur doit correspondre à au moins un de ces schémas", + anyOfNoOptions: "Aucune option définie", + + schemaTypeOneOf: "Exactement l'un de", + oneOfAddOption: "Ajouter une option", + oneOfRemoveOption: "Supprimer l'option", + oneOfOptionLabel: "Option", + oneOfDescription: + "La valeur doit correspondre à exactement un de ces schémas", + oneOfNoOptions: "Aucune option définie", + + schemaTypeAllOf: "Tous de", + allOfAddSchema: "Ajouter un schéma", + allOfRemoveSchema: "Supprimer le schéma", + allOfSchemaLabel: "Schéma", + allOfDescription: "La valeur doit correspondre à tous ces schémas", + allOfNoSchemas: "Aucun schéma défini", + schemaTypeArray: "Liste", schemaTypeBoolean: "Oui/Non", schemaTypeNumber: "Nombre", diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts index 2b81ab1..7b19c46 100644 --- a/src/i18n/locales/pl.ts +++ b/src/i18n/locales/pl.ts @@ -126,6 +126,29 @@ export const pl: Translation = { stringValidationErrorLengthRange: "'Minimalna długość' nie może być większa niż 'Maksymalna długość'.", + schemaTypeAnyOf: "Jeden z", + anyOfAddOption: "Dodaj opcję", + anyOfRemoveOption: "Usuń opcję", + anyOfOptionLabel: "Opcja", + anyOfDescription: + "Wartość musi pasować do co najmniej jednego z tych schematów", + anyOfNoOptions: "Nie zdefiniowano opcji", + + schemaTypeOneOf: "Dokładnie jeden z", + oneOfAddOption: "Dodaj opcję", + oneOfRemoveOption: "Usuń opcję", + oneOfOptionLabel: "Opcja", + oneOfDescription: + "Wartość musi pasować do dokładnie jednego z tych schematów", + oneOfNoOptions: "Nie zdefiniowano opcji", + + schemaTypeAllOf: "Wszystkie z", + allOfAddSchema: "Dodaj schemat", + allOfRemoveSchema: "Usuń schemat", + allOfSchemaLabel: "Schemat", + allOfDescription: "Wartość musi pasować do wszystkich tych schematów", + allOfNoSchemas: "Nie zdefiniowano schematów", + schemaTypeArray: "Lista", schemaTypeBoolean: "Tak/Nie", schemaTypeNumber: "Liczba", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index f50ed7a..f484665 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -127,6 +127,28 @@ export const ru: Translation = { stringValidationErrorLengthRange: "'Минимальная длина' не может быть больше 'Максимальной длины'.", + schemaTypeAnyOf: "Одно из", + anyOfAddOption: "Добавить вариант", + anyOfRemoveOption: "Удалить вариант", + anyOfOptionLabel: "Вариант", + anyOfDescription: + "Значение должно соответствовать хотя бы одной из этих схем", + anyOfNoOptions: "Варианты не определены", + + schemaTypeOneOf: "Ровно одно из", + oneOfAddOption: "Добавить вариант", + oneOfRemoveOption: "Удалить вариант", + oneOfOptionLabel: "Вариант", + oneOfDescription: "Значение должно соответствовать ровно одной из этих схем", + oneOfNoOptions: "Варианты не определены", + + schemaTypeAllOf: "Все из", + allOfAddSchema: "Добавить схему", + allOfRemoveSchema: "Удалить схему", + allOfSchemaLabel: "Схема", + allOfDescription: "Значение должно соответствовать всем этим схемам", + allOfNoSchemas: "Схемы не определены", + schemaTypeArray: "Список", schemaTypeBoolean: "Да/Нет", schemaTypeNumber: "Число", diff --git a/src/i18n/locales/uk.ts b/src/i18n/locales/uk.ts index 46d0246..ce58516 100644 --- a/src/i18n/locales/uk.ts +++ b/src/i18n/locales/uk.ts @@ -126,6 +126,27 @@ export const uk: Translation = { stringValidationErrorLengthRange: "'Мінімальна довжина' не може бути більшою за 'Максимальну довжину'.", + schemaTypeAnyOf: "Одне з", + anyOfAddOption: "Додати варіант", + anyOfRemoveOption: "Видалити варіант", + anyOfOptionLabel: "Варіант", + anyOfDescription: "Значення має відповідати хоча б одній з цих схем", + anyOfNoOptions: "Варіанти не визначені", + + schemaTypeOneOf: "Рівно одне з", + oneOfAddOption: "Додати варіант", + oneOfRemoveOption: "Видалити варіант", + oneOfOptionLabel: "Варіант", + oneOfDescription: "Значення має відповідати рівно одній з цих схем", + oneOfNoOptions: "Варіанти не визначені", + + schemaTypeAllOf: "Усі з", + allOfAddSchema: "Додати схему", + allOfRemoveSchema: "Видалити схему", + allOfSchemaLabel: "Схема", + allOfDescription: "Значення має відповідати всім цим схемам", + allOfNoSchemas: "Схеми не визначені", + schemaTypeArray: "Список", schemaTypeBoolean: "Так/Ні", schemaTypeNumber: "Число", diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index abc795c..e59bbdb 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -121,6 +121,27 @@ export const zh: Translation = { stringFormatSelectPlaceholder: "选择格式", stringValidationErrorLengthRange: "「最小长度」不能大于「最大长度」", + schemaTypeAnyOf: "任意一个", + anyOfAddOption: "添加选项", + anyOfRemoveOption: "删除选项", + anyOfOptionLabel: "选项", + anyOfDescription: "值必须符合至少一个以下 Schema", + anyOfNoOptions: "未定义选项", + + schemaTypeOneOf: "恰好一个", + oneOfAddOption: "添加选项", + oneOfRemoveOption: "删除选项", + oneOfOptionLabel: "选项", + oneOfDescription: "值必须恰好符合一个以下 Schema", + oneOfNoOptions: "未定义选项", + + schemaTypeAllOf: "全部", + allOfAddSchema: "添加 Schema", + allOfRemoveSchema: "删除 Schema", + allOfSchemaLabel: "Schema", + allOfDescription: "值必须符合所有以下 Schema", + allOfNoSchemas: "未定义 Schema", + schemaTypeArray: "数组", schemaTypeBoolean: "布尔值", schemaTypeNumber: "数字", diff --git a/src/i18n/translation-keys.ts b/src/i18n/translation-keys.ts index c70f6f8..fa74f3a 100644 --- a/src/i18n/translation-keys.ts +++ b/src/i18n/translation-keys.ts @@ -610,6 +610,117 @@ export interface Translation { */ readonly stringValidationErrorLengthRange: string; + /** + * The translation for the key `schemaTypeAnyOf`. English default is: + * + * > Any Of + */ + readonly schemaTypeAnyOf: string; + /** + * The translation for the key `anyOfAddOption`. English default is: + * + * > Add Option + */ + readonly anyOfAddOption: string; + /** + * The translation for the key `anyOfRemoveOption`. English default is: + * + * > Remove option + */ + readonly anyOfRemoveOption: string; + /** + * The translation for the key `anyOfOptionLabel`. English default is: + * + * > Option + */ + readonly anyOfOptionLabel: string; + /** + * The translation for the key `anyOfDescription`. English default is: + * + * > Value must match at least one of these schemas + */ + readonly anyOfDescription: string; + /** + * The translation for the key `anyOfNoOptions`. English default is: + * + * > No options defined + */ + readonly anyOfNoOptions: string; + + /** + * The translation for the key `schemaTypeOneOf`. English default is: + * + * > One Of + */ + readonly schemaTypeOneOf: string; + /** + * The translation for the key `oneOfAddOption`. English default is: + * + * > Add Option + */ + readonly oneOfAddOption: string; + /** + * The translation for the key `oneOfRemoveOption`. English default is: + * + * > Remove option + */ + readonly oneOfRemoveOption: string; + /** + * The translation for the key `oneOfOptionLabel`. English default is: + * + * > Option + */ + readonly oneOfOptionLabel: string; + /** + * The translation for the key `oneOfDescription`. English default is: + * + * > Value must match exactly one of these schemas + */ + readonly oneOfDescription: string; + /** + * The translation for the key `oneOfNoOptions`. English default is: + * + * > No options defined + */ + readonly oneOfNoOptions: string; + + /** + * The translation for the key `schemaTypeAllOf`. English default is: + * + * > All Of + */ + readonly schemaTypeAllOf: string; + /** + * The translation for the key `allOfAddSchema`. English default is: + * + * > Add Schema + */ + readonly allOfAddSchema: string; + /** + * The translation for the key `allOfRemoveSchema`. English default is: + * + * > Remove schema + */ + readonly allOfRemoveSchema: string; + /** + * The translation for the key `allOfSchemaLabel`. English default is: + * + * > Schema + */ + readonly allOfSchemaLabel: string; + /** + * The translation for the key `allOfDescription`. English default is: + * + * > Value must match all of these schemas + */ + readonly allOfDescription: string; + /** + * The translation for the key `allOfNoSchemas`. English default is: + * + * > No schemas defined + */ + readonly allOfNoSchemas: string; + /** * The translation for the key `schemaTypeString`. English default is: * diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 201a265..8930b36 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,14 +1,14 @@ import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; import type { Translation } from "../i18n/translation-keys.ts"; -import type { SchemaType } from "../types/jsonSchema.ts"; +import type { SchemaEditorType } from "../types/jsonSchema.ts"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } // Helper functions for backward compatibility -export const getTypeColor = (type: SchemaType): string => { +export const getTypeColor = (type: SchemaEditorType): string => { switch (type) { case "string": return "text-blue-500 bg-blue-50"; @@ -23,11 +23,20 @@ export const getTypeColor = (type: SchemaType): string => { return "text-pink-500 bg-pink-50"; case "null": return "text-gray-500 bg-gray-50"; + case "anyOf": + return "text-teal-500 bg-teal-50"; + case "oneOf": + return "text-cyan-500 bg-cyan-50"; + case "allOf": + return "text-indigo-500 bg-indigo-50"; } }; // Get type display label -export const getTypeLabel = (t: Translation, type: SchemaType): string => { +export const getTypeLabel = ( + t: Translation, + type: SchemaEditorType, +): string => { switch (type) { case "string": return t.schemaTypeString; @@ -42,5 +51,11 @@ export const getTypeLabel = (t: Translation, type: SchemaType): string => { return t.schemaTypeArray; case "null": return t.schemaTypeNull; + case "anyOf": + return t.schemaTypeAnyOf; + case "oneOf": + return t.schemaTypeOneOf; + case "allOf": + return t.schemaTypeAllOf; } }; diff --git a/src/types/jsonSchema.ts b/src/types/jsonSchema.ts index c894f4e..2c320a5 100644 --- a/src/types/jsonSchema.ts +++ b/src/types/jsonSchema.ts @@ -152,6 +152,9 @@ export interface SchemaEditorState { export type ObjectJSONSchema = Exclude; +/** Virtual type used in the editor UI to represent combinator schemas */ +export type SchemaEditorType = SchemaType | "anyOf" | "oneOf" | "allOf"; + export function isBooleanSchema(schema: JSONSchema): schema is boolean { return typeof schema === "boolean"; } @@ -174,3 +177,26 @@ export function withObjectSchema( ): T { return isObjectSchema(schema) ? fn(schema) : defaultValue; } + +export function isAnyOfSchema(schema: JSONSchema): boolean { + return isObjectSchema(schema) && Array.isArray(schema.anyOf); +} + +export function isOneOfSchema(schema: JSONSchema): boolean { + return isObjectSchema(schema) && Array.isArray(schema.oneOf); +} + +export function isAllOfSchema(schema: JSONSchema): boolean { + return isObjectSchema(schema) && Array.isArray(schema.allOf); +} + +export function getEditorType(schema: JSONSchema): SchemaEditorType { + if (isAnyOfSchema(schema)) return "anyOf"; + if (isOneOfSchema(schema)) return "oneOf"; + if (isAllOfSchema(schema)) return "allOf"; + return withObjectSchema( + schema, + (s) => (s.type || "object") as SchemaType, + "object" as SchemaType, + ); +}