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>
This commit is contained in:
kyuno053 2026-08-26 21:51:45 +02:00 committed by GitHub
parent 550627919d
commit eef5db92b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1365 additions and 62 deletions

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 * Renumbers every one of `stepId`'s `StepTechStep` rows' `order` by
* ascending `start` (nulls-still-possible legacy rows, see that model's * 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` * Applies a correction's *effect* on `stepId`'s real `StepTechStep`
* sequence, immediately not just recorded as a pending suggestion for * 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 * `contextEnd` a correction only ever carries the tight span the user
* themselves selected/clicked, nothing wider to highlight around it. * themselves selected/clicked, nothing wider to highlight around it.
* - `previousTechStepId` alone (remove, `correctedTechStepId: null`): the * - `previousTechStepId` alone (remove, `correctedTechStepId: null`): the
* matching existing entry is deleted outright. A no-op if none matches * matching existing entry is deleted outright (cascading away any
* (nothing to remove). * 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 * Runs inside the same transaction {@link submitTechStepCorrection} uses
* for the audit-trail insert, so a request never leaves the two effects * for the audit-trail insert, so a request never leaves any of these
* (the permanent correction record, the live sequence change) only * effects (the permanent correction record, the live sequence change, the
* partially applied. * metadata replacement) only partially applied.
*/ */
async function applyManualCorrection( async function applyManualCorrection(
tx: Prisma.TransactionClient, tx: Prisma.TransactionClient,
@ -156,6 +254,7 @@ async function applyManualCorrection(
span: { start: number; end: number }, span: { start: number; end: number },
previousTechStepId: number | null, previousTechStepId: number | null,
correctedTechStepId: number | null, correctedTechStepId: number | null,
metadata?: { ingredients: ManualIngredientMention[]; utensils: ManualUtensilMention[] },
): Promise<void> { ): Promise<void> {
const existing = await tx.stepTechStep.findMany({ where: { stepId } }); const existing = await tx.stepTechStep.findMany({ where: { stepId } });
@ -172,9 +271,12 @@ async function applyManualCorrection(
: undefined; : undefined;
if (correctedTechStepId !== null) { if (correctedTechStepId !== null) {
const order = target
? target.order
: existing.reduce((max, row) => Math.max(max, row.order), -1) + 1;
if (target) { if (target) {
await tx.stepTechStep.update({ await tx.stepTechStep.update({
where: { stepId_order: { stepId, order: target.order } }, where: { stepId_order: { stepId, order } },
data: { data: {
techStepId: correctedTechStepId, techStepId: correctedTechStepId,
start: span.start, start: span.start,
@ -185,18 +287,48 @@ async function applyManualCorrection(
}, },
}); });
} else { } else {
const nextOrder = existing.reduce((max, row) => Math.max(max, row.order), -1) + 1;
await tx.stepTechStep.create({ await tx.stepTechStep.create({
data: { data: {
stepId, stepId,
techStepId: correctedTechStepId, techStepId: correctedTechStepId,
order: nextOrder, order,
start: span.start, start: span.start,
end: span.end, end: span.end,
source: "manual", 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) { } else if (target) {
await tx.stepTechStep.delete({ where: { stepId_order: { stepId, order: target.order } } }); 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 * @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` see
* {@link loadVisibleStepOrThrow}. `400 INVALID_CORRECTION_SPAN` if * {@link loadVisibleStepOrThrow}. `400 INVALID_CORRECTION_SPAN` if
* `start`/`end` fall outside the step's current `description` (it may * `start`/`end` (the correction's own span, or any of
* have been edited since the user last saw it). `404 TECH_STEP_NOT_FOUND` * `input.ingredients`/`input.utensils`' own spans) fall outside the
* if either tech-step id doesn't exist. * 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( export async function submitTechStepCorrection(
recipeId: number, recipeId: number,
@ -246,18 +381,32 @@ export async function submitTechStepCorrection(
try { try {
const step = await loadVisibleStepOrThrow(recipeId, stepId, correctorId, viewerHouseId); 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( throw new HttpError(
400, 400,
ErrorCode.INVALID_CORRECTION_SPAN, 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( const techStepIds = [input.previousTechStepId, input.correctedTechStepId].filter(
(id): id is number => id !== null && id !== undefined, (id): id is number => id !== null && id !== undefined,
); );
await assertTechStepsExist(techStepIds); 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 { correction, techSteps } = await prisma.$transaction(async (tx) => {
const createdCorrection = await tx.stepTechStepCorrection.create({ const createdCorrection = await tx.stepTechStepCorrection.create({
@ -278,6 +427,22 @@ export async function submitTechStepCorrection(
{ start: input.start, end: input.end }, { start: input.start, end: input.end },
input.previousTechStepId ?? null, input.previousTechStepId ?? null,
input.correctedTechStepId ?? 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 // 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), unit: stepTechStepIngredient.unit === null ? null : toUnitView(stepTechStepIngredient.unit),
start: stepTechStepIngredient.start, start: stepTechStepIngredient.start,
end: stepTechStepIngredient.end, end: stepTechStepIngredient.end,
// Same narrowing posture as the technique's own `source` above.
source: stepTechStepIngredient.source === "manual" ? "manual" : "auto",
})), })),
utensils: utensils.map((stepTechStepUtensil) => ({ utensils: utensils.map((stepTechStepUtensil) => ({
utensil: { id: stepTechStepUtensil.utensil.id, key: stepTechStepUtensil.utensil.key }, utensil: { id: stepTechStepUtensil.utensil.id, key: stepTechStepUtensil.utensil.key },
start: stepTechStepUtensil.start, start: stepTechStepUtensil.start,
end: stepTechStepUtensil.end, 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, unit: mention.unitId !== null ? (unitById.get(mention.unitId) ?? null) : null,
start: mention.start, start: mention.start,
end: mention.end, 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) => { utensils: match.utensils.flatMap((mention) => {
const utensil = utensilById.get(mention.utensilId); 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; 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", () => { describe("Recipe tech-step corrections", () => {
const app = createApp(); 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", () => { describe("GET /recipes/:id/steps/:stepId/corrections", () => {
it("returns every correction submitted for the step, most recent first", async () => { it("returns every correction submitted for the step, most recent first", async () => {
const { agent, profileId } = await signup(); const { agent, profileId } = await signup();

View file

@ -1,3 +1,4 @@
import { useState } from "react";
import "../../src/i18n/i18n"; import "../../src/i18n/i18n";
import { TechStepCorrectionPopover } from "../../src/features/recipes/steps/TechStepCorrectionPopover"; 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 cook = { id: 1, key: "cook" };
const simmer = { id: 3, key: "simmer" }; 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; previousTechStepId: number | null;
existingIngredients: unknown[];
existingUtensils: unknown[];
onClose: () => void; onClose: () => void;
onSubmitted: (correction: unknown) => void; onSubmitted: (result: unknown) => void;
}> = {}, }>) {
) { const [resolvedMetadataSpan, setResolvedMetadataSpan] = useState<{
cy.mount( nonce: number;
kind: "ingredient" | "utensil";
range: { start: number; end: number };
text: string;
} | null>(null);
return (
<div> <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. */} {/* 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 }} /> <div data-testid="outside-popover" style={{ height: 20 }} />
@ -28,11 +54,25 @@ function mountPopover(
stepId={2} stepId={2}
selectedText="Cuire" selectedText="Cuire"
range={{ start: 0, end: 5 }} range={{ start: 0, end: 5 }}
previousTechStepId={overrides.previousTechStepId ?? null} previousTechStepId={previousTechStepId}
onClose={overrides.onClose ?? (() => {})} // biome-ignore lint/suspicious/noExplicitAny: test harness stands in for real StepTechStepIngredientView/UtensilView props — precise typing isn't the point here.
onSubmitted={overrides.onSubmitted ?? (() => {})} 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,10 +81,17 @@ describe("TechStepCorrectionPopover", () => {
cy.intercept("GET", "**/reference/tech-steps", { statusCode: 200, body: [cook, simmer] }).as( cy.intercept("GET", "**/reference/tech-steps", { statusCode: 200, body: [cook, simmer] }).as(
"getTechSteps", "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", () => { it("shows the selected text and every technique option once loaded", () => {
mountPopover(); cy.mount(<Harness />);
cy.wait("@getTechSteps"); cy.wait("@getTechSteps");
cy.contains(".tech-step-correction-popover__selection", "Cuire").should("be.visible"); cy.contains(".tech-step-correction-popover__selection", "Cuire").should("be.visible");
@ -52,21 +99,28 @@ describe("TechStepCorrectionPopover", () => {
}); });
it("offers a 'no technique here' option only when correcting an existing match", () => { it("offers a 'no technique here' option only when correcting an existing match", () => {
mountPopover({ previousTechStepId: null }); cy.mount(<Harness previousTechStepId={null} />);
cy.wait("@getTechSteps"); cy.wait("@getTechSteps");
cy.get(".tech-step-correction-popover__remove").should("not.exist"); cy.get(".tech-step-correction-popover__remove").should("not.exist");
mountPopover({ previousTechStepId: cook.id }); cy.mount(<Harness previousTechStepId={cook.id} />);
cy.wait("@getTechSteps"); cy.wait("@getTechSteps");
cy.get(".tech-step-correction-popover__remove").should("exist"); cy.get(".tech-step-correction-popover__remove").should("exist");
}); });
it("submits the selected technique and calls onSubmitted", () => { it("selecting a technique reveals the Ingrédients/Ustensiles sections instead of submitting immediately", () => {
// Asserting on the resolved `@submitCorrection` interception below, cy.mount(<Harness />);
// rather than inside this handler — a Chai assertion failing *inside* cy.wait("@getTechSteps");
// a `cy.intercept` callback surfaces as an opaque "onResponse cannot be
// called twice" Cypress internal error instead of a normal assertion cy.contains(".tech-step-correction-popover__list button", "Mijoter").click();
// failure, found while writing this exact test.
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", { cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
statusCode: 201, statusCode: 201,
body: { body: {
@ -79,10 +133,11 @@ describe("TechStepCorrectionPopover", () => {
}, },
}).as("submitCorrection"); }).as("submitCorrection");
const onSubmitted = cy.stub().as("onSubmitted"); const onSubmitted = cy.stub().as("onSubmitted");
mountPopover({ onSubmitted }); cy.mount(<Harness onSubmitted={onSubmitted} />);
cy.wait("@getTechSteps"); cy.wait("@getTechSteps");
cy.contains(".tech-step-correction-popover__list button", "Mijoter").click(); cy.contains(".tech-step-correction-popover__list button", "Mijoter").click();
cy.contains("button", "Valider").click();
cy.wait("@submitCorrection").its("request.body").should("deep.equal", { cy.wait("@submitCorrection").its("request.body").should("deep.equal", {
start: 0, start: 0,
@ -93,16 +148,77 @@ describe("TechStepCorrectionPopover", () => {
cy.get("@onSubmitted").should("have.been.calledOnce"); 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.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(
<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" }]}
/>,
);
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", () => { it("shows an error message and stays open when the submission fails", () => {
cy.intercept("POST", "**/recipes/2/steps/2/corrections", { cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
statusCode: 404, statusCode: 404,
body: { code: 4051, message: "TechStep not found" }, body: { code: 4051, message: "TechStep not found" },
}).as("submitCorrection"); }).as("submitCorrection");
const onClose = cy.stub().as("onClose"); const onClose = cy.stub().as("onClose");
mountPopover({ onClose }); cy.mount(<Harness onClose={onClose} />);
cy.wait("@getTechSteps"); cy.wait("@getTechSteps");
cy.contains(".tech-step-correction-popover__list button", "Cuire").click(); cy.contains(".tech-step-correction-popover__list button", "Cuire").click();
cy.contains("button", "Valider").click();
cy.wait("@submitCorrection"); cy.wait("@submitCorrection");
cy.get(".field-error").should("be.visible"); cy.get(".field-error").should("be.visible");
@ -111,7 +227,7 @@ describe("TechStepCorrectionPopover", () => {
it("calls onClose on an outside click", () => { it("calls onClose on an outside click", () => {
const onClose = cy.stub().as("onClose"); const onClose = cy.stub().as("onClose");
mountPopover({ onClose }); cy.mount(<Harness onClose={onClose} />);
cy.wait("@getTechSteps"); cy.wait("@getTechSteps");
cy.get('[data-testid="outside-popover"]').click(); 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" }, correctedTechStep: { id: 3, key: "simmer" },
createdAt: new Date().toISOString(), 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"); }).as("correction");
}); });
@ -101,8 +110,14 @@ Then("I should see the technique correction options", () => {
cy.get(".tech-step-correction-popover").should("be.visible"); cy.get(".tech-step-correction-popover").should("be.visible");
}); });
// Picking a technique only *selects* it now — it takes a separate
// "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) => { 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__list button", label).click();
cy.contains(".tech-step-correction-popover__confirm-button", "Valider").click();
}); });
Then( Then(

View file

@ -27,6 +27,7 @@ import {
type ThemePreference, type ThemePreference,
type UnitView, type UnitView,
type UpdateRecipeInput, type UpdateRecipeInput,
type UtensilView,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
/** /**
@ -201,6 +202,11 @@ export class ApiClient {
return this._request("/reference/tech-steps"); 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. */ /** 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[]> { public getSources(): Promise<SourceView[]> {
return this._request("/reference/sources"); return this._request("/reference/sources");

View file

@ -775,6 +775,174 @@
padding: 0; padding: 0;
font-size: var(--font-size-sm); 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) ----------------------------- // --- Favorite star toggle (detail panel header) -----------------------------

View file

@ -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 (
<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" onClick={() => onSelect(item.id)}>
{item.label}
</button>
</li>
))}
</ul>
)}
</div>
);
}

View file

@ -76,10 +76,45 @@ export function StepDescription({
previousTechStepId: number | null; previousTechStepId: number | null;
} | null>(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() { function handleMouseUp() {
if (!editable) return; if (!editable) return;
const range = getSelectionRange(); const range = getSelectionRange();
if (!range) return; 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({ setActiveCorrection({
range, range,
selectedText: description.slice(range.start, range.end), selectedText: description.slice(range.start, range.end),
@ -91,6 +126,21 @@ export function StepDescription({
setLiveTechSteps(result.techSteps); 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 // Tracks each segment's own absolute start offset into `description` as
// the map below walks them in order — segments are contiguous and cover // the map below walks them in order — segments are contiguous and cover
// the whole description (see `splitDescriptionByTechSteps`'s doc // the whole description (see `splitDescriptionByTechSteps`'s doc
@ -154,12 +204,19 @@ export function StepDescription({
data-offset={editable ? start : undefined} data-offset={editable ? start : undefined}
onClick={ onClick={
editable 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({ setActiveCorrection({
range: { start, end }, range: { start, end },
selectedText: segment.text, selectedText: segment.text,
previousTechStepId: techStep.id, previousTechStepId: techStep.id,
}) });
}
: undefined : undefined
} }
> >
@ -176,7 +233,11 @@ export function StepDescription({
range={activeCorrection.range} range={activeCorrection.range}
selectedText={activeCorrection.selectedText} selectedText={activeCorrection.selectedText}
previousTechStepId={activeCorrection.previousTechStepId} previousTechStepId={activeCorrection.previousTechStepId}
onClose={() => setActiveCorrection(null)} existingIngredients={activeStepTechStep?.ingredients ?? []}
existingUtensils={activeStepTechStep?.utensils ?? []}
resolvedMetadataSpan={resolvedMetadataSpan}
onRequestSpan={setPendingSpanRequest}
onClose={closeActiveCorrection}
onSubmitted={handleSubmitted} onSubmitted={handleSubmitted}
/> />
)} )}

View file

@ -1,14 +1,48 @@
import { import {
ErrorCode, ErrorCode,
type IngredientView,
type StepTechStepIngredientView,
type StepTechStepUtensilView,
type SubmitTechStepCorrectionResult, type SubmitTechStepCorrectionResult,
type TechStepView, type TechStepView,
type UnitView,
type UtensilView,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ApiError, apiClient } from "../../../api/client"; import { ApiError, apiClient } from "../../../api/client";
import { errorMessageService } from "../../../services/error-message.service"; import { errorMessageService } from "../../../services/error-message.service";
import { CatalogSearchPicker } from "./CatalogSearchPicker";
import type { TextSelectionRange } from "./use-text-selection"; 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 * 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 * 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 * `StepDescription.tsx`), not floating anchored at the selection's exact
* position simpler and more robust than tracking a caret-anchored * position simpler and more robust than tracking a caret-anchored
* position across scroll/resize, at the cost of a little visual distance * 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 * **Removing** a match (`submit(null)`) stays a single immediate action
* real `StepTechStep` sequence as it records the correction (a `"manual"`- * nothing to attach when removing. **Picking/relabeling** a technique used
* tagged entry, see `StepTechStepCorrection`'s schema doc comment) and * to submit immediately too; it no longer does, so there's room to attach
* returns the fresh sequence, which `onSubmitted` hands back to * metadata before committing clicking a technique now only *selects* it
* `StepDescription` to render right away, styled differently from an * (`selectedTechStepId`), revealing the Ingrédients/Ustensiles sections and
* `"auto"` match. * 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({ export function TechStepCorrectionPopover({
recipeId, recipeId,
@ -36,6 +80,10 @@ export function TechStepCorrectionPopover({
selectedText, selectedText,
range, range,
previousTechStepId, previousTechStepId,
existingIngredients,
existingUtensils,
resolvedMetadataSpan,
onRequestSpan,
onClose, onClose,
onSubmitted, onSubmitted,
}: { }: {
@ -46,12 +94,67 @@ export function TechStepCorrectionPopover({
range: TextSelectionRange; 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. */ /** 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; 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; onClose: () => void;
onSubmitted: (result: SubmitTechStepCorrectionResult) => void; onSubmitted: (result: SubmitTechStepCorrectionResult) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
const [techSteps, setTechSteps] = useState<TechStepView[] | null>(null); const [techSteps, setTechSteps] = useState<TechStepView[] | null>(null);
const [selectedTechStepId, setSelectedTechStepId] = useState<number | null>(null);
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 [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(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(() => { useEffect(() => {
function handleClickOutside(e: MouseEvent) { function handleClickOutside(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
@ -80,7 +223,7 @@ export function TechStepCorrectionPopover({
return () => document.removeEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]); }, [onClose]);
async function submit(correctedTechStepId: number | null) { async function removeMatch() {
setIsSubmitting(true); setIsSubmitting(true);
setError(null); setError(null);
try { try {
@ -88,7 +231,7 @@ export function TechStepCorrectionPopover({
start: range.start, start: range.start,
end: range.end, end: range.end,
previousTechStepId, previousTechStepId,
correctedTechStepId, correctedTechStepId: null,
}); });
onSubmitted(result); onSubmitted(result);
onClose(); 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 ( return (
<div className="tech-step-correction-popover" ref={popoverRef}> <div className="tech-step-correction-popover" ref={popoverRef}>
<p className="tech-step-correction-popover__selection"> <p className="tech-step-correction-popover__selection">
{t("recipes.techStepCorrection.selectionLabel", { text: selectedText })} {t("recipes.techStepCorrection.selectionLabel", { text: selectedText })}
</p> </p>
{techSteps === null ? (
<p>{t("recipes.loading")}</p> {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>
) : selectedTechStepId === null ? (
<ul className="tech-step-correction-popover__list"> <ul className="tech-step-correction-popover__list">
{previousTechStepId !== null && ( {previousTechStepId !== null && (
<li> <li>
<button <button
type="button" type="button"
disabled={isSubmitting} disabled={isSubmitting}
onClick={() => submit(null)} onClick={removeMatch}
className="tech-step-correction-popover__remove" className="tech-step-correction-popover__remove"
> >
{t("recipes.techStepCorrection.removeMatch")} {t("recipes.techStepCorrection.removeMatch")}
@ -124,15 +409,98 @@ export function TechStepCorrectionPopover({
<li key={techStep.id}> <li key={techStep.id}>
<button <button
type="button" type="button"
disabled={isSubmitting || techStep.id === previousTechStepId} disabled={isSubmitting}
onClick={() => submit(techStep.id)} onClick={() => setSelectedTechStepId(techStep.id)}
> >
{t(`catalog.techSteps.${techStep.key}`)} {t(`catalog.techSteps.${techStep.key}`)}
</button> </button>
</li> </li>
))} ))}
</ul> </ul>
) : (
<div className="tech-step-correction-popover__confirm">
<p className="tech-step-correction-popover__chosen-technique">
{t(
`catalog.techSteps.${techSteps.find((ts) => ts.id === selectedTechStepId)?.key ?? ""}`,
)} )}
<button
type="button"
className="tech-step-correction-popover__change"
onClick={() => setSelectedTechStepId(null)}
disabled={isSubmitting}
>
{t("recipes.techStepCorrection.changeTechnique")}
</button>
</p>
<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"
onClick={() => removeIngredient(index)}
title={t("recipes.techStepCorrection.removeIngredient")}
disabled={isSubmitting}
>
</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}
>
{t("recipes.techStepCorrection.confirm")}
</button>
</div>
)}
{error && <p className="field-error">{error}</p>} {error && <p className="field-error">{error}</p>}
<button <button
type="button" type="button"

View file

@ -44,6 +44,7 @@
"SOURCE_NOT_FOUND": "Une des sources sélectionnées n'existe pas", "SOURCE_NOT_FOUND": "Une des sources sélectionnées n'existe pas",
"STEP_NOT_FOUND": "Cette étape n'existe pas", "STEP_NOT_FOUND": "Cette étape n'existe pas",
"TECH_STEP_NOT_FOUND": "Cette technique 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", "INVALID_CORRECTION_SPAN": "La sélection ne correspond plus au texte de l'étape",
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard" "INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
}, },
@ -168,7 +169,20 @@
"removeMatch": "Aucune technique ici", "removeMatch": "Aucune technique ici",
"cancel": "Annuler", "cancel": "Annuler",
"manualTooltip": "{{technique}} (correction manuelle)", "manualTooltip": "{{technique}} (correction manuelle)",
"discoverabilityHint": "💡 Sélectionnez du texte, ou cliquez sur une technique surlignée, pour la corriger." "discoverabilityHint": "💡 Sélectionnez du texte, ou cliquez sur une technique surlignée, pour la corriger.",
"changeTechnique": "Changer",
"confirm": "Valider",
"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": { "tabs": {
"favoris": "Favoris", "favoris": "Favoris",

View file

@ -68,6 +68,8 @@ export enum ErrorCode {
STEP_NOT_FOUND = 4050, STEP_NOT_FOUND = 4050,
/** A tech-step correction's `previousTechStepId`/`correctedTechStepId` doesn't match any reference `TechStep` row. */ /** A tech-step correction's `previousTechStepId`/`correctedTechStepId` doesn't match any reference `TechStep` row. */
TECH_STEP_NOT_FOUND = 4051, 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`. */ /** A tech-step correction's `start`/`end` span falls outside the target step's `description`, or `start >= end`. */
INVALID_CORRECTION_SPAN = 4002, INVALID_CORRECTION_SPAN = 4002,
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */ /** 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. */ /** Inferred TS type for {@link listRecipesSchema}'s validated output. */
export type ListRecipesInput = z.infer<typeof listRecipesSchema>; 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 * Payload accepted by `POST /recipes/:id/steps/:stepId/corrections` a
* user asserting what technique a `[start, end)` span of a step's * 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 * (`recipe-tech-step-correction.service.ts`) needs the target step's
* `description` length to validate `start`/`end` against, which this shape * `description` length to validate `start`/`end` against, which this shape
* alone can't see. * 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 export const submitTechStepCorrectionSchema = z
.object({ .object({
@ -136,6 +184,8 @@ export const submitTechStepCorrectionSchema = z
end: z.number().int().nonnegative(), end: z.number().int().nonnegative(),
previousTechStepId: z.number().int().positive().nullable().optional(), previousTechStepId: z.number().int().positive().nullable().optional(),
correctedTechStepId: 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, { .refine((input) => input.end > input.start, {
message: "end must be greater than 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", message: "at least one of previousTechStepId/correctedTechStepId is required",
path: ["correctedTechStepId"], 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. */ /** Inferred TS type for {@link submitTechStepCorrectionSchema}'s validated output. */
export type SubmitTechStepCorrectionInput = z.infer<typeof submitTechStepCorrectionSchema>; 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 * `tech-step-matcher.ts`'s `IngredientMention`. `start`/`end` are the
* mention's own span in the step's `description`, same `[start, end)` * mention's own span in the step's `description`, same `[start, end)`
* convention as {@link StepTechStepView.start}. * 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 { export interface StepTechStepIngredientView {
ingredient: IngredientView; ingredient: IngredientView;
@ -88,13 +92,15 @@ export interface StepTechStepIngredientView {
unit: UnitView | null; unit: UnitView | null;
start: number; start: number;
end: 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 { export interface StepTechStepUtensilView {
utensil: UtensilView; utensil: UtensilView;
start: number; start: number;
end: number; end: number;
source: "auto" | "manual";
} }
/** /**