Compare commits

..

12 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
5ab9832131 fix(recipes): pre-selectionne la technique deja detectee dans le correcteur
Ouvrir le popover de correction depuis un highlight existant tombait sur
la liste de choix (avec l'option "Aucune technique ici"), pas sur la vue
metadonnees — pour voir/editer les ingredients/ustensiles deja rattaches
a une technique correctement detectee, il fallait recliquer cette meme
technique dans la liste, sans aucun indice que c'est ce qu'il fallait
faire (rien ne la distingue des autres dans cette liste). Resultat cote
utilisateur : la fonctionnalite de correction/edition de metadonnees
etait techniquement presente mais invisible en pratique.

TechStepCorrectionPopover demarre desormais selectionne sur
`previousTechStepId` quand il est defini (un clic sur un highlight
existant) — droit dans la vue Ingredients/Ustensiles, deja pre-remplie.
"Changer" reste disponible pour rejoindre la liste complete (relabelliser
ou supprimer la correspondance).

Tests Cypress (component + e2e) mis a jour pour ce nouveau point d'entree
par defaut.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 08:13:41 +02:00
kyuno053
eef5db92b5
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:51:45 +02:00
7 changed files with 231 additions and 159 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,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(<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
// `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(<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("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(<Harness />); cy.mount(<Harness />);
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.get(".tech-step-correction-popover__list").should("not.exist"); cy.contains(".tech-step-correction-popover__chosen-technique", "Mijoter");
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,
@ -136,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", {
@ -162,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));
@ -199,9 +234,11 @@ 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();
@ -217,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,13 +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. // 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) => { 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(); 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-
@ -118,7 +123,14 @@ 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);
const [selectedTechStepId, setSelectedTechStepId] = useState<number | null>(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<number | null>(previousTechStepId);
const [catalogs, setCatalogs] = useState<{ const [catalogs, setCatalogs] = useState<{
ingredients: IngredientView[]; ingredients: IngredientView[];
units: UnitView[]; units: UnitView[];
@ -173,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]) => {
@ -190,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
@ -391,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}
@ -403,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>
@ -494,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",