fix(ci): corrige lint/tests cassés par la migration portions

- Dialog.tsx : remplace le div role="dialog" par un <dialog> natif
  (showModal) — corrige lint/a11y/useSemanticElements, récupère
  gratuitement le piège de focus et l'Échap natifs. Le clic extérieur
  est rebranché en imperative addEventListener pour éviter
  lint/a11y/useKeyWithClickEvents sur un élément non interactif.
- RecipePickerDialog.tsx : retire l'autoFocus (lint/a11y/noAutofocus),
  ordre des imports/formatage corrigés par `biome check --write`.
- apps/api/test/planning.test.ts, apps/api/test/recipe.test.ts :
  les fixtures qui créent un `PlanningItem` directement via Prisma
  n'avaient pas le nouveau champ `portions` requis.
- apps/web/cypress/e2e/planning-page.cy.ts : ajoute `portions` aux
  items mockés pour rester fidèle au contrat `PlanningItemView`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-08-19 14:56:50 +02:00
parent 8bdbfda3ae
commit c29648e293
6 changed files with 121 additions and 78 deletions

View file

@ -108,7 +108,13 @@ describe("Planning", () => {
}, },
}); });
await prisma.planningItem.create({ await prisma.planningItem.create({
data: { planningId: planning.id, weekDay: "lundi", meal: "diner", recipeId: recipe.id }, data: {
planningId: planning.id,
weekDay: "lundi",
meal: "diner",
recipeId: recipe.id,
portions: 4,
},
}); });
const res = await agent.get("/planning").query({ date: today() }); const res = await agent.get("/planning").query({ date: today() });
@ -159,7 +165,13 @@ describe("Planning", () => {
}, },
}); });
await prisma.planningItem.create({ await prisma.planningItem.create({
data: { planningId: planning.id, weekDay: "mardi", meal: "dejeuner", recipeId: recipe.id }, data: {
planningId: planning.id,
weekDay: "mardi",
meal: "dejeuner",
recipeId: recipe.id,
portions: 2,
},
}); });
const res = await agent.get("/planning").query({ date: isoDate(nextWeek) }); const res = await agent.get("/planning").query({ date: isoDate(nextWeek) });

View file

@ -461,7 +461,13 @@ describe("Recipes", () => {
}, },
}); });
await prisma.planningItem.create({ await prisma.planningItem.create({
data: { planningId: planning.id, weekDay: "lundi", meal: "diner", recipeId: recipe.id }, data: {
planningId: planning.id,
weekDay: "lundi",
meal: "diner",
recipeId: recipe.id,
portions: 4,
},
}); });
const res = await agent.delete(`/recipes/${recipe.id}`); const res = await agent.delete(`/recipes/${recipe.id}`);

View file

@ -82,11 +82,18 @@ describe("Planning grid", () => {
startDate: "2026-08-17T00:00:00.000Z", startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z", finishDate: "2026-08-23T00:00:00.000Z",
items: [ items: [
{ id: 1, weekDay: "mardi", meal: "diner", recipe: { id: 1, name: "Ratatouille" } }, {
id: 1,
weekDay: "mardi",
meal: "diner",
portions: 4,
recipe: { id: 1, name: "Ratatouille" },
},
{ {
id: 2, id: 2,
weekDay: "mercredi", weekDay: "mercredi",
meal: "dejeuner", meal: "dejeuner",
portions: 2,
recipe: { id: 2, name: "Curry de lentilles" }, recipe: { id: 2, name: "Curry de lentilles" },
}, },
], ],

View file

@ -2,63 +2,88 @@ import { type ReactNode, useEffect, useRef } from "react";
import "./dialog.scss"; import "./dialog.scss";
/** /**
* App-wide modal primitive full-screen backdrop + a centered panel, * App-wide modal primitive a native `<dialog>` (`showModal()`), not a
* closing on Escape or an outside click (same `mousedown`-outside pattern * `role="dialog"` div: the browser handles the modal semantics, the
* as `PlanningPage.tsx`'s `CalendarPopover`, generalized here instead of * focus trap, Escape-to-close and the backdrop for free instead of this
* duplicated a third time). First modal in the app every other * component reimplementing all four. First modal in the app every other
* "confirm/cancel" surface so far (`RecipeDetailPanel`'s delete button, * "confirm/cancel" surface so far (`RecipeDetailPanel`'s delete button,
* the settings pages' danger zones) is an inline two-step reveal, not an * the settings pages' danger zones) is an inline two-step reveal, not an
* overlay; a full recipe catalog + filters (`RecipePickerDialog`) doesn't * overlay; a full recipe catalog + filters (`RecipePickerDialog`) doesn't
* fit inline, hence this. * fit inline, hence this.
* *
* Renders nothing while `isOpen` is `false` callers don't need to guard * Mounted only while open (see `PlanningPage`'s conditional rendering of
* mounting it themselves. * `RecipePickerDialog`, same convention as its own `CalendarPopover`)
* `showModal()` fires once on mount rather than toggling on an `isOpen`
* prop, since closing this component means the caller stops rendering it
* rather than flipping a prop on a permanently-mounted instance.
*/ */
export function Dialog({ export function Dialog({
isOpen,
onClose, onClose,
title, title,
children, children,
className, className,
}: { }: {
isOpen: boolean;
onClose: () => void; onClose: () => void;
title?: string; title?: string;
children: ReactNode; children: ReactNode;
className?: string; className?: string;
}) { }) {
const panelRef = useRef<HTMLDivElement>(null); const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => { useEffect(() => {
if (!isOpen) return; dialogRef.current?.showModal();
}, []);
function handleKeyDown(e: KeyboardEvent) { useEffect(() => {
if (e.key === "Escape") onClose(); const dialog = dialogRef.current;
if (!dialog) return;
// `close` covers every way a native dialog can close — Escape (which
// fires `cancel` first, then `close`) as much as a future
// `<form method="dialog">` — so this is the one listener needed to
// keep the caller's own "is this open" state (e.g. `PlanningPage`'s
// `openSlot`) in sync with it.
dialog.addEventListener("close", onClose);
return () => dialog.removeEventListener("close", onClose);
}, [onClose]);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
// Attached imperatively (not a JSX `onClick`) since the click-to-close
// it implements is already reachable from the keyboard via Escape
// (native `cancel`/`close`, wired above) — a JSX `onClick` here would
// trip the "needs a matching keyboard handler" a11y lint for a
// non-interactive element even though one already exists, just not in
// a form that lint rule can see.
function handleClick(e: MouseEvent) {
// Re-read from the ref (not the outer `dialog` const) — TS can't
// carry that early-return narrowing into a nested function, since it
// can't prove the function won't run at some later point where it no
// longer holds (even though here, as an event listener on this same
// element, it trivially still does).
const current = dialogRef.current;
if (!current) return;
// A click lands on the `<dialog>` element itself both for the
// backdrop *and* for its own unfilled padding/margin — comparing
// against its content box (not just `e.target`) is what actually
// distinguishes "outside the panel" from "on it".
const rect = current.getBoundingClientRect();
const inside =
e.clientX >= rect.left &&
e.clientX <= rect.right &&
e.clientY >= rect.top &&
e.clientY <= rect.bottom;
if (!inside) current.close();
} }
document.addEventListener("keydown", handleKeyDown); dialog.addEventListener("click", handleClick);
return () => document.removeEventListener("keydown", handleKeyDown); return () => dialog.removeEventListener("click", handleClick);
}, [isOpen, onClose]); }, []);
if (!isOpen) return null;
return ( return (
<div <dialog
className="dialog-overlay" ref={dialogRef}
// Closing on the overlay itself (not on a bubbled click from the
// panel) — same "outside click" idea as CalendarPopover, expressed
// via where the click *landed* instead of a document-level listener
// + ref containment check, since the overlay already exactly frames
// "outside the panel".
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
className={["dialog-panel", className].filter(Boolean).join(" ")} className={["dialog-panel", className].filter(Boolean).join(" ")}
role="dialog"
aria-modal="true"
aria-label={title} aria-label={title}
ref={panelRef}
> >
{title && ( {title && (
<div className="dialog-panel__header"> <div className="dialog-panel__header">
@ -66,7 +91,7 @@ export function Dialog({
<button <button
type="button" type="button"
className="dialog-panel__close" className="dialog-panel__close"
onClick={onClose} onClick={() => dialogRef.current?.close()}
aria-label="Fermer" aria-label="Fermer"
> >
@ -74,7 +99,6 @@ export function Dialog({
</div> </div>
)} )}
<div className="dialog-panel__body">{children}</div> <div className="dialog-panel__body">{children}</div>
</div> </dialog>
</div>
); );
} }

View file

@ -1,17 +1,9 @@
// Modal overlay + panel see Dialog.tsx. Colocated here rather than in // Modal panel see Dialog.tsx. A native <dialog> opened via showModal(),
// global.scss since it's one component's styling, same convention as every // so the browser supplies the backdrop/centering/focus-trap; this only
// feature's own .scss file (recipes.scss, planning-page.scss, …). // resets its default UA styling (border, padding, colors) and layers the
// header/body layout on top. Colocated here rather than in global.scss
.dialog-overlay { // since it's one component's styling, same convention as every feature's
position: fixed; // own .scss file (recipes.scss, planning-page.scss, ).
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-lg);
background: rgba(0, 0, 0, 0.45);
}
.dialog-panel { .dialog-panel {
display: flex; display: flex;
@ -19,10 +11,18 @@
width: 100%; width: 100%;
max-width: 48rem; max-width: 48rem;
max-height: calc(100vh - var(--space-2xl)); max-height: calc(100vh - var(--space-2xl));
margin: auto; // UA default for a shown <dialog>, kept explicit
padding: 0;
border: none;
background: var(--color-surface); background: var(--color-surface);
color: var(--color-text);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
overflow: hidden; overflow: hidden;
&::backdrop {
background: rgba(0, 0, 0, 0.45);
}
} }
.dialog-panel__header { .dialog-panel__header {

View file

@ -1,6 +1,6 @@
import { import {
ErrorCode,
type DietView, type DietView,
ErrorCode,
type IngredientView, type IngredientView,
type Meal, type Meal,
type PlanningItemView, type PlanningItemView,
@ -167,7 +167,6 @@ export function RecipePickerDialog({
if (selectedRecipe) { if (selectedRecipe) {
return ( return (
<Dialog <Dialog
isOpen
onClose={onClose} onClose={onClose}
title={t("planning.picker.confirmTitle", { recipe: selectedRecipe.name })} title={t("planning.picker.confirmTitle", { recipe: selectedRecipe.name })}
> >
@ -180,7 +179,6 @@ export function RecipePickerDialog({
step="1" step="1"
value={portions} value={portions}
onChange={(e) => setPortions(e.target.value)} onChange={(e) => setPortions(e.target.value)}
autoFocus
/> />
{submitError && <p className="field-error">{submitError}</p>} {submitError && <p className="field-error">{submitError}</p>}
<div className="recipe-picker-confirm__actions"> <div className="recipe-picker-confirm__actions">
@ -202,7 +200,7 @@ export function RecipePickerDialog({
} }
return ( return (
<Dialog isOpen onClose={onClose} title={t("planning.picker.title")} className="recipe-picker-dialog"> <Dialog onClose={onClose} title={t("planning.picker.title")} className="recipe-picker-dialog">
<div className="recipe-picker__filters"> <div className="recipe-picker__filters">
<input <input
type="search" type="search"
@ -246,9 +244,7 @@ export function RecipePickerDialog({
<IngredientPicker <IngredientPicker
ingredients={ingredientsCatalog} ingredients={ingredientsCatalog}
excludeIds={selectedIngredientIds} excludeIds={selectedIngredientIds}
onSelect={(ingredient) => onSelect={(ingredient) => setSelectedIngredientIds((ids) => [...ids, ingredient.id])}
setSelectedIngredientIds((ids) => [...ids, ingredient.id])
}
/> />
)} )}
</div> </div>
@ -268,9 +264,7 @@ export function RecipePickerDialog({
<p className="recipes-page__status">{t("planning.picker.loading")}</p> <p className="recipes-page__status">{t("planning.picker.loading")}</p>
)} )}
{listState.status === "error" && ( {listState.status === "error" && (
<p className="recipes-page__status recipes-page__status--error"> <p className="recipes-page__status recipes-page__status--error">{t("common.loadError")}</p>
{t("common.loadError")}
</p>
)} )}
{listState.status === "loaded" && listState.recipes.length === 0 && ( {listState.status === "loaded" && listState.recipes.length === 0 && (
<p className="recipes-page__status">{t("planning.picker.empty")}</p> <p className="recipes-page__status">{t("planning.picker.empty")}</p>