omnidiag / frontend /src /components /DynamicClinicalForm.jsx
yahyoha's picture
feat: integrate Clinical Data Dictionary into UI + documentation
242cae9
Raw
History Blame Contribute Delete
9.85 kB
/**
* 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 (
<div className="card overflow-hidden">
<button
type="button"
onClick={() => setIsOpen((prev) => !prev)}
className="card-header w-full flex items-center justify-between cursor-pointer hover:bg-gray-50 transition-colors"
>
<h3 className="text-sm font-semibold text-gray-800 flex items-center gap-2">
<Icon className="w-4 h-4 text-primary-500" />
{category}
<span className="text-[10px] font-normal text-gray-400 ml-1">
({fields.length})
</span>
</h3>
<ChevronDown
className={`w-4 h-4 text-gray-400 transition-transform duration-200 ${
isOpen ? 'rotate-180' : ''
}`}
/>
</button>
{isOpen && (
<div className="card-body border-t border-clinical-border">
{/* Toggle fields β€” compact inline grid */}
{toggleFields.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 mb-4">
{toggleFields.map((field) => (
<div key={field.name} className="flex items-center justify-between py-1">
<SchemaFieldFactory
meta={field}
control={control}
errors={errors}
/>
</div>
))}
</div>
)}
{/* Other fields β€” standard grid */}
{otherFields.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{otherFields.map((field) => (
<SchemaFieldFactory
key={field.name}
meta={field}
control={control}
errors={errors}
/>
))}
</div>
)}
</div>
)}
</div>
);
}
/**
* DynamicClinicalForm β€” the main form component.
*
* @param {{
* diseaseName: string,
* onRunInference: (formData: object) => Promise<void>,
* 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 (
<FormSkeleton
fieldCount={fields.length || 12}
diseaseName={diseaseName}
/>
);
}
// ── Schema error ──
if (schemaError) {
return (
<div className="card border-red-200">
<div className="card-body text-center py-8">
<AlertCircle className="w-8 h-8 text-red-400 mx-auto mb-3" />
<p className="text-sm font-medium text-red-700 mb-1">
Failed to load form schema
</p>
<p className="text-xs text-red-500 mb-4">{schemaError}</p>
<button onClick={refetchSchema} className="btn-secondary text-xs">
Retry
</button>
</div>
</div>
);
}
// ── Empty schema ──
if (!fields || fields.length === 0) {
return (
<div className="card">
<div className="card-body text-center py-8 text-gray-400 text-sm">
No form fields available for this disease.
</div>
</div>
);
}
// ── Render form ──
return (
<>
<form onSubmit={submitForm} className="space-y-6">
{/* Form header with controls */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<p className="text-xs text-gray-500">
{fields.length} feature{fields.length !== 1 ? 's' : ''} Β·{' '}
{categorizedFields.size} categor{categorizedFields.size !== 1 ? 'ies' : 'y'}
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setScalesModalOpen(true)}
className="btn-secondary text-xs"
title="View clinical variable definitions and scales"
>
<BookOpen className="w-3.5 h-3.5" />
Scales
</button>
<button
type="button"
onClick={randomizeFields}
className="btn-secondary text-xs"
title="Fill all fields with random valid data"
>
<Shuffle className="w-3.5 h-3.5" />
Randomize
</button>
<button
type="button"
onClick={resetForm}
className="btn-secondary text-xs"
title="Reset to defaults"
>
<RotateCcw className="w-3.5 h-3.5" />
Reset
</button>
<button
type="submit"
disabled={loading || hasErrors}
className="btn-primary text-sm"
>
{loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Running Inference...
</>
) : (
<>
<Play className="w-4 h-4" />
Run Inference
</>
)}
</button>
</div>
</div>
{/* Validation error summary */}
{hasErrors && (
<div className="flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg">
<AlertCircle className="w-4 h-4 text-amber-500 mt-0.5 shrink-0" />
<p className="text-xs text-amber-700">
Please fix the highlighted fields before running inference.
</p>
</div>
)}
{/* Categorized accordion cards */}
<div className="space-y-4">
{Array.from(categorizedFields.entries()).map(([category, categoryFields]) => (
<CategoryCard
key={category}
category={category}
fields={categoryFields}
control={control}
errors={errors}
defaultOpen={defaultOpenCategories.includes(category)}
/>
))}
</div>
</form>
<VariableScalesModal
isOpen={scalesModalOpen}
onClose={() => setScalesModalOpen(false)}
fields={fields}
diseaseName={diseaseName}
categorizedFields={categorizedFields}
/>
</>
);
}