Tests + docs: sidebar/planning Cypress coverage, frontend-architecture.md (step 5/5)
- New apps/web/cypress/e2e/home-planning.cy.ts (mocked API, same cy.intercept convention as auth.cy.ts): - sidebar nav between sections + active-link highlighting - user name + logout from the sidebar footer - home planning: empty / loaded (table rows) / error states - specs/frontend-architecture.md: documents AppLayout (single RequireAuth+AppLayout parent route, nested routes via <Outlet />), the ComingSoonPage stub pattern, updated folder tree and i18n namespace list, updated routing diagram. - README.md: new "Accueil, sidebar & sections" section; notes the Cypress-can't-run-headless-here environment limitation (confirmed pre-existing on main) and the docker-vs-local-dev CORS_ORIGIN gotcha hit while manually verifying this feature. Closes out the home-page-after-login feature (5 commits, this PR): GET /planning/current -> AppLayout -> routing/stub pages -> HomePage planning view -> this commit.
This commit is contained in:
parent
ac7f8d9685
commit
72472581d6
3 changed files with 187 additions and 14 deletions
32
README.md
32
README.md
|
|
@ -204,11 +204,33 @@ a mis au jour une contrainte générique trop stricte, corrigée à la source :
|
||||||
Détail de l'organisation complète (dossiers, routing, SCSS/theming) :
|
Détail de l'organisation complète (dossiers, routing, SCSS/theming) :
|
||||||
[specs/frontend-architecture.md](specs/frontend-architecture.md).
|
[specs/frontend-architecture.md](specs/frontend-architecture.md).
|
||||||
|
|
||||||
Tests Cypress (`apps/web/cypress/e2e/`) : `smoke.cy.ts` + `auth.cy.ts` mockent l'API via
|
## Accueil, sidebar & sections (apps/web)
|
||||||
`cy.intercept` plutôt que de dépendre d'un vrai backend — le job e2e de la CI ne
|
|
||||||
provisionne pas de Postgres/API, seulement le serveur de dev Vite. Le comportement
|
Une fois connecté, l'utilisateur atterrit sur `src/layouts/AppLayout.tsx` — sidebar
|
||||||
réel de l'API est couvert par les suites Mocha/Cucumber d'`apps/api` (contre une vraie
|
(nav Planning/Recettes/Liste de courses/Foyer & profil + nom/déconnexion en pied) et
|
||||||
base).
|
`<Outlet />` pour la route active — montée une seule fois comme route parente de tout
|
||||||
|
l'espace authentifié (`App.tsx`), pas dupliquée par page. `src/pages/HomePage.tsx`
|
||||||
|
(routée sur `/`) affiche le planning de la semaine du foyer (`GET /planning/current`,
|
||||||
|
voir plus haut) avec ses états chargement/erreur/vide/rempli ; `Recettes`, `Liste de
|
||||||
|
courses` et `Foyer & profil` n'ont pas encore de backend dédié et rendent pour
|
||||||
|
l'instant le même composant `ComingSoonPage`. Détail complet (pourquoi une seule
|
||||||
|
route parente, pourquoi un composant stub partagé) :
|
||||||
|
[specs/frontend-architecture.md](specs/frontend-architecture.md#applayout--sidebar-commune-à-lespace-connecté).
|
||||||
|
|
||||||
|
Tests Cypress (`apps/web/cypress/e2e/`) : `smoke.cy.ts` + `auth.cy.ts` +
|
||||||
|
`home-planning.cy.ts` mockent l'API via `cy.intercept` plutôt que de dépendre d'un
|
||||||
|
vrai backend — le job e2e de la CI ne provisionne pas de Postgres/API, seulement le
|
||||||
|
serveur de dev Vite. Le comportement réel de l'API est couvert par les suites
|
||||||
|
Mocha/Cucumber d'`apps/api` (contre une vraie base).
|
||||||
|
|
||||||
|
> **Cypress ne peut pas tourner en local dans un environnement Windows sandboxé** :
|
||||||
|
> Chromium/Electron headless plante au lancement du process GPU
|
||||||
|
> (`GPU process isn't usable`), reproductible sur `main` aussi bien que sur une
|
||||||
|
> branche de feature — pas un problème introduit par une modification du code.
|
||||||
|
> `pnpm --filter web e2e` fonctionne normalement en CI (GitHub Actions) et sur une
|
||||||
|
> machine de dev classique ; dans cet environnement précis, vérifier manuellement via
|
||||||
|
> le serveur de dev (`pnpm dev:web` + `pnpm dev:api` en local, pas les conteneurs
|
||||||
|
> Docker dont le `CORS_ORIGIN` cible `localhost:8080`, pas `localhost:5173`).
|
||||||
|
|
||||||
## Gestion des erreurs (API ↔ web)
|
## Gestion des erreurs (API ↔ web)
|
||||||
|
|
||||||
|
|
|
||||||
106
apps/web/cypress/e2e/home-planning.cy.ts
Normal file
106
apps/web/cypress/e2e/home-planning.cy.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
// Mocks the API via cy.intercept — see auth.cy.ts for the rationale (no
|
||||||
|
// live backend in this CI job; apps/api's own Mocha/Cucumber suites cover
|
||||||
|
// real API behavior against a real database).
|
||||||
|
|
||||||
|
const authenticatedProfile = {
|
||||||
|
id: 1,
|
||||||
|
firstName: "Alice",
|
||||||
|
lastName: "Martin",
|
||||||
|
email: "alice@example.com",
|
||||||
|
tokenVersion: 0,
|
||||||
|
houseId: 1,
|
||||||
|
dietId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Sidebar navigation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
||||||
|
cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null });
|
||||||
|
cy.visit("/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("highlights the current section and navigates between stub pages", () => {
|
||||||
|
cy.contains("nav a", "Planning").should("have.class", "active");
|
||||||
|
|
||||||
|
cy.contains("nav a", "Recettes").click();
|
||||||
|
cy.url().should("include", "/recettes");
|
||||||
|
cy.contains("h1", "Recettes").should("be.visible");
|
||||||
|
cy.contains("nav a", "Recettes").should("have.class", "active");
|
||||||
|
cy.contains("nav a", "Planning").should("not.have.class", "active");
|
||||||
|
|
||||||
|
cy.contains("nav a", "Liste de courses").click();
|
||||||
|
cy.url().should("include", "/liste-de-courses");
|
||||||
|
cy.contains("h1", "Liste de courses").should("be.visible");
|
||||||
|
|
||||||
|
cy.contains("nav a", "Foyer & profil").click();
|
||||||
|
cy.url().should("include", "/foyer");
|
||||||
|
cy.contains("h1", "Foyer & profil").should("be.visible");
|
||||||
|
|
||||||
|
cy.contains("nav a", "Planning").click();
|
||||||
|
cy.url().should("eq", `${Cypress.config().baseUrl}/`);
|
||||||
|
cy.contains("h1", "Planning de la semaine").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the signed-in user's name and lets them log out from the sidebar", () => {
|
||||||
|
cy.intercept("POST", "**/auth/logout", { statusCode: 204 }).as("logout");
|
||||||
|
|
||||||
|
cy.contains("Bonjour Alice").should("be.visible");
|
||||||
|
cy.contains("button", "Se déconnecter").click();
|
||||||
|
|
||||||
|
cy.wait("@logout");
|
||||||
|
cy.url().should("include", "/login");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Home planning view", () => {
|
||||||
|
it("shows an empty state when the household has no current planning", () => {
|
||||||
|
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
||||||
|
cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null });
|
||||||
|
|
||||||
|
cy.visit("/");
|
||||||
|
|
||||||
|
cy.contains("h1", "Planning de la semaine").should("be.visible");
|
||||||
|
cy.contains("Aucun planning pour cette semaine.").should("be.visible");
|
||||||
|
cy.get("table").should("not.exist");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the current planning's meals when there is one", () => {
|
||||||
|
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
||||||
|
cy.intercept("GET", "**/planning/current", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
id: 1,
|
||||||
|
startDate: "2026-08-10T00:00:00.000Z",
|
||||||
|
finishDate: "2026-08-16T00:00:00.000Z",
|
||||||
|
items: [
|
||||||
|
{ id: 1, weekDay: "lundi", meal: "Dîner", recipe: { id: 1, name: "Ratatouille" } },
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
weekDay: "mardi",
|
||||||
|
meal: "Déjeuner",
|
||||||
|
recipe: { id: 2, name: "Curry de lentilles" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.visit("/");
|
||||||
|
|
||||||
|
cy.contains("Aucun planning pour cette semaine.").should("not.exist");
|
||||||
|
cy.get("table.planning-table tbody tr").should("have.length", 2);
|
||||||
|
cy.contains("td", "Ratatouille").should("be.visible");
|
||||||
|
cy.contains("td", "Curry de lentilles").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an error state when the planning request fails", () => {
|
||||||
|
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
||||||
|
cy.intercept("GET", "**/planning/current", {
|
||||||
|
statusCode: 500,
|
||||||
|
body: { code: 5000, message: "boom" },
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.visit("/");
|
||||||
|
|
||||||
|
cy.contains("Impossible de charger le planning, réessayez plus tard").should("be.visible");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -14,7 +14,7 @@ apps/web/src/
|
||||||
├── i18n/
|
├── i18n/
|
||||||
│ └── i18n.ts # config i18next, importé une fois (main.tsx) pour son effet de bord
|
│ └── i18n.ts # config i18next, importé une fois (main.tsx) pour son effet de bord
|
||||||
├── locales/
|
├── locales/
|
||||||
│ └── fr/translation.json # libellés français (errors.*, auth.*, home.*)
|
│ └── fr/translation.json # libellés français (errors.*, auth.*, layout.*, home.*, recipes.*, shoppingList.*, household.*)
|
||||||
├── services/
|
├── services/
|
||||||
│ └── error-message.service.ts # ErrorMessageService — code d'erreur → clé i18next
|
│ └── error-message.service.ts # ErrorMessageService — code d'erreur → clé i18next
|
||||||
├── features/
|
├── features/
|
||||||
|
|
@ -23,10 +23,14 @@ apps/web/src/
|
||||||
│ ├── RequireAuth.tsx # garde de route : redirige vers /login si non connecté
|
│ ├── RequireAuth.tsx # garde de route : redirige vers /login si non connecté
|
||||||
│ ├── RedirectIfAuthenticated.tsx # garde de route inverse (pour /login, /signup)
|
│ ├── RedirectIfAuthenticated.tsx # garde de route inverse (pour /login, /signup)
|
||||||
│ └── auth-form.scss # styles partagés par LoginPage et SignupPage
|
│ └── auth-form.scss # styles partagés par LoginPage et SignupPage
|
||||||
|
├── layouts/
|
||||||
|
│ └── AppLayout.tsx + .scss # sidebar (nav + user/logout) commune à tout l'espace connecté, voir plus bas
|
||||||
├── pages/
|
├── pages/
|
||||||
│ ├── LoginPage.tsx / .scss (via auth-form.scss, partagé)
|
│ ├── LoginPage.tsx / .scss (via auth-form.scss, partagé)
|
||||||
│ ├── SignupPage.tsx / .scss (via auth-form.scss, partagé)
|
│ ├── SignupPage.tsx / .scss (via auth-form.scss, partagé)
|
||||||
│ └── HomePage.tsx + HomePage.scss
|
│ ├── HomePage.tsx + HomePage.scss # planning de la semaine (routée sur "/")
|
||||||
|
│ ├── ComingSoonPage.tsx + .scss # placeholder partagé par les sections sans backend encore
|
||||||
|
│ ├── RecipesPage.tsx / ShoppingListPage.tsx / HouseholdPage.tsx # fines enveloppes autour de ComingSoonPage
|
||||||
├── styles/
|
├── styles/
|
||||||
│ ├── _theme.scss # tokens de design (couleurs, espacements, typographie)
|
│ ├── _theme.scss # tokens de design (couleurs, espacements, typographie)
|
||||||
│ └── global.scss # reset minimal + import du theme — importé une seule fois (main.tsx)
|
│ └── global.scss # reset minimal + import du theme — importé une seule fois (main.tsx)
|
||||||
|
|
@ -56,11 +60,15 @@ flowchart TB
|
||||||
CHECK -->|"200 (session valide)"| AUTHED["user défini"]
|
CHECK -->|"200 (session valide)"| AUTHED["user défini"]
|
||||||
CHECK -->|"401 (pas de session)"| ANON["user = null"]
|
CHECK -->|"401 (pas de session)"| ANON["user = null"]
|
||||||
|
|
||||||
AUTHED --> ROUTE_HOME["/ → HomePage"]
|
AUTHED --> LAYOUT["RequireAuth → AppLayout (sidebar)"]
|
||||||
|
LAYOUT --> ROUTE_HOME["/ → HomePage (planning)"]
|
||||||
|
LAYOUT --> ROUTE_RECIPES["/recettes → RecipesPage"]
|
||||||
|
LAYOUT --> ROUTE_SHOPPING["/liste-de-courses → ShoppingListPage"]
|
||||||
|
LAYOUT --> ROUTE_HOUSEHOLD["/foyer → HouseholdPage"]
|
||||||
AUTHED --> ROUTE_LOGIN_A["/login ou /signup"]
|
AUTHED --> ROUTE_LOGIN_A["/login ou /signup"]
|
||||||
ROUTE_LOGIN_A -->|"RedirectIfAuthenticated"| ROUTE_HOME
|
ROUTE_LOGIN_A -->|"RedirectIfAuthenticated"| ROUTE_HOME
|
||||||
|
|
||||||
ANON --> ROUTE_HOME_A["/"]
|
ANON --> ROUTE_HOME_A["/, /recettes, ..."]
|
||||||
ROUTE_HOME_A -->|"RequireAuth"| ROUTE_LOGIN["/login"]
|
ROUTE_HOME_A -->|"RequireAuth"| ROUTE_LOGIN["/login"]
|
||||||
ANON --> ROUTE_LOGIN2["/login ou /signup → rendu normal"]
|
ANON --> ROUTE_LOGIN2["/login ou /signup → rendu normal"]
|
||||||
```
|
```
|
||||||
|
|
@ -69,10 +77,45 @@ flowchart TB
|
||||||
fois au montage pour restaurer la session depuis le cookie httpOnly — c'est ce qui
|
fois au montage pour restaurer la session depuis le cookie httpOnly — c'est ce qui
|
||||||
permet à un rechargement de page de garder l'utilisateur connecté.
|
permet à un rechargement de page de garder l'utilisateur connecté.
|
||||||
- `RequireAuth` et `RedirectIfAuthenticated` sont deux gardes de route
|
- `RequireAuth` et `RedirectIfAuthenticated` sont deux gardes de route
|
||||||
(`react-router-dom`) qui lisent cet état : la première protège `/`, la seconde
|
(`react-router-dom`) qui lisent cet état : la première protège tout l'espace
|
||||||
protège `/login` et `/signup` (redirige un utilisateur déjà connecté vers `/`).
|
connecté (voir `AppLayout` ci-dessous), la seconde protège `/login` et `/signup`
|
||||||
Les deux affichent `null` tant que la vérification initiale est en cours, pour
|
(redirige un utilisateur déjà connecté vers `/`). Les deux affichent `null` tant
|
||||||
éviter un flash de contenu suivi d'une redirection.
|
que la vérification initiale est en cours, pour éviter un flash de contenu suivi
|
||||||
|
d'une redirection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `AppLayout` — sidebar commune à l'espace connecté
|
||||||
|
|
||||||
|
`App.tsx` monte **un seul** `RequireAuth` + `AppLayout` comme route parente de
|
||||||
|
toutes les routes authentifiées (routes imbriquées `react-router-dom`) :
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Route element={<RequireAuth><AppLayout /></RequireAuth>}>
|
||||||
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/recettes" element={<RecipesPage />} />
|
||||||
|
<Route path="/liste-de-courses" element={<ShoppingListPage />} />
|
||||||
|
<Route path="/foyer" element={<HouseholdPage />} />
|
||||||
|
</Route>
|
||||||
|
```
|
||||||
|
|
||||||
|
`AppLayout` (`layouts/AppLayout.tsx`) rend une sidebar (marque, nav des sections,
|
||||||
|
nom de l'utilisateur + déconnexion en pied de sidebar) et un `<main>` qui affiche
|
||||||
|
la route enfant matchée via `<Outlet />` — la garde d'auth et le chrome de
|
||||||
|
navigation ne sont donc écrits qu'une fois, pas dupliqués par page comme
|
||||||
|
`RequireAuth` l'était individuellement avant cette feature. En dessous de 640px la
|
||||||
|
sidebar devient une barre horizontale (voir `AppLayout.scss`) — pertinent tôt
|
||||||
|
puisque l'app est prévue pour être embarquée par Capacitor plus tard (voir le
|
||||||
|
README racine).
|
||||||
|
|
||||||
|
### Sections sans backend — `ComingSoonPage`
|
||||||
|
|
||||||
|
`Recettes`, `Liste de courses` et `Foyer & profil` n'ont pas encore de backend
|
||||||
|
dédié (seul `/planning/current` existe, voir le README). Chacune a néanmoins sa
|
||||||
|
propre route/page (`RecipesPage.tsx`, etc. — choix délibéré pour que construire la
|
||||||
|
vraie fonctionnalité plus tard soit réécrire un fichier dédié, pas éclater une
|
||||||
|
route générique), mais toutes rendent le même composant `ComingSoonPage`
|
||||||
|
(`title`/`description`) pour éviter de tripler un même bloc de markup.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -101,7 +144,9 @@ JSON, jamais codé en dur dans un composant.
|
||||||
une seule fois pour son effet de bord dans `main.tsx`, avant le premier rendu.
|
une seule fois pour son effet de bord dans `main.tsx`, avant le premier rendu.
|
||||||
- `locales/fr/translation.json` — toutes les chaînes françaises, organisées par
|
- `locales/fr/translation.json` — toutes les chaînes françaises, organisées par
|
||||||
namespace : `errors.*` (voir [error-handling.md](./error-handling.md)),
|
namespace : `errors.*` (voir [error-handling.md](./error-handling.md)),
|
||||||
`auth.login.*` / `auth.signup.*`, `home.*`.
|
`auth.login.*` / `auth.signup.*`, `layout.*` (nav de la sidebar, salutation,
|
||||||
|
déconnexion — `AppLayout`), `home.*` (planning), `recipes.*` / `shoppingList.*`
|
||||||
|
/ `household.*` (copie des pages stub, voir `ComingSoonPage` plus haut).
|
||||||
- Dans un composant : `const { t } = useTranslation(); t("auth.login.title")`.
|
- Dans un composant : `const { t } = useTranslation(); t("auth.login.title")`.
|
||||||
- Ajouter une langue : créer `locales/<lng>/translation.json` avec les mêmes clés,
|
- Ajouter une langue : créer `locales/<lng>/translation.json` avec les mêmes clés,
|
||||||
ajouter `resources.<lng>` dans `i18n/i18n.ts` — aucun composant à toucher.
|
ajouter `resources.<lng>` dans `i18n/i18n.ts` — aucun composant à toucher.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue