diff --git a/README.md b/README.md index d4ecbd3..0a0714a 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,50 @@ export function App() { } ``` +### Enum Change Callbacks + +You can subscribe to enum value changes in the visual editor via `onAddEnum` and `onDeleteEnum`. + +Both callbacks receive a single context object: + +```ts +type EnumChangeContext = { + value: string | number | boolean; + index: number; + schemaKey?: string; +}; +``` + +- `value`: enum value that was added/removed +- `index`: index in the enum list at the time of the change +- `schemaKey`: path-like key of the edited field (for example: `person.firstName`, `hobbies[].name`) + +Example: + +```tsx +import "jsonjoy-builder/styles.css"; +import { type JSONSchema, JsonSchemaEditor } from "jsonjoy-builder"; +import { useState } from "react"; + +export function App() { + const [schema, setSchema] = useState({ type: "object" }); + + return ( + { + console.log("enum:add", { value, index, schemaKey }); + }} + onDeleteEnum={({ value, index, schemaKey }) => { + console.log("enum:delete", { value, index, schemaKey }); + }} + /> + ); +} +``` + ### Styling To style the component, add custom CSS. For basic styling, there are some CSS custom properties ("variables") diff --git a/src/components/SchemaEditor/JsonSchemaEditor.tsx b/src/components/SchemaEditor/JsonSchemaEditor.tsx index 46b868e..4f27681 100644 --- a/src/components/SchemaEditor/JsonSchemaEditor.tsx +++ b/src/components/SchemaEditor/JsonSchemaEditor.tsx @@ -16,12 +16,15 @@ import { cn } from "../../lib/utils.ts"; import type { JSONSchema } from "../../types/jsonSchema.ts"; import JsonSchemaVisualizer from "./JsonSchemaVisualizer.tsx"; import SchemaVisualEditor from "./SchemaVisualEditor.tsx"; +import type { EnumChangeContext } from "./TypeEditor.tsx"; /** @public */ export interface JsonSchemaEditorProps { schema?: JSONSchema; readOnly: boolean; setSchema?: (schema: JSONSchema) => void; + onAddEnum?: (ctx: EnumChangeContext) => void; + onDeleteEnum?: (ctx: EnumChangeContext) => void; className?: string; } @@ -30,6 +33,8 @@ const JsonSchemaEditor: FC = ({ schema = { type: "object" }, readOnly = false, setSchema, + onAddEnum, + onDeleteEnum, className, }) => { // Handle schema changes and propagate to parent if needed @@ -124,6 +129,8 @@ const JsonSchemaEditor: FC = ({ readOnly={readOnly} schema={schema} onChange={handleSchemaChange} + onAddEnum={onAddEnum} + onDeleteEnum={onDeleteEnum} /> @@ -170,6 +177,8 @@ const JsonSchemaEditor: FC = ({ readOnly={readOnly} schema={schema} onChange={handleSchemaChange} + onAddEnum={onAddEnum} + onDeleteEnum={onDeleteEnum} /> {/** biome-ignore lint/a11y/noStaticElementInteractions: What exactly does this div do? */} diff --git a/src/components/SchemaEditor/SchemaFieldList.tsx b/src/components/SchemaEditor/SchemaFieldList.tsx index f597fdb..e1831b6 100644 --- a/src/components/SchemaEditor/SchemaFieldList.tsx +++ b/src/components/SchemaEditor/SchemaFieldList.tsx @@ -14,10 +14,13 @@ import { } from "../../types/jsonSchema.ts"; import { buildValidationTree } from "../../types/validation.ts"; import SchemaPropertyEditor from "./SchemaPropertyEditor.tsx"; +import type { EnumChangeContext } from "./TypeEditor.tsx"; interface SchemaFieldListProps { schema: JSONSchemaType; readOnly: boolean; + onAddEnum?: (ctx: EnumChangeContext) => void; + onDeleteEnum?: (ctx: EnumChangeContext) => void; onAddField: (newField: NewField) => void; onEditField: (name: string, updatedField: NewField) => void; onDeleteField: (name: string) => void; @@ -27,6 +30,8 @@ const SchemaFieldList: FC = ({ schema, onEditField, onDeleteField, + onAddEnum, + onDeleteEnum, readOnly = false, }) => { const t = useTranslation(); @@ -143,9 +148,12 @@ const SchemaFieldList: FC = ({ onDeleteField(property.name)} onNameChange={(newName) => handleNameChange(property.name, newName)} onRequiredChange={(required) => diff --git a/src/components/SchemaEditor/SchemaPropertyEditor.tsx b/src/components/SchemaEditor/SchemaPropertyEditor.tsx index 9506880..6ead922 100644 --- a/src/components/SchemaEditor/SchemaPropertyEditor.tsx +++ b/src/components/SchemaEditor/SchemaPropertyEditor.tsx @@ -17,13 +17,17 @@ import type { ValidationTreeNode } from "../../types/validation.ts"; import { Badge } from "../ui/badge.tsx"; import { ButtonToggle } from "../ui/button-toggle.tsx"; import TypeDropdown from "./TypeDropdown.tsx"; +import type { EnumChangeContext } from "./TypeEditor.tsx"; import TypeEditor from "./TypeEditor.tsx"; export interface SchemaPropertyEditorProps { name: string; schema: JSONSchema; + schemaKey?: string; required: boolean; readOnly: boolean; validationNode?: ValidationTreeNode; + onAddEnum?: (ctx: EnumChangeContext) => void; + onDeleteEnum?: (ctx: EnumChangeContext) => void; onDelete: () => void; onNameChange: (newName: string) => void; onRequiredChange: (required: boolean) => void; @@ -34,9 +38,12 @@ export interface SchemaPropertyEditorProps { export const SchemaPropertyEditor: React.FC = ({ name, schema, + schemaKey, required, readOnly = false, validationNode, + onAddEnum, + onDeleteEnum, onDelete, onNameChange, onRequiredChange, @@ -254,6 +261,9 @@ export const SchemaPropertyEditor: React.FC = ({ readOnly={readOnly} validationNode={validationNode} onChange={handleSchemaUpdate} + schemaKey={schemaKey ?? name} + onAddEnum={onAddEnum} + onDeleteEnum={onDeleteEnum} depth={depth + 1} /> diff --git a/src/components/SchemaEditor/SchemaVisualEditor.tsx b/src/components/SchemaEditor/SchemaVisualEditor.tsx index 52b2ef9..093836f 100644 --- a/src/components/SchemaEditor/SchemaVisualEditor.tsx +++ b/src/components/SchemaEditor/SchemaVisualEditor.tsx @@ -10,18 +10,23 @@ import type { JSONSchema, NewField } from "../../types/jsonSchema.ts"; import { asObjectSchema, isBooleanSchema } from "../../types/jsonSchema.ts"; import AddFieldButton from "./AddFieldButton.tsx"; import SchemaFieldList from "./SchemaFieldList.tsx"; +import type { EnumChangeContext } from "./TypeEditor.tsx"; /** @public */ export interface SchemaVisualEditorProps { schema: JSONSchema; readOnly: boolean; onChange: (schema: JSONSchema) => void; + onAddEnum?: (ctx: EnumChangeContext) => void; + onDeleteEnum?: (ctx: EnumChangeContext) => void; } /** @public */ const SchemaVisualEditor: FC = ({ schema, onChange, + onAddEnum, + onDeleteEnum, readOnly = false, }) => { const t = useTranslation(); @@ -125,6 +130,8 @@ const SchemaVisualEditor: FC = ({ import("./types/ObjectEditor.tsx")); const ArrayEditor = lazy(() => import("./types/ArrayEditor.tsx")); const CombinatorEditor = lazy(() => import("./types/CombinatorEditor.tsx")); +export interface EnumChangeContext { + value: string | number | boolean; + index: number; + schemaKey?: string; +} + export interface TypeEditorProps { schema: JSONSchema; readOnly: boolean; validationNode: ValidationTreeNode | undefined; onChange: (schema: ObjectJSONSchema) => void; + schemaKey?: string; + onAddEnum?: (ctx: EnumChangeContext) => void; + onDeleteEnum?: (ctx: EnumChangeContext) => void; depth?: number; } @@ -24,6 +33,9 @@ const TypeEditor: React.FC = ({ schema, validationNode, onChange, + schemaKey, + onAddEnum, + onDeleteEnum, depth = 0, readOnly = false, }) => { @@ -37,6 +49,9 @@ const TypeEditor: React.FC = ({ readOnly={readOnly} schema={schema} onChange={onChange} + schemaKey={schemaKey} + onAddEnum={onAddEnum} + onDeleteEnum={onDeleteEnum} depth={depth} validationNode={validationNode} /> @@ -46,6 +61,9 @@ const TypeEditor: React.FC = ({ readOnly={readOnly} schema={schema} onChange={onChange} + schemaKey={schemaKey} + onAddEnum={onAddEnum} + onDeleteEnum={onDeleteEnum} depth={depth} validationNode={validationNode} /> @@ -55,6 +73,9 @@ const TypeEditor: React.FC = ({ readOnly={readOnly} schema={schema} onChange={onChange} + schemaKey={schemaKey} + onAddEnum={onAddEnum} + onDeleteEnum={onDeleteEnum} depth={depth} validationNode={validationNode} integer @@ -65,6 +86,9 @@ const TypeEditor: React.FC = ({ readOnly={readOnly} schema={schema} onChange={onChange} + schemaKey={schemaKey} + onAddEnum={onAddEnum} + onDeleteEnum={onDeleteEnum} depth={depth} validationNode={validationNode} /> @@ -74,6 +98,9 @@ const TypeEditor: React.FC = ({ readOnly={readOnly} schema={schema} onChange={onChange} + schemaKey={schemaKey} + onAddEnum={onAddEnum} + onDeleteEnum={onDeleteEnum} depth={depth} validationNode={validationNode} /> @@ -83,6 +110,9 @@ const TypeEditor: React.FC = ({ readOnly={readOnly} schema={schema} onChange={onChange} + schemaKey={schemaKey} + onAddEnum={onAddEnum} + onDeleteEnum={onDeleteEnum} depth={depth} validationNode={validationNode} /> @@ -92,6 +122,9 @@ const TypeEditor: React.FC = ({ readOnly={readOnly} schema={schema} onChange={onChange} + schemaKey={schemaKey} + onAddEnum={onAddEnum} + onDeleteEnum={onDeleteEnum} depth={depth} validationNode={validationNode} combinator={type} diff --git a/src/components/SchemaEditor/types/ArrayEditor.tsx b/src/components/SchemaEditor/types/ArrayEditor.tsx index 79ca7fa..3452a3d 100644 --- a/src/components/SchemaEditor/types/ArrayEditor.tsx +++ b/src/components/SchemaEditor/types/ArrayEditor.tsx @@ -24,6 +24,9 @@ const ArrayEditor: React.FC = ({ readOnly = false, validationNode, onChange, + schemaKey, + onAddEnum, + onDeleteEnum, depth = 0, }) => { const t = useTranslation(); @@ -43,6 +46,7 @@ const ArrayEditor: React.FC = ({ // Get the array's item schema const itemsSchema = getArrayItemsSchema(schema) || { type: "string" }; + const itemSchemaKey = schemaKey ? `${schemaKey}[]` : undefined; // Get the type of the array items const itemType = withObjectSchema( @@ -276,6 +280,9 @@ const ArrayEditor: React.FC = ({ schema={itemsSchema} validationNode={validationNode} onChange={handleItemSchemaChange} + schemaKey={itemSchemaKey} + onAddEnum={onAddEnum} + onDeleteEnum={onDeleteEnum} depth={depth + 1} /> diff --git a/src/components/SchemaEditor/types/BooleanEditor.tsx b/src/components/SchemaEditor/types/BooleanEditor.tsx index b20c314..0d01bbf 100644 --- a/src/components/SchemaEditor/types/BooleanEditor.tsx +++ b/src/components/SchemaEditor/types/BooleanEditor.tsx @@ -9,6 +9,9 @@ import type { TypeEditorProps } from "../TypeEditor.tsx"; const BooleanEditor: React.FC = ({ schema, onChange, + schemaKey, + onAddEnum, + onDeleteEnum, readOnly = false, }) => { const t = useTranslation(); @@ -30,6 +33,7 @@ const BooleanEditor: React.FC = ({ // Handle changing the allowed values const handleAllowedChange = (value: boolean, allowed: boolean) => { let newEnum: boolean[] | undefined; + let enumAction: "add" | "delete" | null = null; if (allowed) { // If allowing this value @@ -45,6 +49,7 @@ const BooleanEditor: React.FC = ({ // Add this value to enum newEnum = enumValues ? [...enumValues, value] : [value]; + enumAction = "add"; // If both are now allowed, we can remove the enum constraint if (newEnum.includes(true) && newEnum.includes(false)) { @@ -59,6 +64,7 @@ const BooleanEditor: React.FC = ({ // Create a new enum with just the opposite value newEnum = [!value]; + enumAction = "delete"; } // Create a new validation object with just the type and enum @@ -71,10 +77,42 @@ const BooleanEditor: React.FC = ({ } else { // Remove enum property if no restrictions onChange({ type: "boolean" }); + if (enumAction === "add") { + onAddEnum?.({ + value, + index: enumValues?.length ?? 0, + schemaKey, + }); + } + if (enumAction === "delete") { + const deleteIndex = + enumValues?.indexOf(value) ?? [true, false].indexOf(value); + onDeleteEnum?.({ + value, + index: Math.max(deleteIndex, 0), + schemaKey, + }); + } return; } onChange(updatedValidation); + if (enumAction === "add") { + onAddEnum?.({ + value, + index: newEnum.indexOf(value), + schemaKey, + }); + } + if (enumAction === "delete") { + const deleteIndex = + enumValues?.indexOf(value) ?? [true, false].indexOf(value); + onDeleteEnum?.({ + value, + index: Math.max(deleteIndex, 0), + schemaKey, + }); + } }; const hasEnum = enumValues && enumValues.length > 0; diff --git a/src/components/SchemaEditor/types/CombinatorEditor.tsx b/src/components/SchemaEditor/types/CombinatorEditor.tsx index 7a903d6..70be327 100644 --- a/src/components/SchemaEditor/types/CombinatorEditor.tsx +++ b/src/components/SchemaEditor/types/CombinatorEditor.tsx @@ -81,6 +81,9 @@ const CombinatorEditor: React.FC = ({ readOnly = false, validationNode, onChange, + schemaKey, + onAddEnum, + onDeleteEnum, depth = 0, combinator, }) => { @@ -219,6 +222,13 @@ const CombinatorEditor: React.FC = ({ onChange={(updatedSchema) => handleOptionSchemaChange(index, updatedSchema) } + schemaKey={ + schemaKey + ? `${schemaKey}.${combinator}[${index}]` + : `${combinator}[${index}]` + } + onAddEnum={onAddEnum} + onDeleteEnum={onDeleteEnum} depth={depth + 1} /> diff --git a/src/components/SchemaEditor/types/NumberEditor.tsx b/src/components/SchemaEditor/types/NumberEditor.tsx index f8d7460..6eb4b40 100644 --- a/src/components/SchemaEditor/types/NumberEditor.tsx +++ b/src/components/SchemaEditor/types/NumberEditor.tsx @@ -27,6 +27,9 @@ const NumberEditor: React.FC = ({ schema, validationNode, onChange, + schemaKey, + onAddEnum, + onDeleteEnum, integer = false, readOnly = false, }) => { @@ -142,6 +145,15 @@ const NumberEditor: React.FC = ({ onChange(baseProperties as ObjectJSONSchema); }; + const applyEnumValues = (values: number[]) => { + if (values.length > 0) { + handleValidationChange("enum", values); + return; + } + + handleValidationChange("enum", undefined); + }; + // Handle adding enum value const handleAddEnumValue = () => { if (!enumValue.trim()) return; @@ -153,7 +165,9 @@ const NumberEditor: React.FC = ({ const validValue = integer ? Math.floor(numValue) : numValue; if (!enumValues.includes(validValue)) { - handleValidationChange("enum", [...enumValues, validValue]); + const addedIndex = enumValues.length; + applyEnumValues([...enumValues, validValue]); + onAddEnum?.({ value: validValue, index: addedIndex, schemaKey }); } setEnumValue(""); @@ -161,15 +175,13 @@ const NumberEditor: React.FC = ({ // Handle removing enum value const handleRemoveEnumValue = (index: number) => { + const removedValue = enumValues[index]; + if (removedValue === undefined) return; + const newEnumValues = [...enumValues]; newEnumValues.splice(index, 1); - - if (newEnumValues.length === 0) { - // If empty, remove the enum property entirely by setting it to undefined - handleValidationChange("enum", undefined); - } else { - handleValidationChange("enum", newEnumValues); - } + applyEnumValues(newEnumValues); + onDeleteEnum?.({ value: removedValue, index, schemaKey }); }; const minMaxError = useMemo( diff --git a/src/components/SchemaEditor/types/ObjectEditor.tsx b/src/components/SchemaEditor/types/ObjectEditor.tsx index a5a8428..0ce512a 100644 --- a/src/components/SchemaEditor/types/ObjectEditor.tsx +++ b/src/components/SchemaEditor/types/ObjectEditor.tsx @@ -16,6 +16,9 @@ const ObjectEditor: React.FC = ({ schema, validationNode, onChange, + schemaKey, + onAddEnum, + onDeleteEnum, depth = 0, readOnly = false, }) => { @@ -136,9 +139,14 @@ const ObjectEditor: React.FC = ({ readOnly={readOnly} key={property.name} name={property.name} + schemaKey={ + schemaKey ? `${schemaKey}.${property.name}` : property.name + } schema={property.schema} required={property.required} validationNode={validationNode?.children[property.name]} + onAddEnum={onAddEnum} + onDeleteEnum={onDeleteEnum} onDelete={() => handleDeleteProperty(property.name)} onNameChange={(newName) => handlePropertyNameChange(property.name, newName) diff --git a/src/components/SchemaEditor/types/StringEditor.tsx b/src/components/SchemaEditor/types/StringEditor.tsx index 352e3a5..d4fec6a 100644 --- a/src/components/SchemaEditor/types/StringEditor.tsx +++ b/src/components/SchemaEditor/types/StringEditor.tsx @@ -24,6 +24,9 @@ const StringEditor: React.FC = ({ schema, validationNode, onChange, + schemaKey, + onAddEnum, + onDeleteEnum, readOnly = false, }) => { const t = useTranslation(); @@ -66,12 +69,41 @@ const StringEditor: React.FC = ({ onChange(updatedValidation); }; + const applyEnumValues = (values: string[]) => { + if (values.length > 0) { + const updatedSchema: ObjectJSONSchema = { + ...(isBooleanSchema(schema) + ? { type: "string" as const } + : { ...schema }), + type: "string", + enum: values, + }; + onChange(updatedSchema); + return; + } + + const baseSchema = isBooleanSchema(schema) + ? { type: "string" as const } + : { ...schema }; + + if (!isBooleanSchema(baseSchema) && "enum" in baseSchema) { + const { enum: _, ...rest } = baseSchema; + onChange(rest as ObjectJSONSchema); + return; + } + + onChange(baseSchema as ObjectJSONSchema); + }; + // Handle adding enum value const handleAddEnumValue = () => { - if (!enumValue.trim()) return; + const trimmedValue = enumValue.trim(); + if (!trimmedValue) return; - if (!enumValues.includes(enumValue)) { - handleValidationChange("enum", [...enumValues, enumValue]); + if (!enumValues.includes(trimmedValue)) { + const addedIndex = enumValues.length; + applyEnumValues([...enumValues, trimmedValue]); + onAddEnum?.({ value: trimmedValue, index: addedIndex, schemaKey }); } setEnumValue(""); @@ -79,25 +111,13 @@ const StringEditor: React.FC = ({ // Handle removing enum value const handleRemoveEnumValue = (index: number) => { + const removedValue = enumValues[index]; + if (removedValue === undefined) return; + const newEnumValues = [...enumValues]; newEnumValues.splice(index, 1); - - if (newEnumValues.length === 0) { - // If empty, remove the enum property entirely - const baseSchema = isBooleanSchema(schema) - ? { type: "string" as const } - : { ...schema }; - - // Use a type safe approach - if (!isBooleanSchema(baseSchema) && "enum" in baseSchema) { - const { enum: _, ...rest } = baseSchema; - onChange(rest as ObjectJSONSchema); - } else { - onChange(baseSchema as ObjectJSONSchema); - } - } else { - handleValidationChange("enum", newEnumValues); - } + applyEnumValues(newEnumValues); + onDeleteEnum?.({ value: removedValue, index, schemaKey }); }; const minMaxError = useMemo( diff --git a/src/index.ts b/src/index.ts index 860f5e5..a2e483f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import SchemaVisualEditor, { export * from "./components/features/JsonValidator.tsx"; export * from "./components/features/SchemaInferencer.tsx"; +export type { EnumChangeContext } from "./components/SchemaEditor/TypeEditor.tsx"; export * from "./i18n/locales/de.ts"; export * from "./i18n/locales/en.ts"; export * from "./i18n/locales/es.ts";