302 lines
8.6 KiB
TypeScript
302 lines
8.6 KiB
TypeScript
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;
|
|
type:
|
|
| 'text'
|
|
| 'email'
|
|
| 'password'
|
|
| 'number'
|
|
| 'textarea'
|
|
| 'select'
|
|
| 'date'
|
|
| 'file';
|
|
label: string;
|
|
placeholder?: string;
|
|
required?: boolean;
|
|
disabled?: boolean;
|
|
defaultValue?: any;
|
|
validation?: (value: any) => 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[];
|
|
onSubmit: (data: Record<string, any>) => void;
|
|
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>>(() => {
|
|
const initial: Record<string, any> = {};
|
|
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') {
|
|
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>>({});
|
|
|
|
const validateField = useCallback(
|
|
(field: FormField, value: any): string | null => {
|
|
// Validation required
|
|
if (field.required) {
|
|
if (
|
|
value === null ||
|
|
value === undefined ||
|
|
value === '' ||
|
|
(Array.isArray(value) && value.length === 0) ||
|
|
(typeof value === 'object' &&
|
|
field.type === 'date' &&
|
|
field.mode === 'range' &&
|
|
(!value.start || !value.end))
|
|
) {
|
|
return `${field.label} is required`;
|
|
}
|
|
}
|
|
|
|
// Validation email
|
|
if (field.type === 'email' && value) {
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
if (!emailRegex.test(value)) {
|
|
return 'Please enter a valid email address';
|
|
}
|
|
}
|
|
|
|
// Validation personnalisée
|
|
if (field.validation) {
|
|
const customError = field.validation(value);
|
|
if (customError) {
|
|
return customError;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleFieldChange = useCallback(
|
|
(fieldName: string, value: any) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
[fieldName]: value,
|
|
}));
|
|
|
|
// 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) {
|
|
const value = formData[fieldName];
|
|
const error = validateField(field, value);
|
|
setErrors((prev) => {
|
|
if (error) {
|
|
return { ...prev, [fieldName]: error };
|
|
} else {
|
|
const newErrors = { ...prev };
|
|
delete newErrors[fieldName];
|
|
return newErrors;
|
|
}
|
|
});
|
|
}
|
|
},
|
|
[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> = {};
|
|
|
|
fields.forEach((field) => {
|
|
allTouched[field.name] = true;
|
|
const value = formData[field.name];
|
|
const error = validateField(field, value);
|
|
if (error) {
|
|
newErrors[field.name] = error;
|
|
}
|
|
});
|
|
|
|
setTouched(allTouched);
|
|
setErrors(newErrors);
|
|
|
|
// Si pas d'erreurs, soumettre
|
|
if (Object.keys(newErrors).length === 0) {
|
|
onSubmit(formData);
|
|
}
|
|
},
|
|
[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}
|
|
value={formData[field.name] || ''}
|
|
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',
|
|
hasError && 'border-destructive',
|
|
)}
|
|
/>
|
|
);
|
|
|
|
case 'select':
|
|
return (
|
|
<Select
|
|
options={field.options || []}
|
|
value={formData[field.name]}
|
|
onChange={(value) => handleFieldChange(field.name, value)}
|
|
multiple={field.multiple}
|
|
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}
|
|
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)}>
|
|
{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}
|
|
{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>
|
|
);
|
|
}
|