Merge pull request 'feat(cooking-session): déplie une recette de l'onglet Recettes au lieu de rediriger' (#28) from feat/cooking-recipes-inline-detail into main
All checks were successful
CI / lint (push) Successful in 2m50s
CI / build (push) Successful in 2m54s
CI / intent-service-test (push) Successful in 4m29s
CI / e2e (push) Successful in 8m9s
CI / test (push) Successful in 14m48s

This commit is contained in:
kyuno 2026-09-04 23:20:38 +02:00
commit cb43c9c6c5
6 changed files with 258 additions and 48 deletions

View file

@ -186,6 +186,28 @@ function setupPlanFixture() {
};
}
/**
* Minimal `RecipeView` detail for one of `planFixture()`'s recipes — just
* enough (name/portions/one step) to prove the right recipe's own detail
* rendered inline, not a full catalog-style fixture.
*/
function recipeDetail(id: number, name: string, portions: number, stepDescription: string) {
return {
id,
name,
description: null,
picture: null,
portions,
authorId: 1,
visibility: "PERSONAL",
allergens: [],
diets: [],
isFavorite: false,
ingredients: [],
steps: [{ id: 1, description: stepDescription, picture: null, order: 1, techSteps: [] }],
};
}
describe("Cooking session page", () => {
beforeEach(() => {
cy.viewport(1400, 900);
@ -287,25 +309,59 @@ describe("Cooking session page", () => {
cy.get(".cooking-stepper__node").first().should("have.class", "cooking-stepper__node--done");
});
it("lists the week's recipes on the Recettes tab, each linking to its own page", () => {
it("lists the week's recipes on the Recettes tab, each showing its portions", () => {
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() });
cy.visit("/cuisiner?date=2026-08-17");
cy.contains(".cooking-session-page__tab", "Recettes").click();
cy.get(".cooking-recipes__item").should("have.length", 2);
cy.contains(".cooking-recipes__item", "Soupe à l'oignon")
.find("a")
.should("have.attr", "href", "/recettes/1");
cy.contains(".cooking-recipes__item", "Tarte à l'oignon")
.should("contain.text", "6 portions")
.find("a")
.should("have.attr", "href", "/recettes/2");
cy.contains(".cooking-recipes__item", "Soupe à l'oignon").should("be.visible");
cy.contains(".cooking-recipes__item", "Tarte à l'oignon").should("contain.text", "6 portions");
// The stepper isn't part of this tab.
cy.get(".cooking-stepper").should("not.exist");
});
it("expands a recipe's detail inline on the Recettes tab instead of navigating away", () => {
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() });
cy.intercept("GET", "**/recipes/1", {
statusCode: 200,
body: recipeDetail(1, "Soupe à l'oignon", 4, "Émincer les oignons."),
}).as("getSoupe");
cy.intercept("GET", "**/recipes/2", {
statusCode: 200,
body: recipeDetail(2, "Tarte à l'oignon", 6, "Étaler la pâte."),
}).as("getTarte");
cy.visit("/cuisiner?date=2026-08-17");
cy.contains(".cooking-session-page__tab", "Recettes").click();
// Nothing unfolded yet, and no more link out to the catalog page —
// clicking a row is what unfolds it now.
cy.get(".cooking-recipes").find("a").should("not.exist");
cy.get(".cooking-recipes__detail").should("not.exist");
cy.contains(".cooking-recipes__row", "Soupe à l'oignon").click();
cy.wait("@getSoupe");
cy.location("pathname").should("eq", "/cuisiner"); // no navigation happened
cy.get(".cooking-recipes__detail .recipe-detail-panel").within(() => {
cy.contains("h2", "Soupe à l'oignon").should("be.visible");
cy.contains("Émincer les oignons.").should("be.visible");
});
// Only one recipe unfolds at a time — opening Tarte's closes Soupe's.
cy.contains(".cooking-recipes__row", "Tarte à l'oignon").click();
cy.wait("@getTarte");
cy.get(".cooking-recipes__detail").should("have.length", 1);
cy.get(".cooking-recipes__detail .recipe-detail-panel")
.should("contain.text", "Étaler la pâte.")
.and("not.contain.text", "Émincer");
// Clicking the open one again collapses it back.
cy.contains(".cooking-recipes__row", "Tarte à l'oignon").click();
cy.get(".cooking-recipes__detail").should("not.exist");
});
it("renders a merged-setup preheat with its temperature and the oven-conflict note", () => {
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: setupPlanFixture() });
cy.visit("/cuisiner?date=2026-08-17");

View file

@ -52,8 +52,16 @@ Feature: Start cooking an optimized plan
When I visit "/cuisiner?date=2026-08-17"
And I open the "Recettes" cooking tab
Then I should see "Soupe à l'oignon"
And the recipe "Soupe à l'oignon" should link to "/recettes/1"
And the recipe "Tarte à l'oignon" should link to "/recettes/2"
Scenario: Clicking a recipe on the Recettes tab unfolds it instead of navigating away
Given the planning for this week has recipes "Soupe à l'oignon" and "Tarte à l'oignon"
And the cooking plan for "2026-08-17" has a mise en place then a cooking step
And recipe 1's detail is available
When I visit "/cuisiner?date=2026-08-17"
And I open the "Recettes" cooking tab
And I click the recipe "Soupe à l'oignon"
Then the recipe detail "Soupe à l'oignon" should be expanded
And the URL should include "/cuisiner"
Scenario: Two recipes preheat the oven to different temperatures
Given the planning for this week has recipes "Gratin" and "Rôti"

View file

@ -249,6 +249,39 @@ Then("the current cooking step should be {string}", (position: string) => {
cy.contains(".cooking-plan__position", position).should("be.visible");
});
Then("the recipe {string} should link to {string}", (name: string, href: string) => {
cy.contains(".cooking-recipes__item", name).find("a").should("have.attr", "href", href);
Given("recipe 1's detail is available", () => {
// Minimal `RecipeView` — just enough (name + one step) to prove the
// right recipe's own detail unfolded inline.
cy.intercept("GET", "**/recipes/1", {
statusCode: 200,
body: {
id: 1,
name: "Soupe à l'oignon",
description: null,
picture: null,
portions: 4,
authorId: 1,
visibility: "PERSONAL",
allergens: [],
diets: [],
isFavorite: false,
ingredients: [],
steps: [
{ id: 1, description: "Émincer les oignons.", picture: null, order: 1, techSteps: [] },
],
},
});
});
// The task card is a `<label>` wrapping a visually-hidden checkbox — see
// "I tick the cooking task" above; a recipe row is a plain `<button>`,
// clicked the same straightforward way.
When("I click the recipe {string}", (name: string) => {
cy.contains(".cooking-recipes__row", name).click();
});
Then("the recipe detail {string} should be expanded", (name: string) => {
cy.get(".cooking-recipes__detail .recipe-detail-panel").within(() => {
cy.contains("h2", name).should("be.visible");
});
});

View file

@ -361,8 +361,7 @@
"sharedBadge": "Mutualisé"
},
"recipesTab": {
"portions": "{{n}} portions",
"viewRecipe": "Voir la recette"
"portions": "{{n}} portions"
}
},
"account": {

View file

@ -1,17 +1,22 @@
import { DateTime, formatDateOnly, getWeekStart, parseDateOnly } from "@batch-cooking/date-tools";
import type {
CookingBackgroundTaskView,
CookingPhaseView,
CookingSessionRecipeRef,
CookingTaskView,
OptimizedCookingPlanView,
import {
type CookingBackgroundTaskView,
type CookingPhaseView,
type CookingSessionRecipeRef,
type CookingTaskView,
ErrorCode,
type OptimizedCookingPlanView,
} from "@batch-cooking/shared";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useSearchParams } from "react-router-dom";
import { apiClient } from "../../api/client";
import { useSearchParams } from "react-router-dom";
import { ApiError, apiClient } from "../../api/client";
import { CheckboxOption } from "../../components/ui/Checkbox";
import { WeekNavigator } from "../../features/planning/WeekNavigator";
import {
RecipeDetailPanel,
type RecipeDetailState,
} from "../../features/recipes/RecipeDetailPanel";
import { cookingProgress } from "./cooking-progress";
import {
groupTasksByRecipes,
@ -33,7 +38,8 @@ type CookingSessionState =
* Which panel of the cooking view is showing:
* - `"plan"` (default) — the step-by-step cooking plan, one phase at a time.
* - `"recipes"` — the flat list of the week's planned recipes, for a quick
* "what am I even making" glance / a jump to a recipe's own page.
* "what am I even making" glance — each one expands inline on click (see
* `RecipesTab`) rather than navigating to its own page.
*/
type CookingTab = "plan" | "recipes";
@ -125,15 +131,63 @@ export function CookingSessionPage() {
// from localStorage and resets the stepper to its first phase.
<PlanTab key={weekKey} plan={state.plan} weekKey={weekKey} />
) : (
<RecipesTab recipes={state.plan.recipes} />
// Keyed by week too, so switching weeks collapses whichever
// recipe was unfolded rather than leaving a now-unrelated detail
// panel open.
<RecipesTab key={weekKey} recipes={state.plan.recipes} />
))}
</div>
);
}
/** The "Recettes" tab — the week's planned recipes as a flat list, each linking to its own page. */
/**
* The "Recettes" tab — the week's planned recipes as a flat list. Clicking
* one no longer navigates to `/recettes/:id`: it unfolds that recipe's full
* detail inline (fetched on demand, same {@link RecipeDetailPanel} as the
* catalog's own master-detail view, `showActions` off since managing the
* recipe isn't the point of a quick glance mid-cooking) — same
* one-open-at-a-time behavior as the sidebar's own collapsible sections
* (`AppLayout`'s `SettingsMenu`).
*/
function RecipesTab({ recipes }: { recipes: readonly CookingSessionRecipeRef[] }) {
const { t } = useTranslation();
// Which recipe (by id) is currently unfolded — `null` when none is.
// Single value rather than a Set: only one recipe's detail shows at a
// time, collapsing whichever was open before.
const [expandedId, setExpandedId] = useState<number | null>(null);
const [detailState, setDetailState] = useState<RecipeDetailState>({ status: "empty" });
// Mirrors `RecipesPage`'s own fetch-on-select effect: fires on every
// `expandedId` change (including re-expanding a recipe already seen this
// visit — no cache, same recompute-over-cache stance as the rest of this
// page) and ignores a response that arrives after the viewer has already
// moved on (`cancelled`).
useEffect(() => {
if (expandedId === null) {
setDetailState({ status: "empty" });
return;
}
let cancelled = false;
setDetailState({ status: "loading" });
apiClient
.getRecipe(expandedId)
.then((recipe) => {
if (!cancelled) setDetailState({ status: "loaded", recipe });
})
.catch((err) => {
if (cancelled) return;
if (err instanceof ApiError && err.code === ErrorCode.RECIPE_NOT_FOUND) {
setDetailState({ status: "not-found" });
} else {
setDetailState({ status: "error" });
}
});
return () => {
cancelled = true;
};
}, [expandedId]);
if (recipes.length === 0) {
return <p className="cooking-session-page__status">{t("cookingSession.empty")}</p>;
@ -141,17 +195,33 @@ function RecipesTab({ recipes }: { recipes: readonly CookingSessionRecipeRef[] }
return (
<ul className="cooking-recipes" aria-label={t("cookingSession.recipesLegend")}>
{recipes.map((recipe) => (
{recipes.map((recipe) => {
const isExpanded = expandedId === recipe.recipeId;
return (
<li key={`${recipe.recipeId}-${recipe.portions}`} className="cooking-recipes__item">
<button
type="button"
className="cooking-recipes__row"
aria-expanded={isExpanded}
onClick={() => setExpandedId(isExpanded ? null : recipe.recipeId)}
>
<span className="cooking-recipes__name">{recipe.name}</span>
<span className="cooking-recipes__portions">
{t("cookingSession.recipesTab.portions", { n: recipe.portions })}
</span>
<Link className="cooking-recipes__link" to={`/recettes/${recipe.recipeId}`}>
{t("cookingSession.recipesTab.viewRecipe")}
</Link>
<span className="cooking-recipes__chevron" aria-hidden="true">
{isExpanded ? "▾" : "▸"}
</span>
</button>
{isExpanded && (
<div className="cooking-recipes__detail">
<RecipeDetailPanel state={detailState} showActions={false} />
</div>
)}
</li>
))}
);
})}
</ul>
);
}

View file

@ -86,9 +86,10 @@
}
// --- "Recettes" tab ---------------------------------------------------------
// The week's planned recipes as a flat list — name, its own portion count,
// and a jump to the recipe's page. Deliberately thin: `plan.recipes` only
// carries `{recipeId, name, portions}`.
// The week's planned recipes as a flat list — name and its own portion
// count on an always-visible summary row; clicking one unfolds its full
// detail inline underneath (fetched on demand, see `RecipesTab`/`&__detail`
// below) instead of navigating to the recipe's own page.
.cooking-recipes {
flex: 1;
min-height: 0;
@ -100,14 +101,37 @@
flex-direction: column;
gap: var(--space-xs);
// The card: background/radius/shadow live here (not on `&__row` below) so
// the summary row and, once unfolded, the detail panel read as one block
// instead of two stacked cards.
&__item {
display: flex;
flex-direction: column;
background: var(--color-surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
// Keeps the detail panel's own edges (see `&__detail` below) inside the
// card's rounded corners instead of poking past them.
overflow: hidden;
}
// The always-visible clickable summary — a real `<button>` (not a `<div
// onClick>`) so the whole row is a proper keyboard/click target for the
// expand/collapse toggle (`RecipesTab`), styled to read exactly like the
// plain flex row it replaces.
&__row {
appearance: none;
width: 100%;
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: var(--color-surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
border: none;
background: none;
font: inherit;
text-align: start;
color: inherit;
cursor: pointer;
}
&__name {
@ -124,14 +148,34 @@
font-variant-numeric: tabular-nums;
}
// Pushed to the trailing edge — a quiet secondary action, not the point
// of the row.
&__link {
// Pushed to the trailing edge — a quiet affordance hinting the row
// unfolds, not an action of its own (the whole row is already clickable).
&__chevron {
margin-left: auto;
flex-shrink: 0;
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--color-primary);
color: var(--color-text-muted);
}
// The unfolded recipe — reuses `.recipe-detail-panel` wholesale (see
// recipes.scss) rather than re-implementing a second "read a recipe"
// layout, just inset under the summary row with a separating rule.
&__detail {
padding: 0 var(--space-md) var(--space-md);
border-top: 1px solid var(--color-border);
.recipe-detail-panel {
// The shared panel is styled for a standalone side panel (its own
// card background/shadow, fills 100% of a sized flex parent, scrolls
// internally) — already sitting inside this card and in normal
// document flow here, so neutralize all of that rather than double
// up on card chrome or clip content to a height nothing constrains.
height: auto;
overflow-y: visible;
padding-top: var(--space-md);
background: none;
box-shadow: none;
border-radius: 0;
}
}
}