Compare commits

..

1 commit

Author SHA1 Message Date
a488704a77 feat(recipes): permet d'associer ingredients/ustensiles a une correction de technique
Etend le flux de correction existant (TechStepCorrectionPopover) pour que
l'utilisateur associe lui-meme des ingredients (avec quantite/unite) et
des ustensiles a la technique qu'il corrige, avec le meme marquage
source: "manual" que la technique elle-meme.

Backend :
- submitTechStepCorrectionSchema (packages/shared) accepte des tableaux
  ingredients/utensils optionnels, chacun avec son propre span [start,end)
  selectionne par l'utilisateur. Omis = ne touche pas aux metadonnees
  existantes ; tableau (meme vide) = remplace tout ce qui existait sur
  cette occurrence (auto ET manuel precedent - decision validee avec
  l'utilisateur).
- applyManualCorrection (recipe-tech-step-correction.service.ts) ecrit
  les nouvelles lignes StepTechStepIngredient/StepTechStepUtensil apres
  avoir vide celles de l'occurrence via deleteMany - meme chemin de code
  que ce soit une creation ou une mise a jour de la technique.
- Nouveaux asserts d'existence (ingredient/unite/ustensile) + validation
  de span, nouveau code d'erreur UTENSIL_NOT_FOUND.
- source ajoute a StepTechStepIngredientView/StepTechStepUtensilView
  (le calque manquait ce que la colonne DB portait deja).

Frontend :
- TechStepCorrectionPopover passe d'un clic = soumission immediate a un
  flux selection-puis-confirmation, avec deux nouvelles sections
  Ingredients/Ustensiles pre-remplies avec l'existant.
- Ajouter un ingredient/ustensile demande une selection de texte dediee
  dans la description encore visible (StepDescription geree via un
  nouvel etat pendingSpanRequest/resolvedMetadataSpan) - pas de raccourci
  sur le span de la correction elle-meme.
- Nouveau CatalogSearchPicker.tsx, plus leger que IngredientPicker pour
  ce contexte de popover, reutilise pour les deux catalogues.
- getUtensils() ajoute a apiClient.

Tests : nouveaux cas Mocha (attache/remplace/omission/validations) dans
recipe-tech-step-correction.test.ts, TechStepCorrectionPopover.cy.tsx
etendu avec le nouveau flux, recipes.ts (e2e) ajuste au clic Valider
supplementaire.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 21:08:18 +02:00
7 changed files with 157 additions and 226 deletions

View file

@ -12,7 +12,7 @@ on:
push: push:
env: env:
DATABASE_URL: "postgresql://ci:ci@postgres:5432/batchcooking_ci?schema=public" DATABASE_URL: "postgresql://ci:ci@localhost:5432/batchcooking_ci?schema=public"
# Test-only secret, never used outside CI — real deployments must set their own. # Test-only secret, never used outside CI — real deployments must set their own.
JWT_SECRET: "ci-only-secret-not-used-anywhere-else-32chars+" JWT_SECRET: "ci-only-secret-not-used-anywhere-else-32chars+"
# Same reasoning as JWT_SECRET above — lets tech-step-worker.routes.test.ts # Same reasoning as JWT_SECRET above — lets tech-step-worker.routes.test.ts
@ -33,11 +33,11 @@ jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: https://github.com/actions/checkout@v4 - uses: actions/checkout@v4
- uses: https://github.com/pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: https://github.com/actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm cache: pnpm
@ -55,25 +55,27 @@ jobs:
POSTGRES_PASSWORD: ci POSTGRES_PASSWORD: ci
POSTGRES_DB: batchcooking_ci POSTGRES_DB: batchcooking_ci
ports: ports:
- 5433:5432 - 5432:5432
options: >- options: >-
--health-cmd pg_isready --health-cmd pg_isready
--health-interval 5s --health-interval 5s
--health-timeout 5s --health-timeout 5s
--health-retries 10 --health-retries 10
steps: steps:
- uses: https://github.com/actions/checkout@v4 - uses: actions/checkout@v4
- uses: https://github.com/pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: https://github.com/actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm cache: pnpm
- uses: https://github.com/astral-sh/setup-uv@v5 - uses: actions/setup-python@v5
with: with:
python-version: "3.12" python-version: "3.12"
- uses: astral-sh/setup-uv@v3
with:
enable-cache: true enable-cache: true
# `services:` (like the `postgres` container above) can only pull an # `services:` (like the `postgres` container above) can only pull an
@ -107,11 +109,13 @@ jobs:
intent-service-test: intent-service-test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: https://github.com/actions/checkout@v4 - uses: actions/checkout@v4
- uses: https://github.com/astral-sh/setup-uv@v5 - uses: actions/setup-python@v5
with: with:
python-version: "3.12" python-version: "3.12"
- uses: astral-sh/setup-uv@v3
with:
enable-cache: true enable-cache: true
- name: Install services/tech-step-intent-service - name: Install services/tech-step-intent-service
@ -124,11 +128,11 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: https://github.com/actions/checkout@v4 - uses: actions/checkout@v4
- uses: https://github.com/pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: https://github.com/actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm cache: pnpm
@ -139,17 +143,17 @@ jobs:
e2e: e2e:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: https://github.com/actions/checkout@v4 - uses: actions/checkout@v4
- uses: https://github.com/pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: https://github.com/actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm cache: pnpm
- name: Cache Cypress binary - name: Cache Cypress binary
uses: https://github.com/actions/cache@v4 uses: actions/cache@v4
with: with:
path: ~/.cache/Cypress path: ~/.cache/Cypress
key: cypress-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} key: cypress-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}

View file

@ -90,65 +90,36 @@ describe("TechStepCorrectionPopover", () => {
); );
}); });
it("shows the selected text, the technique catalog (searchable) and the metadata sections all together", () => { it("shows the selected text and every technique option once loaded", () => {
cy.mount(<Harness />); cy.mount(<Harness />);
cy.wait("@getTechSteps"); cy.wait("@getTechSteps");
cy.contains(".tech-step-correction-popover__selection", "Cuire").should("be.visible"); cy.contains(".tech-step-correction-popover__selection", "Cuire").should("be.visible");
// Merged editor (see TechStepCorrectionPopover's own doc comment) — no cy.get(".tech-step-correction-popover__list button").should("have.length", 2);
// 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", () => { it("offers a 'no technique here' option only when correcting an existing match", () => {
cy.mount(<Harness previousTechStepId={null} />); cy.mount(<Harness previousTechStepId={null} />);
cy.wait("@getTechSteps"); cy.wait("@getTechSteps");
cy.get(".tech-step-correction-popover__remove").should("not.exist"); 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.mount(<Harness previousTechStepId={cook.id} />);
cy.wait("@getTechSteps"); cy.wait("@getTechSteps");
cy.get(".tech-step-correction-popover__remove").should("exist"); 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", () => { it("selecting a technique reveals the Ingrédients/Ustensiles sections instead of submitting immediately", () => {
cy.mount(<Harness />); cy.mount(<Harness />);
cy.wait("@getTechSteps"); cy.wait("@getTechSteps");
cy.contains( cy.contains(".tech-step-correction-popover__list button", "Mijoter").click();
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
"Mijoter",
).click();
cy.contains(".tech-step-correction-popover__chosen-technique", "Mijoter"); 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("button", "Valider").should("be.visible"); 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", () => { it("submits the selected technique (no metadata touched) with ingredients/utensils omitted from the request", () => {
cy.intercept("POST", "**/recipes/2/steps/2/corrections", { cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
statusCode: 201, statusCode: 201,
@ -165,10 +136,7 @@ describe("TechStepCorrectionPopover", () => {
cy.mount(<Harness onSubmitted={onSubmitted} />); cy.mount(<Harness onSubmitted={onSubmitted} />);
cy.wait("@getTechSteps"); cy.wait("@getTechSteps");
cy.contains( cy.contains(".tech-step-correction-popover__list button", "Mijoter").click();
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
"Mijoter",
).click();
cy.contains("button", "Valider").click(); cy.contains("button", "Valider").click();
cy.wait("@submitCorrection").its("request.body").should("deep.equal", { cy.wait("@submitCorrection").its("request.body").should("deep.equal", {
@ -194,13 +162,10 @@ describe("TechStepCorrectionPopover", () => {
}).as("submitCorrection"); }).as("submitCorrection");
cy.mount(<Harness />); cy.mount(<Harness />);
cy.wait("@getTechSteps"); cy.wait("@getTechSteps");
cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]);
cy.contains( cy.contains(".tech-step-correction-popover__list button", "Mijoter").click();
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
"Mijoter",
).click();
cy.contains("button", "+ Ajouter un ingrédient").click(); cy.contains("button", "+ Ajouter un ingrédient").click();
cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]);
cy.contains(".catalog-search-picker button", "Beurre").click(); cy.contains(".catalog-search-picker button", "Beurre").click();
cy.get('input[type="number"]').type("50"); cy.get('input[type="number"]').type("50");
@ -234,11 +199,9 @@ describe("TechStepCorrectionPopover", () => {
existingUtensils={[{ utensil: pan, start: 14, end: 23, 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("@getTechSteps");
cy.contains(".tech-step-correction-popover__list button", "Mijoter").click();
cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]); 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", "50 g Beurre").find("button").click();
@ -254,10 +217,7 @@ describe("TechStepCorrectionPopover", () => {
cy.mount(<Harness onClose={onClose} />); cy.mount(<Harness onClose={onClose} />);
cy.wait("@getTechSteps"); cy.wait("@getTechSteps");
cy.contains( cy.contains(".tech-step-correction-popover__list button", "Cuire").click();
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
"Cuire",
).click();
cy.contains("button", "Valider").click(); cy.contains("button", "Valider").click();
cy.wait("@submitCorrection"); cy.wait("@submitCorrection");

View file

@ -110,20 +110,13 @@ Then("I should see the technique correction options", () => {
cy.get(".tech-step-correction-popover").should("be.visible"); cy.get(".tech-step-correction-popover").should("be.visible");
}); });
// Picking a technique only *selects* it — it takes a separate "Valider" // Picking a technique only *selects* it now — it takes a separate
// click to actually submit (room was made for attaching ingredient/utensil // "Valider" click to actually submit (room was made for attaching
// metadata alongside it, see `TechStepCorrectionPopover.tsx`'s own doc // ingredient/utensil metadata first, see `TechStepCorrectionPopover.tsx`'s
// comment on its merged editor) — folded into this one step since nothing // own doc comment) — folded into this one step since nothing in this
// in this scenario cares about that intermediate state on its own. The // scenario cares about that intermediate state on its own.
// 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) => { When("I choose {string} as the correct technique", (label: string) => {
cy.contains( cy.contains(".tech-step-correction-popover__list button", label).click();
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
label,
).click();
cy.contains(".tech-step-correction-popover__confirm-button", "Valider").click(); cy.contains(".tech-step-correction-popover__confirm-button", "Valider").click();
}); });

View file

@ -729,41 +729,43 @@
margin: 0 0 var(--space-sm); margin: 0 0 var(--space-sm);
} }
// Technique picker + Ingrédients/Ustensiles render together as one &__list {
// 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-muted);
}
}
&__technique-header {
display: flex; display: flex;
align-items: baseline; flex-wrap: wrap;
justify-content: space-between; gap: var(--space-xs);
gap: var(--space-sm); list-style: none;
} margin: 0 0 var(--space-sm);
padding: 0;
&__remove { button {
padding: 0.2rem 0.5rem; padding: 0.3rem 0.6rem;
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
color: var(--color-error); color: var(--color-text);
background: none; background: var(--color-surface);
border: 1px solid var(--color-error); border: 1px solid var(--color-border);
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
cursor: pointer; cursor: pointer;
&:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
border-color: var(--color-primary);
}
&:disabled { &:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
} }
// 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);
}
}
&__cancel { &__cancel {
background: none; background: none;
border: none; border: none;
@ -806,27 +808,36 @@
gap: var(--space-sm); 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 { &__chosen-technique {
margin: 0 0 var(--space-xs); display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
margin: 0;
font-weight: 600; font-weight: 600;
} }
&__metadata-section { &__change {
margin-top: var(--space-sm); 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 {
h4 { h4 {
margin: 0 0 var(--space-xs); margin: 0 0 var(--space-xs);
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
color: var(--color-text-muted); color: var(--color-text-muted);
} }
// "+ Ajouter…" button deliberately a plain text-link style, not // "+ Ajouter…" button deliberately the same plain-text-link styling
// another pill button (`.catalog-search-picker__list button`) this // as `&__change` above, not another pill button (`&__list button`)
// is a secondary action inside an already-open popover, not a // this is a secondary action inside an already-open popover, not a
// top-level choice competing with the chips above it. // top-level choice competing with the chips above it.
> button { > button {
background: none; background: none;
@ -914,10 +925,7 @@
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
// Raised from the original 8rem this component is now also the max-height: 8rem;
// 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; overflow-y: auto;
button { button {
@ -933,18 +941,6 @@
background: color-mix(in srgb, var(--color-primary) 14%, transparent); background: color-mix(in srgb, var(--color-primary) 14%, transparent);
border-color: var(--color-primary); 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;
}
} }
} }
} }

View file

@ -3,30 +3,25 @@ import { useState } from "react";
/** /**
* Small search-and-pick list a lighter alternative to `IngredientPicker.tsx` * Small search-and-pick list a lighter alternative to `IngredientPicker.tsx`
* (category/subcategory grid + allergen/diet toggles) for a context that * (category/subcategory grid + allergen/diet toggles) for a context that
* doesn't have room for that: `TechStepCorrectionPopover.tsx`'s ingredient/ * doesn't have room for that: `TechStepCorrectionPopover.tsx`'s "attach an
* utensil/**technique** pickers, all embedded in a small popover rather than * ingredient/utensil to this correction" flow, embedded in a small popover
* a full recipe form. Reused for all three an ingredient, a utensil, and a * rather than a full recipe form. Reused for both an ingredient and a
* technique are all "search a reference list by translated label, pick one" * utensil are both "search a short reference list by translated label, pick
* from this component's point of view, the only difference is which * one" from this component's point of view, the only difference is which
* `items`/labels the caller passes in. The technique catalog in particular * `items`/labels the caller passes in.
* (~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`/ * Deliberately just `{ id, label }` in, `id` out no `IngredientView`/
* `UtensilView`/`TechStepView` dependency here, so this stays reusable for * `UtensilView` dependency here, so this stays reusable for any future
* any future "search this small reference catalog" need without growing a * "search this small reference catalog" need without growing a new prop
* new prop per catalog shape. * per catalog shape.
*/ */
export function CatalogSearchPicker({ export function CatalogSearchPicker({
items, items,
selectedId,
onSelect, onSelect,
placeholder, placeholder,
emptyLabel, emptyLabel,
}: { }: {
items: { id: number; label: string }[]; 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; onSelect: (id: number) => void;
placeholder: string; placeholder: string;
emptyLabel: string; emptyLabel: string;
@ -53,13 +48,7 @@ export function CatalogSearchPicker({
<ul className="catalog-search-picker__list"> <ul className="catalog-search-picker__list">
{visible.map((item) => ( {visible.map((item) => (
<li key={item.id}> <li key={item.id}>
<button <button type="button" onClick={() => onSelect(item.id)}>
type="button"
className={
item.id === selectedId ? "catalog-search-picker__item--selected" : undefined
}
onClick={() => onSelect(item.id)}
>
{item.label} {item.label}
</button> </button>
</li> </li>

View file

@ -61,17 +61,12 @@ function toPendingUtensil(view: StepTechStepUtensilView): PendingUtensil {
* first the description stays fully visible and selectable the whole * first the description stays fully visible and selectable the whole
* time, nothing overlays it. * time, nothing overlays it.
* *
* **One merged editor, not a wizard**: picking a technique * **Removing** a match (`submit(null)`) stays a single immediate action
* (`CatalogSearchPicker`, searchable the reference catalog is ~74 * nothing to attach when removing. **Picking/relabeling** a technique used
* entries, an unfiltered flat list wasn't browsable) and editing its * to submit immediately too; it no longer does, so there's room to attach
* Ingrédients/Ustensiles metadata render together on the same screen, * metadata before committing clicking a technique now only *selects* it
* always there's no separate "pick, then a metadata step reveals * (`selectedTechStepId`), revealing the Ingrédients/Ustensiles sections and
* itself" sequence to go through, and no dead end where metadata is * a final "Valider" button that submits everything together.
* 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`/ * The two metadata sections are pre-seeded from `existingIngredients`/
* `existingUtensils` (whatever's already attached to this occurrence, auto- * `existingUtensils` (whatever's already attached to this occurrence, auto-
@ -123,14 +118,7 @@ export function TechStepCorrectionPopover({
const { t } = useTranslation(); const { t } = useTranslation();
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
const [techSteps, setTechSteps] = useState<TechStepView[] | null>(null); const [techSteps, setTechSteps] = useState<TechStepView[] | null>(null);
// Opening this popover on an *already-detected* match (`previousTechStepId const [selectedTechStepId, setSelectedTechStepId] = useState<number | null>(null);
// !== 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<number | null>(previousTechStepId);
const [catalogs, setCatalogs] = useState<{ const [catalogs, setCatalogs] = useState<{
ingredients: IngredientView[]; ingredients: IngredientView[];
units: UnitView[]; units: UnitView[];
@ -185,12 +173,12 @@ export function TechStepCorrectionPopover({
}; };
}, []); }, []);
// Fetched unconditionally on mount — the Ingrédients/Ustensiles sections // Only fetched once a technique is actually selected — the Ingrédients/
// render alongside the technique picker from the start (see this // Ustensiles sections (the only things that need these) don't render
// component's own doc comment on the merged editor), so there's no later // before then, so a popover only ever used to relabel/remove a technique
// point to defer this to anymore. // never pays for these three extra requests.
useEffect(() => { useEffect(() => {
if (catalogs !== null) return; if (selectedTechStepId === null || catalogs !== null) return;
let cancelled = false; let cancelled = false;
Promise.all([apiClient.getIngredients(), apiClient.getUnits(), apiClient.getUtensils()]) Promise.all([apiClient.getIngredients(), apiClient.getUnits(), apiClient.getUtensils()])
.then(([ingredients, units, utensils]) => { .then(([ingredients, units, utensils]) => {
@ -202,7 +190,7 @@ export function TechStepCorrectionPopover({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [catalogs]); }, [selectedTechStepId, catalogs]);
// Consumes a span `StepDescription` just resolved on this popover's // Consumes a span `StepDescription` just resolved on this popover's
// behalf (see `resolvedMetadataSpan`'s own doc comment above) — opens the // behalf (see `resolvedMetadataSpan`'s own doc comment above) — opens the
@ -403,12 +391,10 @@ export function TechStepCorrectionPopover({
</div> </div>
) : techSteps === null ? ( ) : techSteps === null ? (
<p>{t("recipes.loading")}</p> <p>{t("recipes.loading")}</p>
) : ( ) : selectedTechStepId === null ? (
<div className="tech-step-correction-popover__confirm"> <ul className="tech-step-correction-popover__list">
<section className="tech-step-correction-popover__technique-section">
<div className="tech-step-correction-popover__technique-header">
<h4>{t("recipes.techStepCorrection.techniqueSection")}</h4>
{previousTechStepId !== null && ( {previousTechStepId !== null && (
<li>
<button <button
type="button" type="button"
disabled={isSubmitting} disabled={isSubmitting}
@ -417,28 +403,35 @@ export function TechStepCorrectionPopover({
> >
{t("recipes.techStepCorrection.removeMatch")} {t("recipes.techStepCorrection.removeMatch")}
</button> </button>
</li>
)} )}
</div> {techSteps.map((techStep) => (
<li key={techStep.id}>
<button
type="button"
disabled={isSubmitting}
onClick={() => setSelectedTechStepId(techStep.id)}
>
{t(`catalog.techSteps.${techStep.key}`)}
</button>
</li>
))}
</ul>
) : (
<div className="tech-step-correction-popover__confirm">
<p className="tech-step-correction-popover__chosen-technique"> <p className="tech-step-correction-popover__chosen-technique">
{selectedTechStepId !== null {t(
? t("recipes.techStepCorrection.currentTechnique", {
technique: t(
`catalog.techSteps.${techSteps.find((ts) => ts.id === selectedTechStepId)?.key ?? ""}`, `catalog.techSteps.${techSteps.find((ts) => ts.id === selectedTechStepId)?.key ?? ""}`,
), )}
}) <button
: t("recipes.techStepCorrection.noTechniqueSelected")} type="button"
className="tech-step-correction-popover__change"
onClick={() => setSelectedTechStepId(null)}
disabled={isSubmitting}
>
{t("recipes.techStepCorrection.changeTechnique")}
</button>
</p> </p>
<CatalogSearchPicker
items={techSteps.map((techStep) => ({
id: techStep.id,
label: t(`catalog.techSteps.${techStep.key}`),
}))}
selectedId={selectedTechStepId ?? undefined}
onSelect={setSelectedTechStepId}
placeholder={t("recipes.techStepCorrection.searchTechniquePlaceholder")}
emptyLabel={t("recipes.techStepCorrection.noTechniqueFound")}
/>
</section>
<section className="tech-step-correction-popover__metadata-section"> <section className="tech-step-correction-popover__metadata-section">
<h4>{t("recipes.techStepCorrection.ingredientsSection")}</h4> <h4>{t("recipes.techStepCorrection.ingredientsSection")}</h4>
@ -501,7 +494,7 @@ export function TechStepCorrectionPopover({
type="button" type="button"
className="tech-step-correction-popover__confirm-button" className="tech-step-correction-popover__confirm-button"
onClick={confirm} onClick={confirm}
disabled={isSubmitting || selectedTechStepId === null} disabled={isSubmitting}
> >
{t("recipes.techStepCorrection.confirm")} {t("recipes.techStepCorrection.confirm")}
</button> </button>

View file

@ -170,12 +170,8 @@
"cancel": "Annuler", "cancel": "Annuler",
"manualTooltip": "{{technique}} (correction manuelle)", "manualTooltip": "{{technique}} (correction manuelle)",
"discoverabilityHint": "💡 Sélectionnez du texte, ou cliquez sur une technique surlignée, pour la corriger.", "discoverabilityHint": "💡 Sélectionnez du texte, ou cliquez sur une technique surlignée, pour la corriger.",
"changeTechnique": "Changer",
"confirm": "Valider", "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", "ingredientsSection": "Ingrédients",
"utensilsSection": "Ustensiles", "utensilsSection": "Ustensiles",
"addIngredient": "+ Ajouter un ingrédient", "addIngredient": "+ Ajouter un ingrédient",