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({
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() });
@ -159,7 +165,13 @@ describe("Planning", () => {
},
});
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) });

View file

@ -461,7 +461,13 @@ describe("Recipes", () => {
},
});
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}`);

View file

@ -82,11 +82,18 @@ describe("Planning grid", () => {
startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z",
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,
weekDay: "mercredi",
meal: "dejeuner",
portions: 2,
recipe: { id: 2, name: "Curry de lentilles" },
},
],

View file

@ -2,79 +2,103 @@ import { type ReactNode, useEffect, useRef } from "react";
import "./dialog.scss";
/**
* App-wide modal primitive full-screen backdrop + a centered panel,
* closing on Escape or an outside click (same `mousedown`-outside pattern
* as `PlanningPage.tsx`'s `CalendarPopover`, generalized here instead of
* duplicated a third time). First modal in the app every other
* App-wide modal primitive a native `<dialog>` (`showModal()`), not a
* `role="dialog"` div: the browser handles the modal semantics, the
* focus trap, Escape-to-close and the backdrop for free instead of this
* component reimplementing all four. First modal in the app every other
* "confirm/cancel" surface so far (`RecipeDetailPanel`'s delete button,
* the settings pages' danger zones) is an inline two-step reveal, not an
* overlay; a full recipe catalog + filters (`RecipePickerDialog`) doesn't
* fit inline, hence this.
*
* Renders nothing while `isOpen` is `false` callers don't need to guard
* mounting it themselves.
* Mounted only while open (see `PlanningPage`'s conditional rendering of
* `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({
isOpen,
onClose,
title,
children,
className,
}: {
isOpen: boolean;
onClose: () => void;
title?: string;
children: ReactNode;
className?: string;
}) {
const panelRef = useRef<HTMLDivElement>(null);
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
if (!isOpen) return;
dialogRef.current?.showModal();
}, []);
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
useEffect(() => {
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);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
dialog.addEventListener("click", handleClick);
return () => dialog.removeEventListener("click", handleClick);
}, []);
return (
<div
className="dialog-overlay"
// 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();
}}
<dialog
ref={dialogRef}
className={["dialog-panel", className].filter(Boolean).join(" ")}
aria-label={title}
>
<div
className={["dialog-panel", className].filter(Boolean).join(" ")}
role="dialog"
aria-modal="true"
aria-label={title}
ref={panelRef}
>
{title && (
<div className="dialog-panel__header">
<h2>{title}</h2>
<button
type="button"
className="dialog-panel__close"
onClick={onClose}
aria-label="Fermer"
>
</button>
</div>
)}
<div className="dialog-panel__body">{children}</div>
</div>
</div>
{title && (
<div className="dialog-panel__header">
<h2>{title}</h2>
<button
type="button"
className="dialog-panel__close"
onClick={() => dialogRef.current?.close()}
aria-label="Fermer"
>
</button>
</div>
)}
<div className="dialog-panel__body">{children}</div>
</dialog>
);
}

View file

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

View file

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