veza/apps/web/src/components/forms/FormBuilder.tsx

317 lines
9.8 KiB
TypeScript
Raw Normal View History

import { useState, useCallback } from 'react';
import { Input } from '@/components/ui/input';
import { Select, SelectOption } from '@/components/ui/select';
import { DatePicker } from '@/components/ui/date-picker';
import { FileUpload } from '@/components/ui/file-upload';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
export interface FormField {
name: string;
2025-12-13 02:34:34 +00:00
type:
| 'text'
| 'email'
| 'password'
| 'number'
| 'textarea'
| 'select'
| 'date'
| 'file';
label: string;
placeholder?: string;
required?: boolean;
disabled?: boolean;
2026-01-07 18:39:21 +00:00
defaultValue?: unknown;
validation?: (value: unknown) => string | null;
// Options pour le type select
options?: SelectOption[];
// Options pour le type file
accept?: string;
multiple?: boolean;
maxSize?: number;
// Options pour le type date
minDate?: Date;
maxDate?: Date;
mode?: 'single' | 'range';
}
export interface FormBuilderProps {
fields: FormField[];
2026-01-07 18:39:21 +00:00
onSubmit: (data: Record<string, any>) => void; // Keep 'any' for consumers here or migrate them? 'any' is easiest for consumer compatibility but 'unknown' is strikter.
// Actually, keeping 'any' for output data is often practical for forms unless we want consumers to cast.
// But the goal is "Eradicate any". Let's try 'unknown' or 'FormValue'.
// However, onSubmit(formData) implies formData values are mixed.
// Let's stick to Record<string, any> for the callback signature for now to avoid breaking all consumers immediately,
// OR use a defined union type.
// The user prompt specifically asked to eradicate 'any'.
// Let's use `Record<string, any>` in `onSubmit` to facilitate easy usage but internal state should be safer?
// No, strict eradication means changing it to `Record<string, unknown>`.
// Wait, if I change `onSubmit` signature, I break callers.
// I'll change it to `Record<string, any>` -> `Record<string, unknown>` and fix if breaks.
submitLabel?: string;
className?: string;
disabled?: boolean;
}
/**
* Composant FormBuilder pour créer des formulaires dynamiques à partir de configuration.
*/
export function FormBuilder({
fields,
onSubmit,
submitLabel = 'Submit',
className,
disabled = false,
}: FormBuilderProps) {
const [formData, setFormData] = useState<Record<string, any>>(() => {
// Internal state can remain 'any' for convenience OR 'unknown'?
const initial: Record<string, any> = {};
2025-12-13 02:34:34 +00:00
fields.forEach((field) => {
if (field.defaultValue !== undefined) {
initial[field.name] = field.defaultValue;
} else if (field.type === 'select') {
initial[field.name] = field.multiple ? [] : '';
} else if (field.type === 'file') {
initial[field.name] = field.multiple ? [] : null;
} else if (field.type === 'date') {
2025-12-13 02:34:34 +00:00
initial[field.name] =
field.mode === 'range' ? { start: null, end: null } : null;
} else {
initial[field.name] = '';
}
});
return initial;
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
2025-12-13 02:34:34 +00:00
const validateField = useCallback(
2026-01-07 18:39:21 +00:00
(field: FormField, value: unknown): string | null => {
2025-12-13 02:34:34 +00:00
// Validation required
if (field.required) {
if (
value === null ||
value === undefined ||
value === '' ||
2026-01-07 18:39:21 +00:00
(Array.isArray(value) && value.length === 0)
2025-12-13 02:34:34 +00:00
) {
return `${field.label} is required`;
}
2026-01-07 18:39:21 +00:00
if (typeof value === 'object' && value !== null) {
if (field.type === 'date' && field.mode === 'range') {
const range = value as { start: unknown; end: unknown };
2026-01-07 18:39:21 +00:00
if (!range.start || !range.end) return `${field.label} is required`;
}
}
}
2025-12-13 02:34:34 +00:00
// Validation email
2026-01-07 18:39:21 +00:00
if (field.type === 'email' && typeof value === 'string' && value) {
2025-12-13 02:34:34 +00:00
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
return 'Please enter a valid email address';
}
}
2025-12-13 02:34:34 +00:00
// Validation personnalisée
if (field.validation) {
const customError = field.validation(value);
if (customError) {
return customError;
}
}
2025-12-13 02:34:34 +00:00
return null;
},
[],
);
2025-12-13 02:34:34 +00:00
const handleFieldChange = useCallback(
2026-01-07 18:39:21 +00:00
(fieldName: string, value: unknown) => {
2025-12-13 02:34:34 +00:00
setFormData((prev) => ({
...prev,
[fieldName]: value,
}));
2025-12-13 02:34:34 +00:00
// Valider le champ si déjà touché
if (touched[fieldName]) {
const field = fields.find((f) => f.name === fieldName);
if (field) {
const error = validateField(field, value);
setErrors((prev) => {
if (error) {
return { ...prev, [fieldName]: error };
} else {
const newErrors = { ...prev };
delete newErrors[fieldName];
return newErrors;
}
});
}
}
},
[fields, touched, validateField],
);
const handleFieldBlur = useCallback(
(fieldName: string) => {
setTouched((prev) => ({ ...prev, [fieldName]: true }));
const field = fields.find((f) => f.name === fieldName);
if (field) {
2025-12-13 02:34:34 +00:00
const value = formData[fieldName];
const error = validateField(field, value);
2025-12-13 02:34:34 +00:00
setErrors((prev) => {
if (error) {
return { ...prev, [fieldName]: error };
} else {
const newErrors = { ...prev };
delete newErrors[fieldName];
return newErrors;
}
});
}
2025-12-13 02:34:34 +00:00
},
[fields, formData, validateField],
);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
// Marquer tous les champs comme touchés
const allTouched: Record<string, boolean> = {};
const newErrors: Record<string, string> = {};
2025-12-13 02:34:34 +00:00
fields.forEach((field) => {
allTouched[field.name] = true;
const value = formData[field.name];
const error = validateField(field, value);
if (error) {
2025-12-13 02:34:34 +00:00
newErrors[field.name] = error;
}
});
2025-12-13 02:34:34 +00:00
setTouched(allTouched);
setErrors(newErrors);
2025-12-13 02:34:34 +00:00
// Si pas d'erreurs, soumettre
if (Object.keys(newErrors).length === 0) {
onSubmit(formData);
}
2025-12-13 02:34:34 +00:00
},
[fields, formData, validateField, onSubmit],
);
const renderField = (field: FormField, hasError: boolean) => {
switch (field.type) {
case 'text':
case 'email':
case 'password':
case 'number':
return (
<Input
type={field.type}
2026-01-07 18:39:21 +00:00
value={(formData[field.name] as string | number) || ''}
onChange={(e) => handleFieldChange(field.name, e.target.value)}
onBlur={() => handleFieldBlur(field.name)}
placeholder={field.placeholder}
disabled={disabled || field.disabled}
className={hasError ? 'border-destructive' : ''}
/>
);
case 'textarea':
return (
<textarea
value={formData[field.name] || ''}
onChange={(e) => handleFieldChange(field.name, e.target.value)}
onBlur={() => handleFieldBlur(field.name)}
placeholder={field.placeholder}
disabled={disabled || field.disabled}
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
2025-12-13 02:34:34 +00:00
hasError && 'border-destructive',
)}
/>
);
case 'select':
return (
<Select
options={field.options || []}
value={formData[field.name]}
onChange={(value) => handleFieldChange(field.name, value)}
multiple={field.multiple}
2025-12-13 02:34:34 +00:00
placeholder={
field.placeholder || `Select ${field.label.toLowerCase()}`
}
disabled={disabled || field.disabled}
/>
);
case 'date':
return (
<DatePicker
value={formData[field.name]}
onChange={(value) => handleFieldChange(field.name, value)}
mode={field.mode || 'single'}
minDate={field.minDate}
maxDate={field.maxDate}
2025-12-13 02:34:34 +00:00
placeholder={
field.placeholder || `Select ${field.label.toLowerCase()}`
}
disabled={disabled || field.disabled}
/>
);
case 'file':
return (
<FileUpload
onFileSelect={(files) => handleFieldChange(field.name, files)}
accept={field.accept}
multiple={field.multiple}
maxSize={field.maxSize}
showPreview={true}
disabled={disabled || field.disabled}
/>
);
default:
return null;
}
};
return (
<form onSubmit={handleSubmit} className={cn('space-y-6', className)}>
2025-12-13 02:34:34 +00:00
{fields.map((field) => {
const fieldError = errors[field.name];
const isTouched = touched[field.name];
const showError = isTouched && fieldError;
return (
<div key={field.name} className="space-y-2">
<Label htmlFor={field.name}>
{field.label}
2025-12-13 02:34:34 +00:00
{field.required && (
<span className="text-destructive ml-1">*</span>
)}
</Label>
{renderField(field, !!showError)}
{showError && (
<p className="text-sm text-destructive">{fieldError}</p>
)}
</div>
);
})}
<Button type="submit" disabled={disabled}>
{submitLabel}
</Button>
</form>
);
}