Compare commits

..

10 commits

Author SHA1 Message Date
9ec9b82e06 Actualiser .github/workflows/ci.yml
Some checks failed
CI / lint (push) Successful in 43s
CI / test (push) Successful in 14m18s
CI / intent-service-test (push) Successful in 7m49s
CI / build (push) Successful in 1m57s
CI / e2e (push) Failing after 6m6s
2026-08-27 20:25:50 +02:00
f7aa696f55 Actualiser .github/workflows/ci.yml
Some checks failed
CI / lint (push) Successful in 50s
CI / test (push) Successful in 14m2s
CI / intent-service-test (push) Successful in 7m20s
CI / build (push) Successful in 55s
CI / e2e (push) Failing after 5m58s
2026-08-27 19:32:53 +02:00
f19365e20e Actualiser .github/workflows/ci.yml
Some checks failed
CI / lint (push) Successful in 45s
CI / test (push) Failing after 7m40s
CI / intent-service-test (push) Successful in 7m27s
CI / build (push) Successful in 56s
CI / e2e (push) Failing after 6m41s
2026-08-27 18:55:05 +02:00
8a51f0bd6c Actualiser .github/workflows/ci.yml
Some checks failed
CI / build (push) Waiting to run
CI / e2e (push) Waiting to run
CI / lint (push) Successful in 1m41s
CI / test (push) Failing after 8m25s
CI / intent-service-test (push) Has been cancelled
2026-08-27 18:43:38 +02:00
32c7ed48c6 Actualiser .github/workflows/ci.yml
Some checks failed
CI / lint (push) Successful in 41s
CI / test (push) Failing after 1m2s
CI / intent-service-test (push) Failing after 3s
CI / build (push) Successful in 54s
CI / e2e (push) Has been cancelled
2026-08-27 18:35:36 +02:00
0ab30a4588 Actualiser .github/workflows/ci.yml
Some checks failed
CI / lint (push) Successful in 47s
CI / test (push) Failing after 8s
CI / intent-service-test (push) Failing after 3s
CI / build (push) Successful in 59s
CI / e2e (push) Has been cancelled
2026-08-27 18:26:06 +02:00
3c04efd16f Actualiser .github/workflows/ci.yml
Some checks failed
CI / e2e (push) Waiting to run
CI / lint (push) Successful in 45s
CI / test (push) Failing after 8s
CI / intent-service-test (push) Failing after 3s
CI / build (push) Has been cancelled
2026-08-27 18:19:10 +02:00
7c423cc0fd Actualiser .github/workflows/ci.yml
Some checks failed
CI / lint (push) Successful in 46s
CI / test (push) Failing after 6s
CI / intent-service-test (push) Failing after 3s
CI / build (push) Successful in 57s
CI / e2e (push) Has been cancelled
2026-08-27 18:13:56 +02:00
d58491e6f9 Merge pull request 'feat(recipes): fusionne l'édition de la technique et des métadonnées dans le correcteur' (#6) from feat/tech-step-correction-merged-editor into main
Some checks failed
CI / lint (push) Successful in 1m38s
CI / test (push) Failing after 2s
CI / intent-service-test (push) Failing after 5s
CI / build (push) Successful in 51s
CI / e2e (push) Failing after 5m57s
Reviewed-on: #6
2026-08-27 17:41:01 +02:00
478914787f feat(recipes): fusionne l'edition de la technique et des metadonnees
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>
2026-08-27 08:30:59 +02:00
7 changed files with 237 additions and 187 deletions

View file

@ -12,7 +12,7 @@ on:
push: push:
env: env:
DATABASE_URL: "postgresql://ci:ci@localhost:5432/batchcooking_ci?schema=public" DATABASE_URL: "postgresql://ci:ci@postgres: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: actions/checkout@v4 - uses: https://github.com/actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: https://github.com/pnpm/action-setup@v4
- uses: actions/setup-node@v4 - uses: https://github.com/actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm cache: pnpm
@ -55,27 +55,25 @@ jobs:
POSTGRES_PASSWORD: ci POSTGRES_PASSWORD: ci
POSTGRES_DB: batchcooking_ci POSTGRES_DB: batchcooking_ci
ports: ports:
- 5432:5432 - 5433: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: actions/checkout@v4 - uses: https://github.com/actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: https://github.com/pnpm/action-setup@v4
- uses: actions/setup-node@v4 - uses: https://github.com/actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm cache: pnpm
- uses: actions/setup-python@v5 - uses: https://github.com/astral-sh/setup-uv@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
@ -109,13 +107,11 @@ jobs:
intent-service-test: intent-service-test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: https://github.com/actions/checkout@v4
- uses: actions/setup-python@v5 - uses: https://github.com/astral-sh/setup-uv@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
@ -128,11 +124,11 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: https://github.com/actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: https://github.com/pnpm/action-setup@v4
- uses: actions/setup-node@v4 - uses: https://github.com/actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm cache: pnpm
@ -143,17 +139,17 @@ jobs:
e2e: e2e:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: https://github.com/actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: https://github.com/pnpm/action-setup@v4
- uses: actions/setup-node@v4 - uses: https://github.com/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: actions/cache@v4 uses: https://github.com/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') }}
@ -163,7 +159,10 @@ jobs:
# download (see apps/web's cypress caveat in the README) — install it # download (see apps/web's cypress caveat in the README) — install it
# explicitly so `cypress run` finds it. # explicitly so `cypress run` finds it.
- run: pnpm --filter web exec cypress install - run: pnpm --filter web exec cypress install
- run: pnpm --filter web e2e - name: Run E2E tests
env:
HOST: "0.0.0.0"
run: pnpm --filter web e2e
# No dev server needed here — Cypress spins up its own Vite dev # No dev server needed here — Cypress spins up its own Vite dev
# server internally for component testing (see cypress.config.ts's # server internally for component testing (see cypress.config.ts's
# `component.devServer`), unlike `e2e` above which needs the real app # `component.devServer`), unlike `e2e` above which needs the real app

View file

@ -90,41 +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(<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");
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
it("offers a 'no technique here' option only when correcting an existing match", () => { // `CatalogSearchPicker` as the ingredient/utensil sub-flows (a plain
cy.mount(<Harness previousTechStepId={null} />); // unfiltered list of the real ~74-entry catalog isn't browsable).
cy.wait("@getTechSteps"); cy.get(
cy.get(".tech-step-correction-popover__remove").should("not.exist"); ".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
).should("have.length", 2);
// An existing match starts pre-selected straight into the metadata view
// (see TechStepCorrectionPopover's own doc comment on why) — "Changer"
// reaches the pick list, where the remove option lives.
cy.mount(<Harness previousTechStepId={cook.id} />);
cy.wait("@getTechSteps");
cy.get(".tech-step-correction-popover__remove").should("not.exist");
cy.contains("button", "Changer").click();
cy.get(".tech-step-correction-popover__remove").should("exist");
});
it("selecting a technique reveals the Ingrédients/Ustensiles sections instead of submitting immediately", () => {
cy.mount(<Harness />);
cy.wait("@getTechSteps");
cy.contains(".tech-step-correction-popover__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", "Ingrédients").should("be.visible");
cy.contains("h4", "Ustensiles").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("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", () => { 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,
@ -141,7 +165,10 @@ describe("TechStepCorrectionPopover", () => {
cy.mount(<Harness onSubmitted={onSubmitted} />); cy.mount(<Harness onSubmitted={onSubmitted} />);
cy.wait("@getTechSteps"); 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.contains("button", "Valider").click();
cy.wait("@submitCorrection").its("request.body").should("deep.equal", { cy.wait("@submitCorrection").its("request.body").should("deep.equal", {
@ -167,11 +194,14 @@ describe("TechStepCorrectionPopover", () => {
}).as("submitCorrection"); }).as("submitCorrection");
cy.mount(<Harness />); cy.mount(<Harness />);
cy.wait("@getTechSteps"); 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.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.contains(".catalog-search-picker button", "Beurre").click();
cy.get('input[type="number"]').type("50"); cy.get('input[type="number"]').type("50");
cy.get("select").select(String(gram.id)); cy.get("select").select(String(gram.id));
@ -224,7 +254,10 @@ describe("TechStepCorrectionPopover", () => {
cy.mount(<Harness onClose={onClose} />); cy.mount(<Harness onClose={onClose} />);
cy.wait("@getTechSteps"); 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.contains("button", "Valider").click();
cy.wait("@submitCorrection"); cy.wait("@submitCorrection");

View file

@ -110,18 +110,20 @@ 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 now — it takes a separate // Picking a technique only *selects* it — it takes a separate "Valider"
// "Valider" click to actually submit (room was made for attaching // click to actually submit (room was made for attaching ingredient/utensil
// ingredient/utensil metadata first, see `TechStepCorrectionPopover.tsx`'s // metadata alongside it, see `TechStepCorrectionPopover.tsx`'s own doc
// own doc comment) — folded into this one step since nothing in this // comment on its merged editor) — folded into this one step since nothing
// scenario cares about that intermediate state on its own. This step is // in this scenario cares about that intermediate state on its own. The
// only ever reached after clicking an *existing* highlight (see the // technique catalog is picked via the same searchable `CatalogSearchPicker`
// previous step above), which now opens straight into that same // the ingredient/utensil sub-flows use, scoped to
// technique's metadata view, not the pick list — "Changer" reaches the // `__technique-section` since that same search-and-pick component is
// list this step actually needs to pick a different one from. // 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("button", "Changer").click(); 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,43 +729,41 @@
margin: 0 0 var(--space-sm); margin: 0 0 var(--space-sm);
} }
&__list { // Technique picker + Ingrédients/Ustensiles render together as one
display: flex; // screen now (see `TechStepCorrectionPopover.tsx`'s own doc comment) —
flex-wrap: wrap; // this section just needs its own small header row, the actual picker
gap: var(--space-xs); // is `.catalog-search-picker` (below), reused as-is from the ingredient/
list-style: none; // utensil sub-flows.
margin: 0 0 var(--space-sm); &__technique-section {
padding: 0; h4 {
margin: 0 0 var(--space-xs);
button {
padding: 0.3rem 0.6rem;
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
color: var(--color-text); color: var(--color-text-muted);
background: var(--color-surface); }
border: 1px solid var(--color-border); }
&__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); 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;
@ -808,36 +806,27 @@
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 {
display: flex; margin: 0 0 var(--space-xs);
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
margin: 0;
font-weight: 600; 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 { &__metadata-section {
margin-top: var(--space-sm);
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 the same plain-text-link styling // "+ Ajouter…" button deliberately a plain text-link style, not
// as `&__change` above, not another pill button (`&__list button`) // another pill button (`.catalog-search-picker__list button`) this
// this is a secondary action inside an already-open popover, not a // 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;
@ -925,7 +914,10 @@
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 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; overflow-y: auto;
button { button {
@ -941,6 +933,18 @@
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,25 +3,30 @@ 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 "attach an * doesn't have room for that: `TechStepCorrectionPopover.tsx`'s ingredient/
* ingredient/utensil to this correction" flow, embedded in a small popover * utensil/**technique** pickers, all embedded in a small popover rather than
* rather than a full recipe form. Reused for both an ingredient and a * a full recipe form. Reused for all three an ingredient, a utensil, and a
* utensil are both "search a short reference list by translated label, pick * technique are all "search a reference list by translated label, pick one"
* one" from this component's point of view, the only difference is which * from this component's point of view, the only difference is which
* `items`/labels the caller passes in. * `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`/ * Deliberately just `{ id, label }` in, `id` out no `IngredientView`/
* `UtensilView` dependency here, so this stays reusable for any future * `UtensilView`/`TechStepView` dependency here, so this stays reusable for
* "search this small reference catalog" need without growing a new prop * any future "search this small reference catalog" need without growing a
* per catalog shape. * new prop 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;
@ -48,7 +53,13 @@ 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 type="button" onClick={() => onSelect(item.id)}> <button
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,12 +61,17 @@ 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.
* *
* **Removing** a match (`submit(null)`) stays a single immediate action * **One merged editor, not a wizard**: picking a technique
* nothing to attach when removing. **Picking/relabeling** a technique used * (`CatalogSearchPicker`, searchable the reference catalog is ~74
* to submit immediately too; it no longer does, so there's room to attach * entries, an unfiltered flat list wasn't browsable) and editing its
* metadata before committing clicking a technique now only *selects* it * Ingrédients/Ustensiles metadata render together on the same screen,
* (`selectedTechStepId`), revealing the Ingrédients/Ustensiles sections and * always there's no separate "pick, then a metadata step reveals
* a final "Valider" button that submits everything together. * 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`/ * 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-
@ -120,14 +125,11 @@ export function TechStepCorrectionPopover({
const [techSteps, setTechSteps] = useState<TechStepView[] | null>(null); const [techSteps, setTechSteps] = useState<TechStepView[] | null>(null);
// Opening this popover on an *already-detected* match (`previousTechStepId // Opening this popover on an *already-detected* match (`previousTechStepId
// !== null`, i.e. the user clicked an existing highlight rather than // !== null`, i.e. the user clicked an existing highlight rather than
// selecting fresh text) starts pre-selected on that same technique — // selecting fresh text) starts pre-selected on that same technique, its
// straight into the Ingrédients/Ustensiles view below, not the pick-a- // name shown next to the picker right away — since the picker and the
// technique list. Without this, editing an already-correct technique's // metadata sections render together regardless (see this component's own
// metadata required re-picking that exact same technique from the list // doc comment), this just saves re-picking the technique that's already
// first, with nothing in the list marking it as the current one — the // correct before its metadata becomes editable.
// metadata sections were technically reachable but effectively
// undiscoverable. "Changer de technique" (below) still reaches the full
// list — to relabel, or to remove via the option only shown there.
const [selectedTechStepId, setSelectedTechStepId] = useState<number | null>(previousTechStepId); const [selectedTechStepId, setSelectedTechStepId] = useState<number | null>(previousTechStepId);
const [catalogs, setCatalogs] = useState<{ const [catalogs, setCatalogs] = useState<{
ingredients: IngredientView[]; ingredients: IngredientView[];
@ -183,12 +185,12 @@ export function TechStepCorrectionPopover({
}; };
}, []); }, []);
// Only fetched once a technique is actually selected — the Ingrédients/ // Fetched unconditionally on mount — the Ingrédients/Ustensiles sections
// Ustensiles sections (the only things that need these) don't render // render alongside the technique picker from the start (see this
// before then, so a popover only ever used to relabel/remove a technique // component's own doc comment on the merged editor), so there's no later
// never pays for these three extra requests. // point to defer this to anymore.
useEffect(() => { useEffect(() => {
if (selectedTechStepId === null || catalogs !== null) return; if (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]) => {
@ -200,7 +202,7 @@ export function TechStepCorrectionPopover({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [selectedTechStepId, catalogs]); }, [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
@ -401,10 +403,12 @@ export function TechStepCorrectionPopover({
</div> </div>
) : techSteps === null ? ( ) : techSteps === null ? (
<p>{t("recipes.loading")}</p> <p>{t("recipes.loading")}</p>
) : selectedTechStepId === null ? ( ) : (
<ul className="tech-step-correction-popover__list"> <div className="tech-step-correction-popover__confirm">
<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}
@ -413,35 +417,28 @@ export function TechStepCorrectionPopover({
> >
{t("recipes.techStepCorrection.removeMatch")} {t("recipes.techStepCorrection.removeMatch")}
</button> </button>
</li>
)} )}
{techSteps.map((techStep) => ( </div>
<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">
{t( {selectedTechStepId !== null
? 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 })
type="button" : t("recipes.techStepCorrection.noTechniqueSelected")}
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>
@ -504,7 +501,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} disabled={isSubmitting || selectedTechStepId === null}
> >
{t("recipes.techStepCorrection.confirm")} {t("recipes.techStepCorrection.confirm")}
</button> </button>

View file

@ -170,8 +170,12 @@
"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",