Compare commits

...

10 commits

Author SHA1 Message Date
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
16 changed files with 1514 additions and 142 deletions

View file

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

View file

@ -84,6 +84,75 @@ async function assertTechStepsExist(ids: number[]): Promise<void> {
}
}
/** Throws `404 INGREDIENT_NOT_FOUND` if any id in `ids` doesn't match a reference `Ingredient` row — same shape as {@link assertTechStepsExist}, checking `input.ingredients[].ingredientId` instead. */
async function assertIngredientsExist(ids: number[]): Promise<void> {
try {
const uniqueIds = [...new Set(ids)];
if (uniqueIds.length === 0) return;
const found = await prisma.ingredient.findMany({
where: { id: { in: uniqueIds } },
select: { id: true },
});
const foundIds = new Set(found.map((ingredient) => ingredient.id));
const missing = uniqueIds.filter((id) => !foundIds.has(id));
if (missing.length > 0) {
throw new HttpError(
404,
ErrorCode.INGREDIENT_NOT_FOUND,
`Ingredient ids not found: ${missing.join(", ")}`,
);
}
} catch (err) {
throw err; // see loadVisibleStepOrThrow's catch comment
}
}
/** Throws `404 UNIT_NOT_FOUND` if any id in `ids` doesn't match a reference `Unit` row — same shape as {@link assertIngredientsExist}, checking `input.ingredients[].unitId` instead. */
async function assertUnitsExist(ids: number[]): Promise<void> {
try {
const uniqueIds = [...new Set(ids)];
if (uniqueIds.length === 0) return;
const found = await prisma.unit.findMany({
where: { id: { in: uniqueIds } },
select: { id: true },
});
const foundIds = new Set(found.map((unit) => unit.id));
const missing = uniqueIds.filter((id) => !foundIds.has(id));
if (missing.length > 0) {
throw new HttpError(
404,
ErrorCode.UNIT_NOT_FOUND,
`Unit ids not found: ${missing.join(", ")}`,
);
}
} catch (err) {
throw err; // see loadVisibleStepOrThrow's catch comment
}
}
/** Throws `404 UTENSIL_NOT_FOUND` if any id in `ids` doesn't match a reference `Utensil` row — same shape as {@link assertIngredientsExist}, checking `input.utensils[].utensilId` instead. */
async function assertUtensilsExist(ids: number[]): Promise<void> {
try {
const uniqueIds = [...new Set(ids)];
if (uniqueIds.length === 0) return;
const found = await prisma.utensil.findMany({
where: { id: { in: uniqueIds } },
select: { id: true },
});
const foundIds = new Set(found.map((utensil) => utensil.id));
const missing = uniqueIds.filter((id) => !foundIds.has(id));
if (missing.length > 0) {
throw new HttpError(
404,
ErrorCode.UTENSIL_NOT_FOUND,
`Utensil ids not found: ${missing.join(", ")}`,
);
}
} catch (err) {
throw err; // see loadVisibleStepOrThrow's catch comment
}
}
/**
* Renumbers every one of `stepId`'s `StepTechStep` rows' `order` by
* ascending `start` (nulls-still-possible legacy rows, see that model's
@ -125,6 +194,20 @@ export async function renumberStepTechSteps(
}
}
/** One ingredient/utensil mention the viewer themselves selected, ready to persist — see {@link applyManualCorrection}'s own doc comment for the "manual replaces all" semantics these are written under. */
interface ManualIngredientMention {
ingredientId: number;
quantity: number | null;
unitId: number | null;
start: number;
end: number;
}
interface ManualUtensilMention {
utensilId: number;
start: number;
end: number;
}
/**
* Applies a correction's *effect* on `stepId`'s real `StepTechStep`
* sequence, immediately not just recorded as a pending suggestion for
@ -142,13 +225,28 @@ export async function renumberStepTechSteps(
* `contextEnd` a correction only ever carries the tight span the user
* themselves selected/clicked, nothing wider to highlight around it.
* - `previousTechStepId` alone (remove, `correctedTechStepId: null`): the
* matching existing entry is deleted outright. A no-op if none matches
* (nothing to remove).
* matching existing entry is deleted outright (cascading away any
* ingredient/utensil metadata attached to it, auto or manual nothing
* left to attach metadata to once the technique itself is gone). A
* no-op if none matches (nothing to remove).
*
* `metadata`, when given (only ever alongside a real `correctedTechStepId`
* enforced by `submitTechStepCorrectionSchema`, not re-checked here),
* replaces *every* `StepTechStepIngredient`/`StepTechStepUtensil` row on
* this occurrence `source: "auto"` (the classifier's own detection) and
* any earlier `"manual"` set alike with the newly-submitted one. This is
* "le manuel remplace tout" (confirmed with the user): the resolved
* `order` this technique ends up at (whichever branch above produced it)
* is the same `techStepOrder` both metadata tables key on, so the same
* `deleteMany` + `createMany` pair below is correct whether this call just
* updated an existing row (which may already carry auto-detected
* metadata) or created a brand new one (nothing to delete yet a no-op
* `deleteMany`, not a special case).
*
* Runs inside the same transaction {@link submitTechStepCorrection} uses
* for the audit-trail insert, so a request never leaves the two effects
* (the permanent correction record, the live sequence change) only
* partially applied.
* for the audit-trail insert, so a request never leaves any of these
* effects (the permanent correction record, the live sequence change, the
* metadata replacement) only partially applied.
*/
async function applyManualCorrection(
tx: Prisma.TransactionClient,
@ -156,6 +254,7 @@ async function applyManualCorrection(
span: { start: number; end: number },
previousTechStepId: number | null,
correctedTechStepId: number | null,
metadata?: { ingredients: ManualIngredientMention[]; utensils: ManualUtensilMention[] },
): Promise<void> {
const existing = await tx.stepTechStep.findMany({ where: { stepId } });
@ -172,9 +271,12 @@ async function applyManualCorrection(
: undefined;
if (correctedTechStepId !== null) {
const order = target
? target.order
: existing.reduce((max, row) => Math.max(max, row.order), -1) + 1;
if (target) {
await tx.stepTechStep.update({
where: { stepId_order: { stepId, order: target.order } },
where: { stepId_order: { stepId, order } },
data: {
techStepId: correctedTechStepId,
start: span.start,
@ -185,18 +287,48 @@ async function applyManualCorrection(
},
});
} else {
const nextOrder = existing.reduce((max, row) => Math.max(max, row.order), -1) + 1;
await tx.stepTechStep.create({
data: {
stepId,
techStepId: correctedTechStepId,
order: nextOrder,
order,
start: span.start,
end: span.end,
source: "manual",
},
});
}
if (metadata !== undefined) {
await tx.stepTechStepIngredient.deleteMany({ where: { stepId, techStepOrder: order } });
await tx.stepTechStepUtensil.deleteMany({ where: { stepId, techStepOrder: order } });
if (metadata.ingredients.length > 0) {
await tx.stepTechStepIngredient.createMany({
data: metadata.ingredients.map((ingredient) => ({
stepId,
techStepOrder: order,
ingredientId: ingredient.ingredientId,
quantity: ingredient.quantity,
unitId: ingredient.unitId,
start: ingredient.start,
end: ingredient.end,
source: "manual",
})),
});
}
if (metadata.utensils.length > 0) {
await tx.stepTechStepUtensil.createMany({
data: metadata.utensils.map((utensil) => ({
stepId,
techStepOrder: order,
utensilId: utensil.utensilId,
start: utensil.start,
end: utensil.end,
source: "manual",
})),
});
}
}
} else if (target) {
await tx.stepTechStep.delete({ where: { stepId_order: { stepId, order: target.order } } });
}
@ -232,9 +364,12 @@ function toCorrectionView(correction: CorrectionWithTechSteps): StepTechStepCorr
*
* @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` see
* {@link loadVisibleStepOrThrow}. `400 INVALID_CORRECTION_SPAN` if
* `start`/`end` fall outside the step's current `description` (it may
* have been edited since the user last saw it). `404 TECH_STEP_NOT_FOUND`
* if either tech-step id doesn't exist.
* `start`/`end` (the correction's own span, or any of
* `input.ingredients`/`input.utensils`' own spans) fall outside the
* step's current `description` (it may have been edited since the user
* last saw it). `404 TECH_STEP_NOT_FOUND`/`404 INGREDIENT_NOT_FOUND`/
* `404 UNIT_NOT_FOUND`/`404 UTENSIL_NOT_FOUND` if any referenced id
* doesn't exist.
*/
export async function submitTechStepCorrection(
recipeId: number,
@ -246,18 +381,32 @@ export async function submitTechStepCorrection(
try {
const step = await loadVisibleStepOrThrow(recipeId, stepId, correctorId, viewerHouseId);
if (input.start >= step.descriptionLength || input.end > step.descriptionLength) {
const spans = [
{ start: input.start, end: input.end },
...(input.ingredients ?? []),
...(input.utensils ?? []),
];
for (const span of spans) {
if (span.start >= step.descriptionLength || span.end > step.descriptionLength) {
throw new HttpError(
400,
ErrorCode.INVALID_CORRECTION_SPAN,
`Span [${input.start}, ${input.end}) falls outside step ${stepId}'s description (length ${step.descriptionLength})`,
`Span [${span.start}, ${span.end}) falls outside step ${stepId}'s description (length ${step.descriptionLength})`,
);
}
}
const techStepIds = [input.previousTechStepId, input.correctedTechStepId].filter(
(id): id is number => id !== null && id !== undefined,
);
await assertTechStepsExist(techStepIds);
await assertIngredientsExist((input.ingredients ?? []).map((i) => i.ingredientId));
await assertUnitsExist(
(input.ingredients ?? []).flatMap((i) =>
i.unitId !== null && i.unitId !== undefined ? [i.unitId] : [],
),
);
await assertUtensilsExist((input.utensils ?? []).map((u) => u.utensilId));
const { correction, techSteps } = await prisma.$transaction(async (tx) => {
const createdCorrection = await tx.stepTechStepCorrection.create({
@ -278,6 +427,22 @@ export async function submitTechStepCorrection(
{ start: input.start, end: input.end },
input.previousTechStepId ?? null,
input.correctedTechStepId ?? null,
input.ingredients === undefined && input.utensils === undefined
? undefined
: {
ingredients: (input.ingredients ?? []).map((ingredient) => ({
ingredientId: ingredient.ingredientId,
quantity: ingredient.quantity ?? null,
unitId: ingredient.unitId ?? null,
start: ingredient.start,
end: ingredient.end,
})),
utensils: (input.utensils ?? []).map((utensil) => ({
utensilId: utensil.utensilId,
start: utensil.start,
end: utensil.end,
})),
},
);
// Same nested `ingredients`/`utensils` include as `recipe.service.ts`'s

View file

@ -189,11 +189,14 @@ export function toStepTechStepViews(
unit: stepTechStepIngredient.unit === null ? null : toUnitView(stepTechStepIngredient.unit),
start: stepTechStepIngredient.start,
end: stepTechStepIngredient.end,
// Same narrowing posture as the technique's own `source` above.
source: stepTechStepIngredient.source === "manual" ? "manual" : "auto",
})),
utensils: utensils.map((stepTechStepUtensil) => ({
utensil: { id: stepTechStepUtensil.utensil.id, key: stepTechStepUtensil.utensil.key },
start: stepTechStepUtensil.start,
end: stepTechStepUtensil.end,
source: stepTechStepUtensil.source === "manual" ? "manual" : "auto",
})),
});
}

View file

@ -249,12 +249,16 @@ export async function previewSourceItem(
unit: mention.unitId !== null ? (unitById.get(mention.unitId) ?? null) : null,
start: mention.start,
end: mention.end,
// Same reasoning as this match's own `source` above — a draft preview only ever holds live classifier output.
source: "auto" as const,
},
];
}),
utensils: match.utensils.flatMap((mention) => {
const utensil = utensilById.get(mention.utensilId);
return utensil ? [{ utensil, start: mention.start, end: mention.end }] : [];
return utensil
? [{ utensil, start: mention.start, end: mention.end, source: "auto" as const }]
: [];
}),
},
]

View file

@ -25,6 +25,24 @@ async function techStepId(key: string): Promise<number> {
return techStep.id;
}
/** Same as {@link techStepId}, for a reference `Ingredient`. */
async function ingredientId(key: string): Promise<number> {
const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { key } });
return ingredient.id;
}
/** Same as {@link techStepId}, for a reference `Unit`. */
async function unitId(key: string): Promise<number> {
const unit = await prisma.unit.findFirstOrThrow({ where: { key } });
return unit.id;
}
/** Same as {@link techStepId}, for a reference `Utensil`. */
async function utensilId(key: string): Promise<number> {
const utensil = await prisma.utensil.findFirstOrThrow({ where: { key } });
return utensil.id;
}
describe("Recipe tech-step corrections", () => {
const app = createApp();
@ -259,6 +277,244 @@ describe("Recipe tech-step corrections", () => {
});
});
describe("POST /recipes/:id/steps/:stepId/corrections — ingredients/utensils metadata", () => {
it("attaches manually-selected ingredients and utensils to a corrected technique", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer");
const butterId = await ingredientId("butter");
const gramId = await unitId("gram");
const panId = await utensilId("pan");
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
correctedTechStepId: simmerId,
ingredients: [{ ingredientId: butterId, quantity: 50, unitId: gramId, start: 0, end: 6 }],
utensils: [{ utensilId: panId, start: 14, end: 23 }],
});
expect(res.status).to.equal(201);
expect(res.body.techSteps).to.deep.equal([
{
techStep: { id: simmerId, key: "simmer" },
start: 6,
end: 13,
source: "manual",
ingredients: [
{
ingredient: res.body.techSteps[0].ingredients[0].ingredient,
quantity: 50,
unit: res.body.techSteps[0].ingredients[0].unit,
start: 0,
end: 6,
source: "manual",
},
],
utensils: [
{
utensil: res.body.techSteps[0].utensils[0].utensil,
start: 14,
end: 23,
source: "manual",
},
],
},
]);
expect(res.body.techSteps[0].ingredients[0].ingredient.id).to.equal(butterId);
expect(res.body.techSteps[0].ingredients[0].unit.id).to.equal(gramId);
expect(res.body.techSteps[0].utensils[0].utensil).to.deep.equal({ id: panId, key: "pan" });
});
it("attaches an ingredient with no quantity/unit (both omitted)", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer");
const butterId = await ingredientId("butter");
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
correctedTechStepId: simmerId,
ingredients: [{ ingredientId: butterId, start: 0, end: 6 }],
});
expect(res.status).to.equal(201);
expect(res.body.techSteps[0].ingredients[0].quantity).to.equal(null);
expect(res.body.techSteps[0].ingredients[0].unit).to.equal(null);
});
it("replaces both auto-detected and previously-manual metadata on the same occurrence — never accumulates", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer");
const boilId = await techStepId("boil");
const butterId = await ingredientId("butter");
const carrotId = await ingredientId("carrot");
const panId = await utensilId("pan");
const saucepanId = await utensilId("saucepan");
// First correction creates the occurrence (order 0) — simulate an
// auto-detected ingredient already sitting on it, exactly as
// tech-step-matcher.ts would have written one at save time (bypassed
// here for a deterministic fixture, not dependent on the real
// classifier's own output for this text).
await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 6, end: 13, correctedTechStepId: simmerId });
await prisma.stepTechStepIngredient.create({
data: {
stepId,
techStepOrder: 0,
ingredientId: butterId,
start: 0,
end: 6,
source: "auto",
},
});
await prisma.stepTechStepUtensil.create({
data: { stepId, techStepOrder: 0, utensilId: panId, start: 14, end: 23, source: "auto" },
});
// Second correction — relabels the technique *and* submits a whole
// new, disjoint metadata set.
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
previousTechStepId: simmerId,
correctedTechStepId: boilId,
ingredients: [{ ingredientId: carrotId, start: 0, end: 6 }],
utensils: [{ utensilId: saucepanId, start: 14, end: 23 }],
});
expect(res.status).to.equal(201);
expect(res.body.techSteps).to.have.length(1);
// Neither the auto-detected butter/pan nor an empty leftover row
// survive — only the freshly-submitted carrot/saucepan.
expect(
res.body.techSteps[0].ingredients.map(
(i: { ingredient: { id: number } }) => i.ingredient.id,
),
).to.deep.equal([carrotId]);
expect(
res.body.techSteps[0].utensils.map((u: { utensil: { id: number } }) => u.utensil.id),
).to.deep.equal([saucepanId]);
});
it("leaves existing metadata untouched when ingredients/utensils are omitted from the request", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer");
const boilId = await techStepId("boil");
const butterId = await ingredientId("butter");
await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
correctedTechStepId: simmerId,
ingredients: [{ ingredientId: butterId, start: 0, end: 6 }],
});
// Relabels the technique again, but says nothing about metadata at all.
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
previousTechStepId: simmerId,
correctedTechStepId: boilId,
});
expect(res.status).to.equal(201);
expect(res.body.techSteps[0].ingredients).to.have.length(1);
expect(res.body.techSteps[0].ingredients[0].ingredient.id).to.equal(butterId);
});
it("rejects metadata submitted alongside correctedTechStepId: null with 400 VALIDATION_ERROR", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer");
const butterId = await ingredientId("butter");
await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 6, end: 13, correctedTechStepId: simmerId });
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
previousTechStepId: simmerId,
correctedTechStepId: null,
ingredients: [{ ingredientId: butterId, start: 0, end: 6 }],
});
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("rejects an unknown ingredientId with 404 INGREDIENT_NOT_FOUND", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
correctedTechStepId: await techStepId("simmer"),
ingredients: [{ ingredientId: 999_999, start: 0, end: 6 }],
});
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND);
});
it("rejects an unknown unitId with 404 UNIT_NOT_FOUND", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
correctedTechStepId: await techStepId("simmer"),
ingredients: [
{ ingredientId: await ingredientId("butter"), unitId: 999_999, start: 0, end: 6 },
],
});
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.UNIT_NOT_FOUND);
});
it("rejects an unknown utensilId with 404 UTENSIL_NOT_FOUND", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
correctedTechStepId: await techStepId("simmer"),
utensils: [{ utensilId: 999_999, start: 0, end: 6 }],
});
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.UTENSIL_NOT_FOUND);
});
it("rejects a metadata span past the end of the step's description with 400 INVALID_CORRECTION_SPAN", async () => {
const { agent, profileId } = await signup();
const description = "Court.";
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId, description);
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 0,
end: description.length,
correctedTechStepId: await techStepId("simmer"),
ingredients: [
{ ingredientId: await ingredientId("butter"), start: 0, end: description.length + 10 },
],
});
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.INVALID_CORRECTION_SPAN);
});
});
describe("GET /recipes/:id/steps/:stepId/corrections", () => {
it("returns every correction submitted for the step, most recent first", async () => {
const { agent, profileId } = await signup();

View file

@ -1,3 +1,4 @@
import { useState } from "react";
import "../../src/i18n/i18n";
import { TechStepCorrectionPopover } from "../../src/features/recipes/steps/TechStepCorrectionPopover";
@ -11,15 +12,40 @@ import { TechStepCorrectionPopover } from "../../src/features/recipes/steps/Tech
const cook = { id: 1, key: "cook" };
const simmer = { id: 3, key: "simmer" };
const butter = { id: 10, key: "butter" };
const pan = { id: 20, key: "pan" };
const gram = { id: 30, key: "gram" };
function mountPopover(
overrides: Partial<{
/**
* A real `StepDescription` resolves `onRequestSpan` into a fresh
* `resolvedMetadataSpan` via an actual browser text selection out of
* scope for a component test of the popover alone (covered by the e2e
* scenario instead). This harness fakes that round-trip with a fixed
* span, so tests here can exercise everything the popover itself is
* responsible for once a span comes back, without needing a real
* `StepDescription` in the tree.
*/
function Harness({
previousTechStepId = null,
existingIngredients = [],
existingUtensils = [],
onClose = () => {},
onSubmitted = () => {},
}: Partial<{
previousTechStepId: number | null;
existingIngredients: unknown[];
existingUtensils: unknown[];
onClose: () => void;
onSubmitted: (correction: unknown) => void;
}> = {},
) {
cy.mount(
onSubmitted: (result: unknown) => void;
}>) {
const [resolvedMetadataSpan, setResolvedMetadataSpan] = useState<{
nonce: number;
kind: "ingredient" | "utensil";
range: { start: number; end: number };
text: string;
} | null>(null);
return (
<div>
{/* A genuinely separate sibling to click for the "outside click closes it" test — clicking blindly at a viewport coordinate would risk still landing inside the popover, which fills most of the mounted area on its own. */}
<div data-testid="outside-popover" style={{ height: 20 }} />
@ -28,11 +54,25 @@ function mountPopover(
stepId={2}
selectedText="Cuire"
range={{ start: 0, end: 5 }}
previousTechStepId={overrides.previousTechStepId ?? null}
onClose={overrides.onClose ?? (() => {})}
onSubmitted={overrides.onSubmitted ?? (() => {})}
previousTechStepId={previousTechStepId}
// biome-ignore lint/suspicious/noExplicitAny: test harness stands in for real StepTechStepIngredientView/UtensilView props — precise typing isn't the point here.
existingIngredients={existingIngredients as any}
// biome-ignore lint/suspicious/noExplicitAny: see above.
existingUtensils={existingUtensils as any}
resolvedMetadataSpan={resolvedMetadataSpan}
onRequestSpan={(kind) =>
setResolvedMetadataSpan({
nonce: Date.now(),
kind,
range: { start: 20, end: 26 },
text: "Beurre",
})
}
onClose={onClose}
// biome-ignore lint/suspicious/noExplicitAny: see above.
onSubmitted={onSubmitted as any}
/>
</div>,
</div>
);
}
@ -41,32 +81,75 @@ describe("TechStepCorrectionPopover", () => {
cy.intercept("GET", "**/reference/tech-steps", { statusCode: 200, body: [cook, simmer] }).as(
"getTechSteps",
);
cy.intercept("GET", "**/reference/ingredients", { statusCode: 200, body: [butter] }).as(
"getIngredients",
);
cy.intercept("GET", "**/reference/units", { statusCode: 200, body: [gram] }).as("getUnits");
cy.intercept("GET", "**/reference/utensils", { statusCode: 200, body: [pan] }).as(
"getUtensils",
);
});
it("shows the selected text and every technique option once loaded", () => {
mountPopover();
it("shows the selected text, the technique catalog (searchable) and the metadata sections all together", () => {
cy.mount(<Harness />);
cy.wait("@getTechSteps");
cy.contains(".tech-step-correction-popover__selection", "Cuire").should("be.visible");
cy.get(".tech-step-correction-popover__list button").should("have.length", 2);
// Merged editor (see TechStepCorrectionPopover's own doc comment) — no
// separate "pick, then metadata reveals itself" step, both render at
// once, and the technique catalog goes through the same searchable
// `CatalogSearchPicker` as the ingredient/utensil sub-flows (a plain
// unfiltered list of the real ~74-entry catalog isn't browsable).
cy.get(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
).should("have.length", 2);
cy.contains("h4", "Ingrédients").should("be.visible");
cy.contains("h4", "Ustensiles").should("be.visible");
cy.contains("button", "Valider").should("be.visible");
});
it("offers a 'no technique here' option only when correcting an existing match", () => {
mountPopover({ previousTechStepId: null });
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");
mountPopover({ previousTechStepId: cook.id });
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("submits the selected technique and calls onSubmitted", () => {
// Asserting on the resolved `@submitCorrection` interception below,
// rather than inside this handler — a Chai assertion failing *inside*
// a `cy.intercept` callback surfaces as an opaque "onResponse cannot be
// called twice" Cypress internal error instead of a normal assertion
// failure, found while writing this exact test.
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", () => {
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
statusCode: 201,
body: {
@ -79,10 +162,14 @@ describe("TechStepCorrectionPopover", () => {
},
}).as("submitCorrection");
const onSubmitted = cy.stub().as("onSubmitted");
mountPopover({ onSubmitted });
cy.mount(<Harness onSubmitted={onSubmitted} />);
cy.wait("@getTechSteps");
cy.contains(".tech-step-correction-popover__list button", "Mijoter").click();
cy.contains(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
"Mijoter",
).click();
cy.contains("button", "Valider").click();
cy.wait("@submitCorrection").its("request.body").should("deep.equal", {
start: 0,
@ -93,16 +180,85 @@ describe("TechStepCorrectionPopover", () => {
cy.get("@onSubmitted").should("have.been.calledOnce");
});
it("adds an ingredient with quantity/unit via the span-selection flow, included in the submitted request", () => {
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
statusCode: 201,
body: {
id: 1,
start: 0,
end: 5,
previousTechStep: null,
correctedTechStep: simmer,
createdAt: new Date().toISOString(),
},
}).as("submitCorrection");
cy.mount(<Harness />);
cy.wait("@getTechSteps");
cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]);
cy.contains(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
"Mijoter",
).click();
cy.contains("button", "+ Ajouter un ingrédient").click();
cy.contains(".catalog-search-picker button", "Beurre").click();
cy.get('input[type="number"]').type("50");
cy.get("select").select(String(gram.id));
cy.contains("button", "Ajouter").click();
cy.contains(".tech-step-correction-popover__chip", "50 g Beurre").should("be.visible");
cy.contains("button", "Valider").click();
cy.wait("@submitCorrection")
.its("request.body")
.should("deep.equal", {
start: 0,
end: 5,
previousTechStepId: null,
correctedTechStepId: simmer.id,
ingredients: [
{ ingredientId: butter.id, quantity: 50, unitId: gram.id, start: 20, end: 26 },
],
utensils: [],
});
});
it("pre-seeds existing ingredients/utensils, removable via their own chip", () => {
cy.mount(
<Harness
previousTechStepId={cook.id}
existingIngredients={[
{ ingredient: butter, quantity: 50, unit: gram, start: 0, end: 6, 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(["@getIngredients", "@getUnits", "@getUtensils"]);
cy.contains(".tech-step-correction-popover__chip", "50 g Beurre").find("button").click();
cy.contains(".tech-step-correction-popover__chip", "Beurre").should("not.exist");
});
it("shows an error message and stays open when the submission fails", () => {
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
statusCode: 404,
body: { code: 4051, message: "TechStep not found" },
}).as("submitCorrection");
const onClose = cy.stub().as("onClose");
mountPopover({ onClose });
cy.mount(<Harness onClose={onClose} />);
cy.wait("@getTechSteps");
cy.contains(".tech-step-correction-popover__list button", "Cuire").click();
cy.contains(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
"Cuire",
).click();
cy.contains("button", "Valider").click();
cy.wait("@submitCorrection");
cy.get(".field-error").should("be.visible");
@ -111,7 +267,7 @@ describe("TechStepCorrectionPopover", () => {
it("calls onClose on an outside click", () => {
const onClose = cy.stub().as("onClose");
mountPopover({ onClose });
cy.mount(<Harness onClose={onClose} />);
cy.wait("@getTechSteps");
cy.get('[data-testid="outside-popover"]').click();

View file

@ -88,7 +88,16 @@ Given('correcting step 2\'s "Cuire" match will succeed', () => {
correctedTechStep: { id: 3, key: "simmer" },
createdAt: new Date().toISOString(),
},
techSteps: [{ techStep: { id: 3, key: "simmer" }, start: 0, end: 5, source: "manual" }],
techSteps: [
{
techStep: { id: 3, key: "simmer" },
start: 0,
end: 5,
source: "manual",
ingredients: [],
utensils: [],
},
],
},
}).as("correction");
});
@ -101,8 +110,21 @@ Then("I should see the technique correction options", () => {
cy.get(".tech-step-correction-popover").should("be.visible");
});
// Picking a technique only *selects* it — it takes a separate "Valider"
// click to actually submit (room was made for attaching ingredient/utensil
// metadata alongside it, see `TechStepCorrectionPopover.tsx`'s own doc
// comment on its merged editor) — folded into this one step since nothing
// in this scenario cares about that intermediate state on its own. The
// technique catalog is picked via the same searchable `CatalogSearchPicker`
// the ingredient/utensil sub-flows use, scoped to
// `__technique-section` since that same search-and-pick component is
// reused inside this popover for more than just techniques.
When("I choose {string} as the correct technique", (label: string) => {
cy.contains(".tech-step-correction-popover__list button", label).click();
cy.contains(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
label,
).click();
cy.contains(".tech-step-correction-popover__confirm-button", "Valider").click();
});
Then(

View file

@ -27,6 +27,7 @@ import {
type ThemePreference,
type UnitView,
type UpdateRecipeInput,
type UtensilView,
} from "@batch-cooking/shared";
/**
@ -201,6 +202,11 @@ export class ApiClient {
return this._request("/reference/tech-steps");
}
/** Reference list of cooking utensils — static, non-administrable (`TechStepCorrectionPopover`'s utensil picker, once a technique is selected). Public — no session required. */
public getUtensils(): Promise<UtensilView[]> {
return this._request("/reference/utensils");
}
/** Reference list of implemented recipe sources (onboarding wizard's source step, `/parametres/foyer`) — empty until a concrete source is registered. Public — no session required. */
public getSources(): Promise<SourceView[]> {
return this._request("/reference/sources");

View file

@ -729,43 +729,41 @@
margin: 0 0 var(--space-sm);
}
&__list {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
list-style: none;
margin: 0 0 var(--space-sm);
padding: 0;
button {
padding: 0.3rem 0.6rem;
// Technique picker + Ingrédients/Ustensiles render together as one
// screen now (see `TechStepCorrectionPopover.tsx`'s own doc comment) —
// this section just needs its own small header row, the actual picker
// is `.catalog-search-picker` (below), reused as-is from the ingredient/
// utensil sub-flows.
&__technique-section {
h4 {
margin: 0 0 var(--space-xs);
font-size: var(--font-size-sm);
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border);
color: var(--color-text-muted);
}
}
&__technique-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-sm);
}
&__remove {
padding: 0.2rem 0.5rem;
font-size: var(--font-size-sm);
color: var(--color-error);
background: none;
border: 1px solid var(--color-error);
border-radius: var(--radius-pill);
cursor: pointer;
&:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
border-color: var(--color-primary);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
// 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 {
background: none;
border: none;
@ -775,6 +773,180 @@
padding: 0;
font-size: var(--font-size-sm);
}
// Shown in place of the technique list/metadata sections while
// `StepDescription` is waiting on a second text selection (see
// `TechStepCorrectionPopover.tsx`'s own doc comment) — same styling
// intent as `.recipe-detail-panel__tech-step-hint`, a small muted aside.
&__hint {
margin: 0 0 var(--space-sm);
color: var(--color-text-muted);
font-style: italic;
}
&__span-picker {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
&__quantity-line {
display: flex;
align-items: center;
gap: var(--space-xs);
input[type="number"] {
width: 5rem;
}
}
&__confirm {
display: flex;
flex-direction: column;
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 {
margin: 0 0 var(--space-xs);
font-weight: 600;
}
&__metadata-section {
margin-top: var(--space-sm);
h4 {
margin: 0 0 var(--space-xs);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
// "+ Ajouter…" button deliberately a plain text-link style, not
// another pill button (`.catalog-search-picker__list button`) this
// is a secondary action inside an already-open popover, not a
// top-level choice competing with the chips above it.
> button {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
padding: 0;
font-size: var(--font-size-sm);
}
}
&__chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
list-style: none;
margin: 0 0 var(--space-xs);
padding: 0;
}
&__chip {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: 0.3rem 0.6rem;
font-size: var(--font-size-sm);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
button {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
padding: 0;
line-height: 1;
&:hover:not(:disabled) {
color: var(--color-error);
}
}
}
&__confirm-button {
align-self: flex-start;
padding: 0.4rem 1rem;
font-size: var(--font-size-sm);
color: var(--color-surface);
background: var(--color-primary);
border: none;
border-radius: var(--radius-md);
cursor: pointer;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
// Reused by both the ingredient and utensil "attach to this correction"
// sub-flows (`TechStepCorrectionPopover.tsx`) deliberately lighter than
// `.ingredient-picker` (no category/subcategory grid, no allergen/diet
// toggles), sized for a small popover rather than a full recipe form.
.catalog-search-picker {
display: flex;
flex-direction: column;
gap: var(--space-xs);
&__input {
width: 100%;
}
&__empty {
margin: 0;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
&__list {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
list-style: none;
margin: 0;
padding: 0;
// Raised from the original 8rem this component is now also the
// technique picker (~74 entries, see this file's own doc comment),
// where 8rem left only a couple of rows visible before scrolling.
max-height: 14rem;
overflow-y: auto;
button {
padding: 0.3rem 0.6rem;
font-size: var(--font-size-sm);
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
cursor: pointer;
&:hover {
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
border-color: var(--color-primary);
}
// The technique picker's current pick (`selectedId` prop) — stays
// visually marked even while filtered/scrolled past, so re-opening
// this popover's picker doesn't read as "nothing chosen yet" when
// something already is. Unused by the ingredient/utensil sub-flows
// (they never pass `selectedId` each pick there just appends a
// fresh mention, nothing to mark as "current").
&.catalog-search-picker__item--selected {
background: color-mix(in srgb, var(--color-primary) 20%, transparent);
border-color: var(--color-primary);
font-weight: 600;
}
}
}
}
// --- Favorite star toggle (detail panel header) -----------------------------

View file

@ -0,0 +1,71 @@
import { useState } from "react";
/**
* Small search-and-pick list a lighter alternative to `IngredientPicker.tsx`
* (category/subcategory grid + allergen/diet toggles) for a context that
* doesn't have room for that: `TechStepCorrectionPopover.tsx`'s ingredient/
* utensil/**technique** pickers, all embedded in a small popover rather than
* a full recipe form. Reused for all three an ingredient, a utensil, and a
* technique are all "search a reference list by translated label, pick one"
* from this component's point of view, the only difference is which
* `items`/labels the caller passes in. The technique catalog in particular
* (~74 entries) is exactly the case a plain unfiltered list stops being
* readable at the original motivation for adding search here at all.
*
* Deliberately just `{ id, label }` in, `id` out no `IngredientView`/
* `UtensilView`/`TechStepView` dependency here, so this stays reusable for
* any future "search this small reference catalog" need without growing a
* new prop per catalog shape.
*/
export function CatalogSearchPicker({
items,
selectedId,
onSelect,
placeholder,
emptyLabel,
}: {
items: { id: number; label: string }[];
/** The currently-picked item, if any — marked with a distinct modifier class so it stays visible at a glance while browsing/filtering a longer list (e.g. `TechStepCorrectionPopover`'s ~74-entry technique catalog), not just implied by whatever's selected elsewhere on screen. Omit for a picker with no notion of a "current" pick (the ingredient/utensil span sub-flows — each `onSelect` there just appends a brand-new mention, nothing to mark as already chosen). */
selectedId?: number;
onSelect: (id: number) => void;
placeholder: string;
emptyLabel: string;
}) {
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLowerCase();
const visible =
normalizedQuery.length === 0
? items
: items.filter((item) => item.label.toLowerCase().includes(normalizedQuery));
return (
<div className="catalog-search-picker">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
className="catalog-search-picker__input"
/>
{visible.length === 0 ? (
<p className="catalog-search-picker__empty">{emptyLabel}</p>
) : (
<ul className="catalog-search-picker__list">
{visible.map((item) => (
<li key={item.id}>
<button
type="button"
className={
item.id === selectedId ? "catalog-search-picker__item--selected" : undefined
}
onClick={() => onSelect(item.id)}
>
{item.label}
</button>
</li>
))}
</ul>
)}
</div>
);
}

View file

@ -76,10 +76,45 @@ export function StepDescription({
previousTechStepId: number | null;
} | null>(null);
// Routes the *next* text selection to the open `TechStepCorrectionPopover`
// (as an ingredient/utensil mention span) instead of opening a brand-new
// correction — set when that popover calls `onRequestSpan`, cleared once
// `handleMouseUp` resolves the selection below. See
// `TechStepCorrectionPopover.tsx`'s own doc comment for why this can live
// entirely alongside the still-visible, still-selectable description
// rather than needing the popover itself to move/hide.
const [pendingSpanRequest, setPendingSpanRequest] = useState<"ingredient" | "utensil" | null>(
null,
);
const [resolvedMetadataSpan, setResolvedMetadataSpan] = useState<{
nonce: number;
kind: "ingredient" | "utensil";
range: TextSelectionRange;
text: string;
} | null>(null);
const nextMetadataSpanNonce = useRef(0);
function closeActiveCorrection() {
setActiveCorrection(null);
setPendingSpanRequest(null);
setResolvedMetadataSpan(null);
}
function handleMouseUp() {
if (!editable) return;
const range = getSelectionRange();
if (!range) return;
if (pendingSpanRequest !== null) {
nextMetadataSpanNonce.current += 1;
setResolvedMetadataSpan({
nonce: nextMetadataSpanNonce.current,
kind: pendingSpanRequest,
range,
text: description.slice(range.start, range.end),
});
setPendingSpanRequest(null);
return;
}
setActiveCorrection({
range,
selectedText: description.slice(range.start, range.end),
@ -91,6 +126,21 @@ export function StepDescription({
setLiveTechSteps(result.techSteps);
}
// The occurrence `activeCorrection` is currently open for, matched by its
// exact `[start, end)` (not just `techStep.id` — the same technique can
// legitimately occur more than once in one description) — whatever
// ingredients/utensils it already carries seed
// `TechStepCorrectionPopover`'s own pending lists. `undefined` (not an
// empty array) for a brand-new selection, same as "nothing to look up
// yet".
const activeStepTechStep = activeCorrection
? liveTechSteps.find(
(techStep) =>
techStep.start === activeCorrection.range.start &&
techStep.end === activeCorrection.range.end,
)
: undefined;
// Tracks each segment's own absolute start offset into `description` as
// the map below walks them in order — segments are contiguous and cover
// the whole description (see `splitDescriptionByTechSteps`'s doc
@ -154,12 +204,19 @@ export function StepDescription({
data-offset={editable ? start : undefined}
onClick={
editable
? () =>
? () => {
// Clears any in-progress ingredient/utensil
// span-selection from whatever correction was open
// before — opening a *different* one has nothing
// left to resolve that selection into.
setPendingSpanRequest(null);
setResolvedMetadataSpan(null);
setActiveCorrection({
range: { start, end },
selectedText: segment.text,
previousTechStepId: techStep.id,
})
});
}
: undefined
}
>
@ -176,7 +233,11 @@ export function StepDescription({
range={activeCorrection.range}
selectedText={activeCorrection.selectedText}
previousTechStepId={activeCorrection.previousTechStepId}
onClose={() => setActiveCorrection(null)}
existingIngredients={activeStepTechStep?.ingredients ?? []}
existingUtensils={activeStepTechStep?.utensils ?? []}
resolvedMetadataSpan={resolvedMetadataSpan}
onRequestSpan={setPendingSpanRequest}
onClose={closeActiveCorrection}
onSubmitted={handleSubmitted}
/>
)}

View file

@ -1,14 +1,48 @@
import {
ErrorCode,
type IngredientView,
type StepTechStepIngredientView,
type StepTechStepUtensilView,
type SubmitTechStepCorrectionResult,
type TechStepView,
type UnitView,
type UtensilView,
} from "@batch-cooking/shared";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ApiError, apiClient } from "../../../api/client";
import { errorMessageService } from "../../../services/error-message.service";
import { CatalogSearchPicker } from "./CatalogSearchPicker";
import type { TextSelectionRange } from "./use-text-selection";
/** One ingredient the viewer has attached (or is about to submit) — the trimmed-down shape `POST .../corrections`'s `ingredients[]` expects, kept separately from `StepTechStepIngredientView` since a pending one has no resolved `IngredientView`/`UnitView` to carry yet, only ids. */
interface PendingIngredient {
ingredientId: number;
quantity: number | null;
unitId: number | null;
start: number;
end: number;
}
/** Same as {@link PendingIngredient}, for a utensil (no quantity/unit — nothing to measure). */
interface PendingUtensil {
utensilId: number;
start: number;
end: number;
}
function toPendingIngredient(view: StepTechStepIngredientView): PendingIngredient {
return {
ingredientId: view.ingredient.id,
quantity: view.quantity,
unitId: view.unit?.id ?? null,
start: view.start,
end: view.end,
};
}
function toPendingUtensil(view: StepTechStepUtensilView): PendingUtensil {
return { utensilId: view.utensil.id, start: view.start, end: view.end };
}
/**
* Small non-modal popover letting a viewer assign a technique to a selected
* span of a step's description, or clear/relabel an existing match
@ -21,14 +55,29 @@ import type { TextSelectionRange } from "./use-text-selection";
* `StepDescription.tsx`), not floating anchored at the selection's exact
* position simpler and more robust than tracking a caret-anchored
* position across scroll/resize, at the cost of a little visual distance
* from the selected text itself.
* from the selected text itself. That placement matters beyond cosmetics
* here: it's *why* the "attach an ingredient/utensil" flow below can ask
* the viewer to select a second span of text without closing this popover
* first the description stays fully visible and selectable the whole
* time, nothing overlays it.
*
* Submitting takes effect immediately the API applies it to the step's
* real `StepTechStep` sequence as it records the correction (a `"manual"`-
* tagged entry, see `StepTechStepCorrection`'s schema doc comment) and
* returns the fresh sequence, which `onSubmitted` hands back to
* `StepDescription` to render right away, styled differently from an
* `"auto"` match.
* **One merged editor, not a wizard**: picking a technique
* (`CatalogSearchPicker`, searchable the reference catalog is ~74
* entries, an unfiltered flat list wasn't browsable) and editing its
* Ingrédients/Ustensiles metadata render together on the same screen,
* always there's no separate "pick, then a metadata step reveals
* itself" sequence to go through, and no dead end where metadata is
* technically attachable but not visible until some other action happens
* first. A single "Valider" submits everything at once; disabled until a
* technique is actually selected (there's nothing to attach metadata to
* otherwise). **Removing** a match (`submit(null)`) stays its own
* immediate action next to the picker nothing to attach when removing.
*
* The two metadata sections are pre-seeded from `existingIngredients`/
* `existingUtensils` (whatever's already attached to this occurrence, auto-
* or manually-sourced `[]` for a brand-new technique) and editable via
* add/remove see `metadataTouched` below for why what's *displayed* here
* isn't automatically what gets *submitted*.
*/
export function TechStepCorrectionPopover({
recipeId,
@ -36,6 +85,10 @@ export function TechStepCorrectionPopover({
selectedText,
range,
previousTechStepId,
existingIngredients,
existingUtensils,
resolvedMetadataSpan,
onRequestSpan,
onClose,
onSubmitted,
}: {
@ -46,12 +99,74 @@ export function TechStepCorrectionPopover({
range: TextSelectionRange;
/** Set when correcting an already-detected match (opened from clicking its highlight) rather than a fresh selection — passed through as-is on submit, and offers a "remove" option `null` doesn't. */
previousTechStepId: number | null;
/** Whatever ingredients/utensils already sit on this occurrence (both `"auto"` and `"manual"` sourced) — `[]` for a brand-new technique, nothing to pre-seed. */
existingIngredients: StepTechStepIngredientView[];
existingUtensils: StepTechStepUtensilView[];
/**
* A text span `StepDescription` just resolved on this popover's behalf,
* after a call to `onRequestSpan` below `null` until then. Identified
* by `nonce` (not by value) so this popover's own `useEffect` reliably
* fires once per fresh selection, even if the exact same span is
* selected twice in a row.
*/
resolvedMetadataSpan: {
nonce: number;
kind: "ingredient" | "utensil";
range: TextSelectionRange;
text: string;
} | null;
/** Tells `StepDescription` "the next text selection in the description is for an ingredient/utensil mention, not a new technique correction" — see this component's own doc comment. */
onRequestSpan: (kind: "ingredient" | "utensil") => void;
onClose: () => void;
onSubmitted: (result: SubmitTechStepCorrectionResult) => void;
}) {
const { t } = useTranslation();
const popoverRef = useRef<HTMLDivElement>(null);
const [techSteps, setTechSteps] = useState<TechStepView[] | 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<{
ingredients: IngredientView[];
units: UnitView[];
utensils: UtensilView[];
} | null>(null);
const [pendingIngredients, setPendingIngredients] = useState<PendingIngredient[]>(() =>
existingIngredients.map(toPendingIngredient),
);
const [pendingUtensils, setPendingUtensils] = useState<PendingUtensil[]>(() =>
existingUtensils.map(toPendingUtensil),
);
// Flips true the moment the viewer adds/removes a pending entry — never
// from the initial seeding above. `submit()` below only includes
// `ingredients`/`utensils` in the request when this is true, so
// relabeling/confirming a technique without ever opening either section
// leaves existing metadata completely alone server-side (see
// `submitTechStepCorrectionSchema`'s own doc comment, `packages/shared`,
// for why an *omitted* field — not an empty array — is what "don't
// touch it" means over the wire).
const [metadataTouched, setMetadataTouched] = useState(false);
const [awaitingSpanFor, setAwaitingSpanFor] = useState<"ingredient" | "utensil" | null>(null);
const [activeSpan, setActiveSpan] = useState<{
kind: "ingredient" | "utensil";
range: TextSelectionRange;
text: string;
} | null>(null);
// Only meaningful while `activeSpan?.kind === "ingredient"` — the
// ingredient sub-flow is itself two steps (pick the ingredient, then its
// quantity/unit), this is where the first step's choice waits until the
// second is confirmed.
const [pickedIngredientId, setPickedIngredientId] = useState<number | null>(null);
const [spanQuantity, setSpanQuantity] = useState("");
const [spanUnitId, setSpanUnitId] = useState<number | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -70,6 +185,46 @@ export function TechStepCorrectionPopover({
};
}, []);
// Fetched unconditionally on mount — the Ingrédients/Ustensiles sections
// render alongside the technique picker from the start (see this
// component's own doc comment on the merged editor), so there's no later
// point to defer this to anymore.
useEffect(() => {
if (catalogs !== null) return;
let cancelled = false;
Promise.all([apiClient.getIngredients(), apiClient.getUnits(), apiClient.getUtensils()])
.then(([ingredients, units, utensils]) => {
if (!cancelled) setCatalogs({ ingredients, units, utensils });
})
.catch(() => {
if (!cancelled) setCatalogs({ ingredients: [], units: [], utensils: [] });
});
return () => {
cancelled = true;
};
}, [catalogs]);
// Consumes a span `StepDescription` just resolved on this popover's
// behalf (see `resolvedMetadataSpan`'s own doc comment above) — opens the
// matching sub-picker and clears the "awaiting a selection" hint.
useEffect(() => {
if (resolvedMetadataSpan === null) return;
setActiveSpan({
kind: resolvedMetadataSpan.kind,
range: resolvedMetadataSpan.range,
text: resolvedMetadataSpan.text,
});
setAwaitingSpanFor(null);
setPickedIngredientId(null);
setSpanQuantity("");
setSpanUnitId(null);
// Depends on the whole object, not just `.nonce` — `StepDescription`
// only ever calls its setter with a brand-new object (never mutates
// one in place), so reference equality alone already gives this the
// "fires once per fresh selection" behavior `nonce` documents, with no
// need to silence the exhaustive-deps lint to get there.
}, [resolvedMetadataSpan]);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
@ -80,7 +235,7 @@ export function TechStepCorrectionPopover({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]);
async function submit(correctedTechStepId: number | null) {
async function removeMatch() {
setIsSubmitting(true);
setError(null);
try {
@ -88,7 +243,7 @@ export function TechStepCorrectionPopover({
start: range.start,
end: range.end,
previousTechStepId,
correctedTechStepId,
correctedTechStepId: null,
});
onSubmitted(result);
onClose();
@ -99,40 +254,260 @@ export function TechStepCorrectionPopover({
}
}
async function confirm() {
if (selectedTechStepId === null) return;
setIsSubmitting(true);
setError(null);
try {
const result = await apiClient.submitTechStepCorrection(recipeId, stepId, {
start: range.start,
end: range.end,
previousTechStepId,
correctedTechStepId: selectedTechStepId,
...(metadataTouched ? { ingredients: pendingIngredients, utensils: pendingUtensils } : {}),
});
onSubmitted(result);
onClose();
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setError(errorMessageService.getLabel(code));
setIsSubmitting(false);
}
}
function requestSpan(kind: "ingredient" | "utensil") {
setAwaitingSpanFor(kind);
onRequestSpan(kind);
}
function cancelSpanSelection() {
setAwaitingSpanFor(null);
setActiveSpan(null);
setPickedIngredientId(null);
}
function confirmIngredientSpan() {
if (activeSpan === null || pickedIngredientId === null) return;
const trimmed = spanQuantity.trim();
const parsedQuantity = trimmed.length > 0 ? Number(trimmed) : null;
setPendingIngredients((prev) => [
...prev,
{
ingredientId: pickedIngredientId,
quantity:
parsedQuantity !== null && Number.isFinite(parsedQuantity) ? parsedQuantity : null,
unitId: spanUnitId,
start: activeSpan.range.start,
end: activeSpan.range.end,
},
]);
setMetadataTouched(true);
setActiveSpan(null);
setPickedIngredientId(null);
}
function confirmUtensilSpan(utensilId: number) {
if (activeSpan === null) return;
setPendingUtensils((prev) => [
...prev,
{ utensilId, start: activeSpan.range.start, end: activeSpan.range.end },
]);
setMetadataTouched(true);
setActiveSpan(null);
}
function removeIngredient(index: number) {
setPendingIngredients((prev) => prev.filter((_, i) => i !== index));
setMetadataTouched(true);
}
function removeUtensil(index: number) {
setPendingUtensils((prev) => prev.filter((_, i) => i !== index));
setMetadataTouched(true);
}
const ingredientById = new Map((catalogs?.ingredients ?? []).map((i) => [i.id, i]));
const unitById = new Map((catalogs?.units ?? []).map((u) => [u.id, u]));
const utensilById = new Map((catalogs?.utensils ?? []).map((u) => [u.id, u]));
return (
<div className="tech-step-correction-popover" ref={popoverRef}>
<p className="tech-step-correction-popover__selection">
{t("recipes.techStepCorrection.selectionLabel", { text: selectedText })}
</p>
{techSteps === null ? (
{awaitingSpanFor !== null ? (
<p className="tech-step-correction-popover__hint">
{t("recipes.techStepCorrection.selectSpanHint")}
</p>
) : activeSpan !== null ? (
<div className="tech-step-correction-popover__span-picker">
<p className="tech-step-correction-popover__selection">
{t("recipes.techStepCorrection.selectionLabel", { text: activeSpan.text })}
</p>
{activeSpan.kind === "ingredient" ? (
pickedIngredientId === null ? (
<CatalogSearchPicker
items={(catalogs?.ingredients ?? []).map((ingredient) => ({
id: ingredient.id,
label: t(`catalog.ingredients.${ingredient.key}`),
}))}
onSelect={setPickedIngredientId}
placeholder={t("recipes.form.searchIngredientPlaceholder")}
emptyLabel={t("recipes.form.noIngredientFound")}
/>
) : (
<div className="tech-step-correction-popover__quantity-line">
<input
type="number"
min="0"
step="any"
value={spanQuantity}
onChange={(e) => setSpanQuantity(e.target.value)}
aria-label={t("recipes.form.quantityLabel")}
/>
<select
value={spanUnitId ?? ""}
onChange={(e) => setSpanUnitId(e.target.value ? Number(e.target.value) : null)}
aria-label={t("recipes.form.unitLabel")}
>
<option value="">{t("recipes.form.unitPlaceholder")}</option>
{(catalogs?.units ?? []).map((unit) => (
<option key={unit.id} value={unit.id}>
{t(`catalog.units.${unit.key}`)}
</option>
))}
</select>
<button type="button" onClick={confirmIngredientSpan}>
{t("recipes.techStepCorrection.addToList")}
</button>
</div>
)
) : (
<CatalogSearchPicker
items={(catalogs?.utensils ?? []).map((utensil) => ({
id: utensil.id,
label: t(`catalog.utensils.${utensil.key}`),
}))}
onSelect={confirmUtensilSpan}
placeholder={t("recipes.techStepCorrection.searchUtensilPlaceholder")}
emptyLabel={t("recipes.techStepCorrection.noUtensilFound")}
/>
)}
<button
type="button"
className="tech-step-correction-popover__cancel"
onClick={cancelSpanSelection}
>
{t("recipes.techStepCorrection.cancelSpanSelection")}
</button>
</div>
) : techSteps === null ? (
<p>{t("recipes.loading")}</p>
) : (
<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 && (
<li>
<button
type="button"
disabled={isSubmitting}
onClick={() => submit(null)}
onClick={removeMatch}
className="tech-step-correction-popover__remove"
>
{t("recipes.techStepCorrection.removeMatch")}
</button>
</li>
)}
{techSteps.map((techStep) => (
<li key={techStep.id}>
</div>
<p className="tech-step-correction-popover__chosen-technique">
{selectedTechStepId !== null
? t("recipes.techStepCorrection.currentTechnique", {
technique: t(
`catalog.techSteps.${techSteps.find((ts) => ts.id === selectedTechStepId)?.key ?? ""}`,
),
})
: t("recipes.techStepCorrection.noTechniqueSelected")}
</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">
<h4>{t("recipes.techStepCorrection.ingredientsSection")}</h4>
<ul className="tech-step-correction-popover__chips">
{pendingIngredients.map((ingredient, index) => {
const view = ingredientById.get(ingredient.ingredientId);
const unit =
ingredient.unitId !== null ? unitById.get(ingredient.unitId) : undefined;
const label = view ? t(`catalog.ingredients.${view.key}`) : "…";
return (
// biome-ignore lint/suspicious/noArrayIndexKey: `pendingIngredients` has no other stable identity (an ingredient can appear more than once, each with its own span) — always fully rebuilt on add/remove, never reordered in place.
<li key={index} className="tech-step-correction-popover__chip">
{ingredient.quantity !== null ? `${ingredient.quantity} ` : ""}
{unit ? `${t(`catalog.units.${unit.key}`)} ` : ""}
{label}
<button
type="button"
disabled={isSubmitting || techStep.id === previousTechStepId}
onClick={() => submit(techStep.id)}
onClick={() => removeIngredient(index)}
title={t("recipes.techStepCorrection.removeIngredient")}
disabled={isSubmitting}
>
{t(`catalog.techSteps.${techStep.key}`)}
</button>
</li>
))}
);
})}
</ul>
<button type="button" onClick={() => requestSpan("ingredient")} disabled={isSubmitting}>
{t("recipes.techStepCorrection.addIngredient")}
</button>
</section>
<section className="tech-step-correction-popover__metadata-section">
<h4>{t("recipes.techStepCorrection.utensilsSection")}</h4>
<ul className="tech-step-correction-popover__chips">
{pendingUtensils.map((utensil, index) => {
const view = utensilById.get(utensil.utensilId);
return (
// biome-ignore lint/suspicious/noArrayIndexKey: same reasoning as the ingredient chip list above.
<li key={index} className="tech-step-correction-popover__chip">
{view ? t(`catalog.utensils.${view.key}`) : "…"}
<button
type="button"
onClick={() => removeUtensil(index)}
title={t("recipes.techStepCorrection.removeUtensil")}
disabled={isSubmitting}
>
</button>
</li>
);
})}
</ul>
<button type="button" onClick={() => requestSpan("utensil")} disabled={isSubmitting}>
{t("recipes.techStepCorrection.addUtensil")}
</button>
</section>
<button
type="button"
className="tech-step-correction-popover__confirm-button"
onClick={confirm}
disabled={isSubmitting || selectedTechStepId === null}
>
{t("recipes.techStepCorrection.confirm")}
</button>
</div>
)}
{error && <p className="field-error">{error}</p>}
<button
type="button"

View file

@ -44,6 +44,7 @@
"SOURCE_NOT_FOUND": "Une des sources sélectionnées n'existe pas",
"STEP_NOT_FOUND": "Cette étape n'existe pas",
"TECH_STEP_NOT_FOUND": "Cette technique n'existe pas",
"UTENSIL_NOT_FOUND": "Un des ustensiles sélectionnés n'existe pas",
"INVALID_CORRECTION_SPAN": "La sélection ne correspond plus au texte de l'étape",
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
},
@ -168,7 +169,24 @@
"removeMatch": "Aucune technique ici",
"cancel": "Annuler",
"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.",
"confirm": "Valider",
"techniqueSection": "Technique",
"currentTechnique": "Technique retenue : {{technique}}",
"noTechniqueSelected": "Aucune technique sélectionnée.",
"searchTechniquePlaceholder": "Rechercher une technique…",
"noTechniqueFound": "Aucune technique trouvée.",
"ingredientsSection": "Ingrédients",
"utensilsSection": "Ustensiles",
"addIngredient": "+ Ajouter un ingrédient",
"addUtensil": "+ Ajouter un ustensile",
"removeIngredient": "Retirer cet ingrédient",
"removeUtensil": "Retirer cet ustensile",
"selectSpanHint": "Sélectionnez le passage de texte concerné dans la description ci-dessus…",
"cancelSpanSelection": "Annuler la sélection",
"searchUtensilPlaceholder": "Rechercher un ustensile…",
"noUtensilFound": "Aucun ustensile trouvé.",
"addToList": "Ajouter"
},
"tabs": {
"favoris": "Favoris",

View file

@ -68,6 +68,8 @@ export enum ErrorCode {
STEP_NOT_FOUND = 4050,
/** A tech-step correction's `previousTechStepId`/`correctedTechStepId` doesn't match any reference `TechStep` row. */
TECH_STEP_NOT_FOUND = 4051,
/** A tech-step correction's manually-attached `utensils[].utensilId` doesn't match any reference `Utensil` row. */
UTENSIL_NOT_FOUND = 4052,
/** A tech-step correction's `start`/`end` span falls outside the target step's `description`, or `start >= end`. */
INVALID_CORRECTION_SPAN = 4002,
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */

View file

@ -117,6 +117,41 @@ export const listRecipesSchema = z.object({
/** Inferred TS type for {@link listRecipesSchema}'s validated output. */
export type ListRecipesInput = z.infer<typeof listRecipesSchema>;
/**
* One ingredient mention the user themselves points at while correcting a
* technique `start`/`end` is *their own* selection of the exact passage
* of `description` that names it (a separate selection from the
* correction's own `[start, end)`, see `TechStepCorrectionPopover.tsx`),
* not derived from anything the classifier found. `quantity`/`unitId`
* are optional a mention with no quantity attached ("ajouter le sel")
* is still worth recording. See `submitTechStepCorrectionSchema`'s own
* doc comment for how `ingredients` as a whole behaves.
*/
const manualStepTechStepIngredientInputSchema = z
.object({
ingredientId: z.number().int().positive(),
quantity: z.number().positive("La quantité doit être positive").nullable().optional(),
unitId: z.number().int().positive().nullable().optional(),
start: z.number().int().nonnegative(),
end: z.number().int().nonnegative(),
})
.refine((ingredient) => ingredient.end > ingredient.start, {
message: "end must be greater than start",
path: ["end"],
});
/** A utensil mention the user points at while correcting a technique — same `start`/`end` convention as {@link manualStepTechStepIngredientInputSchema}, no quantity/unit (nothing to measure for a utensil). */
const manualStepTechStepUtensilInputSchema = z
.object({
utensilId: z.number().int().positive(),
start: z.number().int().nonnegative(),
end: z.number().int().nonnegative(),
})
.refine((utensil) => utensil.end > utensil.start, {
message: "end must be greater than start",
path: ["end"],
});
/**
* Payload accepted by `POST /recipes/:id/steps/:stepId/corrections` a
* user asserting what technique a `[start, end)` span of a step's
@ -129,6 +164,19 @@ export type ListRecipesInput = z.infer<typeof listRecipesSchema>;
* (`recipe-tech-step-correction.service.ts`) needs the target step's
* `description` length to validate `start`/`end` against, which this shape
* alone can't see.
*
* `ingredients`/`utensils` let the user attach metadata to the technique
* they're asserting (`correctedTechStepId`), same `source: "manual"`
* distinction the technique itself gets. **Omitted (`undefined`) means
* "leave whatever metadata already exists on this occurrence alone"
* an explicit array, even `[]`, means "this is now the complete set,
* replace everything that was there" (auto-detected included; see
* `applyManualCorrection`'s own doc comment). This is why neither field
* has a `.default([])`: that would silently turn every plain relabel into
* a metadata wipe.** Only meaningful alongside a real `correctedTechStepId`
* enforced by this schema's own refine below, since there's no live
* `StepTechStep` row to attach to otherwise (removing a match, or a
* request with neither id set).
*/
export const submitTechStepCorrectionSchema = z
.object({
@ -136,6 +184,8 @@ export const submitTechStepCorrectionSchema = z
end: z.number().int().nonnegative(),
previousTechStepId: z.number().int().positive().nullable().optional(),
correctedTechStepId: z.number().int().positive().nullable().optional(),
ingredients: z.array(manualStepTechStepIngredientInputSchema).optional(),
utensils: z.array(manualStepTechStepUtensilInputSchema).optional(),
})
.refine((input) => input.end > input.start, {
message: "end must be greater than start",
@ -148,6 +198,15 @@ export const submitTechStepCorrectionSchema = z
message: "at least one of previousTechStepId/correctedTechStepId is required",
path: ["correctedTechStepId"],
},
)
.refine(
(input) =>
(input.ingredients === undefined && input.utensils === undefined) ||
(input.correctedTechStepId ?? null) !== null,
{
message: "ingredients/utensils require a correctedTechStepId to attach to",
path: ["correctedTechStepId"],
},
);
/** Inferred TS type for {@link submitTechStepCorrectionSchema}'s validated output. */
export type SubmitTechStepCorrectionInput = z.infer<typeof submitTechStepCorrectionSchema>;

View file

@ -81,6 +81,10 @@ export interface StepTechStepView {
* `tech-step-matcher.ts`'s `IngredientMention`. `start`/`end` are the
* mention's own span in the step's `description`, same `[start, end)`
* convention as {@link StepTechStepView.start}.
*
* `source` mirrors {@link StepTechStepView.source} `"auto"` is the
* classifier's own detection, `"manual"` is a viewer's own selection
* (`SubmitTechStepCorrectionInput.ingredients`, `TechStepCorrectionPopover.tsx`).
*/
export interface StepTechStepIngredientView {
ingredient: IngredientView;
@ -88,13 +92,15 @@ export interface StepTechStepIngredientView {
unit: UnitView | null;
start: number;
end: number;
source: "auto" | "manual";
}
/** A utensil mentioned in the same clause as a detected technique (see {@link StepTechStepView.utensils}). */
/** A utensil mentioned in the same clause as a detected technique (see {@link StepTechStepView.utensils}) — `source` mirrors {@link StepTechStepIngredientView.source}. */
export interface StepTechStepUtensilView {
utensil: UtensilView;
start: number;
end: number;
source: "auto" | "manual";
}
/**