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