Compare commits
8 commits
main
...
feat/tech-
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ae0b5aabb | |||
| c7116d4a29 | |||
| 74cd14c0a5 | |||
| e2ffa7d103 | |||
| 84ccfec02c | |||
| 0dadadfa24 | |||
| b886a0fc16 | |||
| 4e0a9ce8d2 |
16 changed files with 143 additions and 1518 deletions
45
.github/workflows/ci.yml
vendored
45
.github/workflows/ci.yml
vendored
|
|
@ -12,7 +12,7 @@ on:
|
||||||
push:
|
push:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: "postgresql://ci:ci@postgres:5432/batchcooking_ci?schema=public"
|
DATABASE_URL: "postgresql://ci:ci@localhost:5432/batchcooking_ci?schema=public"
|
||||||
# Test-only secret, never used outside CI — real deployments must set their own.
|
# Test-only secret, never used outside CI — real deployments must set their own.
|
||||||
JWT_SECRET: "ci-only-secret-not-used-anywhere-else-32chars+"
|
JWT_SECRET: "ci-only-secret-not-used-anywhere-else-32chars+"
|
||||||
# Same reasoning as JWT_SECRET above — lets tech-step-worker.routes.test.ts
|
# Same reasoning as JWT_SECRET above — lets tech-step-worker.routes.test.ts
|
||||||
|
|
@ -33,11 +33,11 @@ jobs:
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: https://github.com/pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- uses: https://github.com/actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
@ -55,25 +55,27 @@ jobs:
|
||||||
POSTGRES_PASSWORD: ci
|
POSTGRES_PASSWORD: ci
|
||||||
POSTGRES_DB: batchcooking_ci
|
POSTGRES_DB: batchcooking_ci
|
||||||
ports:
|
ports:
|
||||||
- 5433:5432
|
- 5432:5432
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd pg_isready
|
--health-cmd pg_isready
|
||||||
--health-interval 5s
|
--health-interval 5s
|
||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 10
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: https://github.com/pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- uses: https://github.com/actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- uses: https://github.com/astral-sh/setup-uv@v5
|
- uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
- uses: astral-sh/setup-uv@v3
|
||||||
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
|
|
||||||
# `services:` (like the `postgres` container above) can only pull an
|
# `services:` (like the `postgres` container above) can only pull an
|
||||||
|
|
@ -107,11 +109,13 @@ jobs:
|
||||||
intent-service-test:
|
intent-service-test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: https://github.com/astral-sh/setup-uv@v5
|
- uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
- uses: astral-sh/setup-uv@v3
|
||||||
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
|
|
||||||
- name: Install services/tech-step-intent-service
|
- name: Install services/tech-step-intent-service
|
||||||
|
|
@ -124,11 +128,11 @@ jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: https://github.com/pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- uses: https://github.com/actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
@ -139,17 +143,17 @@ jobs:
|
||||||
e2e:
|
e2e:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: https://github.com/pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- uses: https://github.com/actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- name: Cache Cypress binary
|
- name: Cache Cypress binary
|
||||||
uses: https://github.com/actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/Cypress
|
path: ~/.cache/Cypress
|
||||||
key: cypress-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
key: cypress-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
|
@ -159,10 +163,7 @@ jobs:
|
||||||
# download (see apps/web's cypress caveat in the README) — install it
|
# download (see apps/web's cypress caveat in the README) — install it
|
||||||
# explicitly so `cypress run` finds it.
|
# explicitly so `cypress run` finds it.
|
||||||
- run: pnpm --filter web exec cypress install
|
- run: pnpm --filter web exec cypress install
|
||||||
- name: Run E2E tests
|
- run: pnpm --filter web e2e
|
||||||
env:
|
|
||||||
HOST: "0.0.0.0"
|
|
||||||
run: pnpm --filter web e2e
|
|
||||||
# No dev server needed here — Cypress spins up its own Vite dev
|
# No dev server needed here — Cypress spins up its own Vite dev
|
||||||
# server internally for component testing (see cypress.config.ts's
|
# server internally for component testing (see cypress.config.ts's
|
||||||
# `component.devServer`), unlike `e2e` above which needs the real app
|
# `component.devServer`), unlike `e2e` above which needs the real app
|
||||||
|
|
|
||||||
|
|
@ -84,75 +84,6 @@ 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
|
||||||
|
|
@ -194,20 +125,6 @@ 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
|
||||||
|
|
@ -225,28 +142,13 @@ interface ManualUtensilMention {
|
||||||
* `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 (cascading away any
|
* matching existing entry is deleted outright. A no-op if none matches
|
||||||
* ingredient/utensil metadata attached to it, auto or manual — nothing
|
* (nothing to remove).
|
||||||
* 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 any of these
|
* for the audit-trail insert, so a request never leaves the two effects
|
||||||
* effects (the permanent correction record, the live sequence change, the
|
* (the permanent correction record, the live sequence change) only
|
||||||
* metadata replacement) only partially applied.
|
* partially applied.
|
||||||
*/
|
*/
|
||||||
async function applyManualCorrection(
|
async function applyManualCorrection(
|
||||||
tx: Prisma.TransactionClient,
|
tx: Prisma.TransactionClient,
|
||||||
|
|
@ -254,7 +156,6 @@ 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 } });
|
||||||
|
|
||||||
|
|
@ -271,12 +172,9 @@ 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 } },
|
where: { stepId_order: { stepId, order: target.order } },
|
||||||
data: {
|
data: {
|
||||||
techStepId: correctedTechStepId,
|
techStepId: correctedTechStepId,
|
||||||
start: span.start,
|
start: span.start,
|
||||||
|
|
@ -287,48 +185,18 @@ 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,
|
order: nextOrder,
|
||||||
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 } } });
|
||||||
}
|
}
|
||||||
|
|
@ -364,12 +232,9 @@ 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` (the correction's own span, or any of
|
* `start`/`end` fall outside the step's current `description` (it may
|
||||||
* `input.ingredients`/`input.utensils`' own spans) fall outside the
|
* have been edited since the user last saw it). `404 TECH_STEP_NOT_FOUND`
|
||||||
* step's current `description` (it may have been edited since the user
|
* if either tech-step id doesn't exist.
|
||||||
* 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,
|
||||||
|
|
@ -381,32 +246,18 @@ export async function submitTechStepCorrection(
|
||||||
try {
|
try {
|
||||||
const step = await loadVisibleStepOrThrow(recipeId, stepId, correctorId, viewerHouseId);
|
const step = await loadVisibleStepOrThrow(recipeId, stepId, correctorId, viewerHouseId);
|
||||||
|
|
||||||
const spans = [
|
if (input.start >= step.descriptionLength || input.end > step.descriptionLength) {
|
||||||
{ 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 [${span.start}, ${span.end}) falls outside step ${stepId}'s description (length ${step.descriptionLength})`,
|
`Span [${input.start}, ${input.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({
|
||||||
|
|
@ -427,22 +278,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -189,14 +189,11 @@ 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",
|
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -249,16 +249,12 @@ 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
|
return utensil ? [{ utensil, start: mention.start, end: mention.end }] : [];
|
||||||
? [{ utensil, start: mention.start, end: mention.end, source: "auto" as const }]
|
|
||||||
: [];
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -25,24 +25,6 @@ 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();
|
||||||
|
|
||||||
|
|
@ -277,244 +259,6 @@ 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();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
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";
|
||||||
|
|
||||||
|
|
@ -12,40 +11,15 @@ 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(
|
||||||
* A real `StepDescription` resolves `onRequestSpan` into a fresh
|
overrides: Partial<{
|
||||||
* `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: (result: unknown) => void;
|
onSubmitted: (correction: unknown) => void;
|
||||||
}>) {
|
}> = {},
|
||||||
const [resolvedMetadataSpan, setResolvedMetadataSpan] = useState<{
|
) {
|
||||||
nonce: number;
|
cy.mount(
|
||||||
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 }} />
|
||||||
|
|
@ -54,25 +28,11 @@ function Harness({
|
||||||
stepId={2}
|
stepId={2}
|
||||||
selectedText="Cuire"
|
selectedText="Cuire"
|
||||||
range={{ start: 0, end: 5 }}
|
range={{ start: 0, end: 5 }}
|
||||||
previousTechStepId={previousTechStepId}
|
previousTechStepId={overrides.previousTechStepId ?? null}
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: test harness stands in for real StepTechStepIngredientView/UtensilView props — precise typing isn't the point here.
|
onClose={overrides.onClose ?? (() => {})}
|
||||||
existingIngredients={existingIngredients as any}
|
onSubmitted={overrides.onSubmitted ?? (() => {})}
|
||||||
// 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>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,75 +41,32 @@ 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, the technique catalog (searchable) and the metadata sections all together", () => {
|
it("shows the selected text and every technique option once loaded", () => {
|
||||||
cy.mount(<Harness />);
|
mountPopover();
|
||||||
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");
|
||||||
// Merged editor (see TechStepCorrectionPopover's own doc comment) — no
|
cy.get(".tech-step-correction-popover__list button").should("have.length", 2);
|
||||||
// separate "pick, then metadata reveals itself" step, both render at
|
|
||||||
// once, and the technique catalog goes through the same searchable
|
|
||||||
// `CatalogSearchPicker` as the ingredient/utensil sub-flows (a plain
|
|
||||||
// unfiltered list of the real ~74-entry catalog isn't browsable).
|
|
||||||
cy.get(
|
|
||||||
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
|
||||||
).should("have.length", 2);
|
|
||||||
cy.contains("h4", "Ingrédients").should("be.visible");
|
|
||||||
cy.contains("h4", "Ustensiles").should("be.visible");
|
|
||||||
cy.contains("button", "Valider").should("be.visible");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("offers a 'no technique here' option, and marks the current pick, only when correcting an existing match", () => {
|
it("offers a 'no technique here' option only when correcting an existing match", () => {
|
||||||
cy.mount(<Harness previousTechStepId={null} />);
|
mountPopover({ previousTechStepId: null });
|
||||||
cy.wait("@getTechSteps");
|
cy.wait("@getTechSteps");
|
||||||
cy.get(".tech-step-correction-popover__remove").should("not.exist");
|
cy.get(".tech-step-correction-popover__remove").should("not.exist");
|
||||||
cy.contains(".tech-step-correction-popover__chosen-technique", "Aucune technique sélectionnée");
|
|
||||||
|
|
||||||
cy.mount(<Harness previousTechStepId={cook.id} />);
|
mountPopover({ previousTechStepId: cook.id });
|
||||||
cy.wait("@getTechSteps");
|
cy.wait("@getTechSteps");
|
||||||
cy.get(".tech-step-correction-popover__remove").should("exist");
|
cy.get(".tech-step-correction-popover__remove").should("exist");
|
||||||
cy.contains(".tech-step-correction-popover__chosen-technique", "Cuire");
|
|
||||||
cy.contains(".catalog-search-picker__list button", "Cuire").should(
|
|
||||||
"have.class",
|
|
||||||
"catalog-search-picker__item--selected",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("picking a technique from the catalog selects it without submitting immediately", () => {
|
it("submits the selected technique and calls onSubmitted", () => {
|
||||||
cy.mount(<Harness />);
|
// Asserting on the resolved `@submitCorrection` interception below,
|
||||||
cy.wait("@getTechSteps");
|
// rather than inside this handler — a Chai assertion failing *inside*
|
||||||
|
// a `cy.intercept` callback surfaces as an opaque "onResponse cannot be
|
||||||
cy.contains(
|
// called twice" Cypress internal error instead of a normal assertion
|
||||||
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
// failure, found while writing this exact test.
|
||||||
"Mijoter",
|
|
||||||
).click();
|
|
||||||
|
|
||||||
cy.contains(".tech-step-correction-popover__chosen-technique", "Mijoter");
|
|
||||||
cy.contains("button", "Valider").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Valider stays disabled until a technique is actually picked", () => {
|
|
||||||
cy.mount(<Harness />);
|
|
||||||
cy.wait("@getTechSteps");
|
|
||||||
|
|
||||||
cy.contains("button", "Valider").should("be.disabled");
|
|
||||||
cy.contains(
|
|
||||||
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
|
||||||
"Mijoter",
|
|
||||||
).click();
|
|
||||||
cy.contains("button", "Valider").should("not.be.disabled");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("submits the selected technique (no metadata touched) with ingredients/utensils omitted from the request", () => {
|
|
||||||
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
|
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
|
||||||
statusCode: 201,
|
statusCode: 201,
|
||||||
body: {
|
body: {
|
||||||
|
|
@ -162,14 +79,10 @@ describe("TechStepCorrectionPopover", () => {
|
||||||
},
|
},
|
||||||
}).as("submitCorrection");
|
}).as("submitCorrection");
|
||||||
const onSubmitted = cy.stub().as("onSubmitted");
|
const onSubmitted = cy.stub().as("onSubmitted");
|
||||||
cy.mount(<Harness onSubmitted={onSubmitted} />);
|
mountPopover({ onSubmitted });
|
||||||
cy.wait("@getTechSteps");
|
cy.wait("@getTechSteps");
|
||||||
|
|
||||||
cy.contains(
|
cy.contains(".tech-step-correction-popover__list button", "Mijoter").click();
|
||||||
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
|
||||||
"Mijoter",
|
|
||||||
).click();
|
|
||||||
cy.contains("button", "Valider").click();
|
|
||||||
|
|
||||||
cy.wait("@submitCorrection").its("request.body").should("deep.equal", {
|
cy.wait("@submitCorrection").its("request.body").should("deep.equal", {
|
||||||
start: 0,
|
start: 0,
|
||||||
|
|
@ -180,85 +93,16 @@ 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.wait(["@getIngredients", "@getUnits", "@getUtensils"]);
|
|
||||||
|
|
||||||
cy.contains(
|
|
||||||
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
|
||||||
"Mijoter",
|
|
||||||
).click();
|
|
||||||
cy.contains("button", "+ Ajouter un ingrédient").click();
|
|
||||||
|
|
||||||
cy.contains(".catalog-search-picker button", "Beurre").click();
|
|
||||||
cy.get('input[type="number"]').type("50");
|
|
||||||
cy.get("select").select(String(gram.id));
|
|
||||||
cy.contains("button", "Ajouter").click();
|
|
||||||
|
|
||||||
cy.contains(".tech-step-correction-popover__chip", "50 g Beurre").should("be.visible");
|
|
||||||
cy.contains("button", "Valider").click();
|
|
||||||
|
|
||||||
cy.wait("@submitCorrection")
|
|
||||||
.its("request.body")
|
|
||||||
.should("deep.equal", {
|
|
||||||
start: 0,
|
|
||||||
end: 5,
|
|
||||||
previousTechStepId: null,
|
|
||||||
correctedTechStepId: simmer.id,
|
|
||||||
ingredients: [
|
|
||||||
{ ingredientId: butter.id, quantity: 50, unitId: gram.id, start: 20, end: 26 },
|
|
||||||
],
|
|
||||||
utensils: [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("pre-seeds existing ingredients/utensils, removable via their own chip", () => {
|
|
||||||
cy.mount(
|
|
||||||
<Harness
|
|
||||||
previousTechStepId={cook.id}
|
|
||||||
existingIngredients={[
|
|
||||||
{ ingredient: butter, quantity: 50, unit: gram, start: 0, end: 6, source: "auto" },
|
|
||||||
]}
|
|
||||||
existingUtensils={[{ utensil: pan, start: 14, end: 23, source: "auto" }]}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
// An existing match starts pre-selected on itself (see
|
|
||||||
// TechStepCorrectionPopover's own doc comment) — the metadata sections,
|
|
||||||
// pre-seeded from `existingIngredients`/`existingUtensils`, are visible
|
|
||||||
// immediately, no need to re-pick "Cuire" from a list first.
|
|
||||||
cy.wait("@getTechSteps");
|
|
||||||
cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]);
|
|
||||||
|
|
||||||
cy.contains(".tech-step-correction-popover__chip", "50 g Beurre").find("button").click();
|
|
||||||
cy.contains(".tech-step-correction-popover__chip", "Beurre").should("not.exist");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows an error message and stays open when the submission fails", () => {
|
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");
|
||||||
cy.mount(<Harness onClose={onClose} />);
|
mountPopover({ onClose });
|
||||||
cy.wait("@getTechSteps");
|
cy.wait("@getTechSteps");
|
||||||
|
|
||||||
cy.contains(
|
cy.contains(".tech-step-correction-popover__list button", "Cuire").click();
|
||||||
".tech-step-correction-popover__technique-section .catalog-search-picker__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");
|
||||||
|
|
@ -267,7 +111,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");
|
||||||
cy.mount(<Harness onClose={onClose} />);
|
mountPopover({ onClose });
|
||||||
cy.wait("@getTechSteps");
|
cy.wait("@getTechSteps");
|
||||||
|
|
||||||
cy.get('[data-testid="outside-popover"]').click();
|
cy.get('[data-testid="outside-popover"]').click();
|
||||||
|
|
|
||||||
|
|
@ -88,16 +88,7 @@ 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: [
|
techSteps: [{ techStep: { id: 3, key: "simmer" }, start: 0, end: 5, source: "manual" }],
|
||||||
{
|
|
||||||
techStep: { id: 3, key: "simmer" },
|
|
||||||
start: 0,
|
|
||||||
end: 5,
|
|
||||||
source: "manual",
|
|
||||||
ingredients: [],
|
|
||||||
utensils: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}).as("correction");
|
}).as("correction");
|
||||||
});
|
});
|
||||||
|
|
@ -110,21 +101,8 @@ 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 — it takes a separate "Valider"
|
|
||||||
// click to actually submit (room was made for attaching ingredient/utensil
|
|
||||||
// metadata alongside it, see `TechStepCorrectionPopover.tsx`'s own doc
|
|
||||||
// comment on its merged editor) — folded into this one step since nothing
|
|
||||||
// in this scenario cares about that intermediate state on its own. The
|
|
||||||
// technique catalog is picked via the same searchable `CatalogSearchPicker`
|
|
||||||
// the ingredient/utensil sub-flows use, scoped to
|
|
||||||
// `__technique-section` since that same search-and-pick component is
|
|
||||||
// reused inside this popover for more than just techniques.
|
|
||||||
When("I choose {string} as the correct technique", (label: string) => {
|
When("I choose {string} as the correct technique", (label: string) => {
|
||||||
cy.contains(
|
cy.contains(".tech-step-correction-popover__list button", label).click();
|
||||||
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
|
||||||
label,
|
|
||||||
).click();
|
|
||||||
cy.contains(".tech-step-correction-popover__confirm-button", "Valider").click();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Then(
|
Then(
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ import {
|
||||||
type ThemePreference,
|
type ThemePreference,
|
||||||
type UnitView,
|
type UnitView,
|
||||||
type UpdateRecipeInput,
|
type UpdateRecipeInput,
|
||||||
type UtensilView,
|
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -202,11 +201,6 @@ 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");
|
||||||
|
|
|
||||||
|
|
@ -729,41 +729,43 @@
|
||||||
margin: 0 0 var(--space-sm);
|
margin: 0 0 var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Technique picker + Ingrédients/Ustensiles render together as one
|
&__list {
|
||||||
// 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 {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
flex-wrap: wrap;
|
||||||
justify-content: space-between;
|
gap: var(--space-xs);
|
||||||
gap: var(--space-sm);
|
list-style: none;
|
||||||
}
|
margin: 0 0 var(--space-sm);
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
&__remove {
|
button {
|
||||||
padding: 0.2rem 0.5rem;
|
padding: 0.3rem 0.6rem;
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
color: var(--color-error);
|
color: var(--color-text);
|
||||||
background: none;
|
background: var(--color-surface);
|
||||||
border: 1px solid var(--color-error);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-pill);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nested (rather than a sibling `&__remove` block) so its border-color
|
||||||
|
// wins over the plain `button` rule above by class-count specificity,
|
||||||
|
// no `!important` needed.
|
||||||
|
.tech-step-correction-popover__remove {
|
||||||
|
color: var(--color-error);
|
||||||
|
border-color: var(--color-error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__cancel {
|
&__cancel {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
|
|
@ -773,180 +775,6 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// "Aucune technique sélectionnée."/"Technique retenue : X" — no button
|
|
||||||
// here anymore (re-picking happens directly through the search picker
|
|
||||||
// right below, see `TechStepCorrectionPopover.tsx`'s doc comment on the
|
|
||||||
// merged editor), just a small status line.
|
|
||||||
&__chosen-technique {
|
|
||||||
margin: 0 0 var(--space-xs);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__metadata-section {
|
|
||||||
margin-top: var(--space-sm);
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
margin: 0 0 var(--space-xs);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
// "+ Ajouter…" button — deliberately a plain text-link style, not
|
|
||||||
// another pill button (`.catalog-search-picker__list button`) — this
|
|
||||||
// is a secondary action inside an already-open popover, not a
|
|
||||||
// top-level choice competing with the chips above it.
|
|
||||||
> button {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__chips {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
list-style: none;
|
|
||||||
margin: 0 0 var(--space-xs);
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__chip {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
padding: 0.3rem 0.6rem;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
|
|
||||||
button {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
line-height: 1;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
color: var(--color-error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__confirm-button {
|
|
||||||
align-self: flex-start;
|
|
||||||
padding: 0.4rem 1rem;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--color-surface);
|
|
||||||
background: var(--color-primary);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reused by both the ingredient and utensil "attach to this correction"
|
|
||||||
// sub-flows (`TechStepCorrectionPopover.tsx`) — deliberately lighter than
|
|
||||||
// `.ingredient-picker` (no category/subcategory grid, no allergen/diet
|
|
||||||
// toggles), sized for a small popover rather than a full recipe form.
|
|
||||||
.catalog-search-picker {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
|
|
||||||
&__input {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__empty {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
// Raised from the original 8rem — this component is now also the
|
|
||||||
// technique picker (~74 entries, see this file's own doc comment),
|
|
||||||
// where 8rem left only a couple of rows visible before scrolling.
|
|
||||||
max-height: 14rem;
|
|
||||||
overflow-y: auto;
|
|
||||||
|
|
||||||
button {
|
|
||||||
padding: 0.3rem 0.6rem;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--color-text);
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The technique picker's current pick (`selectedId` prop) — stays
|
|
||||||
// visually marked even while filtered/scrolled past, so re-opening
|
|
||||||
// this popover's picker doesn't read as "nothing chosen yet" when
|
|
||||||
// something already is. Unused by the ingredient/utensil sub-flows
|
|
||||||
// (they never pass `selectedId` — each pick there just appends a
|
|
||||||
// fresh mention, nothing to mark as "current").
|
|
||||||
&.catalog-search-picker__item--selected {
|
|
||||||
background: color-mix(in srgb, var(--color-primary) 20%, transparent);
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Favorite star toggle (detail panel header) -----------------------------
|
// --- Favorite star toggle (detail panel header) -----------------------------
|
||||||
|
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Small search-and-pick list — a lighter alternative to `IngredientPicker.tsx`
|
|
||||||
* (category/subcategory grid + allergen/diet toggles) for a context that
|
|
||||||
* doesn't have room for that: `TechStepCorrectionPopover.tsx`'s ingredient/
|
|
||||||
* utensil/**technique** pickers, all embedded in a small popover rather than
|
|
||||||
* a full recipe form. Reused for all three — an ingredient, a utensil, and a
|
|
||||||
* technique are all "search a reference list by translated label, pick one"
|
|
||||||
* from this component's point of view, the only difference is which
|
|
||||||
* `items`/labels the caller passes in. The technique catalog in particular
|
|
||||||
* (~74 entries) is exactly the case a plain unfiltered list stops being
|
|
||||||
* readable at — the original motivation for adding search here at all.
|
|
||||||
*
|
|
||||||
* Deliberately just `{ id, label }` in, `id` out — no `IngredientView`/
|
|
||||||
* `UtensilView`/`TechStepView` dependency here, so this stays reusable for
|
|
||||||
* any future "search this small reference catalog" need without growing a
|
|
||||||
* new prop per catalog shape.
|
|
||||||
*/
|
|
||||||
export function CatalogSearchPicker({
|
|
||||||
items,
|
|
||||||
selectedId,
|
|
||||||
onSelect,
|
|
||||||
placeholder,
|
|
||||||
emptyLabel,
|
|
||||||
}: {
|
|
||||||
items: { id: number; label: string }[];
|
|
||||||
/** The currently-picked item, if any — marked with a distinct modifier class so it stays visible at a glance while browsing/filtering a longer list (e.g. `TechStepCorrectionPopover`'s ~74-entry technique catalog), not just implied by whatever's selected elsewhere on screen. Omit for a picker with no notion of a "current" pick (the ingredient/utensil span sub-flows — each `onSelect` there just appends a brand-new mention, nothing to mark as already chosen). */
|
|
||||||
selectedId?: number;
|
|
||||||
onSelect: (id: number) => void;
|
|
||||||
placeholder: string;
|
|
||||||
emptyLabel: string;
|
|
||||||
}) {
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
|
||||||
const visible =
|
|
||||||
normalizedQuery.length === 0
|
|
||||||
? items
|
|
||||||
: items.filter((item) => item.label.toLowerCase().includes(normalizedQuery));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="catalog-search-picker">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
className="catalog-search-picker__input"
|
|
||||||
/>
|
|
||||||
{visible.length === 0 ? (
|
|
||||||
<p className="catalog-search-picker__empty">{emptyLabel}</p>
|
|
||||||
) : (
|
|
||||||
<ul className="catalog-search-picker__list">
|
|
||||||
{visible.map((item) => (
|
|
||||||
<li key={item.id}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={
|
|
||||||
item.id === selectedId ? "catalog-search-picker__item--selected" : undefined
|
|
||||||
}
|
|
||||||
onClick={() => onSelect(item.id)}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -76,45 +76,10 @@ 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),
|
||||||
|
|
@ -126,21 +91,6 @@ 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
|
||||||
|
|
@ -204,19 +154,12 @@ 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
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -233,11 +176,7 @@ export function StepDescription({
|
||||||
range={activeCorrection.range}
|
range={activeCorrection.range}
|
||||||
selectedText={activeCorrection.selectedText}
|
selectedText={activeCorrection.selectedText}
|
||||||
previousTechStepId={activeCorrection.previousTechStepId}
|
previousTechStepId={activeCorrection.previousTechStepId}
|
||||||
existingIngredients={activeStepTechStep?.ingredients ?? []}
|
onClose={() => setActiveCorrection(null)}
|
||||||
existingUtensils={activeStepTechStep?.utensils ?? []}
|
|
||||||
resolvedMetadataSpan={resolvedMetadataSpan}
|
|
||||||
onRequestSpan={setPendingSpanRequest}
|
|
||||||
onClose={closeActiveCorrection}
|
|
||||||
onSubmitted={handleSubmitted}
|
onSubmitted={handleSubmitted}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,14 @@
|
||||||
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 —
|
||||||
|
|
@ -55,29 +21,14 @@ function toPendingUtensil(view: StepTechStepUtensilView): PendingUtensil {
|
||||||
* `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. That placement matters beyond cosmetics
|
* from the selected text itself.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* **One merged editor, not a wizard**: picking a technique
|
* Submitting takes effect immediately — the API applies it to the step's
|
||||||
* (`CatalogSearchPicker`, searchable — the reference catalog is ~74
|
* real `StepTechStep` sequence as it records the correction (a `"manual"`-
|
||||||
* entries, an unfiltered flat list wasn't browsable) and editing its
|
* tagged entry, see `StepTechStepCorrection`'s schema doc comment) and
|
||||||
* Ingrédients/Ustensiles metadata render together on the same screen,
|
* returns the fresh sequence, which `onSubmitted` hands back to
|
||||||
* always — there's no separate "pick, then a metadata step reveals
|
* `StepDescription` to render right away, styled differently from an
|
||||||
* itself" sequence to go through, and no dead end where metadata is
|
* `"auto"` match.
|
||||||
* technically attachable but not visible until some other action happens
|
|
||||||
* first. A single "Valider" submits everything at once; disabled until a
|
|
||||||
* technique is actually selected (there's nothing to attach metadata to
|
|
||||||
* otherwise). **Removing** a match (`submit(null)`) stays its own
|
|
||||||
* immediate action next to the picker — nothing to attach when removing.
|
|
||||||
*
|
|
||||||
* The two metadata sections are pre-seeded from `existingIngredients`/
|
|
||||||
* `existingUtensils` (whatever's already attached to this occurrence, auto-
|
|
||||||
* or manually-sourced — `[]` for a brand-new technique) and editable via
|
|
||||||
* add/remove — see `metadataTouched` below for why what's *displayed* here
|
|
||||||
* isn't automatically what gets *submitted*.
|
|
||||||
*/
|
*/
|
||||||
export function TechStepCorrectionPopover({
|
export function TechStepCorrectionPopover({
|
||||||
recipeId,
|
recipeId,
|
||||||
|
|
@ -85,10 +36,6 @@ export function TechStepCorrectionPopover({
|
||||||
selectedText,
|
selectedText,
|
||||||
range,
|
range,
|
||||||
previousTechStepId,
|
previousTechStepId,
|
||||||
existingIngredients,
|
|
||||||
existingUtensils,
|
|
||||||
resolvedMetadataSpan,
|
|
||||||
onRequestSpan,
|
|
||||||
onClose,
|
onClose,
|
||||||
onSubmitted,
|
onSubmitted,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -99,74 +46,12 @@ 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);
|
||||||
// Opening this popover on an *already-detected* match (`previousTechStepId
|
|
||||||
// !== null`, i.e. the user clicked an existing highlight rather than
|
|
||||||
// selecting fresh text) starts pre-selected on that same technique, its
|
|
||||||
// name shown next to the picker right away — since the picker and the
|
|
||||||
// metadata sections render together regardless (see this component's own
|
|
||||||
// doc comment), this just saves re-picking the technique that's already
|
|
||||||
// correct before its metadata becomes editable.
|
|
||||||
const [selectedTechStepId, setSelectedTechStepId] = useState<number | null>(previousTechStepId);
|
|
||||||
const [catalogs, setCatalogs] = useState<{
|
|
||||||
ingredients: IngredientView[];
|
|
||||||
units: UnitView[];
|
|
||||||
utensils: UtensilView[];
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const [pendingIngredients, setPendingIngredients] = useState<PendingIngredient[]>(() =>
|
|
||||||
existingIngredients.map(toPendingIngredient),
|
|
||||||
);
|
|
||||||
const [pendingUtensils, setPendingUtensils] = useState<PendingUtensil[]>(() =>
|
|
||||||
existingUtensils.map(toPendingUtensil),
|
|
||||||
);
|
|
||||||
// Flips true the moment the viewer adds/removes a pending entry — never
|
|
||||||
// from the initial seeding above. `submit()` below only includes
|
|
||||||
// `ingredients`/`utensils` in the request when this is true, so
|
|
||||||
// relabeling/confirming a technique without ever opening either section
|
|
||||||
// leaves existing metadata completely alone server-side (see
|
|
||||||
// `submitTechStepCorrectionSchema`'s own doc comment, `packages/shared`,
|
|
||||||
// for why an *omitted* field — not an empty array — is what "don't
|
|
||||||
// touch it" means over the wire).
|
|
||||||
const [metadataTouched, setMetadataTouched] = useState(false);
|
|
||||||
|
|
||||||
const [awaitingSpanFor, setAwaitingSpanFor] = useState<"ingredient" | "utensil" | null>(null);
|
|
||||||
const [activeSpan, setActiveSpan] = useState<{
|
|
||||||
kind: "ingredient" | "utensil";
|
|
||||||
range: TextSelectionRange;
|
|
||||||
text: string;
|
|
||||||
} | null>(null);
|
|
||||||
// Only meaningful while `activeSpan?.kind === "ingredient"` — the
|
|
||||||
// ingredient sub-flow is itself two steps (pick the ingredient, then its
|
|
||||||
// quantity/unit), this is where the first step's choice waits until the
|
|
||||||
// second is confirmed.
|
|
||||||
const [pickedIngredientId, setPickedIngredientId] = useState<number | null>(null);
|
|
||||||
const [spanQuantity, setSpanQuantity] = useState("");
|
|
||||||
const [spanUnitId, setSpanUnitId] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -185,46 +70,6 @@ export function TechStepCorrectionPopover({
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Fetched unconditionally on mount — the Ingrédients/Ustensiles sections
|
|
||||||
// render alongside the technique picker from the start (see this
|
|
||||||
// component's own doc comment on the merged editor), so there's no later
|
|
||||||
// point to defer this to anymore.
|
|
||||||
useEffect(() => {
|
|
||||||
if (catalogs !== null) return;
|
|
||||||
let cancelled = false;
|
|
||||||
Promise.all([apiClient.getIngredients(), apiClient.getUnits(), apiClient.getUtensils()])
|
|
||||||
.then(([ingredients, units, utensils]) => {
|
|
||||||
if (!cancelled) setCatalogs({ ingredients, units, utensils });
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) setCatalogs({ ingredients: [], units: [], utensils: [] });
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [catalogs]);
|
|
||||||
|
|
||||||
// Consumes a span `StepDescription` just resolved on this popover's
|
|
||||||
// behalf (see `resolvedMetadataSpan`'s own doc comment above) — opens the
|
|
||||||
// matching sub-picker and clears the "awaiting a selection" hint.
|
|
||||||
useEffect(() => {
|
|
||||||
if (resolvedMetadataSpan === null) return;
|
|
||||||
setActiveSpan({
|
|
||||||
kind: resolvedMetadataSpan.kind,
|
|
||||||
range: resolvedMetadataSpan.range,
|
|
||||||
text: resolvedMetadataSpan.text,
|
|
||||||
});
|
|
||||||
setAwaitingSpanFor(null);
|
|
||||||
setPickedIngredientId(null);
|
|
||||||
setSpanQuantity("");
|
|
||||||
setSpanUnitId(null);
|
|
||||||
// Depends on the whole object, not just `.nonce` — `StepDescription`
|
|
||||||
// only ever calls its setter with a brand-new object (never mutates
|
|
||||||
// one in place), so reference equality alone already gives this the
|
|
||||||
// "fires once per fresh selection" behavior `nonce` documents, with no
|
|
||||||
// need to silence the exhaustive-deps lint to get there.
|
|
||||||
}, [resolvedMetadataSpan]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
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)) {
|
||||||
|
|
@ -235,7 +80,7 @@ export function TechStepCorrectionPopover({
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
async function removeMatch() {
|
async function submit(correctedTechStepId: number | null) {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
|
@ -243,7 +88,7 @@ export function TechStepCorrectionPopover({
|
||||||
start: range.start,
|
start: range.start,
|
||||||
end: range.end,
|
end: range.end,
|
||||||
previousTechStepId,
|
previousTechStepId,
|
||||||
correctedTechStepId: null,
|
correctedTechStepId,
|
||||||
});
|
});
|
||||||
onSubmitted(result);
|
onSubmitted(result);
|
||||||
onClose();
|
onClose();
|
||||||
|
|
@ -254,260 +99,40 @@ 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 ? (
|
||||||
{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>
|
<p>{t("recipes.loading")}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="tech-step-correction-popover__confirm">
|
<ul className="tech-step-correction-popover__list">
|
||||||
<section className="tech-step-correction-popover__technique-section">
|
|
||||||
<div className="tech-step-correction-popover__technique-header">
|
|
||||||
<h4>{t("recipes.techStepCorrection.techniqueSection")}</h4>
|
|
||||||
{previousTechStepId !== null && (
|
{previousTechStepId !== null && (
|
||||||
|
<li>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onClick={removeMatch}
|
onClick={() => submit(null)}
|
||||||
className="tech-step-correction-popover__remove"
|
className="tech-step-correction-popover__remove"
|
||||||
>
|
>
|
||||||
{t("recipes.techStepCorrection.removeMatch")}
|
{t("recipes.techStepCorrection.removeMatch")}
|
||||||
</button>
|
</button>
|
||||||
|
</li>
|
||||||
)}
|
)}
|
||||||
</div>
|
{techSteps.map((techStep) => (
|
||||||
<p className="tech-step-correction-popover__chosen-technique">
|
<li key={techStep.id}>
|
||||||
{selectedTechStepId !== null
|
|
||||||
? t("recipes.techStepCorrection.currentTechnique", {
|
|
||||||
technique: t(
|
|
||||||
`catalog.techSteps.${techSteps.find((ts) => ts.id === selectedTechStepId)?.key ?? ""}`,
|
|
||||||
),
|
|
||||||
})
|
|
||||||
: t("recipes.techStepCorrection.noTechniqueSelected")}
|
|
||||||
</p>
|
|
||||||
<CatalogSearchPicker
|
|
||||||
items={techSteps.map((techStep) => ({
|
|
||||||
id: techStep.id,
|
|
||||||
label: t(`catalog.techSteps.${techStep.key}`),
|
|
||||||
}))}
|
|
||||||
selectedId={selectedTechStepId ?? undefined}
|
|
||||||
onSelect={setSelectedTechStepId}
|
|
||||||
placeholder={t("recipes.techStepCorrection.searchTechniquePlaceholder")}
|
|
||||||
emptyLabel={t("recipes.techStepCorrection.noTechniqueFound")}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="tech-step-correction-popover__metadata-section">
|
|
||||||
<h4>{t("recipes.techStepCorrection.ingredientsSection")}</h4>
|
|
||||||
<ul className="tech-step-correction-popover__chips">
|
|
||||||
{pendingIngredients.map((ingredient, index) => {
|
|
||||||
const view = ingredientById.get(ingredient.ingredientId);
|
|
||||||
const unit =
|
|
||||||
ingredient.unitId !== null ? unitById.get(ingredient.unitId) : undefined;
|
|
||||||
const label = view ? t(`catalog.ingredients.${view.key}`) : "…";
|
|
||||||
return (
|
|
||||||
// biome-ignore lint/suspicious/noArrayIndexKey: `pendingIngredients` has no other stable identity (an ingredient can appear more than once, each with its own span) — always fully rebuilt on add/remove, never reordered in place.
|
|
||||||
<li key={index} className="tech-step-correction-popover__chip">
|
|
||||||
{ingredient.quantity !== null ? `${ingredient.quantity} ` : ""}
|
|
||||||
{unit ? `${t(`catalog.units.${unit.key}`)} ` : ""}
|
|
||||||
{label}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeIngredient(index)}
|
disabled={isSubmitting || techStep.id === previousTechStepId}
|
||||||
title={t("recipes.techStepCorrection.removeIngredient")}
|
onClick={() => submit(techStep.id)}
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
>
|
||||||
✕
|
{t(`catalog.techSteps.${techStep.key}`)}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</ul>
|
</ul>
|
||||||
<button type="button" onClick={() => requestSpan("ingredient")} disabled={isSubmitting}>
|
|
||||||
{t("recipes.techStepCorrection.addIngredient")}
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="tech-step-correction-popover__metadata-section">
|
|
||||||
<h4>{t("recipes.techStepCorrection.utensilsSection")}</h4>
|
|
||||||
<ul className="tech-step-correction-popover__chips">
|
|
||||||
{pendingUtensils.map((utensil, index) => {
|
|
||||||
const view = utensilById.get(utensil.utensilId);
|
|
||||||
return (
|
|
||||||
// biome-ignore lint/suspicious/noArrayIndexKey: same reasoning as the ingredient chip list above.
|
|
||||||
<li key={index} className="tech-step-correction-popover__chip">
|
|
||||||
{view ? t(`catalog.utensils.${view.key}`) : "…"}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeUtensil(index)}
|
|
||||||
title={t("recipes.techStepCorrection.removeUtensil")}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
<button type="button" onClick={() => requestSpan("utensil")} disabled={isSubmitting}>
|
|
||||||
{t("recipes.techStepCorrection.addUtensil")}
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="tech-step-correction-popover__confirm-button"
|
|
||||||
onClick={confirm}
|
|
||||||
disabled={isSubmitting || selectedTechStepId === null}
|
|
||||||
>
|
|
||||||
{t("recipes.techStepCorrection.confirm")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <p className="field-error">{error}</p>}
|
{error && <p className="field-error">{error}</p>}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@
|
||||||
"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"
|
||||||
},
|
},
|
||||||
|
|
@ -169,24 +168,7 @@
|
||||||
"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."
|
||||||
"confirm": "Valider",
|
|
||||||
"techniqueSection": "Technique",
|
|
||||||
"currentTechnique": "Technique retenue : {{technique}}",
|
|
||||||
"noTechniqueSelected": "Aucune technique sélectionnée.",
|
|
||||||
"searchTechniquePlaceholder": "Rechercher une technique…",
|
|
||||||
"noTechniqueFound": "Aucune technique trouvée.",
|
|
||||||
"ingredientsSection": "Ingrédients",
|
|
||||||
"utensilsSection": "Ustensiles",
|
|
||||||
"addIngredient": "+ Ajouter un ingrédient",
|
|
||||||
"addUtensil": "+ Ajouter un ustensile",
|
|
||||||
"removeIngredient": "Retirer cet ingrédient",
|
|
||||||
"removeUtensil": "Retirer cet ustensile",
|
|
||||||
"selectSpanHint": "Sélectionnez le passage de texte concerné dans la description ci-dessus…",
|
|
||||||
"cancelSpanSelection": "Annuler la sélection",
|
|
||||||
"searchUtensilPlaceholder": "Rechercher un ustensile…",
|
|
||||||
"noUtensilFound": "Aucun ustensile trouvé.",
|
|
||||||
"addToList": "Ajouter"
|
|
||||||
},
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"favoris": "Favoris",
|
"favoris": "Favoris",
|
||||||
|
|
|
||||||
|
|
@ -68,8 +68,6 @@ 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. */
|
||||||
|
|
|
||||||
|
|
@ -117,41 +117,6 @@ 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
|
||||||
|
|
@ -164,19 +129,6 @@ const manualStepTechStepUtensilInputSchema = z
|
||||||
* (`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({
|
||||||
|
|
@ -184,8 +136,6 @@ 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",
|
||||||
|
|
@ -198,15 +148,6 @@ 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>;
|
||||||
|
|
|
||||||
|
|
@ -81,10 +81,6 @@ 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;
|
||||||
|
|
@ -92,15 +88,13 @@ 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}) — `source` mirrors {@link StepTechStepIngredientView.source}. */
|
/** A utensil mentioned in the same clause as a detected technique (see {@link StepTechStepView.utensils}). */
|
||||||
export interface StepTechStepUtensilView {
|
export interface StepTechStepUtensilView {
|
||||||
utensil: UtensilView;
|
utensil: UtensilView;
|
||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
source: "auto" | "manual";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue