2025-12-03 21:56:50 +00:00
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 :
2026-01-13 18:47:57 +00:00
| 'text'
| 'email'
| 'password'
| 'number'
| 'textarea'
| 'select'
| 'date'
| 'file' ;
2025-12-03 21:56:50 +00:00
label : string ;
placeholder? : string ;
required? : boolean ;
disabled? : boolean ;
2026-01-07 18:39:21 +00:00
defaultValue? : unknown ;
validation ? : ( value : unknown ) = > string | null ;
2025-12-03 21:56:50 +00:00
// 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.
2025-12-03 21:56:50 +00:00
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 ) {
2026-01-13 18:47:57 +00:00
const [ formData , setFormData ] = useState < Record < string , any > > ( ( ) = > {
// Internal state can remain 'any' for convenience OR 'unknown'?
2025-12-03 21:56:50 +00:00
const initial : Record < string , any > = { } ;
2025-12-13 02:34:34 +00:00
fields . forEach ( ( field ) = > {
2025-12-03 21:56:50 +00:00
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 ;
2025-12-03 21:56:50 +00:00
} 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' ) {
2026-01-13 18:47:57 +00:00
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-03 21:56:50 +00:00
}
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-03 21:56:50 +00:00
}
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-03 21:56:50 +00:00
}
2025-12-13 02:34:34 +00:00
return null ;
} ,
[ ] ,
) ;
2025-12-03 21:56:50 +00:00
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-03 21:56:50 +00:00
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 ) ;
2025-12-03 21:56:50 +00:00
if ( field ) {
2025-12-13 02:34:34 +00:00
const value = formData [ fieldName ] ;
2025-12-03 21:56:50 +00:00
const error = validateField ( field , value ) ;
2025-12-13 02:34:34 +00:00
setErrors ( ( prev ) = > {
2025-12-03 21:56:50 +00:00
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-03 21:56:50 +00:00
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 ) ;
2025-12-03 21:56:50 +00:00
if ( error ) {
2025-12-13 02:34:34 +00:00
newErrors [ field . name ] = error ;
2025-12-03 21:56:50 +00:00
}
} ) ;
2025-12-13 02:34:34 +00:00
setTouched ( allTouched ) ;
setErrors ( newErrors ) ;
2025-12-03 21:56:50 +00:00
2025-12-13 02:34:34 +00:00
// Si pas d'erreurs, soumettre
if ( Object . keys ( newErrors ) . length === 0 ) {
onSubmit ( formData ) ;
2025-12-03 21:56:50 +00:00
}
2025-12-13 02:34:34 +00:00
} ,
[ fields , formData , validateField , onSubmit ] ,
) ;
2025-12-03 21:56:50 +00:00
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 ) || '' }
2025-12-03 21:56:50 +00:00
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 (
aesthetic-improvements: align spacing to 8px grid (Action 11.2.1.3)
- Created automated script (scripts/align-8px-grid.py) to align all spacing to 8px grid
- Replaced non-8px-aligned spacing: gap-3/p-3/m-3 (12px) → gap-4/p-4/m-4 (16px), gap-5/p-5/m-5 (20px) → gap-6/p-6/m-6 (24px), gap-10/p-10/m-10 (40px) → gap-12/p-12/m-12 (48px), gap-20/p-20/m-20 (80px) → gap-24/p-24/m-24 (96px)
- Preserved: 4px values (gap-1, p-1, m-1) as they may be intentional fine-tuning, responsive breakpoints (sm:, md:, lg:), test files, documentation
- Modified files across all components to ensure consistent 8px grid alignment
- Action 11.2.1.3: Align all elements to 8px grid - COMPLETE
2026-01-16 10:50:46 +00:00
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-4 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' ,
2025-12-03 21:56:50 +00:00
) }
/ >
) ;
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 ( ) } `
}
2025-12-03 21:56:50 +00:00
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 ( ) } `
}
2025-12-03 21:56:50 +00:00
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 ) = > {
2025-12-03 21:56:50 +00:00
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 >
) }
2025-12-03 21:56:50 +00:00
< / Label >
2025-12-22 21:56:37 +00:00
{ renderField ( field , ! ! showError ) }
2025-12-03 21:56:50 +00:00
{ showError && (
< p className = "text-sm text-destructive" > { fieldError } < / p >
) }
< / div >
) ;
} ) }
< Button type = "submit" disabled = { disabled } >
{ submitLabel }
< / Button >
< / form >
) ;
}