import React, { useCallback, useMemo, useState } from "react"; import { ChevronDown, ChevronRight, Eye, EyeOff, Plus, Trash2, } from "lucide-react"; import { cn } from "@/lib/utils"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /** * Threshold for string length above which a Textarea is used instead of a standard Input. */ const TEXTAREA_THRESHOLD = 200; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- /** * Subset of JSON Schema properties we understand for form rendering. * We intentionally keep this loose (`Record`) at the top * level to match the `JsonSchema` type in shared, but narrow internally. */ export interface JsonSchemaNode { type?: string | string[]; title?: string; description?: string; default?: unknown; enum?: unknown[]; const?: unknown; format?: string; // String constraints minLength?: number; maxLength?: number; pattern?: string; // Number constraints minimum?: number; maximum?: number; exclusiveMinimum?: number; exclusiveMaximum?: number; multipleOf?: number; // Object properties?: Record; required?: string[]; additionalProperties?: boolean | JsonSchemaNode; // Array items?: JsonSchemaNode; minItems?: number; maxItems?: number; // Metadata readOnly?: boolean; writeOnly?: boolean; // Allow extra keys [key: string]: unknown; } export interface JsonSchemaFormProps { /** The JSON Schema to render. */ schema: JsonSchemaNode; /** Current form values. */ values: Record; /** Called whenever any field value changes. */ onChange: (values: Record) => void; /** Validation errors keyed by JSON pointer path (e.g. "/apiKey"). */ errors?: Record; /** If true, all fields are disabled. */ disabled?: boolean; /** Additional CSS class for the root container. */ className?: string; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Resolve the primary type string from a schema node. */ export function resolveType(schema: JsonSchemaNode): string { if (schema.enum) return "enum"; if (schema.const !== undefined) return "const"; if (schema.format === "secret-ref") return "secret-ref"; if (Array.isArray(schema.type)) { // Use the first non-null type return schema.type.find((t) => t !== "null") ?? "string"; } return schema.type ?? "string"; } /** Human-readable label from schema title or property key. */ export function labelFromKey(key: string, schema: JsonSchemaNode): string { if (schema.title) return schema.title; // Convert camelCase / snake_case to Title Case return key .replace(/([a-z])([A-Z])/g, "$1 $2") .replace(/[_-]+/g, " ") .replace(/\b\w/g, (c) => c.toUpperCase()); } /** Produce a sensible default value for a schema node. */ export function getDefaultForSchema(schema: JsonSchemaNode): unknown { if (schema.default !== undefined) return schema.default; const type = resolveType(schema); switch (type) { case "string": case "secret-ref": return ""; case "number": case "integer": return schema.minimum ?? 0; case "boolean": return false; case "enum": return schema.enum?.[0] ?? ""; case "array": return []; case "object": { if (!schema.properties) return {}; const obj: Record = {}; for (const [key, propSchema] of Object.entries(schema.properties)) { obj[key] = getDefaultForSchema(propSchema); } return obj; } default: return ""; } } /** Validate a single field value against schema constraints. Returns error string or null. */ export function validateField( value: unknown, schema: JsonSchemaNode, isRequired: boolean, ): string | null { const type = resolveType(schema); // Required check if (isRequired && (value === undefined || value === null || value === "")) { return "This field is required"; } // Skip further validation if empty and not required if (value === undefined || value === null || value === "") return null; if (type === "string" || type === "secret-ref") { const str = String(value); if (schema.minLength != null && str.length < schema.minLength) { return `Must be at least ${schema.minLength} characters`; } if (schema.maxLength != null && str.length > schema.maxLength) { return `Must be at most ${schema.maxLength} characters`; } if (schema.pattern) { // Guard against ReDoS: reject overly complex patterns from plugin JSON Schemas. // Limit pattern length and run the regex with a defensive try/catch. const MAX_PATTERN_LENGTH = 512; if (schema.pattern.length <= MAX_PATTERN_LENGTH) { try { const re = new RegExp(schema.pattern); if (!re.test(str)) { return `Must match pattern: ${schema.pattern}`; } } catch { // Invalid regex in schema — skip } } } } if (type === "number" || type === "integer") { const num = Number(value); if (isNaN(num)) return "Must be a valid number"; if (schema.minimum != null && num < schema.minimum) { return `Must be at least ${schema.minimum}`; } if (schema.maximum != null && num > schema.maximum) { return `Must be at most ${schema.maximum}`; } if (schema.exclusiveMinimum != null && num <= schema.exclusiveMinimum) { return `Must be greater than ${schema.exclusiveMinimum}`; } if (schema.exclusiveMaximum != null && num >= schema.exclusiveMaximum) { return `Must be less than ${schema.exclusiveMaximum}`; } if (type === "integer" && !Number.isInteger(num)) { return "Must be a whole number"; } if (schema.multipleOf != null && num % schema.multipleOf !== 0) { return `Must be a multiple of ${schema.multipleOf}`; } } if (type === "array") { const arr = value as unknown[]; if (schema.minItems != null && arr.length < schema.minItems) { return `Must have at least ${schema.minItems} items`; } if (schema.maxItems != null && arr.length > schema.maxItems) { return `Must have at most ${schema.maxItems} items`; } } return null; } /** Public API for validation */ export function validateJsonSchemaForm( schema: JsonSchemaNode, values: Record, path: string[] = [], ): Record { const errors: Record = {}; const properties = schema.properties ?? {}; const requiredFields = new Set(schema.required ?? []); for (const [key, propSchema] of Object.entries(properties)) { const fieldPath = [...path, key]; const errorKey = `/${fieldPath.join("/")}`; const value = values[key]; const isRequired = requiredFields.has(key); const type = resolveType(propSchema); // Per-field validation const fieldErr = validateField(value, propSchema, isRequired); if (fieldErr) { errors[errorKey] = fieldErr; } // Recurse into objects if (type === "object" && propSchema.properties && typeof value === "object" && value !== null) { Object.assign( errors, validateJsonSchemaForm(propSchema, value as Record, fieldPath), ); } // Recurse into arrays if (type === "array" && propSchema.items && Array.isArray(value)) { const itemSchema = propSchema.items as JsonSchemaNode; const isObjectItem = resolveType(itemSchema) === "object"; value.forEach((item, index) => { const itemPath = [...fieldPath, String(index)]; const itemErrorKey = `/${itemPath.join("/")}`; if (isObjectItem) { Object.assign( errors, validateJsonSchemaForm( itemSchema, item as Record, itemPath, ), ); } else { const itemErr = validateField(item, itemSchema, false); if (itemErr) { errors[itemErrorKey] = itemErr; } } }); } } return errors; } /** Public API for default values */ export function getDefaultValues(schema: JsonSchemaNode): Record { const result: Record = {}; const properties = schema.properties ?? {}; for (const [key, propSchema] of Object.entries(properties)) { const def = getDefaultForSchema(propSchema); if (def !== undefined) { result[key] = def; } } return result; } // --------------------------------------------------------------------------- // Internal Components // --------------------------------------------------------------------------- interface FieldWrapperProps { label: string; description?: string; required?: boolean; error?: string; disabled?: boolean; children: React.ReactNode; } /** * Common wrapper for form fields that handles labels, descriptions, and error messages. */ const FieldWrapper = React.memo(({ label, description, required, error, disabled, children, }: FieldWrapperProps) => { return (
{label && ( )}
{children} {description && (

{description}

)} {error && (

{error}

)}
); }); FieldWrapper.displayName = "FieldWrapper"; interface FormFieldProps { propSchema: JsonSchemaNode; value: unknown; onChange: (val: unknown) => void; error?: string; disabled?: boolean; label: string; isRequired?: boolean; errors: Record; // needed for recursion path: string; // needed for recursion error filtering } /** * Specialized field for boolean (checkbox) values. */ const BooleanField = React.memo(({ id, value, onChange, disabled, label, isRequired, description, error, }: { id: string; value: unknown; onChange: (val: unknown) => void; disabled: boolean; label: string; isRequired?: boolean; description?: string; error?: string; }) => (
{label && ( )} {description && (

{description}

)} {error && (

{error}

)}
)); BooleanField.displayName = "BooleanField"; /** * Specialized field for enum (select) values. */ const EnumField = React.memo(({ value, onChange, disabled, label, isRequired, description, error, options, }: { value: unknown; onChange: (val: unknown) => void; disabled: boolean; label: string; isRequired?: boolean; description?: string; error?: string; options: unknown[]; }) => ( )); EnumField.displayName = "EnumField"; /** * Specialized field for secret-ref values, providing a toggleable password input. */ const SecretField = React.memo(({ value, onChange, disabled, label, isRequired, description, error, defaultValue, }: { value: unknown; onChange: (val: unknown) => void; disabled: boolean; label: string; isRequired?: boolean; description?: string; error?: string; defaultValue?: unknown; }) => { const [isVisible, setIsVisible] = useState(false); return (
onChange(e.target.value)} placeholder={String(defaultValue ?? "")} disabled={disabled} className="pr-10" aria-invalid={!!error} />
); }); SecretField.displayName = "SecretField"; /** * Specialized field for numeric (number/integer) values. */ const NumberField = React.memo(({ value, onChange, disabled, label, isRequired, description, error, defaultValue, type, }: { value: unknown; onChange: (val: unknown) => void; disabled: boolean; label: string; isRequired?: boolean; description?: string; error?: string; defaultValue?: unknown; type: "number" | "integer"; }) => ( { const val = e.target.value; onChange(val === "" ? undefined : Number(val)); }} placeholder={String(defaultValue ?? "")} disabled={disabled} aria-invalid={!!error} /> )); NumberField.displayName = "NumberField"; /** * Specialized field for string values, rendering either an Input or Textarea based on length or format. */ const StringField = React.memo(({ value, onChange, disabled, label, isRequired, description, error, defaultValue, format, maxLength, }: { value: unknown; onChange: (val: unknown) => void; disabled: boolean; label: string; isRequired?: boolean; description?: string; error?: string; defaultValue?: unknown; format?: string; maxLength?: number; }) => { const isTextArea = format === "textarea" || (maxLength && maxLength > TEXTAREA_THRESHOLD); return ( {isTextArea ? (