/** * DynamicClinicalForm.jsx * ======================= * The core Schema-Driven Dynamic Form Engine. * * Fetches the JSON Schema for the selected disease via useDiseaseForm hook, * renders categorized accordion cards with dynamic field components, * and provides submit/reset controls. * * Features: * - Schema fetching with loading skeleton * - Error boundary + retry * - Categorized collapsible cards (Vitals, Lifestyle, etc.) * - Toggle rows rendered inline in compact grids * - React Hook Form integration with Zod validation * - Submit button that triggers parent callback */ import { useState } from 'react'; import { Play, RotateCcw, Loader2, ChevronDown, Activity, Heart, User, Stethoscope, Pill, Wind, ClipboardList, AlertCircle, Shuffle, BookOpen, } from 'lucide-react'; import { useDisease } from '../context/DiseaseContext'; import { useDiseaseForm } from '../hooks/useDiseaseForm'; import { SchemaErrorBoundary } from './ErrorBoundary'; import SchemaFieldFactory from './SchemaFieldFactory'; import FormSkeleton from './FormSkeleton'; import { getCategoryIcon } from '../utils/featureCategorizer'; import VariableScalesModal from './VariableScalesModal'; // ── Icon resolver for categories ── const CATEGORY_ICONS = { Activity, Heart, User, Stethoscope, Pill, Wind, ClipboardList, }; function resolveIcon(iconName) { return CATEGORY_ICONS[iconName] || ClipboardList; } /** * Collapsible category card — styled accordion for field groups. */ function CategoryCard({ category, fields, control, errors, defaultOpen }) { const [isOpen, setIsOpen] = useState(defaultOpen); const Icon = resolveIcon(getCategoryIcon(category)); // Separate toggle fields from others for compact layout const toggleFields = fields.filter((f) => f.component === 'toggle'); const otherFields = fields.filter((f) => f.component !== 'toggle'); return (
{isOpen && (
{/* Toggle fields — compact inline grid */} {toggleFields.length > 0 && (
{toggleFields.map((field) => (
))}
)} {/* Other fields — standard grid */} {otherFields.length > 0 && (
{otherFields.map((field) => ( ))}
)}
)}
); } /** * DynamicClinicalForm — the main form component. * * @param {{ * diseaseName: string, * onRunInference: (formData: object) => Promise, * loading?: boolean, * defaultOpenCategories?: string[], * }} props */ export default function DynamicClinicalForm({ diseaseName, onRunInference, loading = false, defaultOpenCategories = ['Vitals & Signs', 'Medical History'], }) { const [hasRun, setHasRun] = useState(false); const [scalesModalOpen, setScalesModalOpen] = useState(false); // ── Randomize all fields with valid data ── const randomizeFields = () => { const randomized = {}; fields.forEach((f) => { if (f.component === 'toggle' || f.component === 'segmented') { randomized[f.name] = Math.random() > 0.5 ? 1 : 0; } else if (f.component === 'select' && f.validation.enum?.length) { const opts = f.validation.enum; randomized[f.name] = opts[Math.floor(Math.random() * opts.length)]; } else if (f.type === 'number' || f.type === 'integer') { const min = f.validation.minimum ?? 0; const max = f.validation.maximum ?? 100; const step = f.validation.step ?? (f.type === 'number' ? 0.1 : 1); const val = min + Math.random() * (max - min); // Align to step increments, then round to avoid floating jitter const stepped = Math.round(val / step) * step; randomized[f.name] = step < 1 ? parseFloat(stepped.toFixed(10)) : Math.round(stepped); } else { randomized[f.name] = f.default ?? ''; } }); form.reset(randomized); }; // ── Form hook ── const { form, categorizedFields, fields, schemaLoading, schemaError, refetchSchema, submitForm, resetForm, hasErrors, } = useDiseaseForm(diseaseName, { onSubmit: async (data) => { setHasRun(true); if (onRunInference) { await onRunInference(data); } }, }); const { control, formState: { errors } } = form; // ── Loading skeleton ── if (schemaLoading) { return ( ); } // ── Schema error ── if (schemaError) { return (

Failed to load form schema

{schemaError}

); } // ── Empty schema ── if (!fields || fields.length === 0) { return (
No form fields available for this disease.
); } // ── Render form ── return ( <>
{/* Form header with controls */}

{fields.length} feature{fields.length !== 1 ? 's' : ''} ·{' '} {categorizedFields.size} categor{categorizedFields.size !== 1 ? 'ies' : 'y'}

{/* Validation error summary */} {hasErrors && (

Please fix the highlighted fields before running inference.

)} {/* Categorized accordion cards */}
{Array.from(categorizedFields.entries()).map(([category, categoryFields]) => ( ))}
setScalesModalOpen(false)} fields={fields} diseaseName={diseaseName} categorizedFields={categorizedFields} /> ); }