feat(recipes): écran de revue et finalisation de l'import (étape 3/4)

Backend :
- `createRecipe` refactorisé en fine enveloppe autour d'un nouvel
  helper interne `createRecipeInternal`, paramétré par une source
  d'import optionnelle ; nouvelle fonction exportée
  `createImportedRecipe` qui réutilise toute la validation
  ingrédients/unités/diets et le matching des tech steps, sans
  dupliquer cette logique.
- La locale de l'adaptateur source est propagée jusqu'au chargement
  des `TechStepMapping`, pour que le texte anglais (TheMealDB, etc.)
  soit matché contre le bon jeu de règles au lieu du défaut français.
- Nouvel endpoint `POST /sources/:sourceKey/import/:externalId` —
  valide le payload via `createRecipeSchema` (même schéma qu'une
  création manuelle) et persiste une vraie `Recipe` liée à la source
  (`sourceId`/`externalId`).
- Nouveau code d'erreur `RECIPE_ALREADY_IMPORTED` (4022) quand
  l'item a déjà été importé pour ce foyer.

Frontend :
- `ImportRecipePage` (nouvelle page, `/recettes/importer/:sourceKey/:externalId`) —
  pré-remplit le formulaire depuis `previewSourceItem`, en miroir de
  `RecipeFormPage` (mêmes sous-composants : `IngredientRow`,
  `IngredientPicker`, `StepListEditor`, `DietTagSelect`). Ajoute une
  section dédiée aux lignes d'ingrédients non résolues automatiquement :
  l'utilisateur choisit un ingrédient réel via l'`IngredientPicker`
  existant ou retire la ligne — aucune recette invalide n'est jamais
  soumise, le bouton d'import reste désactivé tant qu'il en reste.
- `SourceItemPreviewPanel` gagne un lien « Importer cette recette »
  vers cet écran.

Tests :
- Mocha (`apps/api/test/sources.test.ts`) : 6 nouveaux tests sur
  `POST /sources/:sourceKey/import/:externalId` (payload valide,
  ingrédient/unité inconnus, déjà importé, deux foyers distincts,
  locale de la source respectée pour les tech steps). 282 tests
  passent au total, aucune régression.
- Cypress : nouveau scénario Gherkin bout-en-bout dans
  `recipe-sources.feature` (parcourir → prévisualiser → importer →
  résoudre un ingrédient non reconnu → confirmer → atterrir sur la
  recette sauvegardée). Steps d'édition d'ingrédients/étapes
  génériques déplacés de `recipe-form.ts` vers
  `cypress/support/step_definitions/common.steps.ts`, réutilisables
  par ce nouveau scénario.

Suite : étape 4 (ajouter au planning déclenche l'import si nécessaire).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-08-20 16:56:06 +02:00
parent a860363438
commit e4c8d910d4
15 changed files with 895 additions and 76 deletions

View file

@ -364,11 +364,46 @@ export async function createRecipe(
input: CreateRecipeInput, input: CreateRecipeInput,
authorId: number, authorId: number,
authorHouseId: number | null, authorHouseId: number | null,
): Promise<RecipeView> {
return createRecipeInternal(input, authorId, authorHouseId, null);
}
/**
* Finalizes an import from an external source same validation/creation
* path as {@link createRecipe} (by the time this is called, `input` has
* already been reviewed and every ingredient resolved to a real catalog
* id, same as a manual creation see `sources.service.ts`'s
* `importSourceItem`, the only caller), plus stamping `sourceId`/
* `externalId` and matching techniques against `locale` (the source's own
* e.g. `"en"` for TheMealDB) instead of the hardcoded French default,
* since the step text is still in whatever language the source wrote it
* in.
*
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
*/
export async function createImportedRecipe(
input: CreateRecipeInput,
authorId: number,
authorHouseId: number | null,
source: { sourceId: number; externalId: string; locale: string },
): Promise<RecipeView> {
return createRecipeInternal(input, authorId, authorHouseId, source);
}
async function createRecipeInternal(
input: CreateRecipeInput,
authorId: number,
authorHouseId: number | null,
source: { sourceId: number; externalId: string; locale: string } | null,
): Promise<RecipeView> { ): Promise<RecipeView> {
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId)); await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
await assertUnitsExist(input.ingredients.map((i) => i.unitId)); await assertUnitsExist(input.ingredients.map((i) => i.unitId));
await assertDietsExist(input.dietIds); await assertDietsExist(input.dietIds);
const techStepMappings = await loadTechStepMappingRules(DEFAULT_TECH_STEP_LOCALE); const techStepMappings = await loadTechStepMappingRules(
source?.locale ?? DEFAULT_TECH_STEP_LOCALE,
);
const created = await prisma.recipe.create({ const created = await prisma.recipe.create({
data: { data: {
@ -379,6 +414,8 @@ export async function createRecipe(
authorId, authorId,
authorHouseId, authorHouseId,
visibility: input.visibility, visibility: input.visibility,
sourceId: source?.sourceId ?? null,
externalId: source?.externalId ?? null,
ingredients: { ingredients: {
create: input.ingredients.map((ingredient) => ({ create: input.ingredients.map((ingredient) => ({
ingredientId: ingredient.ingredientId, ingredientId: ingredient.ingredientId,

View file

@ -1,9 +1,9 @@
import { HttpError } from "@batch-cooking/error-tools"; import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools"; import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { ErrorCode, browseSourceSchema } from "@batch-cooking/shared"; import { ErrorCode, browseSourceSchema, createRecipeSchema } from "@batch-cooking/shared";
import { Router } from "express"; import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { browseSource, previewSourceItem } from "./sources.service.js"; import { browseSource, importSourceItem, previewSourceItem } from "./sources.service.js";
/** /**
* Router mounted at `/sources` in app.ts browsing/previewing a * Router mounted at `/sources` in app.ts browsing/previewing a
@ -49,3 +49,23 @@ sourcesRouter.get(
); );
}), }),
); );
sourcesRouter.post(
"/:sourceKey/import/:externalId",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = createRecipeSchema.parse(req.body);
const { id: authorId, houseId } = res.locals.userProfile;
res
.status(201)
.json(
await importSourceItem(
requireParam(req.params.sourceKey),
requireParam(req.params.externalId),
input,
authorId,
houseId,
),
);
}),
);

View file

@ -1,10 +1,12 @@
import { HttpError } from "@batch-cooking/error-tools"; import { HttpError } from "@batch-cooking/error-tools";
import { import {
type BrowsableSourceItemView, type BrowsableSourceItemView,
type CreateRecipeInput,
type DraftRecipeIngredientView, type DraftRecipeIngredientView,
type DraftRecipeStepView, type DraftRecipeStepView,
ErrorCode, ErrorCode,
type RecipeImportDraftView, type RecipeImportDraftView,
type RecipeView,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js"; import { prisma } from "../../db/prisma.js";
import { findImportedRecipeIds } from "../../db/recipe-source-sync.js"; import { findImportedRecipeIds } from "../../db/recipe-source-sync.js";
@ -20,18 +22,20 @@ import { getRecipeSource } from "../../lib/recipe-source-registry.js";
import { translateRecipeIngredients } from "../../lib/recipe-translation.js"; import { translateRecipeIngredients } from "../../lib/recipe-translation.js";
import { loadTechStepMappingRules, matchTechStepSpans } from "../../lib/tech-step-matcher.js"; import { loadTechStepMappingRules, matchTechStepSpans } from "../../lib/tech-step-matcher.js";
import { getHouseSourceIds } from "../house/house.service.js"; import { getHouseSourceIds } from "../house/house.service.js";
import { createImportedRecipe } from "../recipe/recipe.service.js";
import { getIngredients, getUnits } from "../reference/reference.service.js"; import { getIngredients, getUnits } from "../reference/reference.service.js";
/** /**
* Browsing and previewing a household's *enabled* external recipe sources * Browsing, previewing, and importing a household's *enabled* external
* (`HouseSource`) the read-only half of the "onglet Sources" feature * recipe sources (`HouseSource`) the "onglet Sources" feature (see the
* (see the project plan). Neither function here saves anything: browsing * project plan). Browsing lists what a source offers
* lists what a source offers (`RecipeSourceAdapter.list()`), previewing * (`RecipeSourceAdapter.list()`); previewing fully translates one item
* fully translates one item (`translateRecipeIngredients`, * (`translateRecipeIngredients`, `matchTechStepSpans` same building
* `matchTechStepSpans` same building blocks `recipe.service.ts` uses at * blocks `recipe.service.ts` uses at real save time) without persisting
* actual save time) without persisting it. Turning a preview into a real * it; importing (`importSourceItem`) is the only function here that
* `Recipe` (with unresolved ingredients reviewed/fixed up first) is a * actually saves by the time it's called, the caller (the review screen)
* later stage of the same plan, not built here. * has already resolved every ingredient to a real catalog id, same as a
* manual `POST /recipes`.
*/ */
/** /**
@ -50,7 +54,7 @@ import { getIngredients, getUnits } from "../reference/reference.service.js";
async function assertSourceEnabled( async function assertSourceEnabled(
houseId: number | null, houseId: number | null,
sourceKey: string, sourceKey: string,
): Promise<RecipeSourceAdapter> { ): Promise<{ adapter: RecipeSourceAdapter; sourceId: number }> {
const enabledSourceIds = await getHouseSourceIds(houseId); const enabledSourceIds = await getHouseSourceIds(houseId);
const source = await prisma.source.findUnique({ where: { key: sourceKey } }); const source = await prisma.source.findUnique({ where: { key: sourceKey } });
if (!source || !enabledSourceIds.includes(source.id)) { if (!source || !enabledSourceIds.includes(source.id)) {
@ -68,7 +72,7 @@ async function assertSourceEnabled(
`Source "${sourceKey}" has no registered adapter`, `Source "${sourceKey}" has no registered adapter`,
); );
} }
return adapter; return { adapter, sourceId: source.id };
} }
/** /**
@ -84,7 +88,7 @@ export async function browseSource(
houseId: number | null, houseId: number | null,
params: { query?: string; cursor?: string }, params: { query?: string; cursor?: string },
): Promise<{ items: BrowsableSourceItemView[]; nextCursor: string | null }> { ): Promise<{ items: BrowsableSourceItemView[]; nextCursor: string | null }> {
const adapter = await assertSourceEnabled(houseId, sourceKey); const { adapter } = await assertSourceEnabled(houseId, sourceKey);
const result = await adapter.list({ query: params.query, cursor: params.cursor }); const result = await adapter.list({ query: params.query, cursor: params.cursor });
const importedRecipeIds = await findImportedRecipeIds( const importedRecipeIds = await findImportedRecipeIds(
@ -128,7 +132,7 @@ export async function previewSourceItem(
externalId: string, externalId: string,
houseId: number | null, houseId: number | null,
): Promise<RecipeImportDraftView> { ): Promise<RecipeImportDraftView> {
const adapter = await assertSourceEnabled(houseId, sourceKey); const { adapter } = await assertSourceEnabled(houseId, sourceKey);
let parsed: ReturnType<typeof adapter.parse>; let parsed: ReturnType<typeof adapter.parse>;
try { try {
@ -189,3 +193,48 @@ export async function previewSourceItem(
steps, steps,
}; };
} }
/**
* Finalizes an import the review screen (pre-filled from
* {@link previewSourceItem}'s draft, unresolved ingredients fixed up by
* the user via the normal `IngredientPicker`) submits `input` as a
* regular {@link CreateRecipeInput}, exactly like a manually-authored
* recipe. This just adds two things `createRecipe` itself can't:
* confirming `externalId` isn't already imported (the DB's own
* `@@unique([sourceId, externalId])` would reject a second attempt too,
* but as a raw constraint violation checking first gives a clean,
* expected error instead), and stamping `sourceId`/`externalId` plus
* matching techniques against the source's own locale
* (`createImportedRecipe`, `recipe.service.ts`).
*
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
* @throws {HttpError} `404 SOURCE_NOT_FOUND` if `sourceKey` doesn't match a source enabled for this household.
* @throws {HttpError} `409 RECIPE_ALREADY_IMPORTED` if `externalId` was already imported from this source.
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
*/
export async function importSourceItem(
sourceKey: string,
externalId: string,
input: CreateRecipeInput,
authorId: number,
authorHouseId: number | null,
): Promise<RecipeView> {
const { adapter, sourceId } = await assertSourceEnabled(authorHouseId, sourceKey);
const alreadyImported = await findImportedRecipeIds(prisma, sourceKey, [externalId]);
if (alreadyImported.has(externalId)) {
throw new HttpError(
409,
ErrorCode.RECIPE_ALREADY_IMPORTED,
`"${externalId}" from source "${sourceKey}" is already imported`,
);
}
return createImportedRecipe(input, authorId, authorHouseId, {
sourceId,
externalId,
locale: adapter.locale,
});
}

View file

@ -78,6 +78,18 @@ function buildFakeAdapter(key = "fakeSource"): RecipeSourceAdapter<{ externalId:
}; };
} }
/** Resolves a reference ingredient's id by its `reference-seed-data.ts` uid (also its DB `key`) — same reasoning as `recipe.test.ts`'s own helper. */
async function ingredientId(key: string): Promise<number> {
const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { key } });
return ingredient.id;
}
/** Resolves a reference unit's id by its `reference-seed-data.ts` uid (also its DB `key`) — same reasoning as {@link ingredientId}. */
async function unitId(key: string): Promise<number> {
const unit = await prisma.unit.findFirstOrThrow({ where: { key } });
return unit.id;
}
describe("Sources", () => { describe("Sources", () => {
const app = createApp(); const app = createApp();
@ -251,4 +263,123 @@ describe("Sources", () => {
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND); expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
}); });
}); });
describe("POST /sources/:sourceKey/import/:externalId", () => {
async function enableFakeSource(): Promise<{
agent: ReturnType<typeof request.agent>;
sourceId: number;
}> {
const { agent } = await signupWithHouse();
registerRecipeSource(buildFakeAdapter());
await syncRecipeSources(prisma);
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
await agent.patch("/house/current/sources").send({ sourceIds: [source.id] });
return { agent, sourceId: source.id };
}
/** A fully-resolved payload, as the review screen would submit it — every ingredient already has a real ingredientId/unitId, same shape `POST /recipes` accepts. */
async function buildImportPayload() {
return {
name: "Fake recipe 1 (revue)",
portions: 4,
dietIds: [],
ingredients: [
{ ingredientId: await ingredientId("onion"), quantity: 1, unitId: await unitId("piece") },
],
steps: [{ description: "Chop the onions finely" }],
};
}
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app)
.post("/sources/fakeSource/import/1")
.send(await buildImportPayload());
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("rejects a source the household hasn't enabled with 404 SOURCE_NOT_FOUND", async () => {
const { agent } = await signupWithHouse();
registerRecipeSource(buildFakeAdapter());
await syncRecipeSources(prisma);
const res = await agent.post("/sources/fakeSource/import/1").send(await buildImportPayload());
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.SOURCE_NOT_FOUND);
});
it("creates the recipe with sourceId/externalId set, matching techniques against the source's own locale", async () => {
const { agent, sourceId } = await enableFakeSource();
const chop = await prisma.techStep.findFirstOrThrow({ where: { key: "chop" } });
const res = await agent.post("/sources/fakeSource/import/1").send(await buildImportPayload());
expect(res.status).to.equal(201);
const created = await prisma.recipe.findUniqueOrThrow({ where: { id: res.body.id } });
expect(created.sourceId).to.equal(sourceId);
expect(created.externalId).to.equal("1");
// The step text is English ("Chop the onions finely") — this only
// matches "chop" if the fake adapter's own locale ("en") was used
// for tech-step matching, not the hardcoded French default (which
// would find nothing in English text — see recipe-translation.test.ts's
// "locales are separate rule sets" test for the same point made the
// other way around).
const step = await prisma.step.findFirstOrThrow({ where: { recipeId: created.id } });
const stepTechSteps = await prisma.stepTechStep.findMany({ where: { stepId: step.id } });
expect(stepTechSteps.map((s) => s.techStepId)).to.deep.equal([chop.id]);
});
it("rejects a second import of the same item with 409 RECIPE_ALREADY_IMPORTED", async () => {
const { agent } = await enableFakeSource();
const first = await agent
.post("/sources/fakeSource/import/1")
.send(await buildImportPayload());
expect(first.status).to.equal(201);
const second = await agent
.post("/sources/fakeSource/import/1")
.send(await buildImportPayload());
expect(second.status).to.equal(409);
expect(second.body.code).to.equal(ErrorCode.RECIPE_ALREADY_IMPORTED);
});
it("rejects an unknown ingredientId with 404 INGREDIENT_NOT_FOUND, same as a manual creation", async () => {
const { agent } = await enableFakeSource();
const payload = await buildImportPayload();
payload.ingredients[0].ingredientId = 999_999;
const res = await agent.post("/sources/fakeSource/import/1").send(payload);
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND);
});
it("rejects a second household's import of the same item too — the item's identity is global, not per-household", async () => {
// Registers/syncs the adapter once — enableFakeSource() itself does
// this too, and registerRecipeSource() throws on a duplicate key, so
// calling it twice in one test (once per household) isn't an option.
registerRecipeSource(buildFakeAdapter());
await syncRecipeSources(prisma);
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
const { agent: firstAgent } = await signupWithHouse();
await firstAgent.patch("/house/current/sources").send({ sourceIds: [source.id] });
const firstImport = await firstAgent
.post("/sources/fakeSource/import/1")
.send(await buildImportPayload());
expect(firstImport.status).to.equal(201);
const { agent: secondAgent } = await signupWithHouse();
await secondAgent.patch("/house/current/sources").send({ sourceIds: [source.id] });
const secondImport = await secondAgent
.post("/sources/fakeSource/import/1")
.send(await buildImportPayload());
expect(secondImport.status).to.equal(409);
expect(secondImport.body.code).to.equal(ErrorCode.RECIPE_ALREADY_IMPORTED);
});
});
}); });

View file

@ -57,66 +57,6 @@ When("I visit the new recipe form without a secure random UUID", () => {
}); });
}); });
When("I search the ingredient picker for {string}", (text: string) => {
cy.get("input[placeholder='Rechercher un ingrédient…']").type(text);
});
When("I select the ingredient {string} from the picker", (name: string) => {
cy.contains(".ingredient-picker__card", name).click();
});
Then("the ingredient {string} should no longer be in the picker", (name: string) => {
cy.contains(".ingredient-picker__card", name).should("not.exist");
});
Then("the ingredient {string} should be visible in the picker", (name: string) => {
cy.contains(".ingredient-picker__card", name).should("be.visible");
});
Then("the recipe should include the ingredient {string}", (name: string) => {
cy.contains(".ingredient-row__name", name).should("be.visible");
});
Then("there should be {int} ingredient rows", (count: number) => {
cy.get(".ingredient-row").should("have.length", count);
});
When("I remove the ingredient {string} from the recipe", (name: string) => {
cy.contains(".ingredient-row", name).find("button[title='Retirer cet ingrédient']").click();
});
When(
"I fill in the ingredient's quantity with {string} and unit {string}",
(quantity: string, unit: string) => {
cy.get(".ingredient-row .ingredient-row__quantity").type(quantity);
cy.get(".ingredient-row .ingredient-row__unit").select(unit);
},
);
Then("the ingredient's quantity should be {string}", (quantity: string) => {
cy.get(".ingredient-row .ingredient-row__quantity").should("have.value", quantity);
});
When(
"I fill in the last ingredient's quantity with {string} and unit {string}",
(quantity: string, unit: string) => {
cy.get(".ingredient-row .ingredient-row__quantity").last().type(quantity);
cy.get(".ingredient-row .ingredient-row__unit").last().select(unit);
},
);
When("I add a step", () => {
cy.contains("button", "Ajouter une étape").click();
});
When("I fill in the step description with {string}", (text: string) => {
cy.get(".step-list-editor__item textarea").type(text);
});
Then("there should be {int} step editor items", (count: number) => {
cy.get(".step-list-editor__item").should("have.length", count);
});
Then( Then(
"the recipe creation request should have included name {string}, portions {int}, and ingredient {int} with quantity {int} and unitId {int}", "the recipe creation request should have included name {string}, portions {int}, and ingredient {int} with quantity {int} and unitId {int}",
(name: string, portions: number, ingredientId: number, quantity: number, unitId: number) => { (name: string, portions: number, ingredientId: number, quantity: number, unitId: number) => {

View file

@ -44,3 +44,30 @@ Feature: Browsing external recipe sources
And I click the source item "Fish Pie" And I click the source item "Fish Pie"
Then the recipe detail panel heading should be "Fish Pie" Then the recipe detail panel heading should be "Fish Pie"
And I should see the highlighted technique "Cuire" And I should see the highlighted technique "Cuire"
Scenario: Reviews an import, resolving an unrecognized ingredient before confirming
Given the recipe catalog contains nothing
And the sources reference list has options
And the household has enabled TheMealDB
And browsing TheMealDB returns some items
And previewing TheMealDB item "9999" is available
And the ingredient and diet catalog is available for import
And importing the previewed item will succeed and return id 99
When I visit "/recettes"
And I click the button "Sources"
And I click the source item "Fish Pie"
And I click the button "Importer cette recette"
Then the "recipe-name" field should have the value "Fish Pie"
And the recipe should include the ingredient "Oignon"
When I choose an ingredient for the unresolved line "some mystery paste"
And I select the ingredient "Sel" from the picker
Then the unresolved ingredients section should no longer be shown
And there should be 2 ingredient rows
When I select unit "unité" for the first ingredient
And I fill in the last ingredient's quantity with "1" and unit "unité"
Then the "Importer" button should not be disabled
When I click the button "Importer"
Then the import request should have included ingredient 2 with quantity 1 and unitId 1
And the URL should include "/recettes/99"

View file

@ -134,3 +134,67 @@ When("I click the source item {string}", (title: string) => {
Then("the source item {string} should be marked as already imported", (title: string) => { Then("the source item {string} should be marked as already imported", (title: string) => {
cy.contains("tr", title).find(".source-item-table__imported-badge").should("be.visible"); cy.contains("tr", title).find(".source-item-table__imported-badge").should("be.visible");
}); });
// ImportRecipePage (the review screen) loads its own ingredient/diet/unit
// catalogs the same way RecipeFormPage does — "onion" matches the resolved
// line in "previewing TheMealDB item ... is available" above, "salt" is
// what "some mystery paste" (unresolved in that same fixture) gets
// corrected to in the review-and-import scenario.
Given("the ingredient and diet catalog is available for import", () => {
cy.intercept("GET", "**/reference/ingredients", {
statusCode: 200,
body: [
{
id: 1,
key: "onion",
icon: "VEGETABLE",
category: "freshProduce",
subcategory: "vegetables",
allergens: [],
diets: [],
},
{
id: 2,
key: "salt",
icon: "SPICE",
category: "condimentsAndSpices",
subcategory: "spices",
allergens: [],
diets: [],
},
],
});
cy.intercept("GET", "**/reference/diets", {
statusCode: 200,
body: [{ id: 1, key: "omnivore" }],
});
cy.intercept("GET", "**/reference/units", {
statusCode: 200,
body: [{ id: 1, key: "piece", type: "COUNT", toBaseFactor: 1 }],
});
});
Given("importing the previewed item will succeed and return id {int}", (id: number) => {
cy.intercept("POST", "**/sources/theMealDb/import/9999", { statusCode: 201, body: { id } }).as(
"importRecipe",
);
});
When("I choose an ingredient for the unresolved line {string}", (rawText: string) => {
cy.contains(".import-recipe__unresolved-row", rawText)
.contains("button", "Choisir un ingrédient")
.click();
});
Then("the unresolved ingredients section should no longer be shown", () => {
cy.get(".import-recipe__unresolved").should("not.exist");
});
Then(
"the import request should have included ingredient {int} with quantity {int} and unitId {int}",
(ingredientId: number, quantity: number, unitId: number) => {
cy.wait("@importRecipe")
.its("request.body.ingredients")
.should("include.deep.members", [{ ingredientId, quantity, unitId }]);
},
);

View file

@ -159,6 +159,74 @@ Then("the tooltip should show {string}", (label: string) => {
cy.get(".tooltip__bubble").contains(label).should("be.visible"); cy.get(".tooltip__bubble").contains(label).should("be.visible");
}); });
// `IngredientPicker`/`IngredientRow`/`StepListEditor` (features/recipes/)
// back both RecipeFormPage and ImportRecipePage — recipe-form.feature and
// import-recipe.feature both need these.
When("I search the ingredient picker for {string}", (text: string) => {
cy.get("input[placeholder='Rechercher un ingrédient…']").type(text);
});
When("I select the ingredient {string} from the picker", (name: string) => {
cy.contains(".ingredient-picker__card", name).click();
});
Then("the ingredient {string} should no longer be in the picker", (name: string) => {
cy.contains(".ingredient-picker__card", name).should("not.exist");
});
Then("the ingredient {string} should be visible in the picker", (name: string) => {
cy.contains(".ingredient-picker__card", name).should("be.visible");
});
Then("the recipe should include the ingredient {string}", (name: string) => {
cy.contains(".ingredient-row__name", name).should("be.visible");
});
Then("there should be {int} ingredient rows", (count: number) => {
cy.get(".ingredient-row").should("have.length", count);
});
When("I remove the ingredient {string} from the recipe", (name: string) => {
cy.contains(".ingredient-row", name).find("button[title='Retirer cet ingrédient']").click();
});
When(
"I fill in the ingredient's quantity with {string} and unit {string}",
(quantity: string, unit: string) => {
cy.get(".ingredient-row .ingredient-row__quantity").type(quantity);
cy.get(".ingredient-row .ingredient-row__unit").select(unit);
},
);
Then("the ingredient's quantity should be {string}", (quantity: string) => {
cy.get(".ingredient-row .ingredient-row__quantity").should("have.value", quantity);
});
When("I select unit {string} for the first ingredient", (unit: string) => {
cy.get(".ingredient-row .ingredient-row__unit").first().select(unit);
});
When(
"I fill in the last ingredient's quantity with {string} and unit {string}",
(quantity: string, unit: string) => {
cy.get(".ingredient-row .ingredient-row__quantity").last().type(quantity);
cy.get(".ingredient-row .ingredient-row__unit").last().select(unit);
},
);
When("I add a step", () => {
cy.contains("button", "Ajouter une étape").click();
});
When("I fill in the step description with {string}", (text: string) => {
cy.get(".step-list-editor__item textarea").type(text);
});
Then("there should be {int} step editor items", (count: number) => {
cy.get(".step-list-editor__item").should("have.length", count);
});
Then("the checkbox {string} should be checked", (label: string) => { Then("the checkbox {string} should be checked", (label: string) => {
cy.contains("label", label).find("input[type=checkbox]").should("be.checked"); cy.contains("label", label).find("input[type=checkbox]").should("be.checked");
}); });

View file

@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from "react-router-dom";
import { RedirectIfAuthenticated } from "./features/auth/RedirectIfAuthenticated"; import { RedirectIfAuthenticated } from "./features/auth/RedirectIfAuthenticated";
import { RequireAuth } from "./features/auth/RequireAuth"; import { RequireAuth } from "./features/auth/RequireAuth";
import { AppLayout } from "./layouts/AppLayout"; import { AppLayout } from "./layouts/AppLayout";
import { ImportRecipePage } from "./pages/ImportRecipePage";
import { LoginPage } from "./pages/LoginPage"; import { LoginPage } from "./pages/LoginPage";
import { PlanningPage } from "./pages/PlanningPage"; import { PlanningPage } from "./pages/PlanningPage";
import { RecipeFormPage } from "./pages/RecipeFormPage"; import { RecipeFormPage } from "./pages/RecipeFormPage";
@ -59,6 +60,7 @@ export function App() {
(see RecipesPage.tsx). */} (see RecipesPage.tsx). */}
<Route path="/recettes" element={<RecipesPage />} /> <Route path="/recettes" element={<RecipesPage />} />
<Route path="/recettes/nouvelle" element={<RecipeFormPage />} /> <Route path="/recettes/nouvelle" element={<RecipeFormPage />} />
<Route path="/recettes/importer/:sourceKey/:externalId" element={<ImportRecipePage />} />
<Route path="/recettes/:id" element={<RecipesPage />} /> <Route path="/recettes/:id" element={<RecipesPage />} />
<Route path="/recettes/:id/modifier" element={<RecipeFormPage />} /> <Route path="/recettes/:id/modifier" element={<RecipeFormPage />} />
<Route path="/liste-de-courses" element={<ShoppingListPage />} /> <Route path="/liste-de-courses" element={<ShoppingListPage />} />

View file

@ -192,6 +192,18 @@ export class ApiClient {
return this.request(`/sources/${sourceKey}/preview/${encodeURIComponent(externalId)}`); return this.request(`/sources/${sourceKey}/preview/${encodeURIComponent(externalId)}`);
} }
/** Finalizes an import — `input` is a fully-resolved `CreateRecipeInput`, exactly like a manual `createRecipe()` call (the review screen, `ImportRecipePage`, is what makes sure of that before calling this). Rejects with `RECIPE_ALREADY_IMPORTED` if this item was imported since the preview was fetched. */
public importSourceItem(
sourceKey: string,
externalId: string,
input: CreateRecipeInput,
): Promise<RecipeView> {
return this.request(`/sources/${sourceKey}/import/${encodeURIComponent(externalId)}`, {
method: "POST",
body: JSON.stringify(input),
});
}
/** /**
* One catalog tab (favoris/perso/foyer/publique see `RecipeTab`), * One catalog tab (favoris/perso/foyer/publique see `RecipeTab`),
* optionally narrowed further `search` (name substring), * optionally narrowed further `search` (name substring),

View file

@ -1,5 +1,6 @@
import type { RecipeImportDraftView } from "@batch-cooking/shared"; import type { RecipeImportDraftView } from "@batch-cooking/shared";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { StepDescription } from "./StepDescription"; import { StepDescription } from "./StepDescription";
import "./recipes.scss"; import "./recipes.scss";
@ -71,6 +72,12 @@ export function SourceItemPreviewPanel({ state }: { state: SourceItemPreviewStat
</div> </div>
<div className="recipe-detail-panel__actions"> <div className="recipe-detail-panel__actions">
<Link
to={`/recettes/importer/${draft.sourceKey}/${encodeURIComponent(draft.externalId)}`}
className="recipes-page__new-button"
>
{t("recipes.sources.detail.importButton")}
</Link>
<a <a
href={draft.sourceUrl} href={draft.sourceUrl}
target="_blank" target="_blank"

View file

@ -780,6 +780,59 @@
} }
} }
// --- Unresolved-ingredient review (ImportRecipePage) ------------------------
// Amber-tinted callout, same "warning" language as .source-item-preview__hint
// each line needs a person to pick the right ingredient (or drop it)
// before the form can submit at all (see ImportRecipePage.tsx's canSubmit).
.import-recipe__unresolved {
margin: var(--space-sm) 0;
padding: var(--space-sm) var(--space-md);
background: color-mix(in srgb, var(--color-warning) 8%, var(--color-surface));
border: 1px solid color-mix(in srgb, var(--color-warning) 30%, var(--color-border));
border-radius: var(--radius-md);
h3 {
margin: 0 0 var(--space-xs);
font-size: var(--font-size-base);
}
}
.import-recipe__unresolved-list {
list-style: none;
margin: var(--space-sm) 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.import-recipe__unresolved-row {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-wrap: wrap;
span {
flex: 1 1 auto;
font-size: var(--font-size-sm);
}
button {
flex: none;
padding: 0.3rem var(--space-sm);
font-family: var(--font-body);
font-size: var(--font-size-xs);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
cursor: pointer;
&:hover {
border-color: var(--color-primary);
}
}
}
// --- Diet tag multi-select (recipe form) ------------------------------------- // --- Diet tag multi-select (recipe form) -------------------------------------
// Checkbox-grid, same visual language as AllergySelect (profile-forms.scss) // Checkbox-grid, same visual language as AllergySelect (profile-forms.scss)
// global.scss's `label:has(> input[type="checkbox"])` rule already // global.scss's `label:has(> input[type="checkbox"])` rule already

View file

@ -19,6 +19,7 @@
"INVITE_CODE_NOT_FOUND": "Ce code d'invitation ne correspond à aucun foyer", "INVITE_CODE_NOT_FOUND": "Ce code d'invitation ne correspond à aucun foyer",
"RECIPE_NOT_FOUND": "Cette recette n'existe pas", "RECIPE_NOT_FOUND": "Cette recette n'existe pas",
"RECIPE_IN_USE": "Cette recette est encore utilisée dans un planning", "RECIPE_IN_USE": "Cette recette est encore utilisée dans un planning",
"RECIPE_ALREADY_IMPORTED": "Cette recette a déjà été importée",
"INGREDIENT_NOT_FOUND": "Un des ingrédients sélectionnés n'existe pas", "INGREDIENT_NOT_FOUND": "Un des ingrédients sélectionnés n'existe pas",
"UNIT_NOT_FOUND": "Une des unités sélectionnées n'existe pas", "UNIT_NOT_FOUND": "Une des unités sélectionnées n'existe pas",
"SOURCE_NOT_FOUND": "Une des sources sélectionnées n'existe pas", "SOURCE_NOT_FOUND": "Une des sources sélectionnées n'existe pas",
@ -184,11 +185,23 @@
"loading": "Chargement de l'aperçu…", "loading": "Chargement de l'aperçu…",
"loadError": "Impossible de charger l'aperçu de cette recette.", "loadError": "Impossible de charger l'aperçu de cette recette.",
"viewSource": "Voir sur le site d'origine", "viewSource": "Voir sur le site d'origine",
"importButton": "Importer cette recette",
"ingredientsCount_one": "{{count}} ingrédient", "ingredientsCount_one": "{{count}} ingrédient",
"ingredientsCount_other": "{{count}} ingrédients", "ingredientsCount_other": "{{count}} ingrédients",
"unresolvedIngredientsHint": "Certains ingrédients n'ont pas été reconnus automatiquement — ils pourront être corrigés à l'import.", "unresolvedIngredientsHint": "Certains ingrédients n'ont pas été reconnus automatiquement — ils pourront être corrigés à l'import.",
"stepsCount_one": "{{count}} étape", "stepsCount_one": "{{count}} étape",
"stepsCount_other": "{{count}} étapes" "stepsCount_other": "{{count}} étapes"
},
"import": {
"title": "Revoir l'import",
"loadError": "Impossible de charger cette recette pour le moment.",
"unresolvedTitle": "Ingrédients à compléter",
"unresolvedHint": "Ces lignes n'ont pas été reconnues automatiquement — choisissez le bon ingrédient, ou retirez-les.",
"resolveButton": "Choisir un ingrédient",
"discardButton": "Retirer cette ligne",
"submit": "Importer",
"submitting": "Import en cours…",
"genericError": "Le formulaire contient des erreurs"
} }
}, },
"detail": { "detail": {

View file

@ -0,0 +1,394 @@
import {
type CreateRecipeInput,
type DietView,
ErrorCode,
type IngredientView,
type RecipeVisibility,
type UnitView,
createRecipeSchema,
} from "@batch-cooking/shared";
import { type FormEvent, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import { ApiError, apiClient } from "../api/client";
import { DietTagSelect } from "../features/recipes/DietTagSelect";
import { IngredientPicker } from "../features/recipes/IngredientPicker";
import { IngredientRow } from "../features/recipes/IngredientRow";
import { type StepDraft, StepListEditor } from "../features/recipes/StepListEditor";
import "../features/recipes/recipes.scss";
import { makeClientKey } from "../lib/client-key";
import { errorMessageService } from "../services/error-message.service";
/** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). Same list as `RecipeFormPage`. */
const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"];
/** A resolved ingredient line — identical shape to `RecipeFormPage`'s own `IngredientLine`. */
interface IngredientLine {
key: string;
ingredient: IngredientView;
quantity: string;
unitId: number | null;
}
/** One draft line that didn't resolve to a real `Ingredient` on preview (`DraftRecipeIngredientView.ingredient === null`) — still needs a person to pick the right one, or discard it, before this recipe can be saved. */
interface UnresolvedIngredientLine {
key: string;
rawText: string;
quantity: string;
}
type LoadState = "loading" | "loaded" | "error";
/**
* Review screen for finalizing an import routed at
* `/recettes/importer/:sourceKey/:externalId` (reached from
* `SourceItemPreviewPanel`'s "Importer cette recette" button). Pre-filled
* from `GET /sources/:sourceKey/preview/:externalId` (the same draft the
* preview panel already showed), structurally the same form as
* `RecipeFormPage` same sub-components (`IngredientRow`,
* `IngredientPicker`, `StepListEditor`, `DietTagSelect`), same
* `CreateRecipeInput` submit shape plus one thing a manual creation
* never has to handle: ingredient lines the automatic matching
* (`ingredient-matcher.ts`) couldn't resolve. Those render as their own
* "à compléter" list, each needing a real ingredient picked (or the line
* discarded) before the form can submit never silently drops/guesses one,
* per the product decision this stage was built against (no invalid
* recipe is ever persisted).
*
* Submits to `POST /sources/:sourceKey/import/:externalId`
* (`apiClient.importSourceItem`) instead of `POST /recipes` the only
* other difference from `RecipeFormPage`'s own submit.
*/
export function ImportRecipePage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { sourceKey, externalId } = useParams<{ sourceKey: string; externalId: string }>();
const [loadState, setLoadState] = useState<LoadState>("loading");
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
const [unitsCatalog, setUnitsCatalog] = useState<UnitView[]>([]);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [picture, setPicture] = useState("");
const [portions, setPortions] = useState("4");
const [visibility, setVisibility] = useState<RecipeVisibility>("PERSONAL");
const [dietIds, setDietIds] = useState<number[]>([]);
const [ingredientLines, setIngredientLines] = useState<IngredientLine[]>([]);
const [unresolvedIngredients, setUnresolvedIngredients] = useState<UnresolvedIngredientLine[]>(
[],
);
// Which unresolved line's picker is currently open — at most one at a
// time (IngredientPicker is a whole browsable grid, not a compact
// popover; showing one per unresolved line at once would be unwieldy).
const [resolvingKey, setResolvingKey] = useState<string | null>(null);
const [steps, setSteps] = useState<StepDraft[]>([]);
const [formError, setFormError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
if (sourceKey === undefined || externalId === undefined) {
setLoadState("error");
return;
}
let cancelled = false;
setLoadState("loading");
Promise.all([
apiClient.getIngredients(),
apiClient.getDiets(),
apiClient.getUnits(),
apiClient.previewSourceItem(sourceKey, externalId),
])
.then(([ingredients, diets, units, draft]) => {
if (cancelled) return;
setIngredientsCatalog(ingredients);
setDietsCatalog(diets);
setUnitsCatalog(units);
setName(draft.name);
setDescription(draft.description ?? "");
setPicture(draft.picture ?? "");
setPortions(draft.portions !== null ? String(draft.portions) : "4");
const resolved: IngredientLine[] = [];
const unresolved: UnresolvedIngredientLine[] = [];
for (const line of draft.ingredients) {
if (line.ingredient !== null) {
resolved.push({
key: makeClientKey(),
ingredient: line.ingredient,
quantity: line.quantity !== null ? String(line.quantity) : "",
unitId: line.unit?.id ?? null,
});
} else {
unresolved.push({
key: makeClientKey(),
rawText: line.rawText,
quantity: line.quantity !== null ? String(line.quantity) : "",
});
}
}
setIngredientLines(resolved);
setUnresolvedIngredients(unresolved);
setSteps(
draft.steps.map((step) => ({
key: makeClientKey(),
description: step.description,
picture: step.picture ?? "",
})),
);
setLoadState("loaded");
})
.catch(() => {
if (!cancelled) setLoadState("error");
});
return () => {
cancelled = true;
};
}, [sourceKey, externalId]);
function addIngredient(ingredient: IngredientView) {
setIngredientLines((lines) => [
...lines,
{ key: makeClientKey(), ingredient, quantity: "", unitId: null },
]);
}
function updateIngredientLine(
key: string,
patch: Partial<Pick<IngredientLine, "quantity" | "unitId">>,
) {
setIngredientLines((lines) =>
lines.map((line) => (line.key === key ? { ...line, ...patch } : line)),
);
}
function removeIngredientLine(key: string) {
setIngredientLines((lines) => lines.filter((line) => line.key !== key));
}
/** Promotes an unresolved line into a real ingredient line, carrying its quantity over — its unit still needs picking, same as a freshly-added ingredient. */
function resolveIngredient(unresolvedKey: string, ingredient: IngredientView) {
setUnresolvedIngredients((lines) => {
const line = lines.find((l) => l.key === unresolvedKey);
if (line) {
setIngredientLines((resolved) => [
...resolved,
{ key: makeClientKey(), ingredient, quantity: line.quantity, unitId: null },
]);
}
return lines.filter((l) => l.key !== unresolvedKey);
});
setResolvingKey(null);
}
function discardUnresolvedIngredient(key: string) {
setUnresolvedIngredients((lines) => lines.filter((line) => line.key !== key));
setResolvingKey((current) => (current === key ? null : current));
}
const canSubmit =
name.trim().length > 0 &&
Number.isInteger(Number(portions)) &&
Number(portions) > 0 &&
ingredientLines.length > 0 &&
ingredientLines.every((line) => Number(line.quantity) > 0 && line.unitId !== null) &&
unresolvedIngredients.length === 0 &&
steps.length > 0 &&
steps.every((step) => step.description.trim().length > 0);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setFormError(null);
if (sourceKey === undefined || externalId === undefined) return;
const payload: CreateRecipeInput = {
name: name.trim(),
description: description.trim() || null,
picture: picture.trim() || null,
portions: Number(portions),
visibility,
dietIds,
ingredients: ingredientLines.map((line) => ({
ingredientId: line.ingredient.id,
quantity: Number(line.quantity),
// `canSubmit` already requires every line to have a unit picked —
// same "?? 0, the schema rejects it if ever reached" reasoning as
// RecipeFormPage's identical submit.
unitId: line.unitId ?? 0,
})),
steps: steps.map((step) => ({
description: step.description.trim(),
picture: step.picture.trim() || null,
})),
};
const result = createRecipeSchema.safeParse(payload);
if (!result.success) {
setFormError(result.error.issues[0]?.message ?? t("recipes.sources.import.genericError"));
return;
}
setIsSubmitting(true);
try {
const saved = await apiClient.importSourceItem(sourceKey, externalId, result.data);
navigate(`/recettes/${saved.id}`);
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setFormError(errorMessageService.getLabel(code));
} finally {
setIsSubmitting(false);
}
}
if (loadState === "loading") {
return (
<div className="recipe-form">
<p className="recipes-page__status">{t("recipes.loading")}</p>
</div>
);
}
if (loadState === "error") {
return (
<div className="recipe-form">
<p className="recipes-page__status recipes-page__status--error">
{t("recipes.sources.import.loadError")}
</p>
</div>
);
}
const selectedIds = ingredientLines.map((line) => line.ingredient.id);
return (
<form className="recipe-form" onSubmit={handleSubmit} noValidate>
<h1>{t("recipes.sources.import.title")}</h1>
<label htmlFor="recipe-name">{t("recipes.form.nameLabel")}</label>
<input id="recipe-name" value={name} onChange={(e) => setName(e.target.value)} />
<label htmlFor="recipe-description">{t("recipes.form.descriptionLabel")}</label>
<textarea
id="recipe-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
<label htmlFor="recipe-picture">{t("recipes.form.pictureLabel")}</label>
<input
id="recipe-picture"
type="url"
value={picture}
onChange={(e) => setPicture(e.target.value)}
placeholder="https://…"
/>
<label htmlFor="recipe-portions">{t("recipes.form.portionsLabel")}</label>
<input
id="recipe-portions"
type="number"
min="1"
step="1"
value={portions}
onChange={(e) => setPortions(e.target.value)}
/>
<label htmlFor="recipe-visibility">{t("recipes.form.visibilityLabel")}</label>
<select
id="recipe-visibility"
value={visibility}
onChange={(e) => setVisibility(e.target.value as RecipeVisibility)}
>
{VISIBILITY_OPTIONS.map((option) => (
<option key={option} value={option}>
{t(`recipes.form.visibility.${option}`)}
</option>
))}
</select>
<DietTagSelect diets={dietsCatalog} value={dietIds} onChange={setDietIds} />
<section className="recipe-form__section">
<h2>{t("recipes.ingredientsTitle")}</h2>
<ul className="recipe-form__ingredient-list">
{ingredientLines.map((line) => (
<IngredientRow
key={line.key}
ingredient={line.ingredient}
quantity={line.quantity}
unitId={line.unitId}
unitsCatalog={unitsCatalog}
onQuantityChange={(quantity) => updateIngredientLine(line.key, { quantity })}
onUnitChange={(unitId) => updateIngredientLine(line.key, { unitId })}
onRemove={() => removeIngredientLine(line.key)}
/>
))}
</ul>
{unresolvedIngredients.length > 0 && (
<section className="import-recipe__unresolved">
<h3>{t("recipes.sources.import.unresolvedTitle")}</h3>
<p className="source-item-preview__hint">
{t("recipes.sources.import.unresolvedHint")}
</p>
<ul className="import-recipe__unresolved-list">
{unresolvedIngredients.map((line) => (
<li key={line.key}>
<div className="import-recipe__unresolved-row">
<span>{line.rawText}</span>
<button
type="button"
onClick={() =>
setResolvingKey((current) => (current === line.key ? null : line.key))
}
>
{t("recipes.sources.import.resolveButton")}
</button>
<button type="button" onClick={() => discardUnresolvedIngredient(line.key)}>
{t("recipes.sources.import.discardButton")}
</button>
</div>
{resolvingKey === line.key && (
<IngredientPicker
ingredients={ingredientsCatalog}
excludeIds={selectedIds}
onSelect={(ingredient) => resolveIngredient(line.key, ingredient)}
/>
)}
</li>
))}
</ul>
</section>
)}
<IngredientPicker
ingredients={ingredientsCatalog}
excludeIds={selectedIds}
onSelect={addIngredient}
/>
</section>
<section className="recipe-form__section">
<h2>{t("recipes.stepsTitle")}</h2>
<StepListEditor steps={steps} onChange={setSteps} />
</section>
{formError && <p className="form-error">{formError}</p>}
<div className="recipe-form__actions">
<button type="submit" disabled={isSubmitting || !canSubmit}>
{isSubmitting
? t("recipes.sources.import.submitting")
: t("recipes.sources.import.submit")}
</button>
</div>
</form>
);
}

View file

@ -38,6 +38,8 @@ export enum ErrorCode {
ALREADY_HAS_HOUSE = 4020, ALREADY_HAS_HOUSE = 4020,
/** `DELETE /recipes/:id` attempted on a recipe still referenced by at least one `PlanningItem`. */ /** `DELETE /recipes/:id` attempted on a recipe still referenced by at least one `PlanningItem`. */
RECIPE_IN_USE = 4021, RECIPE_IN_USE = 4021,
/** `POST /sources/:sourceKey/import/:externalId` attempted on an item already imported (a `Recipe` already exists for that `sourceId`/`externalId` pair). */
RECIPE_ALREADY_IMPORTED = 4022,
/** A household action reserved to its admin (delete the household, remove a member) attempted by a non-admin member. */ /** A household action reserved to its admin (delete the household, remove a member) attempted by a non-admin member. */
NOT_HOUSE_ADMIN = 4030, NOT_HOUSE_ADMIN = 4030,
/** `PATCH /recipes/:id` or `DELETE /recipes/:id` attempted by someone other than the recipe's author — visibility controls reading, not writing. */ /** `PATCH /recipes/:id` or `DELETE /recipes/:id` attempted by someone other than the recipe's author — visibility controls reading, not writing. */