fix(recipes): garde le popover de correction ouvert pendant la sélection d'un span #47
3 changed files with 121 additions and 5 deletions
|
|
@ -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() {
|
||||
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
||||
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", () => {
|
||||
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
|
||||
cy.intercept("GET", "**/recipes/999", {
|
||||
|
|
|
|||
|
|
@ -337,6 +337,7 @@ export function StepDescription({
|
|||
existingUtensils={activeStepTechStep?.utensils ?? []}
|
||||
existingTemperatures={activeStepTechStep?.temperatures ?? []}
|
||||
resolvedMetadataSpan={resolvedMetadataSpan}
|
||||
descriptionContainerRef={containerRef}
|
||||
onRequestSpan={setPendingSpanRequest}
|
||||
onClose={closeActiveCorrection}
|
||||
onSubmitted={handleSubmitted}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
type UnitView,
|
||||
type UtensilView,
|
||||
} 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 { ApiError, apiClient } from "../../../api/client";
|
||||
import { errorMessageService } from "../../../services/error-message.service";
|
||||
|
|
@ -304,6 +304,7 @@ export function TechStepCorrectionPopover({
|
|||
existingUtensils,
|
||||
existingTemperatures,
|
||||
resolvedMetadataSpan,
|
||||
descriptionContainerRef,
|
||||
onRequestSpan,
|
||||
onClose,
|
||||
onSubmitted,
|
||||
|
|
@ -334,6 +335,20 @@ export function TechStepCorrectionPopover({
|
|||
range: TextSelectionRange;
|
||||
text: string;
|
||||
} | 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. */
|
||||
onRequestSpan: (kind: MetadataKind) => void;
|
||||
onClose: () => void;
|
||||
|
|
@ -459,13 +474,17 @@ export function TechStepCorrectionPopover({
|
|||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
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();
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [onClose]);
|
||||
}, [onClose, descriptionContainerRef]);
|
||||
|
||||
async function removeMatch() {
|
||||
setIsSubmitting(true);
|
||||
|
|
|
|||
Loading…
Reference in a new issue