Merge pull request 'feat(recipes): permet de corriger la température d'un tech-step' (#29) from feat/tech-step-temperature-correction into main
This commit is contained in:
commit
81cdc55673
12 changed files with 1652 additions and 387 deletions
|
|
@ -32,22 +32,24 @@ const correctionInclude = {
|
|||
} satisfies Prisma.StepTechStepCorrectionInclude;
|
||||
|
||||
/**
|
||||
* Loads `stepId`'s current `description` length (the only thing a
|
||||
* correction needs from the step itself), or throws — `404 STEP_NOT_FOUND`
|
||||
* if no such step exists, or if it exists but doesn't belong to `recipeId`
|
||||
* (the route's own `:id`/`:stepId` nesting is meaningless otherwise — a
|
||||
* request naming a real step under the wrong recipe should look identical
|
||||
* to naming one that doesn't exist, same "don't leak which part was wrong"
|
||||
* posture `assertRecipeVisible` already has for visibility). Otherwise
|
||||
* whatever {@link assertRecipeVisible} throws (`404 RECIPE_NOT_FOUND`,
|
||||
* never `403`) if the recipe exists but isn't visible to the viewer.
|
||||
* Loads `stepId`'s current `description` (a correction needs its full text
|
||||
* now, not just its length — see {@link submitTechStepCorrection}'s
|
||||
* `raw` derivation for a manual temperature mention), or throws —
|
||||
* `404 STEP_NOT_FOUND` if no such step exists, or if it exists but doesn't
|
||||
* belong to `recipeId` (the route's own `:id`/`:stepId` nesting is
|
||||
* meaningless otherwise — a request naming a real step under the wrong
|
||||
* recipe should look identical to naming one that doesn't exist, same
|
||||
* "don't leak which part was wrong" posture `assertRecipeVisible` already
|
||||
* has for visibility). Otherwise whatever {@link assertRecipeVisible}
|
||||
* throws (`404 RECIPE_NOT_FOUND`, never `403`) if the recipe exists but
|
||||
* isn't visible to the viewer.
|
||||
*/
|
||||
async function loadVisibleStepOrThrow(
|
||||
recipeId: number,
|
||||
stepId: number,
|
||||
viewerId: number,
|
||||
viewerHouseId: number | null,
|
||||
): Promise<{ id: number; descriptionLength: number }> {
|
||||
): Promise<{ id: number; description: string }> {
|
||||
try {
|
||||
const step = await prisma.step.findUnique({
|
||||
where: { id: stepId },
|
||||
|
|
@ -57,7 +59,7 @@ async function loadVisibleStepOrThrow(
|
|||
throw new HttpError(404, ErrorCode.STEP_NOT_FOUND, `Step ${stepId} not found`);
|
||||
}
|
||||
await assertRecipeVisible(step.recipeId, viewerId, viewerHouseId);
|
||||
return { id: step.id, descriptionLength: step.description.length };
|
||||
return { id: step.id, description: step.description };
|
||||
} catch (err) {
|
||||
throw err; // see recipe.service.ts's equivalent catch comment
|
||||
}
|
||||
|
|
@ -208,6 +210,21 @@ interface ManualUtensilMention {
|
|||
start: number;
|
||||
end: number;
|
||||
}
|
||||
/**
|
||||
* Same as {@link ManualIngredientMention}, for a temperature — `raw` is
|
||||
* already resolved by the caller ({@link submitTechStepCorrection}, which
|
||||
* has the step's full `description` text to slice) rather than trusted
|
||||
* from the client; see `manualStepTechStepTemperatureInputSchema`'s own
|
||||
* doc comment (`packages/shared`) for why.
|
||||
*/
|
||||
interface ManualTemperatureMention {
|
||||
celsius: number | null;
|
||||
gasMark: number | null;
|
||||
qualitative: "low" | "medium" | "high" | null;
|
||||
raw: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a correction's *effect* on `stepId`'s real `StepTechStep`
|
||||
|
|
@ -233,16 +250,16 @@ interface ManualUtensilMention {
|
|||
*
|
||||
* `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).
|
||||
* replaces *every* `StepTechStepIngredient`/`StepTechStepUtensil`/
|
||||
* `StepTechStepTemperature` 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` all
|
||||
* three 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 any of these
|
||||
|
|
@ -255,7 +272,11 @@ async function applyManualCorrection(
|
|||
span: { start: number; end: number },
|
||||
previousTechStepId: number | null,
|
||||
correctedTechStepId: number | null,
|
||||
metadata?: { ingredients: ManualIngredientMention[]; utensils: ManualUtensilMention[] },
|
||||
metadata?: {
|
||||
ingredients: ManualIngredientMention[];
|
||||
utensils: ManualUtensilMention[];
|
||||
temperatures: ManualTemperatureMention[];
|
||||
},
|
||||
): Promise<void> {
|
||||
const existing = await tx.stepTechStep.findMany({ where: { stepId } });
|
||||
|
||||
|
|
@ -329,6 +350,22 @@ async function applyManualCorrection(
|
|||
})),
|
||||
});
|
||||
}
|
||||
await tx.stepTechStepTemperature.deleteMany({ where: { stepId, techStepOrder: order } });
|
||||
if (metadata.temperatures.length > 0) {
|
||||
await tx.stepTechStepTemperature.createMany({
|
||||
data: metadata.temperatures.map((temperature) => ({
|
||||
stepId,
|
||||
techStepOrder: order,
|
||||
celsius: temperature.celsius,
|
||||
gasMark: temperature.gasMark,
|
||||
qualitative: temperature.qualitative,
|
||||
raw: temperature.raw,
|
||||
start: temperature.start,
|
||||
end: temperature.end,
|
||||
source: "manual",
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (target) {
|
||||
await tx.stepTechStep.delete({ where: { stepId_order: { stepId, order: target.order } } });
|
||||
|
|
@ -366,10 +403,11 @@ 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` (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
|
||||
* `input.ingredients`/`input.utensils`/`input.temperatures`' 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(
|
||||
|
|
@ -381,18 +419,20 @@ export async function submitTechStepCorrection(
|
|||
): Promise<SubmitTechStepCorrectionResult> {
|
||||
try {
|
||||
const step = await loadVisibleStepOrThrow(recipeId, stepId, correctorId, viewerHouseId);
|
||||
const descriptionLength = step.description.length;
|
||||
|
||||
const spans = [
|
||||
{ start: input.start, end: input.end },
|
||||
...(input.ingredients ?? []),
|
||||
...(input.utensils ?? []),
|
||||
...(input.temperatures ?? []),
|
||||
];
|
||||
for (const span of spans) {
|
||||
if (span.start >= step.descriptionLength || span.end > step.descriptionLength) {
|
||||
if (span.start >= descriptionLength || span.end > descriptionLength) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
ErrorCode.INVALID_CORRECTION_SPAN,
|
||||
`Span [${span.start}, ${span.end}) falls outside step ${stepId}'s description (length ${step.descriptionLength})`,
|
||||
`Span [${span.start}, ${span.end}) falls outside step ${stepId}'s description (length ${descriptionLength})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -428,7 +468,9 @@ export async function submitTechStepCorrection(
|
|||
{ start: input.start, end: input.end },
|
||||
input.previousTechStepId ?? null,
|
||||
input.correctedTechStepId ?? null,
|
||||
input.ingredients === undefined && input.utensils === undefined
|
||||
input.ingredients === undefined &&
|
||||
input.utensils === undefined &&
|
||||
input.temperatures === undefined
|
||||
? undefined
|
||||
: {
|
||||
ingredients: (input.ingredients ?? []).map((ingredient) => ({
|
||||
|
|
@ -443,6 +485,16 @@ export async function submitTechStepCorrection(
|
|||
start: utensil.start,
|
||||
end: utensil.end,
|
||||
})),
|
||||
// `raw` is derived here (not sent by the client) — see
|
||||
// `ManualTemperatureMention`'s own doc comment.
|
||||
temperatures: (input.temperatures ?? []).map((temperature) => ({
|
||||
celsius: temperature.celsius ?? null,
|
||||
gasMark: temperature.gasMark ?? null,
|
||||
qualitative: temperature.qualitative ?? null,
|
||||
raw: step.description.slice(temperature.start, temperature.end),
|
||||
start: temperature.start,
|
||||
end: temperature.end,
|
||||
})),
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -518,6 +518,191 @@ describe("Recipe tech-step corrections", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("POST /recipes/:id/steps/:stepId/corrections — temperature metadata", () => {
|
||||
it("attaches a manually-selected celsius temperature, deriving raw from the step's own description", async () => {
|
||||
const { agent, profileId } = await signup();
|
||||
const description = "Préchauffez à 180°C.";
|
||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId, description);
|
||||
const preheatId = await techStepId("preheat");
|
||||
|
||||
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
|
||||
start: 0,
|
||||
end: 11,
|
||||
correctedTechStepId: preheatId,
|
||||
temperatures: [{ celsius: 180, start: 14, end: 19 }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(201);
|
||||
expect(res.body.techSteps).to.deep.equal([
|
||||
{
|
||||
techStep: { id: preheatId, key: "preheat" },
|
||||
start: 0,
|
||||
end: 11,
|
||||
source: "manual",
|
||||
ingredients: [],
|
||||
utensils: [],
|
||||
temperatures: [
|
||||
{
|
||||
celsius: 180,
|
||||
gasMark: null,
|
||||
qualitative: null,
|
||||
// Not sent by the client — derived server-side from the span
|
||||
// against the step's real description (see
|
||||
// `ManualTemperatureMention`'s doc comment).
|
||||
raw: "180°C",
|
||||
start: 14,
|
||||
end: 19,
|
||||
source: "manual",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("attaches a manually-selected qualitative temperature", async () => {
|
||||
const { agent, profileId } = await signup();
|
||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
||||
const simmerId = await techStepId("simmer");
|
||||
|
||||
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
|
||||
start: 6,
|
||||
end: 13,
|
||||
correctedTechStepId: simmerId,
|
||||
temperatures: [{ qualitative: "low", start: 0, end: 5 }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(201);
|
||||
expect(res.body.techSteps[0].temperatures).to.deep.equal([
|
||||
{
|
||||
celsius: null,
|
||||
gasMark: null,
|
||||
qualitative: "low",
|
||||
raw: "Faire",
|
||||
start: 0,
|
||||
end: 5,
|
||||
source: "manual",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("attaches a manually-selected gas-mark temperature", async () => {
|
||||
const { agent, profileId } = await signup();
|
||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
||||
const simmerId = await techStepId("simmer");
|
||||
|
||||
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
|
||||
start: 6,
|
||||
end: 13,
|
||||
correctedTechStepId: simmerId,
|
||||
temperatures: [{ gasMark: 6, start: 0, end: 5 }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(201);
|
||||
expect(res.body.techSteps[0].temperatures[0]).to.deep.equal({
|
||||
celsius: null,
|
||||
gasMark: 6,
|
||||
qualitative: null,
|
||||
raw: "Faire",
|
||||
start: 0,
|
||||
end: 5,
|
||||
source: "manual",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a temperature with more than one of celsius/gasMark/qualitative set with 400 VALIDATION_ERROR", 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"),
|
||||
temperatures: [{ celsius: 180, qualitative: "high", start: 0, end: 5 }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(400);
|
||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||
});
|
||||
|
||||
it("rejects a temperature with none of celsius/gasMark/qualitative set with 400 VALIDATION_ERROR", 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"),
|
||||
temperatures: [{ start: 0, end: 5 }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(400);
|
||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||
});
|
||||
|
||||
it("replaces both auto-detected and previously-manual temperatures 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");
|
||||
|
||||
await agent
|
||||
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
|
||||
.send({ start: 6, end: 13, correctedTechStepId: simmerId });
|
||||
// Simulate an auto-detected temperature already sitting on the
|
||||
// occurrence, exactly as `toStepTechStepCreateInputs` would have
|
||||
// written one at save time.
|
||||
await prisma.stepTechStepTemperature.create({
|
||||
data: {
|
||||
stepId,
|
||||
techStepOrder: 0,
|
||||
celsius: 100,
|
||||
raw: "100°C",
|
||||
start: 0,
|
||||
end: 5,
|
||||
source: "auto",
|
||||
},
|
||||
});
|
||||
|
||||
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
|
||||
start: 6,
|
||||
end: 13,
|
||||
previousTechStepId: simmerId,
|
||||
correctedTechStepId: boilId,
|
||||
temperatures: [{ gasMark: 6, start: 0, end: 5 }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(201);
|
||||
expect(res.body.techSteps).to.have.length(1);
|
||||
expect(res.body.techSteps[0].temperatures).to.deep.equal([
|
||||
{
|
||||
celsius: null,
|
||||
gasMark: 6,
|
||||
qualitative: null,
|
||||
raw: "Faire",
|
||||
start: 0,
|
||||
end: 5,
|
||||
source: "manual",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects a temperature 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"),
|
||||
temperatures: [{ celsius: 180, 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();
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ const butter = { id: 10, key: "butter" };
|
|||
const pan = { id: 20, key: "pan" };
|
||||
const gram = { id: 30, key: "gram" };
|
||||
|
||||
/** Long enough that `range={start:0,end:5}` ("Cuire") and a resolved metadata span at `[20,26)` ("Beurre") both fall within it — see {@link Harness}'s fixed `onRequestSpan` stub. */
|
||||
const DESCRIPTION = "Cuire un peu de beurre.";
|
||||
|
||||
/**
|
||||
* A real `StepDescription` resolves `onRequestSpan` into a fresh
|
||||
* `resolvedMetadataSpan` via an actual browser text selection — out of
|
||||
|
|
@ -29,18 +32,20 @@ function Harness({
|
|||
previousTechStepId = null,
|
||||
existingIngredients = [],
|
||||
existingUtensils = [],
|
||||
existingTemperatures = [],
|
||||
onClose = () => {},
|
||||
onSubmitted = () => {},
|
||||
}: Partial<{
|
||||
previousTechStepId: number | null;
|
||||
existingIngredients: unknown[];
|
||||
existingUtensils: unknown[];
|
||||
existingTemperatures: unknown[];
|
||||
onClose: () => void;
|
||||
onSubmitted: (result: unknown) => void;
|
||||
}>) {
|
||||
const [resolvedMetadataSpan, setResolvedMetadataSpan] = useState<{
|
||||
nonce: number;
|
||||
kind: "ingredient" | "utensil";
|
||||
kind: "ingredient" | "utensil" | "temperature";
|
||||
range: { start: number; end: number };
|
||||
text: string;
|
||||
} | null>(null);
|
||||
|
|
@ -52,13 +57,16 @@ function Harness({
|
|||
<TechStepCorrectionPopover
|
||||
recipeId={2}
|
||||
stepId={2}
|
||||
description={DESCRIPTION}
|
||||
selectedText="Cuire"
|
||||
range={{ start: 0, end: 5 }}
|
||||
previousTechStepId={previousTechStepId}
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test harness stands in for real StepTechStepIngredientView/UtensilView props — precise typing isn't the point here.
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test harness stands in for real StepTechStepIngredientView/UtensilView/TemperatureView props — precise typing isn't the point here.
|
||||
existingIngredients={existingIngredients as any}
|
||||
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
||||
existingUtensils={existingUtensils as any}
|
||||
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
||||
existingTemperatures={existingTemperatures as any}
|
||||
resolvedMetadataSpan={resolvedMetadataSpan}
|
||||
onRequestSpan={(kind) =>
|
||||
setResolvedMetadataSpan({
|
||||
|
|
@ -90,34 +98,40 @@ describe("TechStepCorrectionPopover", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("shows the selected text, the technique catalog (searchable) and the metadata sections all together", () => {
|
||||
it("shows the selected text, the technique picker (searchable) and all three metadata sections together", () => {
|
||||
cy.mount(<Harness />);
|
||||
cy.wait("@getTechSteps");
|
||||
|
||||
cy.contains(".tech-step-correction-popover__selection", "Cuire").should("be.visible");
|
||||
// Merged editor (see TechStepCorrectionPopover's own doc comment) — no
|
||||
// separate "pick, then metadata reveals itself" step, both render at
|
||||
// once, and the technique catalog goes through the same searchable
|
||||
// `CatalogSearchPicker` as the ingredient/utensil sub-flows (a plain
|
||||
// unfiltered list of the real ~74-entry catalog isn't browsable).
|
||||
// separate "pick, then metadata reveals itself" step, all four
|
||||
// sections render at once, and the technique catalog goes through the
|
||||
// same searchable `CatalogSearchPicker` as the ingredient/utensil
|
||||
// sub-flows (a plain unfiltered list of the real ~74-entry catalog
|
||||
// isn't browsable) — visible by default since nothing's picked yet.
|
||||
cy.get(
|
||||
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
||||
).should("have.length", 2);
|
||||
cy.contains("h4", "Ingrédients").should("be.visible");
|
||||
cy.contains("h4", "Ustensiles").should("be.visible");
|
||||
cy.contains(".tech-step-correction-popover__section-title", "Ingrédients").should("be.visible");
|
||||
cy.contains(".tech-step-correction-popover__section-title", "Ustensiles").should("be.visible");
|
||||
cy.contains(".tech-step-correction-popover__section-title", "Température").should("be.visible");
|
||||
cy.contains("button", "Valider").should("be.visible");
|
||||
});
|
||||
|
||||
it("offers a 'no technique here' option, and marks the current pick, only when correcting an existing match", () => {
|
||||
cy.mount(<Harness previousTechStepId={null} />);
|
||||
cy.wait("@getTechSteps");
|
||||
cy.get(".tech-step-correction-popover__remove").should("not.exist");
|
||||
cy.get(".tech-step-correction-popover__remove-match").should("not.exist");
|
||||
cy.contains(".tech-step-correction-popover__chosen-technique", "Aucune technique sélectionnée");
|
||||
|
||||
cy.mount(<Harness previousTechStepId={cook.id} />);
|
||||
cy.wait("@getTechSteps");
|
||||
cy.get(".tech-step-correction-popover__remove").should("exist");
|
||||
cy.get(".tech-step-correction-popover__remove-match").should("exist");
|
||||
cy.contains(".tech-step-correction-popover__chosen-technique", "Cuire");
|
||||
// The picker itself hides behind "Changer" once something's already
|
||||
// picked (see TechStepCorrectionPopover's own doc comment) — reopened
|
||||
// here just to confirm it still marks the current pick correctly.
|
||||
cy.contains("button", "Changer").click();
|
||||
cy.contains(".catalog-search-picker__list button", "Cuire").should(
|
||||
"have.class",
|
||||
"catalog-search-picker__item--selected",
|
||||
|
|
@ -149,7 +163,7 @@ describe("TechStepCorrectionPopover", () => {
|
|||
cy.contains("button", "Valider").should("not.be.disabled");
|
||||
});
|
||||
|
||||
it("submits the selected technique (no metadata touched) with ingredients/utensils omitted from the request", () => {
|
||||
it("submits the selected technique (no metadata touched) with ingredients/utensils/temperatures omitted from the request", () => {
|
||||
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
|
||||
statusCode: 201,
|
||||
body: {
|
||||
|
|
@ -200,14 +214,16 @@ describe("TechStepCorrectionPopover", () => {
|
|||
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
||||
"Mijoter",
|
||||
).click();
|
||||
cy.contains("button", "+ Ajouter un ingrédient").click();
|
||||
cy.contains("button", "Ajouter un ingrédient").click();
|
||||
|
||||
cy.contains(".catalog-search-picker button", "Beurre").click();
|
||||
cy.get('input[type="number"]').type("50");
|
||||
cy.get("select").select(String(gram.id));
|
||||
cy.contains("button", "Ajouter").click();
|
||||
cy.contains(".tech-step-correction-popover__add-form button", "Ajouter").click();
|
||||
|
||||
cy.contains(".tech-step-correction-popover__chip", "50 g Beurre").should("be.visible");
|
||||
cy.contains(".tech-step-correction-popover__row", "Beurre")
|
||||
.should("contain.text", "Beurre")
|
||||
.and("contain.text", "50 g");
|
||||
cy.contains("button", "Valider").click();
|
||||
|
||||
cy.wait("@submitCorrection")
|
||||
|
|
@ -221,28 +237,85 @@ describe("TechStepCorrectionPopover", () => {
|
|||
{ ingredientId: butter.id, quantity: 50, unitId: gram.id, start: 20, end: 26 },
|
||||
],
|
||||
utensils: [],
|
||||
temperatures: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("pre-seeds existing ingredients/utensils, removable via their own chip", () => {
|
||||
it("adds a temperature via the span-selection flow and its 3-way mode switch, 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__technique-section .catalog-search-picker__list button",
|
||||
"Mijoter",
|
||||
).click();
|
||||
cy.contains("button", "Ajouter une température").click();
|
||||
|
||||
// Defaults to the qualitative mode — switching to °C swaps the input
|
||||
// shown, never both at once (mutually exclusive, see
|
||||
// `manualStepTechStepTemperatureInputSchema`'s own doc comment,
|
||||
// packages/shared).
|
||||
cy.contains(".tech-step-correction-popover__mode-switch button", "°C").click();
|
||||
cy.get('.tech-step-correction-popover__value-line input[type="number"]').type("180");
|
||||
cy.contains(".tech-step-correction-popover__add-form button", "Ajouter").click();
|
||||
|
||||
cy.contains(".tech-step-correction-popover__row", "180")
|
||||
.should("contain.text", "°C")
|
||||
.and("contain.text", "Beurre"); // the (stubbed) source quote
|
||||
cy.contains("button", "Valider").click();
|
||||
|
||||
cy.wait("@submitCorrection")
|
||||
.its("request.body")
|
||||
.should("deep.equal", {
|
||||
start: 0,
|
||||
end: 5,
|
||||
previousTechStepId: null,
|
||||
correctedTechStepId: simmer.id,
|
||||
ingredients: [],
|
||||
utensils: [],
|
||||
temperatures: [{ celsius: 180, gasMark: null, qualitative: null, start: 20, end: 26 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("pre-seeds existing ingredients/utensils/temperatures, each removable via its own row", () => {
|
||||
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" }]}
|
||||
existingUtensils={[{ utensil: pan, start: 9, end: 15, source: "auto" }]}
|
||||
existingTemperatures={[
|
||||
{ celsius: null, gasMark: null, qualitative: "low", start: 16, end: 22, source: "auto" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
// An existing match starts pre-selected on itself (see
|
||||
// TechStepCorrectionPopover's own doc comment) — the metadata sections,
|
||||
// pre-seeded from `existingIngredients`/`existingUtensils`, are visible
|
||||
// immediately, no need to re-pick "Cuire" from a list first.
|
||||
// TechStepCorrectionPopover's own doc comment) — the metadata
|
||||
// sections, pre-seeded from `existingIngredients`/`existingUtensils`/
|
||||
// `existingTemperatures`, are visible immediately, no need to re-pick
|
||||
// "Cuire" from a list first.
|
||||
cy.wait("@getTechSteps");
|
||||
cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]);
|
||||
|
||||
cy.contains(".tech-step-correction-popover__chip", "50 g Beurre").find("button").click();
|
||||
cy.contains(".tech-step-correction-popover__chip", "Beurre").should("not.exist");
|
||||
cy.contains(".tech-step-correction-popover__row", "Beurre").should("be.visible");
|
||||
cy.contains(".tech-step-correction-popover__row", "Feu doux").should("be.visible");
|
||||
|
||||
cy.contains(".tech-step-correction-popover__row", "Beurre")
|
||||
.find(".tech-step-correction-popover__row-remove")
|
||||
.click();
|
||||
cy.contains(".tech-step-correction-popover__row", "Beurre").should("not.exist");
|
||||
});
|
||||
|
||||
it("shows an error message and stays open when the submission fails", () => {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,21 @@ Feature: Managing a recipe from the catalog
|
|||
When I focus the highlighted technique "Cuire"
|
||||
Then the tooltip should show "Mijoter (correction manuelle)"
|
||||
|
||||
Scenario: Clicking a highlighted temperature reopens its technique's correction popover
|
||||
# Same master-detail room requirement as recipes.cy.ts's own equivalent
|
||||
# test — Cypress's default 1000×660 doesn't leave the detail panel
|
||||
# (with the popover open below its step) enough height to show
|
||||
# everything without its own scroll.
|
||||
Given the viewport is 1600 by 900
|
||||
And the recipe catalog contains "Gratin"
|
||||
And recipe 4's detail is available
|
||||
And the tech steps reference list includes preheat
|
||||
When I visit "/recettes/4"
|
||||
Then I should see the highlighted temperature "feu moyen"
|
||||
When I click the highlighted temperature "feu moyen"
|
||||
Then I should see the technique correction options
|
||||
And the chosen technique should be "Préchauffer"
|
||||
|
||||
Scenario: Deletes a recipe after a two-step confirmation, then clears the selection
|
||||
Given the recipe catalog contains "Omelette"
|
||||
And recipe 2's detail is available
|
||||
|
|
|
|||
|
|
@ -48,6 +48,50 @@ const omeletteDetail = {
|
|||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* A separate, self-contained fixture for the "clicking a temperature
|
||||
* highlight" scenario — kept apart from `omeletteDetail` above rather than
|
||||
* adding a temperature to its "Cuire" occurrence, so this doesn't entangle
|
||||
* with that fixture's own "Corrects a detected technique" scenario (which
|
||||
* relabels the same occurrence without touching metadata, and must keep
|
||||
* meaning "nothing here changed").
|
||||
*/
|
||||
const gratinDetail = {
|
||||
id: 4,
|
||||
name: "Gratin",
|
||||
description: null,
|
||||
picture: null,
|
||||
portions: 4,
|
||||
authorId: 1,
|
||||
visibility: "PERSONAL",
|
||||
allergens: [],
|
||||
diets: [],
|
||||
isFavorite: false,
|
||||
ingredients: [],
|
||||
steps: [
|
||||
{
|
||||
id: 10,
|
||||
// "Préchauffez" = [0, 11), "à feu moyen" = [20, 31) — counted char by
|
||||
// char (accented/multi-byte characters here are each still one JS
|
||||
// string index, same convention `start`/`end` use everywhere else).
|
||||
description: "Préchauffez le four à feu moyen.",
|
||||
picture: null,
|
||||
order: 1,
|
||||
techSteps: [
|
||||
{
|
||||
techStep: { id: 9, key: "preheat" },
|
||||
start: 0,
|
||||
end: 11,
|
||||
source: "auto",
|
||||
temperatures: [
|
||||
{ celsius: null, gasMark: null, qualitative: "medium", start: 20, end: 31 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
Given("the disliked ingredients list is empty", () => {
|
||||
cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] });
|
||||
});
|
||||
|
|
@ -68,6 +112,23 @@ Given("deleting recipe 2 will succeed", () => {
|
|||
cy.intercept("DELETE", "**/recipes/2", { statusCode: 204 }).as("deleteRecipe");
|
||||
});
|
||||
|
||||
Given("recipe 4's detail is available", () => {
|
||||
cy.intercept("GET", "**/recipes/4", { statusCode: 200, body: gratinDetail });
|
||||
});
|
||||
|
||||
// TechStepCorrectionPopover fetches the full technique catalog on mount
|
||||
// (to resolve the chosen technique's own label) — a dedicated fixture
|
||||
// naming just `preheat` (`gratinDetail`'s own technique), kept separate
|
||||
// from "the tech steps reference list has options" above (that one's
|
||||
// `cook`/`simmer` pair is purpose-built for the *other* scenario's
|
||||
// relabel, not a generic catalog stand-in).
|
||||
Given("the tech steps reference list includes preheat", () => {
|
||||
cy.intercept("GET", "**/reference/tech-steps", {
|
||||
statusCode: 200,
|
||||
body: [{ id: 9, key: "preheat" }],
|
||||
});
|
||||
});
|
||||
|
||||
// Step 2 is `omeletteDetail`'s "Cuire à la poêle." step, whose only
|
||||
// existing match is `cook` (id 1) — see that fixture above. The response
|
||||
// mirrors `SubmitTechStepCorrectionResult` (packages/shared): the audit
|
||||
|
|
@ -96,6 +157,7 @@ Given('correcting step 2\'s "Cuire" match will succeed', () => {
|
|||
source: "manual",
|
||||
ingredients: [],
|
||||
utensils: [],
|
||||
temperatures: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -110,6 +172,23 @@ Then("I should see the technique correction options", () => {
|
|||
cy.get(".tech-step-correction-popover").should("be.visible");
|
||||
});
|
||||
|
||||
Then("I should see the highlighted temperature {string}", (text: string) => {
|
||||
// Same "off the fold, needs an explicit scroll" reasoning as
|
||||
// common.steps.ts's "I should see the highlighted technique" — a bare
|
||||
// `.should("be.visible")` doesn't auto-scroll.
|
||||
cy.contains(".step-temperature", text).scrollIntoView().should("be.visible");
|
||||
});
|
||||
|
||||
// Temperature is corrected through its owning technique's own popover, not
|
||||
// on its own — see `StepDescription.tsx`'s `findTechStepForTemperature`.
|
||||
When("I click the highlighted temperature {string}", (text: string) => {
|
||||
cy.contains(".step-temperature", text).click();
|
||||
});
|
||||
|
||||
Then("the chosen technique should be {string}", (label: string) => {
|
||||
cy.contains(".tech-step-correction-popover__chosen-technique", label).should("be.visible");
|
||||
});
|
||||
|
||||
// Picking a technique only *selects* it — it takes a separate "Valider"
|
||||
// click to actually submit (room was made for attaching ingredient/utensil
|
||||
// metadata alongside it, see `TechStepCorrectionPopover.tsx`'s own doc
|
||||
|
|
@ -118,8 +197,13 @@ Then("I should see the technique correction options", () => {
|
|||
// technique catalog is picked via the same searchable `CatalogSearchPicker`
|
||||
// the ingredient/utensil sub-flows use, scoped to
|
||||
// `__technique-section` since that same search-and-pick component is
|
||||
// reused inside this popover for more than just techniques.
|
||||
// reused inside this popover for more than just techniques. This step is
|
||||
// only ever used to *relabel* an existing match (this feature's one
|
||||
// scenario using it) — the picker hides behind "Changer" in that case
|
||||
// (nothing to decide once a technique's already confirmed correct, see
|
||||
// TechStepCorrectionPopover's own doc comment), so it's reopened first.
|
||||
When("I choose {string} as the correct technique", (label: string) => {
|
||||
cy.contains(".tech-step-correction-popover__technique-change", "Changer").click();
|
||||
cy.contains(
|
||||
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
||||
label,
|
||||
|
|
|
|||
|
|
@ -673,7 +673,10 @@
|
|||
text-decoration: underline dotted var(--color-primary);
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 0.15em;
|
||||
cursor: help;
|
||||
// A real clickable trigger (opens `TechStepCorrectionPopover`), not just
|
||||
// a hover-for-a-tooltip target — `pointer`, not `help`, same reasoning
|
||||
// now shared by `.step-temperature` below.
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
|
|
@ -702,17 +705,31 @@
|
|||
// `--color-primary`, so a temperature reads as its own kind of metadata at
|
||||
// a glance. `--low`/`--medium`/`--high` only vary the underline weight —
|
||||
// the qualitative scale is a nuance, not three different colors.
|
||||
//
|
||||
// Renders as a `<button>` (not a `<span>`) in editable mode when it has an
|
||||
// owning technique occurrence to reopen (see `StepDescription.tsx`'s
|
||||
// `findTechStepForTemperature`) — same button-reset-to-inline-text
|
||||
// treatment as `.step-tech-step` above (`font`/`color`/`margin`/`border`),
|
||||
// so the two read identically whether they're a `<button>` or a plain
|
||||
// `<span>` (read-only, or no owner to reopen).
|
||||
.step-temperature {
|
||||
display: inline;
|
||||
padding: 0 0.15em;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||
border: none;
|
||||
border-radius: 0.2em;
|
||||
text-decoration: underline dotted var(--color-accent);
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 0.15em;
|
||||
cursor: help;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: color-mix(in srgb, var(--color-accent) 22%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&--high {
|
||||
|
|
@ -746,6 +763,9 @@
|
|||
// in the document flow right below the step's own description instead of
|
||||
// floating anchored at the selection.
|
||||
.tech-step-correction-popover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
margin-top: var(--space-xs);
|
||||
padding: var(--space-md);
|
||||
background: var(--color-surface-alt);
|
||||
|
|
@ -754,30 +774,94 @@
|
|||
|
||||
&__selection {
|
||||
font-weight: 600;
|
||||
margin: 0 0 var(--space-sm);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// Technique picker + Ingrédients/Ustensiles render together as one
|
||||
// screen now (see `TechStepCorrectionPopover.tsx`'s own doc comment) —
|
||||
// this section just needs its own small header row, the actual picker
|
||||
// is `.catalog-search-picker` (below), reused as-is from the ingredient/
|
||||
// utensil sub-flows.
|
||||
&__technique-section {
|
||||
h4 {
|
||||
margin: 0 0 var(--space-xs);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
&__technique-header {
|
||||
// One shared shape for every section (Technique/Ingrédients/Ustensiles/
|
||||
// Température) — see `TechStepCorrectionPopover.tsx`'s own doc comment
|
||||
// for why all four render together, always, rather than one at a time.
|
||||
&__section {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
&__remove {
|
||||
&__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding-bottom: var(--space-xs);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Same "kicker" label language as `.cooking-phase__recipe-group-title`
|
||||
// (cooking-session-page.scss) — a small bold uppercase eyebrow, not a
|
||||
// full heading, since four of these stack in one popover.
|
||||
&__section-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: var(--font-size-sm);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
&__section-count {
|
||||
font-weight: 400;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
// One icon color per category — set on the icon itself (not the whole
|
||||
// section) so the title text next to it stays neutral/muted.
|
||||
&__technique-section &__section-header svg {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
&__ingredients-section &__section-header svg,
|
||||
&__utensils-section &__section-header svg {
|
||||
color: var(--color-tag);
|
||||
}
|
||||
&__temperature-section &__section-header svg {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
&__chosen-technique {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
&__technique-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-surface);
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
border-radius: var(--radius-pill);
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
&__technique-change {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&__remove-match {
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-error);
|
||||
|
|
@ -792,7 +876,8 @@
|
|||
}
|
||||
}
|
||||
|
||||
&__cancel {
|
||||
&__cancel,
|
||||
&__cancel-mini {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
|
|
@ -802,113 +887,262 @@
|
|||
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.
|
||||
// Shown in place of a section's own "+ Ajouter…" button while
|
||||
// `StepDescription` is waiting on a second text selection for *that*
|
||||
// section (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);
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
&__rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
// One attached ingredient/utensil/temperature — a small card of its own
|
||||
// (not a chip pill anymore: showing the source quote alongside the name
|
||||
// needs more room than a pill comfortably gives).
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-sm);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-base);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
&__row-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__row-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__row-quote {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
&__span-picker {
|
||||
&__row-badge {
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
background: var(--color-surface-alt);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 2px var(--space-sm);
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
&__row-remove {
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-base);
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--color-error);
|
||||
background: var(--color-surface-alt);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
// "+ Ajouter…" — a dashed ghost row, not a plain text link: at-a-glance
|
||||
// an "add a new item" affordance, same weight as the rows it adds to.
|
||||
&__add-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-xs);
|
||||
border: 1.5px dashed var(--color-border);
|
||||
border-radius: var(--radius-base);
|
||||
padding: var(--space-sm);
|
||||
color: var(--color-text-muted);
|
||||
background: none;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
// A section's own inline "adding a new mention" sub-form — replaces just
|
||||
// that section's `__add-row` button, every other section (and the
|
||||
// technique picker) stays visible and untouched alongside it.
|
||||
&__add-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-base);
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
&__quantity-line {
|
||||
&__add-form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
&__quantity-line,
|
||||
&__value-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
|
||||
input[type="number"] {
|
||||
width: 5rem;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-base);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
&__confirm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
// "Aucune technique sélectionnée."/"Technique retenue : X" — no button
|
||||
// here anymore (re-picking happens directly through the search picker
|
||||
// right below, see `TechStepCorrectionPopover.tsx`'s doc comment on the
|
||||
// merged editor), just a small status line.
|
||||
&__chosen-technique {
|
||||
margin: 0 0 var(--space-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__metadata-section {
|
||||
margin-top: var(--space-sm);
|
||||
|
||||
h4 {
|
||||
margin: 0 0 var(--space-xs);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
// "+ Ajouter…" button — deliberately a plain text-link style, not
|
||||
// another pill button (`.catalog-search-picker__list button`) — this
|
||||
// is a secondary action inside an already-open popover, not a
|
||||
// top-level choice competing with the chips above it.
|
||||
> button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
}
|
||||
|
||||
&__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
list-style: none;
|
||||
margin: 0 0 var(--space-xs);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&__chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: 0.3rem 0.6rem;
|
||||
&__value-unit {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
// Température's 3-way mode switch (°C / Thermostat / Qualitatif) — a
|
||||
// segmented control, same connected-pill language as the qualitative
|
||||
// sub-choice below, in the accent color (Vermillion, the "heat" accent —
|
||||
// same as `.step-temperature`) so the active mode reads at a glance.
|
||||
&__mode-switch {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: var(--color-surface-alt);
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 2px;
|
||||
|
||||
button {
|
||||
background: none;
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--color-error);
|
||||
&.active {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-surface);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__confirm-button {
|
||||
align-self: flex-start;
|
||||
padding: 0.4rem 1rem;
|
||||
&__qual-switch {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
border: 1.5px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-base);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
&.active {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-surface);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__confirm-mini {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-base);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 700;
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding-top: var(--space-xs);
|
||||
}
|
||||
|
||||
&__confirm-button {
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--color-surface);
|
||||
background: var(--color-primary);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--radius-base);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import type { StepTechStepView, SubmitTechStepCorrectionResult } from "@batch-cooking/shared";
|
||||
import type {
|
||||
StepTechStepTemperatureView,
|
||||
StepTechStepView,
|
||||
SubmitTechStepCorrectionResult,
|
||||
} from "@batch-cooking/shared";
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip } from "../../../components/ui/Tooltip";
|
||||
|
|
@ -7,6 +11,27 @@ import { TechStepCorrectionPopover } from "./TechStepCorrectionPopover";
|
|||
import { temperatureLabel } from "./temperature-label";
|
||||
import { type TextSelectionRange, useTextSelection } from "./use-text-selection";
|
||||
|
||||
/**
|
||||
* Finds which of `techSteps`' occurrences a given temperature mention
|
||||
* belongs to — the read-only highlight flattens every occurrence's
|
||||
* `temperatures` together (see this file's own `temperatures` above),
|
||||
* losing that association, so a click handler that needs to reopen "the
|
||||
* technique this temperature is attached to" re-derives it by matching
|
||||
* span. `undefined` when nothing matches (shouldn't happen for a real
|
||||
* persisted temperature, but degrades to "not clickable" rather than a
|
||||
* crash — same posture as `use-text-selection.ts`'s `resolveOffset`).
|
||||
*/
|
||||
function findTechStepForTemperature(
|
||||
techSteps: StepTechStepView[],
|
||||
temperature: StepTechStepTemperatureView,
|
||||
): StepTechStepView | undefined {
|
||||
return techSteps.find((techStep) =>
|
||||
(techStep.temperatures ?? []).some(
|
||||
(candidate) => candidate.start === temperature.start && candidate.end === temperature.end,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A recipe step's description, with every detected technique's exact
|
||||
* matched words highlighted and given a {@link Tooltip} naming the
|
||||
|
|
@ -32,10 +57,15 @@ import { type TextSelectionRange, useTextSelection } from "./use-text-selection"
|
|||
* color, so the two are visually distinguishable at a glance rather than
|
||||
* only via the tooltip text.
|
||||
*
|
||||
* `editable` (off by default) additionally lets the viewer select text or
|
||||
* click an existing highlight to open a {@link TechStepCorrectionPopover} —
|
||||
* see `use-text-selection.ts` for how a browser selection is translated
|
||||
* into an absolute `[start, end)` span. When `!editable`, every segment
|
||||
* `editable` (off by default) additionally lets the viewer select text, or
|
||||
* click an existing technique or temperature highlight, to open a
|
||||
* {@link TechStepCorrectionPopover} — a temperature highlight reopens
|
||||
* whichever technique occurrence it's attached to (see
|
||||
* `findTechStepForTemperature` above), since temperature is corrected
|
||||
* through that same popover, not on its own (same as ingredients/
|
||||
* utensils). See `use-text-selection.ts` for how a browser selection is
|
||||
* translated into an absolute `[start, end)` span. When `!editable`, every
|
||||
* segment
|
||||
* renders exactly as before (no extra wrapping elements, no `data-offset`,
|
||||
* no click handlers) — this mode is purely additive, not a rewrite of the
|
||||
* read-only rendering.
|
||||
|
|
@ -90,18 +120,18 @@ export function StepDescription({
|
|||
} | 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
|
||||
// (as an ingredient/utensil/temperature 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 [pendingSpanRequest, setPendingSpanRequest] = useState<
|
||||
"ingredient" | "utensil" | "temperature" | null
|
||||
>(null);
|
||||
const [resolvedMetadataSpan, setResolvedMetadataSpan] = useState<{
|
||||
nonce: number;
|
||||
kind: "ingredient" | "utensil";
|
||||
kind: "ingredient" | "utensil" | "temperature";
|
||||
range: TextSelectionRange;
|
||||
text: string;
|
||||
} | null>(null);
|
||||
|
|
@ -185,19 +215,55 @@ export function StepDescription({
|
|||
if (segment.temperature) {
|
||||
// A detected temperature mention ("180°C", "feu doux") — its
|
||||
// own subtle highlight + a Tooltip with the normalized value.
|
||||
// Not correctable (no popover), same as utensils; still gets a
|
||||
// `data-offset` in editable mode so a text selection spanning
|
||||
// it resolves correctly.
|
||||
// In editable mode, clicking it reopens the correction popover
|
||||
// for whichever technique occurrence it's attached to (found by
|
||||
// span, see `findTechStepForTemperature` — temperature is a
|
||||
// per-clause metadata like ingredients/utensils, corrected
|
||||
// through that technique's own popover, not on its own) — same
|
||||
// `data-offset` treatment either way, so a text selection
|
||||
// spanning it still resolves correctly.
|
||||
const label = temperatureLabel(segment.temperature, t);
|
||||
const modifier = segment.temperature.qualitative ?? "value";
|
||||
const owner = editable
|
||||
? findTechStepForTemperature(liveTechSteps, segment.temperature)
|
||||
: undefined;
|
||||
const className = `step-temperature step-temperature--${modifier}`;
|
||||
|
||||
if (owner === undefined || owner.start === null || owner.end === null) {
|
||||
// Read-only, or (for a persisted temperature this shouldn't
|
||||
// happen, see `findTechStepForTemperature`'s own doc comment)
|
||||
// no owning occurrence with a real span to reopen — degrades
|
||||
// to the same non-interactive span as before rather than a
|
||||
// dead click.
|
||||
return (
|
||||
<Tooltip key={key} content={label}>
|
||||
<span className={className} data-offset={editable ? start : undefined}>
|
||||
{segment.text}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const ownerStart = owner.start;
|
||||
const ownerEnd = owner.end;
|
||||
return (
|
||||
<Tooltip key={key} content={label}>
|
||||
<span
|
||||
className={`step-temperature step-temperature--${modifier}`}
|
||||
data-offset={editable ? start : undefined}
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
data-offset={start}
|
||||
onClick={() => {
|
||||
setPendingSpanRequest(null);
|
||||
setResolvedMetadataSpan(null);
|
||||
setActiveCorrection({
|
||||
range: { start: ownerStart, end: ownerEnd },
|
||||
selectedText: description.slice(ownerStart, ownerEnd),
|
||||
previousTechStepId: owner.techStep.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{segment.text}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
@ -263,11 +329,13 @@ export function StepDescription({
|
|||
<TechStepCorrectionPopover
|
||||
recipeId={recipeId}
|
||||
stepId={stepId}
|
||||
description={description}
|
||||
range={activeCorrection.range}
|
||||
selectedText={activeCorrection.selectedText}
|
||||
previousTechStepId={activeCorrection.previousTechStepId}
|
||||
existingIngredients={activeStepTechStep?.ingredients ?? []}
|
||||
existingUtensils={activeStepTechStep?.utensils ?? []}
|
||||
existingTemperatures={activeStepTechStep?.temperatures ?? []}
|
||||
resolvedMetadataSpan={resolvedMetadataSpan}
|
||||
onRequestSpan={setPendingSpanRequest}
|
||||
onClose={closeActiveCorrection}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -183,21 +183,29 @@
|
|||
"discoverabilityHint": "💡 Sélectionnez du texte, ou cliquez sur une technique surlignée, pour la corriger.",
|
||||
"confirm": "Valider",
|
||||
"techniqueSection": "Technique",
|
||||
"currentTechnique": "Technique retenue : {{technique}}",
|
||||
"changeTechnique": "Changer",
|
||||
"noTechniqueSelected": "Aucune technique sélectionnée.",
|
||||
"searchTechniquePlaceholder": "Rechercher une technique…",
|
||||
"noTechniqueFound": "Aucune technique trouvée.",
|
||||
"ingredientsSection": "Ingrédients",
|
||||
"utensilsSection": "Ustensiles",
|
||||
"addIngredient": "+ Ajouter un ingrédient",
|
||||
"addUtensil": "+ Ajouter un ustensile",
|
||||
"temperatureSection": "Température",
|
||||
"addIngredient": "Ajouter un ingrédient",
|
||||
"addUtensil": "Ajouter un ustensile",
|
||||
"addTemperature": "Ajouter une température",
|
||||
"removeIngredient": "Retirer cet ingrédient",
|
||||
"removeUtensil": "Retirer cet ustensile",
|
||||
"removeTemperature": "Retirer cette température",
|
||||
"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"
|
||||
"addToList": "Ajouter",
|
||||
"temperatureMode": {
|
||||
"celsius": "°C",
|
||||
"gasMark": "Thermostat",
|
||||
"qualitative": "Qualitatif"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"favoris": "Favoris",
|
||||
|
|
|
|||
|
|
@ -175,6 +175,48 @@ const manualStepTechStepUtensilInputSchema = z
|
|||
path: ["end"],
|
||||
});
|
||||
|
||||
/**
|
||||
* A temperature mention the user points at while correcting a technique —
|
||||
* same `start`/`end` span convention as
|
||||
* {@link manualStepTechStepIngredientInputSchema}/
|
||||
* {@link manualStepTechStepUtensilInputSchema}. Exactly one of
|
||||
* `celsius`/`gasMark`/`qualitative` must be set: the same mutual-exclusivity
|
||||
* invariant the auto-detection pipeline already produces (see
|
||||
* `StepTechStepTemperatureView`'s doc comment, `packages/shared/src/types/recipe.ts`)
|
||||
* — a manual entry can't represent "both 180 °C and Thermostat 6" any more
|
||||
* than a detected one can.
|
||||
*
|
||||
* No `raw` field here, unlike `StepTechStepTemperatureView`/the Prisma
|
||||
* column: an auto-detected mention's `raw` is the exact text a regex
|
||||
* matched, but a manual one is built from a structured value the user
|
||||
* picked (a number, or a qualitative level), not matched text — the
|
||||
* service derives it from the step's own `description.slice(start, end)`
|
||||
* instead of trusting a client-supplied copy that could drift from what's
|
||||
* actually at that span.
|
||||
*/
|
||||
const manualStepTechStepTemperatureInputSchema = z
|
||||
.object({
|
||||
celsius: z.number().nullable().optional(),
|
||||
gasMark: z.number().int().positive().nullable().optional(),
|
||||
qualitative: z.enum(["low", "medium", "high"]).nullable().optional(),
|
||||
start: z.number().int().nonnegative(),
|
||||
end: z.number().int().nonnegative(),
|
||||
})
|
||||
.refine((temperature) => temperature.end > temperature.start, {
|
||||
message: "end must be greater than start",
|
||||
path: ["end"],
|
||||
})
|
||||
.refine(
|
||||
(temperature) =>
|
||||
[temperature.celsius, temperature.gasMark, temperature.qualitative].filter(
|
||||
(value) => value !== null && value !== undefined,
|
||||
).length === 1,
|
||||
{
|
||||
message: "exactly one of celsius/gasMark/qualitative is required",
|
||||
path: ["celsius"],
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Payload accepted by `POST /recipes/:id/steps/:stepId/corrections` — a
|
||||
* user asserting what technique a `[start, end)` span of a step's
|
||||
|
|
@ -188,16 +230,22 @@ const manualStepTechStepUtensilInputSchema = z
|
|||
* `description` length to validate `start`/`end` against, which this shape
|
||||
* alone can't see.
|
||||
*
|
||||
* `ingredients`/`utensils` let the user attach metadata to the technique
|
||||
* they're asserting (`correctedTechStepId`), same `source: "manual"`
|
||||
* distinction the technique itself gets. **Omitted (`undefined`) means
|
||||
* "leave whatever metadata already exists on this occurrence alone" —
|
||||
* an explicit array, even `[]`, means "this is now the complete set,
|
||||
* replace everything that was there" (auto-detected included; see
|
||||
* `applyManualCorrection`'s own doc comment). This is why neither field
|
||||
* has a `.default([])`: that would silently turn every plain relabel into
|
||||
* a metadata wipe.** Only meaningful alongside a real `correctedTechStepId`
|
||||
* — enforced by this schema's own refine below, since there's no live
|
||||
* `ingredients`/`utensils`/`temperatures` let the user attach metadata to
|
||||
* the technique they're asserting (`correctedTechStepId`), same
|
||||
* `source: "manual"` distinction the technique itself gets. The three
|
||||
* travel as **one combined bundle, not three independent ones**: **omitted
|
||||
* (`undefined`) on all three at once** means "leave whatever metadata
|
||||
* already exists on this occurrence alone" — as soon as *any one* of them
|
||||
* is present, all three are resolved (an absent one defaults to `[]`) and
|
||||
* **replace everything that was there** for that category, auto-detected
|
||||
* included (see `applyManualCorrection`'s own doc comment). This is why
|
||||
* none of the three has a `.default([])`: that would silently turn every
|
||||
* plain relabel into a metadata wipe. A caller touching just one category
|
||||
* (e.g. adding a temperature) must still resend the other two's current
|
||||
* full state alongside it — same contract `TechStepCorrectionPopover.tsx`
|
||||
* already follows for ingredients/utensils, just extended to a third
|
||||
* category. 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).
|
||||
*/
|
||||
|
|
@ -209,6 +257,7 @@ export const submitTechStepCorrectionSchema = z
|
|||
correctedTechStepId: z.number().int().positive().nullable().optional(),
|
||||
ingredients: z.array(manualStepTechStepIngredientInputSchema).optional(),
|
||||
utensils: z.array(manualStepTechStepUtensilInputSchema).optional(),
|
||||
temperatures: z.array(manualStepTechStepTemperatureInputSchema).optional(),
|
||||
})
|
||||
.refine((input) => input.end > input.start, {
|
||||
message: "end must be greater than start",
|
||||
|
|
@ -224,10 +273,12 @@ export const submitTechStepCorrectionSchema = z
|
|||
)
|
||||
.refine(
|
||||
(input) =>
|
||||
(input.ingredients === undefined && input.utensils === undefined) ||
|
||||
(input.ingredients === undefined &&
|
||||
input.utensils === undefined &&
|
||||
input.temperatures === undefined) ||
|
||||
(input.correctedTechStepId ?? null) !== null,
|
||||
{
|
||||
message: "ingredients/utensils require a correctedTechStepId to attach to",
|
||||
message: "ingredients/utensils/temperatures require a correctedTechStepId to attach to",
|
||||
path: ["correctedTechStepId"],
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -587,6 +587,24 @@ ne persiste rien.
|
|||
| `PATCH /recipes/:id` | `updateRecipe` (remplacement complet, pas de fusion partielle) |
|
||||
| `DELETE /recipes/:id` | `deleteRecipe` (409 `RECIPE_IN_USE` si référencée par un `PlanningItem`) |
|
||||
| `POST`/`DELETE /recipes/:id/favorite` | `addFavorite`/`removeFavorite` (idempotents) |
|
||||
| `POST /recipes/:id/steps/:stepId/corrections` | `submitTechStepCorrection` (`recipe-tech-step-correction.service.ts`) — ouvert à tout viewer qui voit la recette, pas seulement son auteur |
|
||||
| `GET /recipes/:id/steps/:stepId/corrections` | `listTechStepCorrections` — historique brut des corrections soumises pour l'étape |
|
||||
|
||||
**Corrections de tech-step** — une correction porte sur un `[start, end)` de
|
||||
`Step.description` (`previousTechStepId`/`correctedTechStepId`, voir
|
||||
[batch-cooking-modele.md](./batch-cooking-modele.md#step-step-et-techniques-détectées)).
|
||||
Elle enregistre systématiquement une ligne d'audit `StepTechStepCorrection`
|
||||
(jamais modifiée ensuite) **et** applique l'effet immédiatement sur la
|
||||
séquence réelle `StepTechStep` de l'étape — pas seulement une suggestion en
|
||||
attente pour `services/tech-step-llm-worker`. `ingredients`/`utensils`/
|
||||
`temperatures` peuvent accompagner un `correctedTechStepId` réel pour
|
||||
attacher/remplacer les métadonnées de cette occurrence ; les trois voyagent
|
||||
comme **un seul lot** — dès que l'une est présente dans la requête, les
|
||||
trois sont résolues (une absente devient `[]`, donc effacée), jamais une
|
||||
fusion partielle catégorie par catégorie. `temperatures[]` exige
|
||||
exactement un de `celsius`/`gasMark`/`qualitative` par entrée (même
|
||||
contrainte que la détection automatique) ; son `raw` n'est jamais envoyé
|
||||
par le client — dérivé côté service de `description.slice(start, end)`.
|
||||
|
||||
**Visibilité** (`RecipeVisibility` — `PERSONAL`/`HOUSE`/`PUBLIC`, voir
|
||||
[batch-cooking-modele.md](./batch-cooking-modele.md)) contrôle uniquement la
|
||||
|
|
|
|||
|
|
@ -379,13 +379,28 @@ dans `Step.description`, utilisé pour le surlignage côté web
|
|||
Chaque `step_tech_step` porte en plus les métadonnées trouvées dans sa propre
|
||||
clause : `step_tech_step_ingredient` (ingrédient résolu contre le catalogue
|
||||
`ingredients` existant, `quantity`/`unit_id` optionnels quand une quantité a
|
||||
pu être extraite juste avant la mention) et `step_tech_step_utensil`
|
||||
pu être extraite juste avant la mention), `step_tech_step_utensil`
|
||||
(ustensile résolu contre un nouveau catalogue `utensil`, même forme
|
||||
minimale `id`/`key` que `tech_step` — voir
|
||||
minimale `id`/`key` que `tech_step`), et `step_tech_step_temperature`
|
||||
(température de la clause — `celsius`/`gas_mark`/`qualitative`
|
||||
mutuellement exclusifs, un seul non-null par ligne ; `raw` conserve le
|
||||
texte exact matché, ex. `"180°C"`/`"th. 6"`/`"feu doux"` ; `qualitative`
|
||||
vaut `"low"`/`"medium"`/`"high"`, contrainte au niveau TypeScript
|
||||
seulement — pas d'enum Postgres ici, contrairement à `AllergenKind`/
|
||||
`ThemePreference`/`UnitType` ailleurs dans ce schéma). Les trois référencent
|
||||
`step_tech_step` par sa clé composite `(step_id, order)`, `onDelete:
|
||||
Cascade` comme le reste de cette chaîne — voir
|
||||
[backend-architecture.md](./backend-architecture.md#détection-des-techniques--tech-step-matcherts)
|
||||
pour comment chacun est détecté). Les deux référencent `step_tech_step` par
|
||||
sa clé composite `(step_id, order)`, `onDelete: Cascade` comme le reste de
|
||||
cette chaîne.
|
||||
pour comment chacune est détectée.
|
||||
|
||||
`source` (`"auto"` | `"manual"`, même contrainte TypeScript-seule que
|
||||
`qualitative` ci-dessus) marque, sur `step_tech_step` et ses trois tables de
|
||||
métadonnées, si la ligne vient du classifieur ou d'une correction manuelle
|
||||
soumise par un utilisateur (`POST /recipes/:id/steps/:stepId/corrections`,
|
||||
voir [backend-architecture.md](./backend-architecture.md#recipe)) — cette
|
||||
route remplace intégralement les trois tables de métadonnées d'une
|
||||
occurrence dès que l'une d'elles est touchée ("le manuel remplace tout"),
|
||||
jamais un ajout partiel.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue