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({
|
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) });
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue