import { useState, useEffect, useCallback, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import JsonEditor from "./JsonEditor"; import { updateValueAtPath } from "@/utils/jsonUtils"; import { generateDefaultValue, formatFieldLabel } from "@/utils/schemaUtils"; import type { JsonValue, JsonSchemaType, JsonObject } from "@/utils/jsonUtils"; interface DynamicJsonFormProps { schema: JsonSchemaType; value: JsonValue; onChange: (value: JsonValue) => void; maxDepth?: number; } const DynamicJsonForm = ({ schema, value, onChange, maxDepth = 3, }: DynamicJsonFormProps) => { const [isJsonMode, setIsJsonMode] = useState(false); const [jsonError, setJsonError] = useState(); // 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), ); // Use a ref to manage debouncing timeouts to avoid parsing JSON // on every keystroke which would be inefficient and error-prone const timeoutRef = useRef>(); // 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 { // Don't set error during normal typing } }, 300); }, [onChange, setJsonError], ); // 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 renderFormFields = ( propSchema: JsonSchemaType, currentValue: JsonValue, path: string[] = [], depth: number = 0, ) => { 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} /> ); } switch (propSchema.type) { case "string": return ( { const val = e.target.value; // Allow clearing non-required fields by setting undefined // This preserves the distinction between empty string and unset if (!val && !propSchema.required) { handleFieldChange(path, undefined); } else { handleFieldChange(path, val); } }} placeholder={propSchema.description} required={propSchema.required} /> ); case "number": return ( { const val = e.target.value; // Allow clearing non-required number fields // This preserves the distinction between 0 and unset if (!val && !propSchema.required) { handleFieldChange(path, undefined); } else { const num = Number(val); if (!isNaN(num)) { handleFieldChange(path, num); } } }} placeholder={propSchema.description} required={propSchema.required} /> ); case "integer": return ( { const val = e.target.value; // Allow clearing non-required integer fields // This preserves the distinction between 0 and unset if (!val && !propSchema.required) { handleFieldChange(path, undefined); } else { const num = Number(val); // Only update if it's a valid integer if (!isNaN(num) && Number.isInteger(num)) { handleFieldChange(path, num); } } }} placeholder={propSchema.description} required={propSchema.required} /> ); case "boolean": return ( handleFieldChange(path, e.target.checked)} className="w-4 h-4" required={propSchema.required} /> ); case "object": { // Handle case where we have a value but no schema properties const objectValue = (currentValue as JsonObject) || {}; // If we have schema properties, use them to render fields if (propSchema.properties) { return (
{Object.entries(propSchema.properties).map(([key, prop]) => (
{renderFormFields( prop, objectValue[key], [...path, key], depth + 1, )}
))}
); } // If we have a value but no schema properties, render fields based on the value else if (Object.keys(objectValue).length > 0) { return (
{Object.entries(objectValue).map(([key, value]) => (
handleFieldChange([...path, key], e.target.value) } />
))}
); } // If we have neither schema properties nor value, return null return null; } case "array": { const arrayValue = Array.isArray(currentValue) ? currentValue : []; if (!propSchema.items) return null; 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, )}
))}
); } 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 && ( )}
{isJsonMode ? ( { // Always update local state setRawJsonValue(newValue); // Use the debounced function to attempt parsing and updating parent debouncedUpdateParent(newValue); }} error={jsonError} /> ) : // 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;