Aller au contenu principal

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

  1. Séparation des responsabilités : Séparez la logique de validation, la gestion d'état et l'affichage
  2. Réutilisabilité : Créez des composants de formulaire réutilisables
  3. Types TypeScript : Définissez des interfaces claires pour les données de formulaire

Performance

  1. Memoization : Utilisez React.memo pour éviter les re-renders inutiles
  2. Lazy loading : Chargez les options de sélection de manière asynchrone
  3. Debouncing : Implémentez le debouncing pour les champs de recherche

Accessibilité

  1. Labels : Assurez-vous que tous les champs ont des labels appropriés
  2. Navigation clavier : Testez la navigation au clavier
  3. Messages d'erreur : Fournissez des messages d'erreur clairs et utiles

Sécurité

  1. Validation côté serveur : Toujours valider les données côté serveur
  2. Sanitisation : Nettoyez les entrées utilisateur
  3. Protection CSRF : Utilisez des tokens CSRF pour les formulaires

Dernière mise à jour : Décembre 2024