Deux retours utilisateur distincts, meme cause : le popover de correction presentait la selection de technique et l'edition des ingredients/ ustensiles comme deux etapes successives (liste plate -> "Valider" pour la selection, puis un ecran separe pour les metadonnees), et cette liste de techniques elle-meme n'etait qu'un flot de ~74 boutons sans recherche ni tri, illisible en pratique. TechStepCorrectionPopover.tsx : les deux fonctions fusionnent en un seul ecran — la technique se choisit desormais via le meme CatalogSearchPicker (recherche + liste filtrée) deja utilise pour les ingredients/ustensiles, avec le choix courant marque visuellement (nouveau prop `selectedId`), et les sections Ingredients/Ustensiles restent affichees en permanence a cote plutot que masquees tant qu'aucune technique n'est choisie. "Valider" reste desactive tant qu'aucune technique n'est selectionnee. CatalogSearchPicker.tsx : nouveau prop optionnel `selectedId` pour marquer visuellement l'item courant dans la liste (utilise par le picker de technique, pas par les sous-flux ingredient/ustensile qui n'ont pas de notion de "choix courant"). Tests Cypress (component + e2e) et traductions mis a jour pour ce nouvel ecran unique. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
277 lines
10 KiB
TypeScript
277 lines
10 KiB
TypeScript
import { useState } from "react";
|
|
import "../../src/i18n/i18n";
|
|
import { TechStepCorrectionPopover } from "../../src/features/recipes/steps/TechStepCorrectionPopover";
|
|
|
|
// Mounts the popover in isolation (no StepDescription/selection plumbing
|
|
// around it) — same "generic component test" posture as CheckboxOption.cy.tsx,
|
|
// but this one needs `../../src/i18n/i18n` imported for its side effect
|
|
// (initializes the default i18next instance `useTranslation` falls back to
|
|
// with no `<I18nextProvider>` in the tree — see that module's own doc
|
|
// comment) since, unlike Checkbox/Radio, this component calls
|
|
// `useTranslation()`.
|
|
|
|
const cook = { id: 1, key: "cook" };
|
|
const simmer = { id: 3, key: "simmer" };
|
|
const butter = { id: 10, key: "butter" };
|
|
const pan = { id: 20, key: "pan" };
|
|
const gram = { id: 30, key: "gram" };
|
|
|
|
/**
|
|
* A real `StepDescription` resolves `onRequestSpan` into a fresh
|
|
* `resolvedMetadataSpan` via an actual browser text selection — out of
|
|
* scope for a component test of the popover alone (covered by the e2e
|
|
* scenario instead). This harness fakes that round-trip with a fixed
|
|
* span, so tests here can exercise everything the popover itself is
|
|
* responsible for once a span comes back, without needing a real
|
|
* `StepDescription` in the tree.
|
|
*/
|
|
function Harness({
|
|
previousTechStepId = null,
|
|
existingIngredients = [],
|
|
existingUtensils = [],
|
|
onClose = () => {},
|
|
onSubmitted = () => {},
|
|
}: Partial<{
|
|
previousTechStepId: number | null;
|
|
existingIngredients: unknown[];
|
|
existingUtensils: unknown[];
|
|
onClose: () => void;
|
|
onSubmitted: (result: unknown) => void;
|
|
}>) {
|
|
const [resolvedMetadataSpan, setResolvedMetadataSpan] = useState<{
|
|
nonce: number;
|
|
kind: "ingredient" | "utensil";
|
|
range: { start: number; end: number };
|
|
text: string;
|
|
} | null>(null);
|
|
|
|
return (
|
|
<div>
|
|
{/* A genuinely separate sibling to click for the "outside click closes it" test — clicking blindly at a viewport coordinate would risk still landing inside the popover, which fills most of the mounted area on its own. */}
|
|
<div data-testid="outside-popover" style={{ height: 20 }} />
|
|
<TechStepCorrectionPopover
|
|
recipeId={2}
|
|
stepId={2}
|
|
selectedText="Cuire"
|
|
range={{ start: 0, end: 5 }}
|
|
previousTechStepId={previousTechStepId}
|
|
// biome-ignore lint/suspicious/noExplicitAny: test harness stands in for real StepTechStepIngredientView/UtensilView props — precise typing isn't the point here.
|
|
existingIngredients={existingIngredients as any}
|
|
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
|
existingUtensils={existingUtensils as any}
|
|
resolvedMetadataSpan={resolvedMetadataSpan}
|
|
onRequestSpan={(kind) =>
|
|
setResolvedMetadataSpan({
|
|
nonce: Date.now(),
|
|
kind,
|
|
range: { start: 20, end: 26 },
|
|
text: "Beurre",
|
|
})
|
|
}
|
|
onClose={onClose}
|
|
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
|
onSubmitted={onSubmitted as any}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
describe("TechStepCorrectionPopover", () => {
|
|
beforeEach(() => {
|
|
cy.intercept("GET", "**/reference/tech-steps", { statusCode: 200, body: [cook, simmer] }).as(
|
|
"getTechSteps",
|
|
);
|
|
cy.intercept("GET", "**/reference/ingredients", { statusCode: 200, body: [butter] }).as(
|
|
"getIngredients",
|
|
);
|
|
cy.intercept("GET", "**/reference/units", { statusCode: 200, body: [gram] }).as("getUnits");
|
|
cy.intercept("GET", "**/reference/utensils", { statusCode: 200, body: [pan] }).as(
|
|
"getUtensils",
|
|
);
|
|
});
|
|
|
|
it("shows the selected text, the technique catalog (searchable) and the metadata sections all together", () => {
|
|
cy.mount(<Harness />);
|
|
cy.wait("@getTechSteps");
|
|
|
|
cy.contains(".tech-step-correction-popover__selection", "Cuire").should("be.visible");
|
|
// Merged editor (see TechStepCorrectionPopover's own doc comment) — no
|
|
// separate "pick, then metadata reveals itself" step, both render at
|
|
// once, and the technique catalog goes through the same searchable
|
|
// `CatalogSearchPicker` as the ingredient/utensil sub-flows (a plain
|
|
// unfiltered list of the real ~74-entry catalog isn't browsable).
|
|
cy.get(
|
|
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
|
).should("have.length", 2);
|
|
cy.contains("h4", "Ingrédients").should("be.visible");
|
|
cy.contains("h4", "Ustensiles").should("be.visible");
|
|
cy.contains("button", "Valider").should("be.visible");
|
|
});
|
|
|
|
it("offers a 'no technique here' option, and marks the current pick, only when correcting an existing match", () => {
|
|
cy.mount(<Harness previousTechStepId={null} />);
|
|
cy.wait("@getTechSteps");
|
|
cy.get(".tech-step-correction-popover__remove").should("not.exist");
|
|
cy.contains(".tech-step-correction-popover__chosen-technique", "Aucune technique sélectionnée");
|
|
|
|
cy.mount(<Harness previousTechStepId={cook.id} />);
|
|
cy.wait("@getTechSteps");
|
|
cy.get(".tech-step-correction-popover__remove").should("exist");
|
|
cy.contains(".tech-step-correction-popover__chosen-technique", "Cuire");
|
|
cy.contains(".catalog-search-picker__list button", "Cuire").should(
|
|
"have.class",
|
|
"catalog-search-picker__item--selected",
|
|
);
|
|
});
|
|
|
|
it("picking a technique from the catalog selects it without submitting immediately", () => {
|
|
cy.mount(<Harness />);
|
|
cy.wait("@getTechSteps");
|
|
|
|
cy.contains(
|
|
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
|
"Mijoter",
|
|
).click();
|
|
|
|
cy.contains(".tech-step-correction-popover__chosen-technique", "Mijoter");
|
|
cy.contains("button", "Valider").should("be.visible");
|
|
});
|
|
|
|
it("Valider stays disabled until a technique is actually picked", () => {
|
|
cy.mount(<Harness />);
|
|
cy.wait("@getTechSteps");
|
|
|
|
cy.contains("button", "Valider").should("be.disabled");
|
|
cy.contains(
|
|
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
|
"Mijoter",
|
|
).click();
|
|
cy.contains("button", "Valider").should("not.be.disabled");
|
|
});
|
|
|
|
it("submits the selected technique (no metadata touched) with ingredients/utensils omitted from the request", () => {
|
|
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
|
|
statusCode: 201,
|
|
body: {
|
|
id: 1,
|
|
start: 0,
|
|
end: 5,
|
|
previousTechStep: null,
|
|
correctedTechStep: simmer,
|
|
createdAt: new Date().toISOString(),
|
|
},
|
|
}).as("submitCorrection");
|
|
const onSubmitted = cy.stub().as("onSubmitted");
|
|
cy.mount(<Harness onSubmitted={onSubmitted} />);
|
|
cy.wait("@getTechSteps");
|
|
|
|
cy.contains(
|
|
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
|
"Mijoter",
|
|
).click();
|
|
cy.contains("button", "Valider").click();
|
|
|
|
cy.wait("@submitCorrection").its("request.body").should("deep.equal", {
|
|
start: 0,
|
|
end: 5,
|
|
previousTechStepId: null,
|
|
correctedTechStepId: simmer.id,
|
|
});
|
|
cy.get("@onSubmitted").should("have.been.calledOnce");
|
|
});
|
|
|
|
it("adds an ingredient with quantity/unit via the span-selection flow, included in the submitted request", () => {
|
|
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
|
|
statusCode: 201,
|
|
body: {
|
|
id: 1,
|
|
start: 0,
|
|
end: 5,
|
|
previousTechStep: null,
|
|
correctedTechStep: simmer,
|
|
createdAt: new Date().toISOString(),
|
|
},
|
|
}).as("submitCorrection");
|
|
cy.mount(<Harness />);
|
|
cy.wait("@getTechSteps");
|
|
cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]);
|
|
|
|
cy.contains(
|
|
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
|
"Mijoter",
|
|
).click();
|
|
cy.contains("button", "+ Ajouter un ingrédient").click();
|
|
|
|
cy.contains(".catalog-search-picker button", "Beurre").click();
|
|
cy.get('input[type="number"]').type("50");
|
|
cy.get("select").select(String(gram.id));
|
|
cy.contains("button", "Ajouter").click();
|
|
|
|
cy.contains(".tech-step-correction-popover__chip", "50 g Beurre").should("be.visible");
|
|
cy.contains("button", "Valider").click();
|
|
|
|
cy.wait("@submitCorrection")
|
|
.its("request.body")
|
|
.should("deep.equal", {
|
|
start: 0,
|
|
end: 5,
|
|
previousTechStepId: null,
|
|
correctedTechStepId: simmer.id,
|
|
ingredients: [
|
|
{ ingredientId: butter.id, quantity: 50, unitId: gram.id, start: 20, end: 26 },
|
|
],
|
|
utensils: [],
|
|
});
|
|
});
|
|
|
|
it("pre-seeds existing ingredients/utensils, removable via their own chip", () => {
|
|
cy.mount(
|
|
<Harness
|
|
previousTechStepId={cook.id}
|
|
existingIngredients={[
|
|
{ ingredient: butter, quantity: 50, unit: gram, start: 0, end: 6, source: "auto" },
|
|
]}
|
|
existingUtensils={[{ utensil: pan, start: 14, end: 23, source: "auto" }]}
|
|
/>,
|
|
);
|
|
// An existing match starts pre-selected on itself (see
|
|
// TechStepCorrectionPopover's own doc comment) — the metadata sections,
|
|
// pre-seeded from `existingIngredients`/`existingUtensils`, are visible
|
|
// immediately, no need to re-pick "Cuire" from a list first.
|
|
cy.wait("@getTechSteps");
|
|
cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]);
|
|
|
|
cy.contains(".tech-step-correction-popover__chip", "50 g Beurre").find("button").click();
|
|
cy.contains(".tech-step-correction-popover__chip", "Beurre").should("not.exist");
|
|
});
|
|
|
|
it("shows an error message and stays open when the submission fails", () => {
|
|
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
|
|
statusCode: 404,
|
|
body: { code: 4051, message: "TechStep not found" },
|
|
}).as("submitCorrection");
|
|
const onClose = cy.stub().as("onClose");
|
|
cy.mount(<Harness onClose={onClose} />);
|
|
cy.wait("@getTechSteps");
|
|
|
|
cy.contains(
|
|
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
|
"Cuire",
|
|
).click();
|
|
cy.contains("button", "Valider").click();
|
|
|
|
cy.wait("@submitCorrection");
|
|
cy.get(".field-error").should("be.visible");
|
|
cy.get("@onClose").should("not.have.been.called");
|
|
});
|
|
|
|
it("calls onClose on an outside click", () => {
|
|
const onClose = cy.stub().as("onClose");
|
|
cy.mount(<Harness onClose={onClose} />);
|
|
cy.wait("@getTechSteps");
|
|
|
|
cy.get('[data-testid="outside-popover"]').click();
|
|
|
|
cy.get("@onClose").should("have.been.calledOnce");
|
|
});
|
|
});
|