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", () => { describe("Cooking session page", () => {
beforeEach(() => { beforeEach(() => {
cy.viewport(1400, 900); 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"); 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.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() });
cy.visit("/cuisiner?date=2026-08-17"); cy.visit("/cuisiner?date=2026-08-17");
cy.contains(".cooking-session-page__tab", "Recettes").click(); cy.contains(".cooking-session-page__tab", "Recettes").click();
cy.get(".cooking-recipes__item").should("have.length", 2); cy.get(".cooking-recipes__item").should("have.length", 2);
cy.contains(".cooking-recipes__item", "Soupe à l'oignon") cy.contains(".cooking-recipes__item", "Soupe à l'oignon").should("be.visible");
.find("a") cy.contains(".cooking-recipes__item", "Tarte à l'oignon").should("contain.text", "6 portions");
.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");
// The stepper isn't part of this tab. // The stepper isn't part of this tab.
cy.get(".cooking-stepper").should("not.exist"); 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", () => { it("renders a merged-setup preheat with its temperature and the oven-conflict note", () => {
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: setupPlanFixture() }); cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: setupPlanFixture() });
cy.visit("/cuisiner?date=2026-08-17"); 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" When I visit "/cuisiner?date=2026-08-17"
And I open the "Recettes" cooking tab And I open the "Recettes" cooking tab
Then I should see "Soupe à l'oignon" 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 Scenario: Two recipes preheat the oven to different temperatures
Given the planning for this week has recipes "Gratin" and "Rôti" 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"); cy.contains(".cooking-plan__position", position).should("be.visible");
}); });
Then("the recipe {string} should link to {string}", (name: string, href: string) => { Given("recipe 1's detail is available", () => {
cy.contains(".cooking-recipes__item", name).find("a").should("have.attr", "href", href); // 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é" "sharedBadge": "Mutualisé"
}, },
"recipesTab": { "recipesTab": {
"portions": "{{n}} portions", "portions": "{{n}} portions"
"viewRecipe": "Voir la recette"
} }
}, },
"account": { "account": {

View file

@ -1,17 +1,22 @@
import { DateTime, formatDateOnly, getWeekStart, parseDateOnly } from "@batch-cooking/date-tools"; import { DateTime, formatDateOnly, getWeekStart, parseDateOnly } from "@batch-cooking/date-tools";
import type { import {
CookingBackgroundTaskView, type CookingBackgroundTaskView,
CookingPhaseView, type CookingPhaseView,
CookingSessionRecipeRef, type CookingSessionRecipeRef,
CookingTaskView, type CookingTaskView,
OptimizedCookingPlanView, ErrorCode,
type OptimizedCookingPlanView,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link, useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { apiClient } from "../../api/client"; import { ApiError, apiClient } from "../../api/client";
import { CheckboxOption } from "../../components/ui/Checkbox"; import { CheckboxOption } from "../../components/ui/Checkbox";
import { WeekNavigator } from "../../features/planning/WeekNavigator"; import { WeekNavigator } from "../../features/planning/WeekNavigator";
import {
RecipeDetailPanel,
type RecipeDetailState,
} from "../../features/recipes/RecipeDetailPanel";
import { cookingProgress } from "./cooking-progress"; import { cookingProgress } from "./cooking-progress";
import { import {
groupTasksByRecipes, groupTasksByRecipes,
@ -33,7 +38,8 @@ type CookingSessionState =
* Which panel of the cooking view is showing: * Which panel of the cooking view is showing:
* - `"plan"` (default) — the step-by-step cooking plan, one phase at a time. * - `"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 * - `"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"; type CookingTab = "plan" | "recipes";
@ -125,15 +131,63 @@ export function CookingSessionPage() {
// from localStorage and resets the stepper to its first phase. // from localStorage and resets the stepper to its first phase.
<PlanTab key={weekKey} plan={state.plan} weekKey={weekKey} /> <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> </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[] }) { function RecipesTab({ recipes }: { recipes: readonly CookingSessionRecipeRef[] }) {
const { t } = useTranslation(); 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) { if (recipes.length === 0) {
return <p className="cooking-session-page__status">{t("cookingSession.empty")}</p>; return <p className="cooking-session-page__status">{t("cookingSession.empty")}</p>;
@ -141,17 +195,33 @@ function RecipesTab({ recipes }: { recipes: readonly CookingSessionRecipeRef[] }
return ( return (
<ul className="cooking-recipes" aria-label={t("cookingSession.recipesLegend")}> <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"> <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__name">{recipe.name}</span>
<span className="cooking-recipes__portions"> <span className="cooking-recipes__portions">
{t("cookingSession.recipesTab.portions", { n: recipe.portions })} {t("cookingSession.recipesTab.portions", { n: recipe.portions })}
</span> </span>
<Link className="cooking-recipes__link" to={`/recettes/${recipe.recipeId}`}> <span className="cooking-recipes__chevron" aria-hidden="true">
{t("cookingSession.recipesTab.viewRecipe")} {isExpanded ? "▾" : "▸"}
</Link> </span>
</button>
{isExpanded && (
<div className="cooking-recipes__detail">
<RecipeDetailPanel state={detailState} showActions={false} />
</div>
)}
</li> </li>
))} );
})}
</ul> </ul>
); );
} }

View file

@ -86,9 +86,10 @@
} }
// --- "Recettes" tab --------------------------------------------------------- // --- "Recettes" tab ---------------------------------------------------------
// The week's planned recipes as a flat list — name, its own portion count, // The week's planned recipes as a flat list — name and its own portion
// and a jump to the recipe's page. Deliberately thin: `plan.recipes` only // count on an always-visible summary row; clicking one unfolds its full
// carries `{recipeId, name, portions}`. // detail inline underneath (fetched on demand, see `RecipesTab`/`&__detail`
// below) instead of navigating to the recipe's own page.
.cooking-recipes { .cooking-recipes {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@ -100,14 +101,37 @@
flex-direction: column; flex-direction: column;
gap: var(--space-xs); 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 { &__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; display: flex;
align-items: center; align-items: center;
gap: var(--space-sm); gap: var(--space-sm);
padding: var(--space-sm) var(--space-md); padding: var(--space-sm) var(--space-md);
background: var(--color-surface); border: none;
border-radius: var(--radius-md); background: none;
box-shadow: var(--shadow-sm); font: inherit;
text-align: start;
color: inherit;
cursor: pointer;
} }
&__name { &__name {
@ -124,14 +148,34 @@
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
// Pushed to the trailing edge — a quiet secondary action, not the point // Pushed to the trailing edge — a quiet affordance hinting the row
// of the row. // unfolds, not an action of its own (the whole row is already clickable).
&__link { &__chevron {
margin-left: auto; margin-left: auto;
flex-shrink: 0; flex-shrink: 0;
font-size: var(--font-size-sm); color: var(--color-text-muted);
font-weight: 600; }
color: var(--color-primary);
// 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;
}
} }
} }