Guide de Développement de Nouveaux Formulaires
Ce guide explique comment créer de nouveaux formulaires dans le système EMTB en suivant les meilleures pratiques établies.
🎯 Vue d'ensemble
Le développement de formulaires dans EMTB suit une approche standardisée utilisant React Hook Form, Material-UI, et Yup pour assurer la cohérence et la maintenabilité.
🏗️ Structure d'un formulaire
Template de base
import React from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import {
Box,
Button,
Paper,
Typography,
Alert,
} from '@mui/material';
import { FormTextField, FormSelect, FormDatePicker } from '$components/forms';
// 1. Définir le schéma de validation
const formSchema = yup.object().shape({
field1: yup.string().required('Ce champ est obligatoire'),
field2: yup.number().positive('Doit être positif').required(),
field3: yup.date().required('Date obligatoire'),
});
// 2. Définir les types TypeScript
interface FormData {
field1: string;
field2: number;
field3: Date;
}
interface MyFormProps {
initialData?: Partial<FormData>;
onSubmit: (data: FormData) => Promise<void>;
loading?: boolean;
}
// 3. Créer le composant
const MyForm: React.FC<MyFormProps> = ({
initialData,
onSubmit,
loading = false,
}) => {
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
reset,
} = useForm<FormData>({
resolver: yupResolver(formSchema),
defaultValues: {
field1: '',
field2: 0,
field3: new Date(),
...initialData,
},
});
const handleFormSubmit = async (data: FormData) => {
try {
await onSubmit(data);
reset();
} catch (error) {
console.error('Erreur lors de la soumission:', error);
}
};
return (
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>
Titre du Formulaire
</Typography>
{/* Affichage des erreurs globales */}
{Object.keys(errors).length > 0 && (
<Alert severity="error" sx={{ mb: 2 }}>
Veuillez corriger les erreurs ci-dessous
</Alert>
)}
<Box component="form" onSubmit={handleSubmit(handleFormSubmit)}>
{/* Champs du formulaire */}
<FormTextField
name="field1"
label="Champ Texte"
control={control}
required
/>
<FormTextField
name="field2"
label="Champ Numérique"
control={control}
type="number"
required
/>
<FormDatePicker
name="field3"
label="Date"
control={control}
required
/>
{/* Boutons d'action */}
<Box sx={{ mt: 3, display: 'flex', gap: 2 }}>
<Button
type="submit"
variant="contained"
disabled={isSubmitting || loading}
>
{isSubmitting ? 'Enregistrement...' : 'Enregistrer'}
</Button>
<Button
type="button"
variant="outlined"
onClick={() => reset()}
disabled={isSubmitting || loading}
>
Réinitialiser
</Button>
</Box>
</Box>
</Paper>
);
};
export default MyForm;
📋 Types de champs disponibles
Champ texte simple
<FormTextField
name="nom"
label="Nom"
control={control}
required
placeholder="Entrez le nom"
/>
Champ texte multiligne
<FormTextField
name="description"
label="Description"
control={control}
multiline
rows={4}
placeholder="Entrez une description détaillée"
/>
Champ numérique
<FormTextField
name="montant"
label="Montant (€)"
control={control}
type="number"
required
inputProps={{ min: 0, step: 0.01 }}
/>
Champ email
<FormTextField
name="email"
label="Email"
control={control}
type="email"
required
/>
Champ téléphone
<FormTextField
name="telephone"
label="Téléphone"
control={control}
placeholder="01 23 45 67 89"
/>
Sélecteur simple
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
];
<FormSelect
name="categorie"
label="Catégorie"
control={control}
options={options}
required
/>
Sélecteur avec chargement asynchrone
const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const loadOptions = async () => {
setLoading(true);
try {
const data = await apiService.getOptions();
setOptions(data.map(item => ({
value: item.id,
label: item.nom,
})));
} catch (error) {
console.error('Erreur lors du chargement des options:', error);
} finally {
setLoading(false);
}
};
loadOptions();
}, []);
<FormSelect
name="clientId"
label="Client"
control={control}
options={options}
required
disabled={loading}
/>
Date picker
<FormDatePicker
name="dateDebut"
label="Date de début"
control={control}
required
/>
Date picker avec restrictions
<FormDatePicker
name="dateFin"
label="Date de fin"
control={control}
required
minDate={new Date()}
maxDate={new Date(Date.now() + 365 * 24 * 60 * 60 * 1000)}
/>
🔧 Validation avancée
Validation conditionnelle
const schema = yup.object().shape({
type: yup.string().required(),
montant: yup.number().when('type', {
is: 'PAYANT',
then: (schema) => schema.required('Le montant est obligatoire pour les services payants'),
otherwise: (schema) => schema.optional(),
}),
});
Validation personnalisée
const schema = yup.object().shape({
email: yup
.string()
.email('Format d\'email invalide')
.test('unique-email', 'Cet email est déjà utilisé', async (value) => {
if (!value) return true;
const exists = await apiService.checkEmailExists(value);
return !exists;
}),
});
Validation de fichiers
const schema = yup.object().shape({
document: yup
.mixed()
.required('Un document est obligatoire')
.test('file-size', 'Le fichier est trop volumineux', (value) => {
return value && value.size <= 5 * 1024 * 1024; // 5MB
})
.test('file-type', 'Type de fichier non supporté', (value) => {
return value && ['application/pdf', 'image/jpeg', 'image/png'].includes(value.type);
}),
});
🎨 Personnalisation visuelle
Formulaire avec étapes
const SteppedForm: React.FC = () => {
const [activeStep, setActiveStep] = useState(0);
const steps = ['Informations de base', 'Détails', 'Confirmation'];
const handleNext = () => {
setActiveStep((prev) => prev + 1);
};
const handleBack = () => {
setActiveStep((prev) => prev - 1);
};
return (
<Box>
<Stepper activeStep={activeStep} sx={{ mb: 3 }}>
{steps.map((label) => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
<Box sx={{ mb: 3 }}>
{activeStep === 0 && <BasicInfoStep />}
{activeStep === 1 && <DetailsStep />}
{activeStep === 2 && <ConfirmationStep />}
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Button
disabled={activeStep === 0}
onClick={handleBack}
>
Précédent
</Button>
<Button
variant="contained"
onClick={activeStep === steps.length - 1 ? handleSubmit : handleNext}
>
{activeStep === steps.length - 1 ? 'Terminer' : 'Suivant'}
</Button>
</Box>
</Box>
);
};
Formulaire avec onglets
const TabbedForm: React.FC = () => {
const [tabValue, setTabValue] = useState(0);
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue);
};
return (
<Box>
<Tabs value={tabValue} onChange={handleTabChange} sx={{ mb: 3 }}>
<Tab label="Informations générales" />
<Tab label="Contact" />
<Tab label="Paramètres" />
</Tabs>
<TabPanel value={tabValue} index={0}>
<GeneralInfoForm />
</TabPanel>
<TabPanel value={tabValue} index={1}>
<ContactForm />
</TabPanel>
<TabPanel value={tabValue} index={2}>
<SettingsForm />
</TabPanel>
</Box>
);
};
🧪 Tests des formulaires
Tests de base
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MyForm } from './MyForm';
describe('MyForm', () => {
it('should render all form fields', () => {
render(<MyForm onSubmit={jest.fn()} />);
expect(screen.getByLabelText(/champ texte/i)).toBeInTheDocument();
expect(screen.getByLabelText(/champ numérique/i)).toBeInTheDocument();
expect(screen.getByLabelText(/date/i)).toBeInTheDocument();
});
it('should validate required fields', async () => {
const user = userEvent.setup();
render(<MyForm onSubmit={jest.fn()} />);
const submitButton = screen.getByRole('button', { name: /enregistrer/i });
await user.click(submitButton);
await waitFor(() => {
expect(screen.getByText('Ce champ est obligatoire')).toBeInTheDocument();
});
});
it('should submit form with valid data', async () => {
const user = userEvent.setup();
const mockSubmit = jest.fn().mockResolvedValue(undefined);
render(<MyForm onSubmit={mockSubmit} />);
await user.type(screen.getByLabelText(/champ texte/i), 'Test Value');
await user.type(screen.getByLabelText(/champ numérique/i), '100');
await user.click(screen.getByRole('button', { name: /enregistrer/i }));
await waitFor(() => {
expect(mockSubmit).toHaveBeenCalledWith({
field1: 'Test Value',
field2: 100,
field3: expect.any(Date),
});
});
});
});
Tests d'intégration
describe('Form Integration', () => {
it('should handle API errors gracefully', async () => {
const user = userEvent.setup();
const mockSubmit = jest.fn().mockRejectedValue(new Error('API Error'));
render(<MyForm onSubmit={mockSubmit} />);
await user.type(screen.getByLabelText(/champ texte/i), 'Test Value');
await user.click(screen.getByRole('button', { name: /enregistrer/i }));
await waitFor(() => {
expect(screen.getByText(/erreur lors de la soumission/i)).toBeInTheDocument();
});
});
});
📚 Bonnes pratiques
Structure et organisation
- Séparation des responsabilités : Séparez la logique de validation, la gestion d'état et l'affichage
- Réutilisabilité : Créez des composants de formulaire réutilisables
- Types TypeScript : Définissez des interfaces claires pour les données de formulaire
Performance
- Memoization : Utilisez React.memo pour éviter les re-renders inutiles
- Lazy loading : Chargez les options de sélection de manière asynchrone
- Debouncing : Implémentez le debouncing pour les champs de recherche
Accessibilité
- Labels : Assurez-vous que tous les champs ont des labels appropriés
- Navigation clavier : Testez la navigation au clavier
- Messages d'erreur : Fournissez des messages d'erreur clairs et utiles
Sécurité
- Validation côté serveur : Toujours valider les données côté serveur
- Sanitisation : Nettoyez les entrées utilisateur
- Protection CSRF : Utilisez des tokens CSRF pour les formulaires
Dernière mise à jour : Décembre 2024