Web: allergies/intolérances séparées + hot saving sur /foyer (step 8/8)
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).
This commit is contained in:
parent
6cfa71730c
commit
8f25e53f11
9 changed files with 220 additions and 135 deletions
21
README.md
21
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
|
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
|
eux-mêmes), partagés par les deux surfaces ci-dessous. `AllergySelect` utilise une
|
||||||
grille de cases à cocher dans un `<fieldset>`/`<legend>` plutôt qu'un
|
grille de cases à cocher dans un `<fieldset>`/`<legend>` plutôt qu'un
|
||||||
`<select multiple>` — bien plus repérable/tapable, notamment sur mobile.
|
`<select multiple>` — 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
|
- `src/pages/onboarding/` — wizard de 3 écrans lancé une fois juste après
|
||||||
l'inscription (`OnboardingHouseholdPage` → `OnboardingDietPage` →
|
l'inscription (`OnboardingHouseholdPage` → `OnboardingDietPage` →
|
||||||
`OnboardingAllergensPage`, routes `/onboarding/{foyer,regime,allergenes}`).
|
`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
|
compris "aucune" pour régime/allergènes) — pas de bouton "Passer" séparé, skip
|
||||||
implicite. Routes top-level `RequireAuth`, **pas** nichées sous `AppLayout` :
|
implicite. Routes top-level `RequireAuth`, **pas** nichées sous `AppLayout` :
|
||||||
wizard plein écran sans sidebar, même langage visuel que `/login`/`/signup`.
|
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,
|
- `src/pages/HouseholdPage.tsx` (routée sur `/foyer`) — mêmes réglages, modifiables
|
||||||
modifiables à tout moment, chaque section (foyer/régime/allergènes) avec son
|
à tout moment. **Hot saving** (retour fonctionnel) : pas de bouton "Enregistrer",
|
||||||
propre bouton "Enregistrer" (3 ressources API indépendantes).
|
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
|
**Piège trouvé en testant dans le navigateur** : `RedirectIfAuthenticated` (garde de
|
||||||
`/login`/`/signup`) réagissait à *chaque* changement de `user`, pas seulement à la
|
`/login`/`/signup`) réagissait à *chaque* changement de `user`, pas seulement à la
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ const authenticatedProfile = {
|
||||||
dietId: 2,
|
dietId: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("Household & profile settings (/foyer)", () => {
|
describe("Household & profile settings (/foyer) — hot saving", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
||||||
cy.intercept("GET", "**/house/current", {
|
cy.intercept("GET", "**/house/current", {
|
||||||
|
|
@ -27,23 +27,30 @@ describe("Household & profile settings (/foyer)", () => {
|
||||||
cy.intercept("GET", "**/reference/allergies", {
|
cy.intercept("GET", "**/reference/allergies", {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: [
|
body: [
|
||||||
{ id: 1, name: "Arachides" },
|
{ id: 1, name: "Arachides", kind: "ALLERGY" },
|
||||||
{ id: 2, name: "Gluten" },
|
{ id: 2, name: "Gluten", kind: "INTOLERANCE" },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
cy.intercept("GET", "**/profile/allergies", { statusCode: 200, body: [2] });
|
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.visit("/foyer");
|
||||||
|
|
||||||
cy.get("#houseName").should("have.value", "Chez Alice");
|
cy.get("#houseName").should("have.value", "Chez Alice");
|
||||||
cy.get("#diet").should("have.value", "2");
|
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", "Gluten").find("input[type=checkbox]").should("be.checked");
|
||||||
cy.contains("label", "Arachides").find("input[type=checkbox]").should("not.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", {
|
cy.intercept("PATCH", "**/house/current", {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: { id: 1, name: "Chez les Martin" },
|
body: { id: 1, name: "Chez les Martin" },
|
||||||
|
|
@ -52,15 +59,22 @@ describe("Household & profile settings (/foyer)", () => {
|
||||||
cy.visit("/foyer");
|
cy.visit("/foyer");
|
||||||
cy.get("#houseName").clear();
|
cy.get("#houseName").clear();
|
||||||
cy.get("#houseName").type("Chez les Martin");
|
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.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", {
|
cy.intercept("PATCH", "**/profile/diet", {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: { ...authenticatedProfile, dietId: 1 },
|
body: { ...authenticatedProfile, dietId: 1 },
|
||||||
|
|
@ -68,24 +82,17 @@ describe("Household & profile settings (/foyer)", () => {
|
||||||
|
|
||||||
cy.visit("/foyer");
|
cy.visit("/foyer");
|
||||||
cy.get("#diet").select("Omnivore");
|
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.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", () => {
|
it("autosaves allergies and intolerances together after checking boxes", () => {
|
||||||
cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [1, 2] }).as(
|
cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [2, 1] }).as(
|
||||||
"updateAllergies",
|
"updateAllergies",
|
||||||
);
|
);
|
||||||
|
|
||||||
cy.visit("/foyer");
|
cy.visit("/foyer");
|
||||||
cy.contains("label", "Arachides").find("input[type=checkbox]").check();
|
cy.contains("label", "Arachides").find("input[type=checkbox]").check();
|
||||||
cy.contains("fieldset", "Allergies").within(() => {
|
|
||||||
cy.contains("button", "Enregistrer").click();
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.wait("@updateAllergies")
|
cy.wait("@updateAllergies")
|
||||||
.its("request.body")
|
.its("request.body")
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,8 @@ describe("Onboarding wizard (household → regime → allergens)", () => {
|
||||||
cy.intercept("GET", "**/reference/allergies", {
|
cy.intercept("GET", "**/reference/allergies", {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: [
|
body: [
|
||||||
{ id: 1, name: "Arachides" },
|
{ id: 1, name: "Arachides", kind: "ALLERGY" },
|
||||||
{ id: 2, name: "Gluten" },
|
{ id: 2, name: "Gluten", kind: "INTOLERANCE" },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
cy.intercept("PATCH", "**/profile/allergies", { statusCode: 200, body: [1] }).as(
|
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.contains("button", "Continuer").click();
|
||||||
cy.wait("@updateDiet").its("request.body").should("deep.equal", { dietId: 2 });
|
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.url().should("include", "/onboarding/allergenes");
|
||||||
cy.contains("Étape 3 sur 3").should("be.visible");
|
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("label", "Arachides").find("input[type=checkbox]").check();
|
||||||
cy.contains("button", "Terminer").click();
|
cy.contains("button", "Terminer").click();
|
||||||
cy.wait("@updateAllergies")
|
cy.wait("@updateAllergies")
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import type { AllergyView } from "@batch-cooking/shared";
|
import type { AllergyView } from "@batch-cooking/shared";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import "./profile-forms.scss";
|
import "./profile-forms.scss";
|
||||||
|
|
||||||
interface AllergySelectProps {
|
interface AllergySelectProps {
|
||||||
|
legend: string;
|
||||||
allergies: AllergyView[];
|
allergies: AllergyView[];
|
||||||
value: number[];
|
value: number[];
|
||||||
onChange: (allergyIds: number[]) => void;
|
onChange: (allergyIds: number[]) => void;
|
||||||
|
|
@ -11,26 +11,30 @@ interface AllergySelectProps {
|
||||||
/**
|
/**
|
||||||
* Multi-select (checkbox grid, not a native `<select multiple>` — far more
|
* Multi-select (checkbox grid, not a native `<select multiple>` — far more
|
||||||
* discoverable/tappable, especially on the mobile viewport this app is
|
* discoverable/tappable, especially on the mobile viewport this app is
|
||||||
* eventually embedded into via Capacitor) for allergens/intolerances. Used
|
* eventually embedded into via Capacitor) for a group of allergens. Used
|
||||||
* both by the signup wizard's allergens step and the `/foyer` settings
|
* both by the signup wizard's allergens step and the `/foyer` settings
|
||||||
* page. An empty `value` is a normal, valid state (no declared allergies,
|
* page, and rendered *twice* by each — once for allergies, once for
|
||||||
* or this skippable step was skipped), not an incomplete one.
|
* intolerances (`AllergyView.kind` groups them; callers filter and pass
|
||||||
|
* two separate lists rather than this component knowing about the split).
|
||||||
|
* An empty `value` is a normal, valid state (no declared allergies, or
|
||||||
|
* this skippable step was skipped), not an incomplete one.
|
||||||
*
|
*
|
||||||
* A `<fieldset>`/`<legend>` (not a bare `<label>`, which only associates
|
* `legend` (not a fixed internal label) — the same component serves both
|
||||||
* with a single control) — the correct semantic label for a group of
|
* groups, only the heading differs. A `<fieldset>`/`<legend>` (not a bare
|
||||||
* checkboxes. Receives `allergies` as a prop rather than fetching them
|
* `<label>`, which only associates with a single control) is the correct
|
||||||
* itself — same rationale as `DietSelect`.
|
* semantic label for a group of checkboxes.
|
||||||
|
*
|
||||||
|
* Receives `allergies` as a prop rather than fetching them itself — same
|
||||||
|
* rationale as `DietSelect`.
|
||||||
*/
|
*/
|
||||||
export function AllergySelect({ allergies, value, onChange }: AllergySelectProps) {
|
export function AllergySelect({ legend, allergies, value, onChange }: AllergySelectProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
function toggle(id: number) {
|
function toggle(id: number) {
|
||||||
onChange(value.includes(id) ? value.filter((existing) => existing !== id) : [...value, id]);
|
onChange(value.includes(id) ? value.filter((existing) => existing !== id) : [...value, id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<fieldset className="allergy-select">
|
<fieldset className="allergy-select">
|
||||||
<legend>{t("household.form.allergiesLabel")}</legend>
|
<legend>{legend}</legend>
|
||||||
{allergies.map((allergy) => (
|
{allergies.map((allergy) => (
|
||||||
<label key={allergy.id} className="allergy-select__option">
|
<label key={allergy.id} className="allergy-select__option">
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -82,8 +82,9 @@
|
||||||
"nameLabel": "Nom du foyer",
|
"nameLabel": "Nom du foyer",
|
||||||
"dietLabel": "Régime alimentaire",
|
"dietLabel": "Régime alimentaire",
|
||||||
"dietNone": "Aucun régime particulier",
|
"dietNone": "Aucun régime particulier",
|
||||||
"allergiesLabel": "Allergies & intolérances",
|
"allergiesLabel": "Allergies",
|
||||||
"save": "Enregistrer",
|
"intolerancesLabel": "Intolérances",
|
||||||
|
"saving": "Enregistrement…",
|
||||||
"saved": "Enregistré ✓"
|
"saved": "Enregistré ✓"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Each of the three settings (household name, regime, allergens) is its
|
// Each of the three settings (household name, regime, allergies +
|
||||||
// own independently-saved section — a card per section, same surface
|
// intolerances) is its own independently-autosaved section — a card per
|
||||||
// treatment used elsewhere (see .planning-table in HomePage.scss), so each
|
// section, same surface treatment used elsewhere (see .planning-table in
|
||||||
// reads as a distinct, self-contained unit rather than one long form.
|
// HomePage.scss), so each reads as a distinct, self-contained unit rather
|
||||||
|
// than one long form. No buttons here (hot saving — see HouseholdPage.tsx).
|
||||||
.household-page__section {
|
.household-page__section {
|
||||||
max-width: 32rem;
|
max-width: 32rem;
|
||||||
margin-top: var(--space-lg);
|
margin-top: var(--space-lg);
|
||||||
|
|
@ -27,33 +28,19 @@
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
|
|
||||||
button {
|
|
||||||
margin-top: var(--space-md);
|
|
||||||
padding: 0.5rem var(--space-lg);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
border: none;
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: #fff;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: var(--color-primary-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.household-page__saving,
|
||||||
.household-page__saved {
|
.household-page__saved {
|
||||||
margin-left: var(--space-sm);
|
margin: var(--space-sm) 0 0;
|
||||||
color: var(--color-success);
|
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.household-page__saving {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.household-page__saved {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import {
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
renameHouseSchema,
|
renameHouseSchema,
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
import { type FormEvent, useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ApiError, apiClient } from "../api/client";
|
import { ApiError, apiClient } from "../api/client";
|
||||||
import { useAuth } from "../features/auth/AuthContext";
|
import { useAuth } from "../features/auth/AuthContext";
|
||||||
|
|
@ -15,17 +15,28 @@ import { fieldErrorsFrom } from "../lib/zod-errors";
|
||||||
import { errorMessageService } from "../services/error-message.service";
|
import { errorMessageService } from "../services/error-message.service";
|
||||||
import "./HouseholdPage.scss";
|
import "./HouseholdPage.scss";
|
||||||
|
|
||||||
/** Status of one section's own save action — sections save independently, each with its own feedback. */
|
/** Status of one section's own autosave — sections save independently, each with its own feedback. */
|
||||||
type SaveState = "idle" | "saving" | "saved" | "error";
|
type SaveState = "idle" | "saving" | "saved" | "error";
|
||||||
|
|
||||||
|
/** Debounce for the household name field (typing) — long enough that saves don't fire on every keystroke. */
|
||||||
|
const HOUSE_NAME_DEBOUNCE_MS = 600;
|
||||||
|
/** Debounce for the allergen checkboxes — coalesces a quick burst of several toggles into one request. */
|
||||||
|
const ALLERGIES_DEBOUNCE_MS = 500;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Household & profile settings — routed at `/foyer`. The always-available
|
* Household & profile settings — routed at `/foyer`. The always-available
|
||||||
* counterpart to the signup wizard (`pages/onboarding/`): same three
|
* counterpart to the signup wizard (`pages/onboarding/`): same concerns
|
||||||
* concerns (household name, dietary regime, allergens/intolerances), same
|
* (household name, dietary regime, allergies, intolerances), same shared
|
||||||
* shared field components, but editable at any time rather than run once.
|
* field components, but editable at any time rather than run once.
|
||||||
* Each section saves independently (three separate resources server-side —
|
*
|
||||||
* `PATCH /house/current`, `/profile/diet`, `/profile/allergies` — so there's
|
* Hot saving (retour fonctionnel) — no "Enregistrer" buttons; each section
|
||||||
* no reason a change to one has to wait on the others).
|
* autosaves shortly after the user stops changing it. Saves are triggered
|
||||||
|
* from the field's own `onChange` handler, *not* a generic `useEffect`
|
||||||
|
* watching the value: an effect keyed on the value would also fire the
|
||||||
|
* moment the initial `GET` calls populate that same state, with no clean
|
||||||
|
* way to tell "just loaded" apart from "user edited" — routing every save
|
||||||
|
* through an explicit handler sidesteps that entirely, since the initial
|
||||||
|
* load never goes through these handlers.
|
||||||
*/
|
*/
|
||||||
export function HouseholdPage() {
|
export function HouseholdPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -38,6 +49,7 @@ export function HouseholdPage() {
|
||||||
const [houseNameErrors, setHouseNameErrors] = useState<Record<string, string>>({});
|
const [houseNameErrors, setHouseNameErrors] = useState<Record<string, string>>({});
|
||||||
const [houseSaveState, setHouseSaveState] = useState<SaveState>("idle");
|
const [houseSaveState, setHouseSaveState] = useState<SaveState>("idle");
|
||||||
const [houseSaveError, setHouseSaveError] = useState<string | null>(null);
|
const [houseSaveError, setHouseSaveError] = useState<string | null>(null);
|
||||||
|
const houseNameTimeout = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
const [diets, setDiets] = useState<DietView[]>([]);
|
const [diets, setDiets] = useState<DietView[]>([]);
|
||||||
const [dietId, setDietId] = useState<number | null>(null);
|
const [dietId, setDietId] = useState<number | null>(null);
|
||||||
|
|
@ -48,6 +60,7 @@ export function HouseholdPage() {
|
||||||
const [allergyIds, setAllergyIds] = useState<number[]>([]);
|
const [allergyIds, setAllergyIds] = useState<number[]>([]);
|
||||||
const [allergySaveState, setAllergySaveState] = useState<SaveState>("idle");
|
const [allergySaveState, setAllergySaveState] = useState<SaveState>("idle");
|
||||||
const [allergySaveError, setAllergySaveError] = useState<string | null>(null);
|
const [allergySaveError, setAllergySaveError] = useState<string | null>(null);
|
||||||
|
const allergiesTimeout = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -82,15 +95,28 @@ export function HouseholdPage() {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function handleSaveHouseName(e: FormEvent) {
|
// Pending debounced saves must not fire after unmount (e.g. the user
|
||||||
e.preventDefault();
|
// navigates away mid-debounce).
|
||||||
const result = renameHouseSchema.safeParse({ name: houseName });
|
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) {
|
if (!result.success) {
|
||||||
setHouseNameErrors(fieldErrorsFrom(result.error));
|
setHouseNameErrors(fieldErrorsFrom(result.error));
|
||||||
|
setHouseSaveState("idle");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setHouseNameErrors({});
|
setHouseNameErrors({});
|
||||||
setHouseSaveState("saving");
|
setHouseSaveState("saving");
|
||||||
|
houseNameTimeout.current = window.setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
await apiClient.renameHouse(result.data.name);
|
await apiClient.renameHouse(result.data.name);
|
||||||
setHouseSaveState("saved");
|
setHouseSaveState("saved");
|
||||||
|
|
@ -99,13 +125,14 @@ export function HouseholdPage() {
|
||||||
setHouseSaveError(errorMessageService.getLabel(code));
|
setHouseSaveError(errorMessageService.getLabel(code));
|
||||||
setHouseSaveState("error");
|
setHouseSaveState("error");
|
||||||
}
|
}
|
||||||
|
}, HOUSE_NAME_DEBOUNCE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSaveDiet(e: FormEvent) {
|
async function handleDietChange(newDietId: number | null) {
|
||||||
e.preventDefault();
|
setDietId(newDietId);
|
||||||
setDietSaveState("saving");
|
setDietSaveState("saving");
|
||||||
try {
|
try {
|
||||||
await apiClient.updateDiet(dietId);
|
await apiClient.updateDiet(newDietId);
|
||||||
// Keeps AuthContext's `user.dietId` in sync — nothing else reads it
|
// Keeps AuthContext's `user.dietId` in sync — nothing else reads it
|
||||||
// today, but the sidebar/anywhere else that might in the future
|
// today, but the sidebar/anywhere else that might in the future
|
||||||
// shouldn't have to know this page exists to stay correct.
|
// shouldn't have to know this page exists to stay correct.
|
||||||
|
|
@ -118,17 +145,20 @@ export function HouseholdPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSaveAllergies(e: FormEvent) {
|
function handleAllergyIdsChange(newAllergyIds: number[]) {
|
||||||
e.preventDefault();
|
setAllergyIds(newAllergyIds);
|
||||||
|
window.clearTimeout(allergiesTimeout.current);
|
||||||
setAllergySaveState("saving");
|
setAllergySaveState("saving");
|
||||||
|
allergiesTimeout.current = window.setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
await apiClient.updateAllergyIds(allergyIds);
|
await apiClient.updateAllergyIds(newAllergyIds);
|
||||||
setAllergySaveState("saved");
|
setAllergySaveState("saved");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
||||||
setAllergySaveError(errorMessageService.getLabel(code));
|
setAllergySaveError(errorMessageService.getLabel(code));
|
||||||
setAllergySaveState("error");
|
setAllergySaveState("error");
|
||||||
}
|
}
|
||||||
|
}, ALLERGIES_DEBOUNCE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|
@ -153,38 +183,57 @@ export function HouseholdPage() {
|
||||||
<div className="household-page">
|
<div className="household-page">
|
||||||
<h1>{t("household.title")}</h1>
|
<h1>{t("household.title")}</h1>
|
||||||
|
|
||||||
<form className="household-page__section" onSubmit={handleSaveHouseName} noValidate>
|
<div className="household-page__section">
|
||||||
<HouseNameField value={houseName} onChange={setHouseName} error={houseNameErrors.name} />
|
<HouseNameField
|
||||||
{houseSaveState === "error" && <p className="field-error">{houseSaveError}</p>}
|
value={houseName}
|
||||||
<button type="submit" disabled={houseSaveState === "saving"}>
|
onChange={handleHouseNameChange}
|
||||||
{t("household.form.save")}
|
error={houseNameErrors.name}
|
||||||
</button>
|
/>
|
||||||
{houseSaveState === "saved" && (
|
<SaveStatus state={houseSaveState} error={houseSaveError} t={t} />
|
||||||
<span className="household-page__saved">{t("household.form.saved")}</span>
|
</div>
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<form className="household-page__section" onSubmit={handleSaveDiet} noValidate>
|
<div className="household-page__section">
|
||||||
<DietSelect diets={diets} value={dietId} onChange={setDietId} />
|
<DietSelect diets={diets} value={dietId} onChange={handleDietChange} />
|
||||||
{dietSaveState === "error" && <p className="field-error">{dietSaveError}</p>}
|
<SaveStatus state={dietSaveState} error={dietSaveError} t={t} />
|
||||||
<button type="submit" disabled={dietSaveState === "saving"}>
|
</div>
|
||||||
{t("household.form.save")}
|
|
||||||
</button>
|
|
||||||
{dietSaveState === "saved" && (
|
|
||||||
<span className="household-page__saved">{t("household.form.saved")}</span>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<form className="household-page__section" onSubmit={handleSaveAllergies} noValidate>
|
<div className="household-page__section">
|
||||||
<AllergySelect allergies={allergies} value={allergyIds} onChange={setAllergyIds} />
|
<AllergySelect
|
||||||
{allergySaveState === "error" && <p className="field-error">{allergySaveError}</p>}
|
legend={t("household.form.allergiesLabel")}
|
||||||
<button type="submit" disabled={allergySaveState === "saving"}>
|
allergies={allergies.filter((allergy) => allergy.kind === "ALLERGY")}
|
||||||
{t("household.form.save")}
|
value={allergyIds}
|
||||||
</button>
|
onChange={handleAllergyIdsChange}
|
||||||
{allergySaveState === "saved" && (
|
/>
|
||||||
<span className="household-page__saved">{t("household.form.saved")}</span>
|
<AllergySelect
|
||||||
)}
|
legend={t("household.form.intolerancesLabel")}
|
||||||
</form>
|
allergies={allergies.filter((allergy) => allergy.kind === "INTOLERANCE")}
|
||||||
|
value={allergyIds}
|
||||||
|
onChange={handleAllergyIdsChange}
|
||||||
|
/>
|
||||||
|
<SaveStatus state={allergySaveState} error={allergySaveError} t={t} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 <p className="household-page__saving">{t("household.form.saving")}</p>;
|
||||||
|
}
|
||||||
|
if (state === "saved") {
|
||||||
|
return <p className="household-page__saved">{t("household.form.saved")}</p>;
|
||||||
|
}
|
||||||
|
if (state === "error") {
|
||||||
|
return <p className="field-error">{error}</p>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,20 @@ export function OnboardingAllergensPage() {
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p>{t("onboarding.loading")}</p>
|
<p>{t("onboarding.loading")}</p>
|
||||||
) : (
|
) : (
|
||||||
<AllergySelect allergies={allergies} value={allergyIds} onChange={setAllergyIds} />
|
<>
|
||||||
|
<AllergySelect
|
||||||
|
legend={t("household.form.allergiesLabel")}
|
||||||
|
allergies={allergies.filter((allergy) => allergy.kind === "ALLERGY")}
|
||||||
|
value={allergyIds}
|
||||||
|
onChange={setAllergyIds}
|
||||||
|
/>
|
||||||
|
<AllergySelect
|
||||||
|
legend={t("household.form.intolerancesLabel")}
|
||||||
|
allergies={allergies.filter((allergy) => allergy.kind === "INTOLERANCE")}
|
||||||
|
value={allergyIds}
|
||||||
|
onChange={setAllergyIds}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{formError && <p className="form-error">{formError}</p>}
|
{formError && <p className="form-error">{formError}</p>}
|
||||||
|
|
|
||||||
|
|
@ -154,14 +154,23 @@ flowchart LR
|
||||||
Chaque étape a un unique bouton "Continuer" qui envoie la valeur courante — pas de
|
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.
|
bouton "Passer" séparé, une valeur "aucune"/vide *est* le skip.
|
||||||
- **`pages/HouseholdPage.tsx`** (routée `/foyer`, dans `AppLayout`) — les mêmes
|
- **`pages/HouseholdPage.tsx`** (routée `/foyer`, dans `AppLayout`) — les mêmes
|
||||||
trois réglages, éditables à tout moment. Trois sections, trois boutons
|
réglages, éditables à tout moment, en **hot saving** (retour fonctionnel : pas de
|
||||||
"Enregistrer" indépendants (3 ressources API distinctes : `PATCH /house/current`,
|
bouton "Enregistrer"). Chaque section sauvegarde peu après la dernière
|
||||||
`/profile/diet`, `/profile/allergies`).
|
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
|
- **`features/profile/`** — `HouseNameField`, `DietSelect`, `AllergySelect` : champs
|
||||||
contrôlés, "dumb" (reçoivent `diets`/`allergies` en props plutôt que de les
|
contrôlés, "dumb" (reçoivent `diets`/`allergies` en props plutôt que de les
|
||||||
fetcher). `AllergySelect` est un `<fieldset>`/`<legend>` + grille de cases à
|
fetcher). `AllergySelect` est un `<fieldset>`/`<legend>` + grille de cases à
|
||||||
cocher, pas un `<select multiple>` — bien plus repérable/tapable, notamment sur
|
cocher, pas un `<select multiple>` — bien plus repérable/tapable, notamment sur
|
||||||
mobile (voir la note Capacitor plus haut).
|
mobile (voir la note Capacitor plus haut). Prend un `legend` en prop : chaque
|
||||||
|
page consommatrice le rend **deux fois** (allergies / intolérances, filtrées
|
||||||
|
côté client via `AllergyView.kind`), mais la sélection reste une seule liste
|
||||||
|
d'IDs partagée entre les deux groupes.
|
||||||
|
|
||||||
### Deux bugs de state trouvés en testant dans le navigateur
|
### Deux bugs de state trouvés en testant dans le navigateur
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue