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 ? (