Ajouter saisie, catégories et filtres
Objectif : Réaliser la saisie d’une dépense, afficher les catégories disponibles et filtrer les dépenses sans dupliquer leur état.
Une dépense peut être correctement saisie, mais rester absente de la liste si le formulaire, le contexte et les filtres ne partagent pas le même flux de données. À l’inverse, enregistrer les résultats filtrés comme un second état crée des copies qui peuvent diverger. Vous allez relier ces éléments en gardant les dépenses dans leur contexte et les critères de filtre dans le composant de liste.
Les responsabilités générales et le trajet entre page, hooks, contexte et service ont été établis dans « Assembler les couches du tableau de bord ». Cette leçon précise le parcours d’ajout et le filtrage de la liste ; elle ne modifie ni l’arborescence du projet ni les classes autorisées.
Faire circuler une dépense de la saisie à la liste
Le formulaire fournit une description, un montant, une catégorie et une date. Le composant vérifie les contraintes métier qui ne sont pas couvertes par les seuls attributs HTML, construit une dépense avec un identifiant, puis appelle la fonction d’ajout exposée par le hook dédié. Le contexte reste l’unique emplacement de l’état partagé : la liste lira la nouvelle valeur au prochain rendu, sans recevoir une copie maintenue à part.
Voici l’implémentation complète de `src/components/FormulaireDepense.tsx`. Elle utilise les types et les fonctions exposés par le projet ; si un nom de propriété ou de fonction diffère dans votre code, conservez le contrat déjà défini dans vos fichiers plutôt que d’en introduire un second. Après un ajout réussi, le formulaire est réinitialisé et la fonction de contexte propage la dépense aux consommateurs concernés.
import { FormEvent } from 'react';
import { useDepenses } from '../hooks/useDepenses';
import type { Depense } from '../types/depense';
export default function FormulaireDepense() {
const { categories, ajouterDepense } = useDepenses();
function gererSoumission(evenement: FormEvent<HTMLFormElement>) {
evenement.preventDefault();
const formulaire = evenement.currentTarget;
const donnees = new FormData(formulaire);
const description = String(donnees.get('description') ?? '').trim();
const montant = Number(donnees.get('montant'));
const categorieId = String(donnees.get('categorie') ?? '');
const date = String(donnees.get('date') ?? '');
if (!description || !Number.isFinite(montant) || montant <= 0 || !date) return;
const categorie = categories.find((element) => element.id === categorieId);
if (!categorie) return;
const depense: Depense = {
id: crypto.randomUUID(),
description,
montant,
categorie: categorie.nom,
date,
};
ajouterDepense(depense);
formulaire.reset();
}
return (
<section id="saisie" className="atelier-depense" aria-labelledby="titre-saisie">
<h2 id="titre-saisie">Ajouter une dépense</h2>
<form className="atelier-depense__formulaire" onSubmit={gererSoumission}>
<label htmlFor="description">Description</label>
<input id="description" name="description" required />
<label htmlFor="montant">Montant</label>
<input id="montant" name="montant" type="number" min="0.01" step="0.01" required />
<label htmlFor="categorie">Catégorie</label>
<select id="categorie" name="categorie" required defaultValue="">
<option value="" disabled>Choisir une catégorie</option>
{categories.map((categorie) => (
<option key={categorie.id} value={categorie.id}>{categorie.nom}</option>
))}
</select>
<label htmlFor="date">Date</label>
<input id="date" name="date" type="date" required />
<button type="submit">Ajouter la dépense</button>
</form>
</section>
);
}Le type `Depense` doit correspondre au modèle défini dans `src/types/depense.ts` ; ici, la dépense conserve le nom de catégorie pour l’affichage. L’identifiant est distinct de la description, qui n’est pas forcément unique. Si votre service assigne déjà les identifiants, utilisez son contrat existant et ne créez pas un second mécanisme de génération.
Filtrer sans enregistrer une seconde liste
Les filtres sont des critères d’interface propres à `FiltresDepenses` et `ListeDepenses`. La liste complète vient de `useDepenses` ; la liste affichée est calculée pendant le rendu en combinant catégorie sélectionnée et texte recherché. Vous ne stockez donc jamais `depensesFiltrees` dans un state : elle est recalculée à partir de ses entrées actuelles, ce qui évite les mises à jour de synchronisation.
import { useMemo, useState } from 'react';
import { useDepenses } from '../hooks/useDepenses';
export default function ListeDepenses() {
const { depenses, categories, supprimerDepense } = useDepenses();
const [categorieSelectionnee, setCategorieSelectionnee] = useState('toutes');
const [recherche, setRecherche] = useState('');
const depensesFiltrees = useMemo(() => {
const terme = recherche.trim().toLocaleLowerCase('fr');
return depenses.filter((depense) => {
const correspondCategorie = categorieSelectionnee === 'toutes'
|| depense.categorie === categories.find((categorie) => categorie.id === categorieSelectionnee)?.nom;
const correspondRecherche = depense.description.toLocaleLowerCase('fr').includes(terme);
return correspondCategorie && correspondRecherche;
});
}, [depenses, categories, categorieSelectionnee, recherche]);
return (
<section id="depenses" className="atelier-liste" aria-labelledby="titre-depenses">
<h2 id="titre-depenses">Dépenses</h2>
<div className="atelier-filtres" aria-label="Filtres des dépenses">
<label htmlFor="filtre-categorie">Catégorie</label>
<select id="filtre-categorie" className="atelier-filtres__selecteur" value={categorieSelectionnee} onChange={(evenement) => setCategorieSelectionnee(evenement.target.value)}>
<option value="toutes">Toutes les catégories</option>
{categories.map((categorie) => <option key={categorie.id} value={categorie.id}>{categorie.nom}</option>)}
</select>
<label htmlFor="filtre-recherche">Recherche</label>
<input id="filtre-recherche" type="search" value={recherche} onChange={(evenement) => setRecherche(evenement.target.value)} />
</div>
{depensesFiltrees.length > 0 ? (
<table className="atelier-liste__tableau">
<caption>Liste des dépenses filtrées</caption>
<thead><tr><th scope="col">Date</th><th scope="col">Description</th><th scope="col">Catégorie</th><th scope="col">Montant</th><th scope="col">Actions</th></tr></thead>
<tbody>
{depensesFiltrees.map((depense) => (
<tr key={depense.id}>
<td>{depense.date}</td><td>{depense.description}</td><td>{depense.categorie}</td><td>{depense.montant}</td>
<td><button type="button" onClick={() => supprimerDepense(depense.id)} aria-label={`Supprimer ${depense.description}`}>Supprimer</button></td>
</tr>
))}
</tbody>
</table>
) : <p className="atelier-etat-vide">Aucune dépense ne correspond aux filtres.</p>}
<p className="atelier-annonce" role="status" aria-live="polite"></p>
</section>
);
}Le nom de catégorie est comparé au nom conservé dans chaque dépense, tandis que le sélecteur utilise l’identifiant stable de la catégorie. Dans un modèle où les dépenses conservent plutôt `categorieId`, comparez directement les identifiants : ne changez pas le type métier uniquement pour cet exemple. Le filtrage par recherche normalise la casse et ignore les espaces en début et en fin de texte.
Vérifier le parcours
- Ouvrez le sélecteur de catégorie : ses options proviennent du contexte, pas d’une liste recopiée dans le formulaire.
- Ajoutez une dépense avec une description, un montant positif, une catégorie et une date ; elle apparaît dans la liste dont les critères la laissent passer.
- Choisissez une autre catégorie ou saisissez un fragment de description : seules les dépenses correspondantes sont affichées.
- Supprimez une dépense puis vérifiez que la liste et les données partagées reflètent la suppression, sans recalcul stocké séparément.
Le message de statut accessible déjà prévu pour le projet doit rester persistant dans le DOM. Conservez sa valeur et sa mise à jour après une action réussie comme établi dans « Annoncer les mises à jour utiles » ; le code de liste ci-dessus laisse le texte vide uniquement pour isoler le filtrage et doit être raccordé à votre message existant avant intégration.
À retenir
- Le formulaire valide les valeurs reçues avant de construire une dépense typée.
- Les catégories et les dépenses partagées sont lues et modifiées via le hook dédié ; le contexte reste leur source de vérité.
- Les critères de filtre restent locaux à la liste et les résultats sont calculés à partir des données actuelles.
- Toute modification de l’état partagé se reflète dans la liste sans conserver une copie filtrée.
Dans la leçon suivante, vous optimiserez le récapitulatif mensuel et déterminerez, à partir de mesures, si son calcul justifie une optimisation.