From 8f25e53f115478ac1bcd521600e613fb268893b0 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 17 Aug 2026 00:09:09 +0200 Subject: [PATCH] =?UTF-8?q?Web:=20allergies/intol=C3=A9rances=20s=C3=A9par?= =?UTF-8?q?=C3=A9es=20+=20hot=20saving=20sur=20/foyer=20(step=208/8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retour fonctionnel : allergies et intolérances doivent être distinguées dans l'UI, et /foyer doit sauvegarder à la volée plutôt que via des boutons "Enregistrer". - AllergySelect prend un `legend` en prop au lieu d'un libellé interne fixe — le même composant est rendu deux fois par chaque page consommatrice (HouseholdPage, OnboardingAllergensPage), une fois par `kind` (ALLERGY / INTOLERANCE), la sélection restant une seule liste d'IDs partagée. - HouseholdPage : suppression des boutons "Enregistrer", autosave déclenché depuis le handler onChange de chaque champ (jamais un useEffect générique sur la valeur — se déclencherait aussi au chargement initial, sans distinction propre "chargé" vs "modifié"). Nom du foyer et allergènes/intolérances debouncés (600ms/500ms), régime sauvegardé immédiatement (sélection discrète). Validation client (nom vide) empêche l'autosave plutôt que de déclencher un aller-retour API voué à l'échec. - i18n : household.form.allergiesLabel devient "Allergies" (au lieu de "Allergies & intolérances"), nouvelle clé intolerancesLabel, save/ saved remplacés par saving/saved (plus de bouton à libeller). - Cypress (household.cy.ts réécrit, onboarding.cy.ts mis à jour) + specs/frontend-architecture.md + README.md. Vérifié dans le navigateur : wizard d'inscription affiche bien les deux groupes (12 allergies / 2 intolérances) ; /foyer sans aucun bouton, chaque section sauvegarde automatiquement (vérifié en base après édition du nom du foyer et du régime) ; compte de test nettoyé. Clôt le retour fonctionnel sur la feature profil/foyer/régime/ allergènes (8 commits au total sur cette PR). --- README.md | 21 ++- apps/web/cypress/e2e/household.cy.ts | 45 +++-- apps/web/cypress/e2e/onboarding.cy.ts | 8 +- .../src/features/profile/AllergySelect.tsx | 28 +-- apps/web/src/locales/fr/translation.json | 5 +- apps/web/src/pages/HouseholdPage.scss | 43 ++--- apps/web/src/pages/HouseholdPage.tsx | 173 +++++++++++------- .../onboarding/OnboardingAllergensPage.tsx | 15 +- specs/frontend-architecture.md | 17 +- 9 files changed, 220 insertions(+), 135 deletions(-) diff --git a/README.md b/README.md index e90929c..6a2261d 100644 --- a/README.md +++ b/README.md @@ -281,7 +281,13 @@ parente, pourquoi un composant stub partagé) : contrôlés et "dumb" (reçoivent leurs données en props, ne fetchent rien eux-mêmes), partagés par les deux surfaces ci-dessous. `AllergySelect` utilise une grille de cases à cocher dans un `
`/`` plutôt qu'un - `` — bien plus repérable/tapable, notamment sur mobile. Prend un + `legend` en prop (pas un libellé fixe interne) : le même composant est rendu + **deux fois** par chaque page consommatrice — une fois pour les allergies + (`AllergyView.kind === "ALLERGY"`), une fois pour les intolérances + (`"INTOLERANCE"`) — les deux listes filtrées côté client à partir d'un seul + `GET /reference/allergies`, mais la sélection (`allergyIds`) reste une seule + liste d'IDs partagée entre les deux groupes (une seule `PATCH /profile/allergies`). - `src/pages/onboarding/` — wizard de 3 écrans lancé une fois juste après l'inscription (`OnboardingHouseholdPage` → `OnboardingDietPage` → `OnboardingAllergensPage`, routes `/onboarding/{foyer,regime,allergenes}`). @@ -289,9 +295,16 @@ parente, pourquoi un composant stub partagé) : compris "aucune" pour régime/allergènes) — pas de bouton "Passer" séparé, skip implicite. Routes top-level `RequireAuth`, **pas** nichées sous `AppLayout` : wizard plein écran sans sidebar, même langage visuel que `/login`/`/signup`. -- `src/pages/HouseholdPage.tsx` (routée sur `/foyer`) — mêmes trois réglages, - modifiables à tout moment, chaque section (foyer/régime/allergènes) avec son - propre bouton "Enregistrer" (3 ressources API indépendantes). +- `src/pages/HouseholdPage.tsx` (routée sur `/foyer`) — mêmes réglages, modifiables + à tout moment. **Hot saving** (retour fonctionnel) : pas de bouton "Enregistrer", + chaque section sauvegarde automatiquement peu après la dernière modification — + nom du foyer et allergènes/intolérances debouncés (respectivement 600ms/500ms, + pour ne pas spammer l'API à chaque frappe/case cochée), régime sauvegardé + immédiatement (sélection discrète, pas de saisie continue). Déclenché depuis le + handler `onChange` de chaque champ, jamais depuis un `useEffect` générique qui + observerait la valeur — un tel effect se déclencherait aussi au chargement + initial (quand le `GET` peuple le même state), sans moyen propre de distinguer + "vient d'être chargé" de "vient d'être modifié par l'utilisateur". **Piège trouvé en testant dans le navigateur** : `RedirectIfAuthenticated` (garde de `/login`/`/signup`) réagissait à *chaque* changement de `user`, pas seulement à la diff --git a/apps/web/cypress/e2e/household.cy.ts b/apps/web/cypress/e2e/household.cy.ts index 93fba34..9255797 100644 --- a/apps/web/cypress/e2e/household.cy.ts +++ b/apps/web/cypress/e2e/household.cy.ts @@ -10,7 +10,7 @@ const authenticatedProfile = { dietId: 2, }; -describe("Household & profile settings (/foyer)", () => { +describe("Household & profile settings (/foyer) — hot saving", () => { beforeEach(() => { cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile }); cy.intercept("GET", "**/house/current", { @@ -27,23 +27,30 @@ describe("Household & profile settings (/foyer)", () => { cy.intercept("GET", "**/reference/allergies", { statusCode: 200, body: [ - { id: 1, name: "Arachides" }, - { id: 2, name: "Gluten" }, + { id: 1, name: "Arachides", kind: "ALLERGY" }, + { id: 2, name: "Gluten", kind: "INTOLERANCE" }, ], }); cy.intercept("GET", "**/profile/allergies", { statusCode: 200, body: [2] }); }); - it("loads the current household name, regime and allergens", () => { + it("loads the current household name, regime, and shows allergies/intolerances as two groups", () => { cy.visit("/foyer"); cy.get("#houseName").should("have.value", "Chez Alice"); cy.get("#diet").should("have.value", "2"); + cy.contains("legend", "Allergies").should("be.visible"); + cy.contains("legend", "Intolérances").should("be.visible"); cy.contains("label", "Gluten").find("input[type=checkbox]").should("be.checked"); cy.contains("label", "Arachides").find("input[type=checkbox]").should("not.be.checked"); }); - it("saves the household name independently of the other sections", () => { + it("has no explicit save button anywhere on the page", () => { + cy.visit("/foyer"); + cy.contains("button", "Enregistrer").should("not.exist"); + }); + + it("autosaves the household name a short pause after typing, no button click", () => { cy.intercept("PATCH", "**/house/current", { statusCode: 200, body: { id: 1, name: "Chez les Martin" }, @@ -52,15 +59,22 @@ describe("Household & profile settings (/foyer)", () => { cy.visit("/foyer"); cy.get("#houseName").clear(); cy.get("#houseName").type("Chez les Martin"); - cy.get("#houseName") - .closest("form") - .within(() => cy.contains("button", "Enregistrer").click()); cy.wait("@renameHouse").its("request.body").should("deep.equal", { name: "Chez les Martin" }); - cy.get("#houseName").closest("form").contains("Enregistré ✓").should("be.visible"); + cy.contains("Enregistré ✓").should("be.visible"); }); - it("saves the regime independently of the other sections", () => { + it("does not autosave an empty household name — shows a validation message instead", () => { + cy.intercept("PATCH", "**/house/current").as("renameHouse"); + + cy.visit("/foyer"); + cy.get("#houseName").clear(); + + cy.contains("Le nom du foyer est requis").should("be.visible"); + cy.get("@renameHouse.all").should("have.length", 0); + }); + + it("autosaves the regime as soon as it's selected", () => { cy.intercept("PATCH", "**/profile/diet", { statusCode: 200, body: { ...authenticatedProfile, dietId: 1 }, @@ -68,24 +82,17 @@ describe("Household & profile settings (/foyer)", () => { cy.visit("/foyer"); cy.get("#diet").select("Omnivore"); - cy.get("#diet") - .closest("form") - .within(() => cy.contains("button", "Enregistrer").click()); cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId: 1 }); - cy.get("#diet").closest("form").contains("Enregistré ✓").should("be.visible"); }); - it("saves the allergen selection independently of the other sections", () => { - cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [1, 2] }).as( + it("autosaves allergies and intolerances together after checking boxes", () => { + cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [2, 1] }).as( "updateAllergies", ); cy.visit("/foyer"); cy.contains("label", "Arachides").find("input[type=checkbox]").check(); - cy.contains("fieldset", "Allergies").within(() => { - cy.contains("button", "Enregistrer").click(); - }); cy.wait("@updateAllergies") .its("request.body") diff --git a/apps/web/cypress/e2e/onboarding.cy.ts b/apps/web/cypress/e2e/onboarding.cy.ts index adce3f4..39dbd0f 100644 --- a/apps/web/cypress/e2e/onboarding.cy.ts +++ b/apps/web/cypress/e2e/onboarding.cy.ts @@ -38,8 +38,8 @@ describe("Onboarding wizard (household → regime → allergens)", () => { cy.intercept("GET", "**/reference/allergies", { statusCode: 200, body: [ - { id: 1, name: "Arachides" }, - { id: 2, name: "Gluten" }, + { id: 1, name: "Arachides", kind: "ALLERGY" }, + { id: 2, name: "Gluten", kind: "INTOLERANCE" }, ], }); cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [1] }).as( @@ -71,9 +71,11 @@ describe("Onboarding wizard (household → regime → allergens)", () => { cy.contains("button", "Continuer").click(); cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId: 2 }); - // Step 3/3 — allergens/intolerances, then finish. + // Step 3/3 — allergens (grouped into two lists) and intolerances, then finish. cy.url().should("include", "/onboarding/allergenes"); cy.contains("Étape 3 sur 3").should("be.visible"); + cy.contains("legend", "Allergies").should("be.visible"); + cy.contains("legend", "Intolérances").should("be.visible"); cy.contains("label", "Arachides").find("input[type=checkbox]").check(); cy.contains("button", "Terminer").click(); cy.wait("@updateAllergies") diff --git a/apps/web/src/features/profile/AllergySelect.tsx b/apps/web/src/features/profile/AllergySelect.tsx index ee27554..d271ac3 100644 --- a/apps/web/src/features/profile/AllergySelect.tsx +++ b/apps/web/src/features/profile/AllergySelect.tsx @@ -1,8 +1,8 @@ import type { AllergyView } from "@batch-cooking/shared"; -import { useTranslation } from "react-i18next"; import "./profile-forms.scss"; interface AllergySelectProps { + legend: string; allergies: AllergyView[]; value: number[]; onChange: (allergyIds: number[]) => void; @@ -11,26 +11,30 @@ interface AllergySelectProps { /** * Multi-select (checkbox grid, not a native `>({}); const [houseSaveState, setHouseSaveState] = useState("idle"); const [houseSaveError, setHouseSaveError] = useState(null); + const houseNameTimeout = useRef(undefined); const [diets, setDiets] = useState([]); const [dietId, setDietId] = useState(null); @@ -48,6 +60,7 @@ export function HouseholdPage() { const [allergyIds, setAllergyIds] = useState([]); const [allergySaveState, setAllergySaveState] = useState("idle"); const [allergySaveError, setAllergySaveError] = useState(null); + const allergiesTimeout = useRef(undefined); useEffect(() => { let cancelled = false; @@ -82,30 +95,44 @@ export function HouseholdPage() { }; }, []); - async function handleSaveHouseName(e: FormEvent) { - e.preventDefault(); - const result = renameHouseSchema.safeParse({ name: houseName }); + // Pending debounced saves must not fire after unmount (e.g. the user + // navigates away mid-debounce). + useEffect(() => { + return () => { + window.clearTimeout(houseNameTimeout.current); + window.clearTimeout(allergiesTimeout.current); + }; + }, []); + + function handleHouseNameChange(name: string) { + setHouseName(name); + window.clearTimeout(houseNameTimeout.current); + + const result = renameHouseSchema.safeParse({ name }); if (!result.success) { setHouseNameErrors(fieldErrorsFrom(result.error)); + setHouseSaveState("idle"); return; } setHouseNameErrors({}); setHouseSaveState("saving"); - try { - await apiClient.renameHouse(result.data.name); - setHouseSaveState("saved"); - } catch (err) { - const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR; - setHouseSaveError(errorMessageService.getLabel(code)); - setHouseSaveState("error"); - } + houseNameTimeout.current = window.setTimeout(async () => { + try { + await apiClient.renameHouse(result.data.name); + setHouseSaveState("saved"); + } catch (err) { + const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR; + setHouseSaveError(errorMessageService.getLabel(code)); + setHouseSaveState("error"); + } + }, HOUSE_NAME_DEBOUNCE_MS); } - async function handleSaveDiet(e: FormEvent) { - e.preventDefault(); + async function handleDietChange(newDietId: number | null) { + setDietId(newDietId); setDietSaveState("saving"); try { - await apiClient.updateDiet(dietId); + await apiClient.updateDiet(newDietId); // Keeps AuthContext's `user.dietId` in sync — nothing else reads it // today, but the sidebar/anywhere else that might in the future // shouldn't have to know this page exists to stay correct. @@ -118,17 +145,20 @@ export function HouseholdPage() { } } - async function handleSaveAllergies(e: FormEvent) { - e.preventDefault(); + function handleAllergyIdsChange(newAllergyIds: number[]) { + setAllergyIds(newAllergyIds); + window.clearTimeout(allergiesTimeout.current); setAllergySaveState("saving"); - try { - await apiClient.updateAllergyIds(allergyIds); - setAllergySaveState("saved"); - } catch (err) { - const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR; - setAllergySaveError(errorMessageService.getLabel(code)); - setAllergySaveState("error"); - } + allergiesTimeout.current = window.setTimeout(async () => { + try { + await apiClient.updateAllergyIds(newAllergyIds); + setAllergySaveState("saved"); + } catch (err) { + const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR; + setAllergySaveError(errorMessageService.getLabel(code)); + setAllergySaveState("error"); + } + }, ALLERGIES_DEBOUNCE_MS); } if (isLoading) { @@ -153,38 +183,57 @@ export function HouseholdPage() {

{t("household.title")}

-
- - {houseSaveState === "error" &&

{houseSaveError}

} - - {houseSaveState === "saved" && ( - {t("household.form.saved")} - )} - +
+ + +
-
- - {dietSaveState === "error" &&

{dietSaveError}

} - - {dietSaveState === "saved" && ( - {t("household.form.saved")} - )} - +
+ + +
-
- - {allergySaveState === "error" &&

{allergySaveError}

} - - {allergySaveState === "saved" && ( - {t("household.form.saved")} - )} - +
+ allergy.kind === "ALLERGY")} + value={allergyIds} + onChange={handleAllergyIdsChange} + /> + allergy.kind === "INTOLERANCE")} + value={allergyIds} + onChange={handleAllergyIdsChange} + /> + +
); } + +/** Inline "saving…"/"saved ✓"/error feedback shared by every autosaved section — `idle` renders nothing. */ +function SaveStatus({ + state, + error, + t, +}: { + state: SaveState; + error: string | null; + t: (key: string) => string; +}) { + if (state === "saving") { + return

{t("household.form.saving")}

; + } + if (state === "saved") { + return

{t("household.form.saved")}

; + } + if (state === "error") { + return

{error}

; + } + return null; +} diff --git a/apps/web/src/pages/onboarding/OnboardingAllergensPage.tsx b/apps/web/src/pages/onboarding/OnboardingAllergensPage.tsx index ff433a2..bb6a1d4 100644 --- a/apps/web/src/pages/onboarding/OnboardingAllergensPage.tsx +++ b/apps/web/src/pages/onboarding/OnboardingAllergensPage.tsx @@ -66,7 +66,20 @@ export function OnboardingAllergensPage() { {isLoading ? (

{t("onboarding.loading")}

) : ( - + <> + allergy.kind === "ALLERGY")} + value={allergyIds} + onChange={setAllergyIds} + /> + allergy.kind === "INTOLERANCE")} + value={allergyIds} + onChange={setAllergyIds} + /> + )} {formError &&

{formError}

} diff --git a/specs/frontend-architecture.md b/specs/frontend-architecture.md index 46b51a3..74436e2 100644 --- a/specs/frontend-architecture.md +++ b/specs/frontend-architecture.md @@ -154,14 +154,23 @@ flowchart LR Chaque étape a un unique bouton "Continuer" qui envoie la valeur courante — pas de bouton "Passer" séparé, une valeur "aucune"/vide *est* le skip. - **`pages/HouseholdPage.tsx`** (routée `/foyer`, dans `AppLayout`) — les mêmes - trois réglages, éditables à tout moment. Trois sections, trois boutons - "Enregistrer" indépendants (3 ressources API distinctes : `PATCH /house/current`, - `/profile/diet`, `/profile/allergies`). + réglages, éditables à tout moment, en **hot saving** (retour fonctionnel : pas de + bouton "Enregistrer"). Chaque section sauvegarde peu après la dernière + modification (nom du foyer et allergènes/intolérances debouncés — 600ms/500ms — + régime immédiat) — 3 ressources API indépendantes (`PATCH /house/current`, + `/profile/diet`, `/profile/allergies`), 3 cycles de sauvegarde indépendants. + Déclenché depuis le handler `onChange` de chaque champ, jamais un `useEffect` + générique sur la valeur — un tel effect se déclencherait aussi au chargement + initial (le `GET` peuple le même state), sans distinction propre entre "vient + d'être chargé" et "vient d'être modifié". - **`features/profile/`** — `HouseNameField`, `DietSelect`, `AllergySelect` : champs contrôlés, "dumb" (reçoivent `diets`/`allergies` en props plutôt que de les fetcher). `AllergySelect` est un `
`/`` + grille de cases à cocher, pas un `