From a488704a77f2815410d01faae762ef874dd16170 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 26 Aug 2026 21:08:18 +0200 Subject: [PATCH] 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 --- .../recipe-tech-step-correction.service.ts | 199 ++++++++- apps/api/src/modules/recipe/recipe.service.ts | 3 + .../src/modules/sources/sources.service.ts | 6 +- .../recipe-tech-step-correction.test.ts | 256 +++++++++++ .../TechStepCorrectionPopover.cy.tsx | 164 ++++++-- apps/web/cypress/e2e/recipes.ts | 17 +- apps/web/src/api/client.ts | 6 + apps/web/src/features/recipes/recipes.scss | 168 ++++++++ .../recipes/steps/CatalogSearchPicker.tsx | 60 +++ .../recipes/steps/StepDescription.tsx | 67 ++- .../steps/TechStepCorrectionPopover.tsx | 396 +++++++++++++++++- apps/web/src/locales/fr/translation.json | 16 +- packages/shared/src/errors/error-codes.ts | 2 + packages/shared/src/schemas/recipe.ts | 59 +++ packages/shared/src/types/recipe.ts | 8 +- 15 files changed, 1365 insertions(+), 62 deletions(-) create mode 100644 apps/web/src/features/recipes/steps/CatalogSearchPicker.tsx diff --git a/apps/api/src/modules/recipe/recipe-tech-step-correction.service.ts b/apps/api/src/modules/recipe/recipe-tech-step-correction.service.ts index 603c5ab..a43a8fb 100644 --- a/apps/api/src/modules/recipe/recipe-tech-step-correction.service.ts +++ b/apps/api/src/modules/recipe/recipe-tech-step-correction.service.ts @@ -84,6 +84,75 @@ async function assertTechStepsExist(ids: number[]): Promise { } } +/** 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 { + 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 { + 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 { + 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 { 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) { - throw new HttpError( - 400, - ErrorCode.INVALID_CORRECTION_SPAN, - `Span [${input.start}, ${input.end}) falls outside step ${stepId}'s description (length ${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 [${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 diff --git a/apps/api/src/modules/recipe/recipe.service.ts b/apps/api/src/modules/recipe/recipe.service.ts index ae2e274..9b7e6c6 100644 --- a/apps/api/src/modules/recipe/recipe.service.ts +++ b/apps/api/src/modules/recipe/recipe.service.ts @@ -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", })), }); } diff --git a/apps/api/src/modules/sources/sources.service.ts b/apps/api/src/modules/sources/sources.service.ts index 28bc3f5..639fa42 100644 --- a/apps/api/src/modules/sources/sources.service.ts +++ b/apps/api/src/modules/sources/sources.service.ts @@ -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 }] + : []; }), }, ] diff --git a/apps/api/test/recipe/recipe-tech-step-correction.test.ts b/apps/api/test/recipe/recipe-tech-step-correction.test.ts index 215b6ff..0dcd5ef 100644 --- a/apps/api/test/recipe/recipe-tech-step-correction.test.ts +++ b/apps/api/test/recipe/recipe-tech-step-correction.test.ts @@ -25,6 +25,24 @@ async function techStepId(key: string): Promise { return techStep.id; } +/** Same as {@link techStepId}, for a reference `Ingredient`. */ +async function ingredientId(key: string): Promise { + 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 { + 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 { + 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(); diff --git a/apps/web/cypress/component/TechStepCorrectionPopover.cy.tsx b/apps/web/cypress/component/TechStepCorrectionPopover.cy.tsx index effbb22..6c0cf07 100644 --- a/apps/web/cypress/component/TechStepCorrectionPopover.cy.tsx +++ b/apps/web/cypress/component/TechStepCorrectionPopover.cy.tsx @@ -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<{ - previousTechStepId: number | null; - onClose: () => void; - onSubmitted: (correction: unknown) => void; - }> = {}, -) { - cy.mount( +/** + * 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: (result: unknown) => void; +}>) { + const [resolvedMetadataSpan, setResolvedMetadataSpan] = useState<{ + nonce: number; + kind: "ingredient" | "utensil"; + range: { start: number; end: number }; + text: string; + } | null>(null); + + return (
{/* 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. */}
@@ -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} /> -
, +
); } @@ -41,10 +81,17 @@ 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(); + cy.mount(); cy.wait("@getTechSteps"); cy.contains(".tech-step-correction-popover__selection", "Cuire").should("be.visible"); @@ -52,21 +99,28 @@ describe("TechStepCorrectionPopover", () => { }); it("offers a 'no technique here' option only when correcting an existing match", () => { - mountPopover({ previousTechStepId: null }); + cy.mount(); cy.wait("@getTechSteps"); cy.get(".tech-step-correction-popover__remove").should("not.exist"); - mountPopover({ previousTechStepId: cook.id }); + cy.mount(); cy.wait("@getTechSteps"); cy.get(".tech-step-correction-popover__remove").should("exist"); }); - 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("selecting a technique reveals the Ingrédients/Ustensiles sections instead of submitting immediately", () => { + cy.mount(); + cy.wait("@getTechSteps"); + + cy.contains(".tech-step-correction-popover__list button", "Mijoter").click(); + + cy.get(".tech-step-correction-popover__list").should("not.exist"); + cy.contains("h4", "Ingrédients").should("be.visible"); + cy.contains("h4", "Ustensiles").should("be.visible"); + cy.contains("button", "Valider").should("be.visible"); + }); + + 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 +133,11 @@ describe("TechStepCorrectionPopover", () => { }, }).as("submitCorrection"); const onSubmitted = cy.stub().as("onSubmitted"); - mountPopover({ onSubmitted }); + cy.mount(); cy.wait("@getTechSteps"); cy.contains(".tech-step-correction-popover__list button", "Mijoter").click(); + cy.contains("button", "Valider").click(); cy.wait("@submitCorrection").its("request.body").should("deep.equal", { start: 0, @@ -93,16 +148,77 @@ 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(); + cy.wait("@getTechSteps"); + + cy.contains(".tech-step-correction-popover__list button", "Mijoter").click(); + cy.contains("button", "+ Ajouter un ingrédient").click(); + cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]); + + cy.contains(".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( + , + ); + cy.wait("@getTechSteps"); + + cy.contains(".tech-step-correction-popover__list button", "Mijoter").click(); + cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]); + + cy.contains(".tech-step-correction-popover__chip", "50 g Beurre").find("button").click(); + 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(); cy.wait("@getTechSteps"); cy.contains(".tech-step-correction-popover__list button", "Cuire").click(); + cy.contains("button", "Valider").click(); cy.wait("@submitCorrection"); cy.get(".field-error").should("be.visible"); @@ -111,7 +227,7 @@ describe("TechStepCorrectionPopover", () => { it("calls onClose on an outside click", () => { const onClose = cy.stub().as("onClose"); - mountPopover({ onClose }); + cy.mount(); cy.wait("@getTechSteps"); cy.get('[data-testid="outside-popover"]').click(); diff --git a/apps/web/cypress/e2e/recipes.ts b/apps/web/cypress/e2e/recipes.ts index e534164..af0f2a9 100644 --- a/apps/web/cypress/e2e/recipes.ts +++ b/apps/web/cypress/e2e/recipes.ts @@ -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,14 @@ Then("I should see the technique correction options", () => { cy.get(".tech-step-correction-popover").should("be.visible"); }); +// Picking a technique only *selects* it now — it takes a separate +// "Valider" click to actually submit (room was made for attaching +// ingredient/utensil metadata first, see `TechStepCorrectionPopover.tsx`'s +// own doc comment) — folded into this one step since nothing in this +// scenario cares about that intermediate state on its own. 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__confirm-button", "Valider").click(); }); Then( diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 66d9594..ac97e1b 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -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 { + 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 { return this._request("/reference/sources"); diff --git a/apps/web/src/features/recipes/recipes.scss b/apps/web/src/features/recipes/recipes.scss index b09b953..cf0a77c 100644 --- a/apps/web/src/features/recipes/recipes.scss +++ b/apps/web/src/features/recipes/recipes.scss @@ -775,6 +775,174 @@ 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); + } + + &__chosen-technique { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-sm); + margin: 0; + 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 { + h4 { + margin: 0 0 var(--space-xs); + font-size: var(--font-size-sm); + color: var(--color-text-muted); + } + + // "+ Ajouter…" button — deliberately the same plain-text-link styling + // as `&__change` above, not another pill button (`&__list button`) — + // this is a secondary action inside an already-open popover, not a + // 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; + max-height: 8rem; + 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); + } + } + } } // --- Favorite star toggle (detail panel header) ----------------------------- diff --git a/apps/web/src/features/recipes/steps/CatalogSearchPicker.tsx b/apps/web/src/features/recipes/steps/CatalogSearchPicker.tsx new file mode 100644 index 0000000..0cccd2f --- /dev/null +++ b/apps/web/src/features/recipes/steps/CatalogSearchPicker.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; + +/** + * Small search-and-pick list — a lighter alternative to `IngredientPicker.tsx` + * (category/subcategory grid + allergen/diet toggles) for a context that + * doesn't have room for that: `TechStepCorrectionPopover.tsx`'s "attach an + * ingredient/utensil to this correction" flow, embedded in a small popover + * rather than a full recipe form. Reused for both — an ingredient and a + * utensil are both "search a short reference list by translated label, pick + * one" from this component's point of view, the only difference is which + * `items`/labels the caller passes in. + * + * Deliberately just `{ id, label }` in, `id` out — no `IngredientView`/ + * `UtensilView` dependency here, so this stays reusable for any future + * "search this small reference catalog" need without growing a new prop + * per catalog shape. + */ +export function CatalogSearchPicker({ + items, + onSelect, + placeholder, + emptyLabel, +}: { + items: { id: number; label: string }[]; + 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 ( +
+ setQuery(e.target.value)} + placeholder={placeholder} + className="catalog-search-picker__input" + /> + {visible.length === 0 ? ( +

{emptyLabel}

+ ) : ( +
    + {visible.map((item) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/features/recipes/steps/StepDescription.tsx b/apps/web/src/features/recipes/steps/StepDescription.tsx index 042478f..3b4d650 100644 --- a/apps/web/src/features/recipes/steps/StepDescription.tsx +++ b/apps/web/src/features/recipes/steps/StepDescription.tsx @@ -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} /> )} diff --git a/apps/web/src/features/recipes/steps/TechStepCorrectionPopover.tsx b/apps/web/src/features/recipes/steps/TechStepCorrectionPopover.tsx index 57d9c09..7493b46 100644 --- a/apps/web/src/features/recipes/steps/TechStepCorrectionPopover.tsx +++ b/apps/web/src/features/recipes/steps/TechStepCorrectionPopover.tsx @@ -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,24 @@ 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. + * **Removing** a match (`submit(null)`) stays a single immediate action — + * nothing to attach when removing. **Picking/relabeling** a technique used + * to submit immediately too; it no longer does, so there's room to attach + * metadata before committing — clicking a technique now only *selects* it + * (`selectedTechStepId`), revealing the Ingrédients/Ustensiles sections and + * a final "Valider" button that submits everything together. + * + * 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 +80,10 @@ export function TechStepCorrectionPopover({ selectedText, range, previousTechStepId, + existingIngredients, + existingUtensils, + resolvedMetadataSpan, + onRequestSpan, onClose, onSubmitted, }: { @@ -46,12 +94,67 @@ 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(null); const [techSteps, setTechSteps] = useState(null); + const [selectedTechStepId, setSelectedTechStepId] = useState(null); + const [catalogs, setCatalogs] = useState<{ + ingredients: IngredientView[]; + units: UnitView[]; + utensils: UtensilView[]; + } | null>(null); + + const [pendingIngredients, setPendingIngredients] = useState(() => + existingIngredients.map(toPendingIngredient), + ); + const [pendingUtensils, setPendingUtensils] = useState(() => + 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(null); + const [spanQuantity, setSpanQuantity] = useState(""); + const [spanUnitId, setSpanUnitId] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); @@ -70,6 +173,46 @@ export function TechStepCorrectionPopover({ }; }, []); + // Only fetched once a technique is actually selected — the Ingrédients/ + // Ustensiles sections (the only things that need these) don't render + // before then, so a popover only ever used to relabel/remove a technique + // never pays for these three extra requests. + useEffect(() => { + if (selectedTechStepId === null || 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; + }; + }, [selectedTechStepId, 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 +223,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 +231,7 @@ export function TechStepCorrectionPopover({ start: range.start, end: range.end, previousTechStepId, - correctedTechStepId, + correctedTechStepId: null, }); onSubmitted(result); onClose(); @@ -99,21 +242,163 @@ 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 (

{t("recipes.techStepCorrection.selectionLabel", { text: selectedText })}

- {techSteps === null ? ( + + {awaitingSpanFor !== null ? ( +

+ {t("recipes.techStepCorrection.selectSpanHint")} +

+ ) : activeSpan !== null ? ( +
+

+ {t("recipes.techStepCorrection.selectionLabel", { text: activeSpan.text })} +

+ {activeSpan.kind === "ingredient" ? ( + pickedIngredientId === null ? ( + ({ + id: ingredient.id, + label: t(`catalog.ingredients.${ingredient.key}`), + }))} + onSelect={setPickedIngredientId} + placeholder={t("recipes.form.searchIngredientPlaceholder")} + emptyLabel={t("recipes.form.noIngredientFound")} + /> + ) : ( +
+ setSpanQuantity(e.target.value)} + aria-label={t("recipes.form.quantityLabel")} + /> + + +
+ ) + ) : ( + ({ + id: utensil.id, + label: t(`catalog.utensils.${utensil.key}`), + }))} + onSelect={confirmUtensilSpan} + placeholder={t("recipes.techStepCorrection.searchUtensilPlaceholder")} + emptyLabel={t("recipes.techStepCorrection.noUtensilFound")} + /> + )} + +
+ ) : techSteps === null ? (

{t("recipes.loading")}

- ) : ( + ) : selectedTechStepId === null ? (
    {previousTechStepId !== null && (
  • ))}
+ ) : ( +
+

+ {t( + `catalog.techSteps.${techSteps.find((ts) => ts.id === selectedTechStepId)?.key ?? ""}`, + )} + +

+ +
+

{t("recipes.techStepCorrection.ingredientsSection")}

+
    + {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. +
  • + {ingredient.quantity !== null ? `${ingredient.quantity} ` : ""} + {unit ? `${t(`catalog.units.${unit.key}`)} ` : ""} + {label} + +
  • + ); + })} +
+ +
+ +
+

{t("recipes.techStepCorrection.utensilsSection")}

+
    + {pendingUtensils.map((utensil, index) => { + const view = utensilById.get(utensil.utensilId); + return ( + // biome-ignore lint/suspicious/noArrayIndexKey: same reasoning as the ingredient chip list above. +
  • + {view ? t(`catalog.utensils.${view.key}`) : "…"} + +
  • + ); + })} +
+ +
+ + +
)} + {error &&

{error}

}