fix(recipes): garde le popover de correction ouvert pendant la sélection d'un span #47

Merged
kyuno merged 2 commits from fix/correction-popover-span-selection into main 2026-09-05 23:14:35 +02:00
3 changed files with 121 additions and 5 deletions

View file

@ -127,6 +127,45 @@ const tarteDetail = {
], ],
}; };
/**
* A one-step recipe whose single technique match carries no attached
* metadata yet — for the issue #36 regression test below, which needs a
* clean correction popover and a description word ("oignons") that appears
* *only* in the step text, nowhere in the popover itself.
*/
const brownStepRecipe = {
id: 6,
name: "Soupe",
description: null,
picture: null,
portions: 4,
authorId: 1,
visibility: "PERSONAL",
allergens: [],
diets: [],
isFavorite: false,
ingredients: [],
steps: [
{
id: 1,
description: "Faire revenir les oignons.",
picture: null,
order: 1,
techSteps: [
{
techStep: { id: 3, key: "brown" },
start: 0,
end: 13,
source: "auto",
ingredients: [],
utensils: [],
temperatures: [],
},
],
},
],
};
function interceptAuth() { function interceptAuth() {
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile }); cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] }); cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] });
@ -293,6 +332,63 @@ describe("Recipe catalog", () => {
}); });
}); });
// Regression (issue #36): while the correction popover's "add an
// ingredient/utensil/temperature" flow waits for a text selection, the
// mousedown that *starts* that drag lands on the step description — a DOM
// sibling of the popover — and used to count as an outside-click,
// closing the popover before mouseup; the selection then opened a fresh
// blank popover for the selected word as a brand-new technique. The
// description container is now exempt from the popover's outside-click.
it("keeps the same correction popover open while selecting a span for an ingredient", () => {
cy.viewport(1600, 900);
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
cy.intercept("GET", "**/recipes/6", { statusCode: 200, body: brownStepRecipe });
cy.intercept("GET", "**/reference/tech-steps", {
statusCode: 200,
body: [{ id: 3, key: "brown" }],
});
cy.intercept("GET", "**/reference/ingredients", { statusCode: 200, body: [] });
cy.intercept("GET", "**/reference/units", { statusCode: 200, body: [] });
cy.intercept("GET", "**/reference/utensils", { statusCode: 200, body: [] });
cy.visit("/recettes/6");
// Open the technique correction, then ask to attach an ingredient — the
// popover now shows the "select the span in the description" hint.
cy.contains(".step-tech-step", "Faire revenir").click();
cy.get(".tech-step-correction-popover").should("have.length", 1);
cy.get(".tech-step-correction-popover__selection").should("contain.text", "Faire revenir");
cy.contains(".tech-step-correction-popover__add-row", "Ajouter un ingrédient").click();
cy.get(".tech-step-correction-popover__hint").should("be.visible");
// Reproduce the real mousedown → drag → mouseup over the description.
// The mousedown is the event that used to close the popover.
cy.contains(".recipe-detail-panel__steps p", "Faire revenir").as("stepParagraph");
cy.get("@stepParagraph").trigger("mousedown");
cy.window().then((win) => {
const paragraph = win.document.querySelector(".recipe-detail-panel__steps p") as HTMLElement;
const span = [...paragraph.querySelectorAll<HTMLElement>("[data-offset]")].find((el) =>
el.textContent?.includes("oignons"),
);
const textNode = span?.firstChild as Text;
const local = textNode.textContent?.indexOf("oignons") ?? 0;
const range = win.document.createRange();
range.setStart(textNode, local);
range.setEnd(textNode, local + "oignons".length);
const selection = win.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
});
cy.get("@stepParagraph").trigger("mouseup");
// Still the *same* popover — its heading still quotes the technique span
// "Faire revenir", not "oignons" — now showing the ingredient sub-form
// for the freshly selected span, not a blank new-technique popover.
cy.get(".tech-step-correction-popover").should("have.length", 1);
cy.get(".tech-step-correction-popover__selection").should("contain.text", "Faire revenir");
cy.get(".tech-step-correction-popover__add-form").should("be.visible");
});
it("shows a not-found message for a selected id the API rejects", () => { it("shows a not-found message for a selected id the API rejects", () => {
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] }); cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
cy.intercept("GET", "**/recipes/999", { cy.intercept("GET", "**/recipes/999", {

View file

@ -337,6 +337,7 @@ export function StepDescription({
existingUtensils={activeStepTechStep?.utensils ?? []} existingUtensils={activeStepTechStep?.utensils ?? []}
existingTemperatures={activeStepTechStep?.temperatures ?? []} existingTemperatures={activeStepTechStep?.temperatures ?? []}
resolvedMetadataSpan={resolvedMetadataSpan} resolvedMetadataSpan={resolvedMetadataSpan}
descriptionContainerRef={containerRef}
onRequestSpan={setPendingSpanRequest} onRequestSpan={setPendingSpanRequest}
onClose={closeActiveCorrection} onClose={closeActiveCorrection}
onSubmitted={handleSubmitted} onSubmitted={handleSubmitted}

View file

@ -9,7 +9,7 @@ import {
type UnitView, type UnitView,
type UtensilView, type UtensilView,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
import { type ReactNode, useEffect, useRef, useState } from "react"; import { type ReactNode, type RefObject, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ApiError, apiClient } from "../../../api/client"; import { ApiError, apiClient } from "../../../api/client";
import { errorMessageService } from "../../../services/error-message.service"; import { errorMessageService } from "../../../services/error-message.service";
@ -304,6 +304,7 @@ export function TechStepCorrectionPopover({
existingUtensils, existingUtensils,
existingTemperatures, existingTemperatures,
resolvedMetadataSpan, resolvedMetadataSpan,
descriptionContainerRef,
onRequestSpan, onRequestSpan,
onClose, onClose,
onSubmitted, onSubmitted,
@ -334,6 +335,20 @@ export function TechStepCorrectionPopover({
range: TextSelectionRange; range: TextSelectionRange;
text: string; text: string;
} | null; } | null;
/**
* The step description's own container (`StepDescription`'s existing
* `containerRef`). A `mousedown` inside it is *not* treated as an
* outside-click here: while the "attach an ingredient/utensil/
* temperature" flow is waiting for a selection, the drag that makes that
* selection starts with a `mousedown` on the description — which is a DOM
* sibling of this popover, so without this exemption it closed the
* popover before the `mouseup` even landed, and the selection then opened
* a brand-new technique correction instead (issue #36). `StepDescription`
* already drives every meaningful interaction inside it
* (`setActiveCorrection`/`setResolvedMetadataSpan`) explicitly, so
* dropping the close here loses nothing.
*/
descriptionContainerRef?: RefObject<HTMLElement | null>;
/** Tells `StepDescription` "the next text selection in the description is for an ingredient/utensil/temperature mention, not a new technique correction" — see this component's own doc comment. */ /** Tells `StepDescription` "the next text selection in the description is for an ingredient/utensil/temperature mention, not a new technique correction" — see this component's own doc comment. */
onRequestSpan: (kind: MetadataKind) => void; onRequestSpan: (kind: MetadataKind) => void;
onClose: () => void; onClose: () => void;
@ -459,13 +474,17 @@ export function TechStepCorrectionPopover({
useEffect(() => { useEffect(() => {
function handleClickOutside(e: MouseEvent) { function handleClickOutside(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { const target = e.target as Node;
if (popoverRef.current?.contains(target)) return;
// A mousedown in the description isn't an "outside" click — it's how
// the viewer selects the span for an ingredient/utensil/temperature
// mention. Let StepDescription handle it (see `descriptionContainerRef`).
if (descriptionContainerRef?.current?.contains(target)) return;
onClose(); onClose();
} }
}
document.addEventListener("mousedown", handleClickOutside); document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]); }, [onClose, descriptionContainerRef]);
async function removeMatch() { async function removeMatch() {
setIsSubmitting(true); setIsSubmitting(true);