diff --git a/apps/web/cypress/component/TechStepCorrectionPopover.cy.tsx b/apps/web/cypress/component/TechStepCorrectionPopover.cy.tsx index 6c0cf07..2eaf71c 100644 --- a/apps/web/cypress/component/TechStepCorrectionPopover.cy.tsx +++ b/apps/web/cypress/component/TechStepCorrectionPopover.cy.tsx @@ -90,36 +90,65 @@ describe("TechStepCorrectionPopover", () => { ); }); - it("shows the selected text and every technique option once loaded", () => { + it("shows the selected text, the technique catalog (searchable) and the metadata sections all together", () => { cy.mount(); cy.wait("@getTechSteps"); cy.contains(".tech-step-correction-popover__selection", "Cuire").should("be.visible"); - cy.get(".tech-step-correction-popover__list button").should("have.length", 2); + // 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 only when correcting an existing match", () => { + it("offers a 'no technique here' option, and marks the current pick, only when correcting an existing match", () => { cy.mount(); 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(); 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("selecting a technique reveals the Ingrédients/Ustensiles sections instead of submitting immediately", () => { + it("picking a technique from the catalog selects it without submitting immediately", () => { cy.mount(); cy.wait("@getTechSteps"); - cy.contains(".tech-step-correction-popover__list button", "Mijoter").click(); + cy.contains( + ".tech-step-correction-popover__technique-section .catalog-search-picker__list button", + "Mijoter", + ).click(); - cy.get(".tech-step-correction-popover__list").should("not.exist"); - cy.contains("h4", "Ingrédients").should("be.visible"); - cy.contains("h4", "Ustensiles").should("be.visible"); + 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(); + 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, @@ -136,7 +165,10 @@ describe("TechStepCorrectionPopover", () => { cy.mount(); cy.wait("@getTechSteps"); - cy.contains(".tech-step-correction-popover__list button", "Mijoter").click(); + 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", { @@ -162,11 +194,14 @@ describe("TechStepCorrectionPopover", () => { }).as("submitCorrection"); cy.mount(); cy.wait("@getTechSteps"); - - cy.contains(".tech-step-correction-popover__list button", "Mijoter").click(); - cy.contains("button", "+ Ajouter un ingrédient").click(); 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)); @@ -199,9 +234,11 @@ describe("TechStepCorrectionPopover", () => { 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.contains(".tech-step-correction-popover__list button", "Mijoter").click(); cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]); cy.contains(".tech-step-correction-popover__chip", "50 g Beurre").find("button").click(); @@ -217,7 +254,10 @@ describe("TechStepCorrectionPopover", () => { cy.mount(); cy.wait("@getTechSteps"); - cy.contains(".tech-step-correction-popover__list button", "Cuire").click(); + cy.contains( + ".tech-step-correction-popover__technique-section .catalog-search-picker__list button", + "Cuire", + ).click(); cy.contains("button", "Valider").click(); cy.wait("@submitCorrection"); diff --git a/apps/web/cypress/e2e/recipes.ts b/apps/web/cypress/e2e/recipes.ts index af0f2a9..206e4fb 100644 --- a/apps/web/cypress/e2e/recipes.ts +++ b/apps/web/cypress/e2e/recipes.ts @@ -110,13 +110,20 @@ Then("I should see the technique correction options", () => { cy.get(".tech-step-correction-popover").should("be.visible"); }); -// Picking a technique only *selects* it now — it takes a separate -// "Valider" click to actually submit (room was made for attaching -// ingredient/utensil metadata first, see `TechStepCorrectionPopover.tsx`'s -// own doc comment) — folded into this one step since nothing in this -// scenario cares about that intermediate state on its own. +// Picking a technique only *selects* it — it takes a separate "Valider" +// click to actually submit (room was made for attaching ingredient/utensil +// metadata alongside it, see `TechStepCorrectionPopover.tsx`'s own doc +// comment on its merged editor) — folded into this one step since nothing +// in this scenario cares about that intermediate state on its own. The +// technique catalog is picked via the same searchable `CatalogSearchPicker` +// the ingredient/utensil sub-flows use, scoped to +// `__technique-section` since that same search-and-pick component is +// reused inside this popover for more than just techniques. When("I choose {string} as the correct technique", (label: string) => { - cy.contains(".tech-step-correction-popover__list button", label).click(); + cy.contains( + ".tech-step-correction-popover__technique-section .catalog-search-picker__list button", + label, + ).click(); cy.contains(".tech-step-correction-popover__confirm-button", "Valider").click(); }); diff --git a/apps/web/src/features/recipes/recipes.scss b/apps/web/src/features/recipes/recipes.scss index cf0a77c..fe06ec9 100644 --- a/apps/web/src/features/recipes/recipes.scss +++ b/apps/web/src/features/recipes/recipes.scss @@ -729,40 +729,38 @@ margin: 0 0 var(--space-sm); } - &__list { - display: flex; - flex-wrap: wrap; - gap: var(--space-xs); - list-style: none; - margin: 0 0 var(--space-sm); - padding: 0; - - button { - padding: 0.3rem 0.6rem; + // Technique picker + Ingrédients/Ustensiles render together as one + // screen now (see `TechStepCorrectionPopover.tsx`'s own doc comment) — + // this section just needs its own small header row, the actual picker + // is `.catalog-search-picker` (below), reused as-is from the ingredient/ + // utensil sub-flows. + &__technique-section { + h4 { + margin: 0 0 var(--space-xs); font-size: var(--font-size-sm); - color: var(--color-text); - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-pill); - cursor: pointer; - - &:hover:not(:disabled) { - background: color-mix(in srgb, var(--color-primary) 14%, transparent); - border-color: var(--color-primary); - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } + color: var(--color-text-muted); } + } - // Nested (rather than a sibling `&__remove` block) so its border-color - // wins over the plain `button` rule above by class-count specificity, - // no `!important` needed. - .tech-step-correction-popover__remove { - color: var(--color-error); - border-color: var(--color-error); + &__technique-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-sm); + } + + &__remove { + padding: 0.2rem 0.5rem; + font-size: var(--font-size-sm); + color: var(--color-error); + background: none; + border: 1px solid var(--color-error); + border-radius: var(--radius-pill); + cursor: pointer; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; } } @@ -808,36 +806,27 @@ gap: var(--space-sm); } + // "Aucune technique sélectionnée."/"Technique retenue : X" — no button + // here anymore (re-picking happens directly through the search picker + // right below, see `TechStepCorrectionPopover.tsx`'s doc comment on the + // merged editor), just a small status line. &__chosen-technique { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-sm); - margin: 0; + margin: 0 0 var(--space-xs); font-weight: 600; } - &__change { - background: none; - border: none; - color: var(--color-primary); - text-decoration: underline; - cursor: pointer; - padding: 0; - font-size: var(--font-size-sm); - font-weight: 400; - } - &__metadata-section { + margin-top: var(--space-sm); + h4 { margin: 0 0 var(--space-xs); font-size: var(--font-size-sm); color: var(--color-text-muted); } - // "+ Ajouter…" button — deliberately the same plain-text-link styling - // as `&__change` above, not another pill button (`&__list button`) — - // this is a secondary action inside an already-open popover, not a + // "+ Ajouter…" button — deliberately a plain text-link style, not + // another pill button (`.catalog-search-picker__list button`) — this + // is a secondary action inside an already-open popover, not a // top-level choice competing with the chips above it. > button { background: none; @@ -925,7 +914,10 @@ list-style: none; margin: 0; padding: 0; - max-height: 8rem; + // Raised from the original 8rem — this component is now also the + // technique picker (~74 entries, see this file's own doc comment), + // where 8rem left only a couple of rows visible before scrolling. + max-height: 14rem; overflow-y: auto; button { @@ -941,6 +933,18 @@ background: color-mix(in srgb, var(--color-primary) 14%, transparent); border-color: var(--color-primary); } + + // The technique picker's current pick (`selectedId` prop) — stays + // visually marked even while filtered/scrolled past, so re-opening + // this popover's picker doesn't read as "nothing chosen yet" when + // something already is. Unused by the ingredient/utensil sub-flows + // (they never pass `selectedId` — each pick there just appends a + // fresh mention, nothing to mark as "current"). + &.catalog-search-picker__item--selected { + background: color-mix(in srgb, var(--color-primary) 20%, transparent); + border-color: var(--color-primary); + font-weight: 600; + } } } } diff --git a/apps/web/src/features/recipes/steps/CatalogSearchPicker.tsx b/apps/web/src/features/recipes/steps/CatalogSearchPicker.tsx index 0cccd2f..47ca431 100644 --- a/apps/web/src/features/recipes/steps/CatalogSearchPicker.tsx +++ b/apps/web/src/features/recipes/steps/CatalogSearchPicker.tsx @@ -3,25 +3,30 @@ import { useState } from "react"; /** * Small search-and-pick list — a lighter alternative to `IngredientPicker.tsx` * (category/subcategory grid + allergen/diet toggles) for a context that - * doesn't have room for that: `TechStepCorrectionPopover.tsx`'s "attach an - * ingredient/utensil to this correction" flow, embedded in a small popover - * rather than a full recipe form. Reused for both — an ingredient and a - * utensil are both "search a short reference list by translated label, pick - * one" from this component's point of view, the only difference is which - * `items`/labels the caller passes in. + * doesn't have room for that: `TechStepCorrectionPopover.tsx`'s ingredient/ + * utensil/**technique** pickers, all embedded in a small popover rather than + * a full recipe form. Reused for all three — an ingredient, a utensil, and a + * technique are all "search a reference list by translated label, pick one" + * from this component's point of view, the only difference is which + * `items`/labels the caller passes in. The technique catalog in particular + * (~74 entries) is exactly the case a plain unfiltered list stops being + * readable at — the original motivation for adding search here at all. * * Deliberately just `{ id, label }` in, `id` out — no `IngredientView`/ - * `UtensilView` dependency here, so this stays reusable for any future - * "search this small reference catalog" need without growing a new prop - * per catalog shape. + * `UtensilView`/`TechStepView` dependency here, so this stays reusable for + * any future "search this small reference catalog" need without growing a + * new prop per catalog shape. */ export function CatalogSearchPicker({ items, + selectedId, onSelect, placeholder, emptyLabel, }: { items: { id: number; label: string }[]; + /** The currently-picked item, if any — marked with a distinct modifier class so it stays visible at a glance while browsing/filtering a longer list (e.g. `TechStepCorrectionPopover`'s ~74-entry technique catalog), not just implied by whatever's selected elsewhere on screen. Omit for a picker with no notion of a "current" pick (the ingredient/utensil span sub-flows — each `onSelect` there just appends a brand-new mention, nothing to mark as already chosen). */ + selectedId?: number; onSelect: (id: number) => void; placeholder: string; emptyLabel: string; @@ -48,7 +53,13 @@ export function CatalogSearchPicker({
    {visible.map((item) => (
  • -
  • diff --git a/apps/web/src/features/recipes/steps/TechStepCorrectionPopover.tsx b/apps/web/src/features/recipes/steps/TechStepCorrectionPopover.tsx index 7493b46..c20e3b8 100644 --- a/apps/web/src/features/recipes/steps/TechStepCorrectionPopover.tsx +++ b/apps/web/src/features/recipes/steps/TechStepCorrectionPopover.tsx @@ -61,12 +61,17 @@ function toPendingUtensil(view: StepTechStepUtensilView): PendingUtensil { * first — the description stays fully visible and selectable the whole * time, nothing overlays it. * - * **Removing** a match (`submit(null)`) stays a single immediate action — - * nothing to attach when removing. **Picking/relabeling** a technique used - * to submit immediately too; it no longer does, so there's room to attach - * metadata before committing — clicking a technique now only *selects* it - * (`selectedTechStepId`), revealing the Ingrédients/Ustensiles sections and - * a final "Valider" button that submits everything together. + * **One merged editor, not a wizard**: picking a technique + * (`CatalogSearchPicker`, searchable — the reference catalog is ~74 + * entries, an unfiltered flat list wasn't browsable) and editing its + * Ingrédients/Ustensiles metadata render together on the same screen, + * always — there's no separate "pick, then a metadata step reveals + * itself" sequence to go through, and no dead end where metadata is + * technically attachable but not visible until some other action happens + * first. A single "Valider" submits everything at once; disabled until a + * technique is actually selected (there's nothing to attach metadata to + * otherwise). **Removing** a match (`submit(null)`) stays its own + * immediate action next to the picker — nothing to attach when removing. * * The two metadata sections are pre-seeded from `existingIngredients`/ * `existingUtensils` (whatever's already attached to this occurrence, auto- @@ -118,7 +123,14 @@ export function TechStepCorrectionPopover({ const { t } = useTranslation(); const popoverRef = useRef(null); const [techSteps, setTechSteps] = useState(null); - const [selectedTechStepId, setSelectedTechStepId] = useState(null); + // Opening this popover on an *already-detected* match (`previousTechStepId + // !== null`, i.e. the user clicked an existing highlight rather than + // selecting fresh text) starts pre-selected on that same technique, its + // name shown next to the picker right away — since the picker and the + // metadata sections render together regardless (see this component's own + // doc comment), this just saves re-picking the technique that's already + // correct before its metadata becomes editable. + const [selectedTechStepId, setSelectedTechStepId] = useState(previousTechStepId); const [catalogs, setCatalogs] = useState<{ ingredients: IngredientView[]; units: UnitView[]; @@ -173,12 +185,12 @@ export function TechStepCorrectionPopover({ }; }, []); - // Only fetched once a technique is actually selected — the Ingrédients/ - // Ustensiles sections (the only things that need these) don't render - // before then, so a popover only ever used to relabel/remove a technique - // never pays for these three extra requests. + // Fetched unconditionally on mount — the Ingrédients/Ustensiles sections + // render alongside the technique picker from the start (see this + // component's own doc comment on the merged editor), so there's no later + // point to defer this to anymore. useEffect(() => { - if (selectedTechStepId === null || catalogs !== null) return; + if (catalogs !== null) return; let cancelled = false; Promise.all([apiClient.getIngredients(), apiClient.getUnits(), apiClient.getUtensils()]) .then(([ingredients, units, utensils]) => { @@ -190,7 +202,7 @@ export function TechStepCorrectionPopover({ return () => { cancelled = true; }; - }, [selectedTechStepId, catalogs]); + }, [catalogs]); // Consumes a span `StepDescription` just resolved on this popover's // behalf (see `resolvedMetadataSpan`'s own doc comment above) — opens the @@ -391,47 +403,42 @@ export function TechStepCorrectionPopover({ ) : techSteps === null ? (

    {t("recipes.loading")}

    - ) : selectedTechStepId === null ? ( -
      - {previousTechStepId !== null && ( -
    • - -
    • - )} - {techSteps.map((techStep) => ( -
    • - -
    • - ))} -
    ) : (
    -

    - {t( - `catalog.techSteps.${techSteps.find((ts) => ts.id === selectedTechStepId)?.key ?? ""}`, - )} - -

    +
    +
    +

    {t("recipes.techStepCorrection.techniqueSection")}

    + {previousTechStepId !== null && ( + + )} +
    +

    + {selectedTechStepId !== null + ? t("recipes.techStepCorrection.currentTechnique", { + technique: t( + `catalog.techSteps.${techSteps.find((ts) => ts.id === selectedTechStepId)?.key ?? ""}`, + ), + }) + : t("recipes.techStepCorrection.noTechniqueSelected")} +

    + ({ + id: techStep.id, + label: t(`catalog.techSteps.${techStep.key}`), + }))} + selectedId={selectedTechStepId ?? undefined} + onSelect={setSelectedTechStepId} + placeholder={t("recipes.techStepCorrection.searchTechniquePlaceholder")} + emptyLabel={t("recipes.techStepCorrection.noTechniqueFound")} + /> +

    {t("recipes.techStepCorrection.ingredientsSection")}

    @@ -494,7 +501,7 @@ export function TechStepCorrectionPopover({ type="button" className="tech-step-correction-popover__confirm-button" onClick={confirm} - disabled={isSubmitting} + disabled={isSubmitting || selectedTechStepId === null} > {t("recipes.techStepCorrection.confirm")} diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 681a8dd..f19bbbf 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -170,8 +170,12 @@ "cancel": "Annuler", "manualTooltip": "{{technique}} (correction manuelle)", "discoverabilityHint": "💡 Sélectionnez du texte, ou cliquez sur une technique surlignée, pour la corriger.", - "changeTechnique": "Changer", "confirm": "Valider", + "techniqueSection": "Technique", + "currentTechnique": "Technique retenue : {{technique}}", + "noTechniqueSelected": "Aucune technique sélectionnée.", + "searchTechniquePlaceholder": "Rechercher une technique…", + "noTechniqueFound": "Aucune technique trouvée.", "ingredientsSection": "Ingrédients", "utensilsSection": "Ustensiles", "addIngredient": "+ Ajouter un ingrédient",