import { useState, useEffect, useCallback, useRef, forwardRef, useImperativeHandle, } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import JsonEditor from "./JsonEditor"; import { updateValueAtPath } from "@/utils/jsonUtils"; import { generateDefaultValue } from "@/utils/schemaUtils"; import type { JsonValue, JsonSchemaType, JsonSchemaConst, } from "@/utils/jsonUtils"; import { useToast } from "@/lib/hooks/useToast"; import { CheckCheck, Copy } from "lucide-react"; interface DynamicJsonFormProps { schema: JsonSchemaType; value: JsonValue; onChange: (value: JsonValue) => void; maxDepth?: number; } export interface DynamicJsonFormRef { validateJson: () => { isValid: boolean; error: string | null }; hasJsonError: () => boolean; } const isTypeSupported = ( type: JsonSchemaType["type"], supportedTypes: string[], ): boolean => { if (Array.isArray(type)) { return type.every((t) => supportedTypes.includes(t)); } return typeof type === "string" && supportedTypes.includes(type); }; const isSimpleObject = (schema: JsonSchemaType): boolean => { const supportedTypes = ["string", "number", "integer", "boolean", "null"]; if (schema.type && isTypeSupported(schema.type, supportedTypes)) return true; if (schema.type === "object") { return Object.values(schema.properties ?? {}).every( (prop) => prop.type && isTypeSupported(prop.type, supportedTypes), ); } if (schema.type === "array") { return !!schema.items && isSimpleObject(schema.items); } return false; }; const getArrayItemDefault = (schema: JsonSchemaType): JsonValue => { if ("default" in schema && schema.default !== undefined) { return schema.default; } switch (schema.type) { case "string": return ""; case "number": case "integer": return 0; case "boolean": return false; case "array": return []; case "object": return {}; case "null": return null; default: return null; } }; const DynamicJsonForm = forwardRef( ({ schema, value, onChange, maxDepth = 3 }, ref) => { // Determine if we can render a form at the top level. // This is more permissive than isSimpleObject(): // - Objects with any properties are form-capable (individual complex fields may still fallback to JSON) // - Arrays with defined items are form-capable // - Primitive types are form-capable const canRenderTopLevelForm = (s: JsonSchemaType): boolean => { const primitiveTypes = ["string", "number", "integer", "boolean", "null"]; const hasType = Array.isArray(s.type) ? s.type.length > 0 : !!s.type; if (!hasType) return false; const includesType = (t: string) => Array.isArray(s.type) ? (s.type as ReadonlyArray).includes(t) : s.type === t; // Primitive at top-level if (primitiveTypes.some(includesType)) return true; // Object with properties if (includesType("object")) { const keys = Object.keys(s.properties ?? {}); return keys.length > 0; } // Array with items if (includesType("array")) { return !!s.items; } return false; }; const isOnlyJSON = !canRenderTopLevelForm(schema); const [isJsonMode, setIsJsonMode] = useState(isOnlyJSON); const [jsonError, setJsonError] = useState(); const [copiedJson, setCopiedJson] = useState(false); const { toast } = useToast(); // Store the raw JSON string to allow immediate feedback during typing // while deferring parsing until the user stops typing const [rawJsonValue, setRawJsonValue] = useState( JSON.stringify(value ?? generateDefaultValue(schema), null, 2), ); const [numericInputDrafts, setNumericInputDrafts] = useState< Record >({}); // Use a ref to manage debouncing timeouts to avoid parsing JSON // on every keystroke which would be inefficient and error-prone const timeoutRef = useRef>(); const hasJsonError = () => { return !!jsonError; }; const getPathKey = (path: string[]) => path.length === 0 ? "$root" : path.join("."); const getNumericDisplayValue = ( path: string[], currentValue: JsonValue, ): string => { const pathKey = getPathKey(path); if (Object.prototype.hasOwnProperty.call(numericInputDrafts, pathKey)) { return numericInputDrafts[pathKey]; } return typeof currentValue === "number" ? currentValue.toString() : ""; }; const updateNumericDraft = (path: string[], draftValue: string) => { const pathKey = getPathKey(path); setNumericInputDrafts((prev) => ({ ...prev, [pathKey]: draftValue })); }; const clearNumericDraft = (path: string[]) => { const pathKey = getPathKey(path); setNumericInputDrafts((prev) => { if (!Object.prototype.hasOwnProperty.call(prev, pathKey)) { return prev; } const next = { ...prev }; delete next[pathKey]; return next; }); }; // Debounce JSON parsing and parent updates to handle typing gracefully const debouncedUpdateParent = useCallback( (jsonString: string) => { // Clear any existing timeout if (timeoutRef.current) { clearTimeout(timeoutRef.current); } // Set a new timeout timeoutRef.current = setTimeout(() => { try { const parsed = JSON.parse(jsonString); onChange(parsed); setJsonError(undefined); } catch (err) { // For invalid JSON, set error and reset to default if it's clearly malformed const errorMessage = err instanceof Error ? err.message : "Invalid JSON"; setJsonError(errorMessage); // Reset to default for clearly invalid JSON (not just incomplete typing) const trimmed = jsonString?.trim(); if (trimmed && trimmed.length > 5 && !trimmed.match(/^[\s[{]/)) { onChange(generateDefaultValue(schema)); } } }, 300); }, [onChange, setJsonError, schema], ); // Update rawJsonValue when value prop changes useEffect(() => { if (!isJsonMode) { setRawJsonValue( JSON.stringify(value ?? generateDefaultValue(schema), null, 2), ); } }, [value, schema, isJsonMode]); const handleSwitchToFormMode = () => { if (isJsonMode) { // When switching to Form mode, ensure we have valid JSON try { const parsed = JSON.parse(rawJsonValue); // Update the parent component's state with the parsed value onChange(parsed); // Switch to form mode setIsJsonMode(false); } catch (err) { setJsonError(err instanceof Error ? err.message : "Invalid JSON"); } } else { // Update raw JSON value when switching to JSON mode setRawJsonValue( JSON.stringify(value ?? generateDefaultValue(schema), null, 2), ); setIsJsonMode(true); } }; const formatJson = () => { try { const jsonStr = rawJsonValue?.trim(); if (!jsonStr) { return; } const formatted = JSON.stringify(JSON.parse(jsonStr), null, 2); setRawJsonValue(formatted); debouncedUpdateParent(formatted); setJsonError(undefined); } catch (err) { setJsonError(err instanceof Error ? err.message : "Invalid JSON"); } }; const validateJson = () => { if (!isJsonMode) return { isValid: true, error: null }; try { const jsonStr = rawJsonValue?.trim(); if (!jsonStr) return { isValid: true, error: null }; const parsed = JSON.parse(jsonStr); // Clear any pending debounced update and immediately update parent if (timeoutRef.current) { clearTimeout(timeoutRef.current); } onChange(parsed); setJsonError(undefined); return { isValid: true, error: null }; } catch (err) { const errorMessage = err instanceof Error ? err.message : "Invalid JSON"; setJsonError(errorMessage); return { isValid: false, error: errorMessage }; } }; const handleCopyJson = useCallback(async () => { try { await navigator.clipboard.writeText( JSON.stringify(value, null, 2) ?? "[]", ); setCopiedJson(true); toast({ title: "JSON copied", description: "The JSON data has been successfully copied to your clipboard.", }); setTimeout(() => { setCopiedJson(false); }, 2000); } catch (error) { toast({ title: "Error", description: `Failed to copy JSON: ${error instanceof Error ? error.message : String(error)}`, variant: "destructive", }); } }, [toast, value]); useImperativeHandle(ref, () => ({ validateJson, hasJsonError, })); const renderFormFields = ( propSchema: JsonSchemaType, currentValue: JsonValue, path: string[] = [], depth: number = 0, parentSchema?: JsonSchemaType, propertyName?: string, ) => { if ( depth >= maxDepth && (propSchema.type === "object" || propSchema.type === "array") ) { // Render as JSON editor when max depth is reached return ( { try { const parsed = JSON.parse(newValue); handleFieldChange(path, parsed); setJsonError(undefined); } catch (err) { setJsonError( err instanceof Error ? err.message : "Invalid JSON", ); } }} error={jsonError} /> ); } // Check if this property is required in the parent schema const isRequired = parentSchema?.required?.includes(propertyName || "") ?? false; let fieldType = propSchema.type; if (Array.isArray(fieldType)) { // Of the possible types, find the first non-null type to determine the control to render fieldType = fieldType.find((t) => t !== "null") ?? fieldType[0]; } switch (fieldType) { case "string": { // Titled single-select using oneOf/anyOf with const/title pairs const titledOptions = ( (propSchema.oneOf ?? propSchema.anyOf) as (JsonSchemaType | JsonSchemaConst)[] | undefined )?.filter((opt): opt is JsonSchemaConst => "const" in opt); if (titledOptions && titledOptions.length > 0) { return (
{propSchema.description && (

{propSchema.description}

)}
); } // Untitled single-select using enum (with optional legacy enumNames for labels) if (propSchema.enum) { const names = Array.isArray(propSchema.enumNames) ? propSchema.enumNames : undefined; return (
{propSchema.description && (

{propSchema.description}

)}
); } let inputType = "text"; switch (propSchema.format) { case "email": inputType = "email"; break; case "uri": inputType = "url"; break; case "date": inputType = "date"; break; case "date-time": inputType = "datetime-local"; break; default: inputType = "text"; break; } return ( { const val = e.target.value; // Always allow setting string values, including empty strings handleFieldChange(path, val); }} placeholder={propSchema.description} required={isRequired} minLength={propSchema.minLength} maxLength={propSchema.maxLength} pattern={propSchema.pattern} /> ); } case "number": return ( { const val = e.target.value; updateNumericDraft(path, val); if (!val && !isRequired) { handleFieldChange(path, undefined); } else { const num = Number(val); if (!isNaN(num)) { handleFieldChange(path, num); } } }} onBlur={(e) => { const val = e.target.value; if (!val) { clearNumericDraft(path); return; } const num = Number(val); if (!isNaN(num)) { handleFieldChange(path, num); } clearNumericDraft(path); }} placeholder={propSchema.description} required={isRequired} min={propSchema.minimum} max={propSchema.maximum} /> ); case "integer": return ( { const val = e.target.value; updateNumericDraft(path, val); if (!val && !isRequired) { handleFieldChange(path, undefined); } else { const num = Number(val); if (!isNaN(num) && Number.isInteger(num)) { handleFieldChange(path, num); } } }} onBlur={(e) => { const val = e.target.value; if (!val) { clearNumericDraft(path); return; } const num = Number(val); if (!isNaN(num) && Number.isInteger(num)) { handleFieldChange(path, num); } clearNumericDraft(path); }} placeholder={propSchema.description} required={isRequired} min={propSchema.minimum} max={propSchema.maximum} /> ); case "boolean": return (
{propSchema.description && (

{propSchema.description}

)} handleFieldChange(path, e.target.checked)} className="w-4 h-4" required={isRequired} />
); case "null": return null; case "object": if (!propSchema.properties) { return ( { try { const parsed = JSON.parse(newValue); handleFieldChange(path, parsed); setJsonError(undefined); } catch (err) { setJsonError( err instanceof Error ? err.message : "Invalid JSON", ); } }} error={jsonError} /> ); } return (
{Object.entries(propSchema.properties).map(([key, subSchema]) => (
{renderFormFields( subSchema as JsonSchemaType, (currentValue as Record)?.[key], [...path, key], depth + 1, propSchema, key, )}
))}
); case "array": { const arrayValue = Array.isArray(currentValue) ? currentValue : []; if (!propSchema.items) return null; // Special handling: array of enums -> render multi-select control const itemSchema = propSchema.items as JsonSchemaType; let multiOptions: { value: string; label: string }[] | null = null; const titledMulti = ( (itemSchema.anyOf ?? itemSchema.oneOf) as (JsonSchemaType | JsonSchemaConst)[] | undefined )?.filter((opt): opt is JsonSchemaConst => "const" in opt); if (titledMulti && titledMulti.length > 0) { multiOptions = titledMulti.map((o) => ({ value: String(o.const), label: o.title ?? String(o.const), })); } else if (itemSchema.enum) { const names = Array.isArray(itemSchema.enumNames) ? itemSchema.enumNames : undefined; multiOptions = itemSchema.enum.map((v, i) => ({ value: v, label: names?.[i] ?? v, })); } if (multiOptions) { const selectSize = Math.min(Math.max(multiOptions.length, 3), 8); return (
{propSchema.description && (

{propSchema.description}

)} {(propSchema.minItems || propSchema.maxItems) && (

{propSchema.minItems ? `Select at least ${propSchema.minItems}. ` : ""} {propSchema.maxItems ? `Select at most ${propSchema.maxItems}.` : ""}

)}
); } // If the array items are simple, render as form fields, otherwise use JSON editor if (isSimpleObject(propSchema.items)) { return (
{propSchema.description && (

{propSchema.description}

)} {propSchema.items?.description && (

Items: {propSchema.items.description}

)}
{arrayValue.map((item, index) => (
{renderFormFields( propSchema.items as JsonSchemaType, item, [...path, index.toString()], depth + 1, )}
))}
); } // For complex arrays, fall back to JSON editor return ( { try { const parsed = JSON.parse(newValue); handleFieldChange(path, parsed); setJsonError(undefined); } catch (err) { setJsonError( err instanceof Error ? err.message : "Invalid JSON", ); } }} error={jsonError} /> ); } default: return null; } }; const handleFieldChange = (path: string[], fieldValue: JsonValue) => { if (path.length === 0) { onChange(fieldValue); return; } try { const newValue = updateValueAtPath(value, path, fieldValue); onChange(newValue); } catch (error) { console.error("Failed to update form value:", error); onChange(value); } }; const shouldUseJsonMode = schema.type === "object" && (!schema.properties || Object.keys(schema.properties).length === 0); useEffect(() => { if (shouldUseJsonMode && !isJsonMode) { setIsJsonMode(true); } }, [shouldUseJsonMode, isJsonMode]); return (
{isJsonMode && ( <> )} {!isOnlyJSON && ( )}
{isJsonMode ? ( { // Always update local state setRawJsonValue(newValue); // Use the debounced function to attempt parsing and updating parent debouncedUpdateParent(newValue); }} error={jsonError} placeholder={schema.description} /> ) : // If schema type is object but value is not an object or is empty, and we have actual JSON data, // render a simple representation of the JSON data schema.type === "object" && (typeof value !== "object" || value === null || Object.keys(value).length === 0) && rawJsonValue && rawJsonValue !== "{}" ? (

Form view not available for this JSON structure. Using simplified view:

              {rawJsonValue}
            

Use JSON mode for full editing capabilities.

) : ( renderFormFields(schema, value) )}
); }, ); export default DynamicJsonForm;