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:
parent
8bdbfda3ae
commit
c29648e293
6 changed files with 121 additions and 78 deletions
|
|
@ -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) });
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -2,63 +2,88 @@ 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();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className={["dialog-panel", className].filter(Boolean).join(" ")}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
ref={panelRef}
|
||||
>
|
||||
{title && (
|
||||
<div className="dialog-panel__header">
|
||||
|
|
@ -66,7 +91,7 @@ export function Dialog({
|
|||
<button
|
||||
type="button"
|
||||
className="dialog-panel__close"
|
||||
onClick={onClose}
|
||||
onClick={() => dialogRef.current?.close()}
|
||||
aria-label="Fermer"
|
||||
>
|
||||
✕
|
||||
|
|
@ -74,7 +99,6 @@ export function Dialog({
|
|||
</div>
|
||||
)}
|
||||
<div className="dialog-panel__body">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue