Merge pull request #46 from kyuno053/feat/browse-sources-ui

feat(recipes): onglet Sources — parcourir les recettes externes (étape 2/4)
This commit is contained in:
kyuno053 2026-08-20 18:40:51 +02:00 committed by GitHub
commit 5f4c1c25ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 2206 additions and 235 deletions

View file

@ -364,11 +364,46 @@ export async function createRecipe(
input: CreateRecipeInput,
authorId: number,
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> {
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
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({
data: {
@ -379,6 +414,8 @@ export async function createRecipe(
authorId,
authorHouseId,
visibility: input.visibility,
sourceId: source?.sourceId ?? null,
externalId: source?.externalId ?? null,
ingredients: {
create: input.ingredients.map((ingredient) => ({
ingredientId: ingredient.ingredientId,

View file

@ -1,9 +1,9 @@
import { HttpError } from "@batch-cooking/error-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 { 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
@ -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 {
type BrowsableSourceItemView,
type CreateRecipeInput,
type DraftRecipeIngredientView,
type DraftRecipeStepView,
ErrorCode,
type RecipeImportDraftView,
type RecipeView,
} from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.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 { loadTechStepMappingRules, matchTechStepSpans } from "../../lib/tech-step-matcher.js";
import { getHouseSourceIds } from "../house/house.service.js";
import { createImportedRecipe } from "../recipe/recipe.service.js";
import { getIngredients, getUnits } from "../reference/reference.service.js";
/**
* Browsing and previewing a household's *enabled* external recipe sources
* (`HouseSource`) the read-only half of the "onglet Sources" feature
* (see the project plan). Neither function here saves anything: browsing
* lists what a source offers (`RecipeSourceAdapter.list()`), previewing
* fully translates one item (`translateRecipeIngredients`,
* `matchTechStepSpans` same building blocks `recipe.service.ts` uses at
* actual save time) without persisting it. Turning a preview into a real
* `Recipe` (with unresolved ingredients reviewed/fixed up first) is a
* later stage of the same plan, not built here.
* Browsing, previewing, and importing a household's *enabled* external
* recipe sources (`HouseSource`) the "onglet Sources" feature (see the
* project plan). Browsing lists what a source offers
* (`RecipeSourceAdapter.list()`); previewing fully translates one item
* (`translateRecipeIngredients`, `matchTechStepSpans` same building
* blocks `recipe.service.ts` uses at real save time) without persisting
* it; importing (`importSourceItem`) is the only function here that
* actually saves by the time it's called, the caller (the review screen)
* 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(
houseId: number | null,
sourceKey: string,
): Promise<RecipeSourceAdapter> {
): Promise<{ adapter: RecipeSourceAdapter; sourceId: number }> {
const enabledSourceIds = await getHouseSourceIds(houseId);
const source = await prisma.source.findUnique({ where: { key: sourceKey } });
if (!source || !enabledSourceIds.includes(source.id)) {
@ -68,7 +72,7 @@ async function assertSourceEnabled(
`Source "${sourceKey}" has no registered adapter`,
);
}
return adapter;
return { adapter, sourceId: source.id };
}
/**
@ -84,7 +88,7 @@ export async function browseSource(
houseId: number | null,
params: { query?: string; cursor?: string },
): 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 importedRecipeIds = await findImportedRecipeIds(
@ -128,7 +132,7 @@ export async function previewSourceItem(
externalId: string,
houseId: number | null,
): Promise<RecipeImportDraftView> {
const adapter = await assertSourceEnabled(houseId, sourceKey);
const { adapter } = await assertSourceEnabled(houseId, sourceKey);
let parsed: ReturnType<typeof adapter.parse>;
try {
@ -189,3 +193,48 @@ export async function previewSourceItem(
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", () => {
const app = createApp();
@ -251,4 +263,123 @@ describe("Sources", () => {
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

@ -0,0 +1,36 @@
Feature: Adding a recipe to the planning
As a signed-in user
I want to add a recipe to a planning slot even when I haven't imported it yet
So that browsing an external source and planning it is a single trip
Background:
Given I am signed in as "Alice" "Martin"
And my household id is 1
And today is frozen at "2026-08-17T09:00:00.000Z"
And the household request returns no household
And the recipe catalog contains nothing
And the ingredient and diet catalog is available for import
And the sources reference list has options
And the household has enabled TheMealDB
And browsing TheMealDB returns some items
And the planning request reflects whatever's been added so far
Scenario: Adds a not-yet-imported source item to a planning slot, importing it on the way
Given previewing TheMealDB item "9999" is available
And importing the previewed item will succeed and return id 99
And adding the imported recipe to the planning will succeed
When I visit "/"
And I click the add button for the first empty planning slot
And I click the button "Sources"
And I click the source item "Fish Pie"
And I click the link "Importer cette recette"
Then I should see "Cette recette sera automatiquement ajoutée à votre planning une fois importée."
When I choose an ingredient for the unresolved line "some mystery paste"
And I select the ingredient "Sel" from the picker
And I select unit "unité" for the first ingredient
And I fill in the last ingredient's quantity with "1" and unit "unité"
And I click the button "Importer"
Then the planning add request should have included recipe 99, weekDay "lundi", meal "petit-dejeuner", and portions 4
And the URL should be the home page
And the recipe "Fish Pie" should appear in the first planning slot with 4 portions

View file

@ -0,0 +1,219 @@
import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
// Mocks the API via cy.intercept — this job doesn't run a live backend (see
// .github/workflows/ci.yml); apps/api's own Mocha suite covers real API
// behavior against a real database (see test/sources.test.ts,
// test/planning.test.ts).
//
// planning.feature's journey crosses both `RecipePickerDialog` (browsing an
// external source from a planning slot) and the import review screen
// (`ImportRecipePage`) it hands off to — same "each spec's own
// self-contained fixtures" precedent recipe-sources.ts already sets (the
// Cucumber preprocessor's step lookup isn't global across cypress/e2e/, see
// its own comment for the full reasoning), so most of what's below mirrors
// recipe-sources.ts's fixtures rather than importing them.
// Flips once, from `false` to `true`, as the single scenario in this file
// actually performs the planning-add — module-level `let` rather than
// something reset per-scenario, since there's only ever the one here (see
// household-settings.ts for the same pattern used across several scenarios
// instead).
let fishPiePlanned = false;
Given("the recipe catalog contains nothing", () => {
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
});
Given("the household has enabled TheMealDB", () => {
cy.intercept("GET", "**/house/current/sources", { statusCode: 200, body: [1] });
});
Given("browsing TheMealDB returns some items", () => {
cy.intercept("GET", "**/sources/theMealDb/browse*", {
statusCode: 200,
body: {
items: [
{
externalId: "52795",
title: "Chicken Handi",
picture: null,
url: "https://www.themealdb.com/meal/52795",
alreadyImported: true,
recipeId: 2,
},
{
externalId: "9999",
title: "Fish Pie",
picture: null,
url: "https://www.themealdb.com/meal/9999",
alreadyImported: false,
recipeId: null,
},
],
nextCursor: null,
},
});
});
Given("previewing TheMealDB item {string} is available", (externalId: string) => {
cy.intercept("GET", `**/sources/theMealDb/preview/${externalId}`, {
statusCode: 200,
body: {
sourceKey: "theMealDb",
externalId,
name: "Fish Pie",
description: null,
picture: null,
portions: 4,
sourceUrl: "https://www.themealdb.com/meal/9999",
ingredients: [
{
rawText: "1 onion",
quantity: 1,
ingredient: {
id: 1,
key: "onion",
icon: "VEGETABLE",
category: "freshProduce",
subcategory: "vegetables",
reproducible: false,
allergens: [],
diets: [],
},
unit: null,
},
{ rawText: "some mystery paste", quantity: null, ingredient: null, unit: null },
],
steps: [{ description: "Cuire à la poêle.", picture: null, techSteps: [] }],
},
});
});
// Covers every reference catalog both `RecipePickerDialog` (ingredients/
// diets, for its own filters) and `ImportRecipePage` (ingredients/diets/
// units, for the review form) fetch — same endpoints, one fixture for both.
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");
});
Given("adding the imported recipe to the planning will succeed", () => {
cy.intercept("POST", "**/planning/items", (req) => {
fishPiePlanned = true;
req.reply({
statusCode: 201,
body: {
id: 1,
weekDay: "lundi",
meal: "petit-dejeuner",
portions: 4,
recipe: { id: 99, name: "Fish Pie" },
},
});
}).as("addPlanningItem");
});
// Stateful — landing back on "/" after the import journey remounts
// `PlanningPage` from scratch (a real cross-route navigation, not a
// same-component state update: see `ImportRecipePage`'s `navigate("/")`),
// so only a fresh `GET /planning?date=` that reflects the just-added item
// makes it show up there — nothing client-side survives that remount to
// patch it in locally the way `PlanningPage`'s own `patchPlanningItems`
// does for an add made without leaving the page.
Given("the planning request reflects whatever's been added so far", () => {
cy.intercept("GET", /\/planning\?/, (req) => {
req.reply({
statusCode: 200,
body: fishPiePlanned
? {
id: 1,
startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z",
items: [
{
id: 1,
weekDay: "lundi",
meal: "petit-dejeuner",
portions: 4,
recipe: { id: 99, name: "Fish Pie" },
},
],
}
: null,
});
});
});
// The very first "+" in DOM order is Lundi's Petit-déjeuner cell (`MEALS`'s
// first entry × `WEEK_DAYS`'s first entry, see `PlanningGrid`) — the exact
// slot this feature's fixtures above (weekDay "lundi", meal
// "petit-dejeuner") are written against.
When("I click the add button for the first empty planning slot", () => {
cy.get(".add-recipe-btn").first().click();
});
When("I click the source item {string}", (title: string) => {
cy.contains(".recipe-table__name", title).click();
});
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 planning add request should have included recipe {int}, weekDay {string}, meal {string}, and portions {int}",
(recipeId: number, weekDay: string, meal: string, portions: number) => {
cy.wait("@addPlanningItem")
.its("request.body")
.should("deep.include", { recipeId, weekDay, meal, portions });
},
);
Then(
"the recipe {string} should appear in the first planning slot with {int} portions",
(name: string, portions: number) => {
cy.get(".planning-grid tbody tr")
.first()
.within(() => {
cy.contains(".recipe-chip", `${name} · ×${portions}`).should("be.visible");
});
},
);

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(
"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) => {

View file

@ -0,0 +1,73 @@
Feature: Browsing external recipe sources
As a signed-in user
I want to browse the recipes available from my household's enabled sources
So that I can find new recipes to import, or jump straight to ones I already have
Background:
Given I am signed in as "Alice" "Martin"
And my household id is 1
And the disliked ingredients list is empty
And the planning request returns nothing
Scenario: Prompts to enable a source when the household hasn't enabled any
Given the recipe catalog contains nothing
And the sources reference list has options
And the household's enabled sources are empty
When I visit "/recettes"
And I click the button "Sources"
Then I should see "Aucune source n'est activée"
Scenario: Browses an enabled source, distinguishing already-imported items from new ones
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 recipe 2's detail is available
When I visit "/recettes"
And I click the button "Sources"
Then I should see the source item "Chicken Handi"
And I should see the source item "Fish Pie"
And the source item "Chicken Handi" should be marked as already imported
When I click the source item "Chicken Handi"
Then the URL should include "/recettes/2"
And the recipe detail panel heading should be "Omelette"
Scenario: Previews a not-yet-imported item, highlighting its detected techniques
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
When I visit "/recettes"
And I click the button "Sources"
And I click the source item "Fish Pie"
Then the recipe detail panel heading should be "Fish Pie"
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 link "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

@ -0,0 +1,200 @@
import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
// Mocks the API via cy.intercept — this job doesn't run a live backend (see
// .github/workflows/ci.yml); apps/api's own Mocha suite covers real API
// behavior against a real database (see test/sources.test.ts).
//
// "the sources reference list has options"/"the household's enabled
// sources are empty" resolve from cypress/support/step_definitions/ (the
// preprocessor's step-lookup is *not* global across cypress/e2e/ — only a
// feature's own same-named file/directory plus that shared folder are
// searched, see its error message when a step isn't found). recipes.ts
// sits directly in cypress/e2e/ (not that shared folder), so its own
// "the disliked ingredients list is empty"/"the recipe catalog
// contains"/"recipe 2's detail is available" are scoped to recipes.feature
// only — this file redeclares its own minimal equivalents rather than
// relocating shared infra, the same "each spec's own self-contained
// fixtures" precedent recipes.cy.ts already sets alongside recipes.ts.
Given("the disliked ingredients list is empty", () => {
cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] });
});
Given("the recipe catalog contains nothing", () => {
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
});
Given("recipe 2's detail is available", () => {
cy.intercept("GET", "**/recipes/2", {
statusCode: 200,
body: {
id: 2,
name: "Omelette",
description: null,
picture: null,
portions: 2,
authorId: 1,
visibility: "PERSONAL",
allergens: [],
diets: [],
isFavorite: false,
ingredients: [],
steps: [
{
id: 1,
description: "Cuire à la poêle.",
picture: null,
order: 1,
techSteps: [{ techStep: { id: 1, key: "cook" }, start: 0, end: 5 }],
},
],
},
});
});
Given("the household has enabled TheMealDB", () => {
cy.intercept("GET", "**/house/current/sources", { statusCode: 200, body: [1] });
});
Given("browsing TheMealDB returns some items", () => {
cy.intercept("GET", "**/sources/theMealDb/browse*", {
statusCode: 200,
body: {
items: [
{
externalId: "52795",
title: "Chicken Handi",
picture: null,
url: "https://www.themealdb.com/meal/52795",
alreadyImported: true,
recipeId: 2,
},
{
externalId: "9999",
title: "Fish Pie",
picture: null,
url: "https://www.themealdb.com/meal/9999",
alreadyImported: false,
recipeId: null,
},
],
nextCursor: null,
},
});
});
Given("previewing TheMealDB item {string} is available", (externalId: string) => {
cy.intercept("GET", `**/sources/theMealDb/preview/${externalId}`, {
statusCode: 200,
body: {
sourceKey: "theMealDb",
externalId,
name: "Fish Pie",
description: null,
picture: null,
portions: 4,
sourceUrl: "https://www.themealdb.com/meal/9999",
ingredients: [
{
rawText: "1 onion",
quantity: 1,
ingredient: {
id: 1,
key: "onion",
icon: "VEGETABLE",
category: "freshProduce",
subcategory: "vegetables",
reproducible: false,
allergens: [],
diets: [],
},
unit: null,
},
{ rawText: "some mystery paste", quantity: null, ingredient: null, unit: null },
],
steps: [
{
description: "Cuire à la poêle.",
picture: null,
techSteps: [{ techStep: { id: 1, key: "cook" }, start: 0, end: 5 }],
},
],
},
});
});
Then("I should see the source item {string}", (title: string) => {
cy.contains(".recipe-table__name", title).should("be.visible");
});
When("I click the source item {string}", (title: string) => {
cy.contains(".recipe-table__name", title).click();
});
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");
});
// 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

@ -136,9 +136,6 @@ describe("Recipe catalog", () => {
cy.get(".recipe-tabs__tab.active").should("contain.text", "Perso");
cy.contains(".recipe-table__name", "Omelette").should("be.visible");
cy.contains(".recipe-table__name", "Ratatouille").should("not.exist");
// The disabled "Sources (bientôt)" placeholder never becomes active.
cy.contains(".recipe-tabs__tab", "Sources (bientôt)").should("be.disabled");
});
it("searches within the active tab, debounced", () => {

View file

@ -80,10 +80,6 @@ Then("the recipe {string} should not be marked as favorite", (name: string) => {
cy.contains(".recipe-table__name", name).find(".recipe-table__fav-mark").should("not.exist");
});
Then("the recipe detail panel heading should be {string}", (text: string) => {
cy.get(".recipe-detail-panel").contains("h2", text).should("be.visible");
});
When("I click the favorite star", () => {
cy.get(".favorite-star-button").click();
});
@ -111,19 +107,3 @@ Then("the delete request should have been made", () => {
Then("the URL should match the recipes list", () => {
cy.url().should("match", /\/recettes\/?$/);
});
Then("I should see the highlighted technique {string}", (text: string) => {
// The steps section sits below the panel's header/photo/description, off
// the fold of `.app-content`'s own scroll (see layout.cy.ts) — a bare
// `.should("be.visible")` doesn't auto-scroll, same fix as
// household-settings.feature's sources-section scenario.
cy.contains(".step-tech-step", text).scrollIntoView().should("be.visible");
});
When("I focus the highlighted technique {string}", (text: string) => {
cy.contains(".step-tech-step", text).focus();
});
Then("the tooltip should show {string}", (label: string) => {
cy.get(".tooltip__bubble").contains(label).should("be.visible");
});

View file

@ -133,6 +133,100 @@ When("I scroll to the section {string}", (legend: string) => {
cy.contains("legend", legend).scrollIntoView();
});
// `.recipe-detail-panel` is used by both a saved recipe's real detail
// (RecipeDetailPanel) and an unsaved source item's read-only preview
// (SourceItemPreviewPanel) — recipes.feature and recipe-sources.feature
// both need this.
Then("the recipe detail panel heading should be {string}", (text: string) => {
cy.get(".recipe-detail-panel").contains("h2", text).should("be.visible");
});
// `.step-tech-step`/`.tooltip__bubble` come from StepDescription/Tooltip
// (components/ui/), rendered by both of those same two panels — same
// reasoning as the detail-panel-heading step above.
Then("I should see the highlighted technique {string}", (text: string) => {
// The steps section can sit below the panel's header/photo/description,
// off the fold of `.app-content`'s own scroll (see layout.cy.ts) — a
// bare `.should("be.visible")` doesn't auto-scroll.
cy.contains(".step-tech-step", text).scrollIntoView().should("be.visible");
});
When("I focus the highlighted technique {string}", (text: string) => {
cy.contains(".step-tech-step", text).focus();
});
Then("the tooltip should show {string}", (label: string) => {
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) => {
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 { RequireAuth } from "./features/auth/RequireAuth";
import { AppLayout } from "./layouts/AppLayout";
import { ImportRecipePage } from "./pages/ImportRecipePage";
import { LoginPage } from "./pages/LoginPage";
import { PlanningPage } from "./pages/PlanningPage";
import { RecipeFormPage } from "./pages/RecipeFormPage";
@ -59,6 +60,7 @@ export function App() {
(see RecipesPage.tsx). */}
<Route path="/recettes" element={<RecipesPage />} />
<Route path="/recettes/nouvelle" element={<RecipeFormPage />} />
<Route path="/recettes/importer/:sourceKey/:externalId" element={<ImportRecipePage />} />
<Route path="/recettes/:id" element={<RecipesPage />} />
<Route path="/recettes/:id/modifier" element={<RecipeFormPage />} />
<Route path="/liste-de-courses" element={<ShoppingListPage />} />

View file

@ -2,6 +2,7 @@ import {
type AddPlanningItemInput,
type AllergyView,
type ApiErrorResponse,
type BrowsableSourceItemView,
type CreateRecipeInput,
type DietView,
ErrorCode,
@ -11,6 +12,7 @@ import {
type PlanningItemView,
type PlanningView,
type PreferencesView,
type RecipeImportDraftView,
type RecipeSummaryView,
type RecipeTab,
type RecipeView,
@ -173,6 +175,35 @@ export class ApiClient {
return this.request("/reference/sources");
}
/** One page of `sourceKey`'s own catalog (recipe catalog's "Sources" tab), each item flagged with whether it's already been imported. Rejects with `SOURCE_NOT_FOUND` unless the viewer's household has this source enabled (`/parametres/foyer`). */
public browseSource(
sourceKey: string,
params: { query?: string; cursor?: string } = {},
): Promise<{ items: BrowsableSourceItemView[]; nextCursor: string | null }> {
const search = new URLSearchParams();
if (params.query) search.set("query", params.query);
if (params.cursor) search.set("cursor", params.cursor);
const queryString = search.toString();
return this.request(`/sources/${sourceKey}/browse${queryString ? `?${queryString}` : ""}`);
}
/** Fully translates one not-yet-saved source item (ingredients/units/techniques resolved where possible) — nothing is persisted. Rejects with `RECIPE_NOT_FOUND` if the source couldn't fetch/parse it. */
public previewSourceItem(sourceKey: string, externalId: string): Promise<RecipeImportDraftView> {
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`),
* optionally narrowed further `search` (name substring),

View file

@ -5,7 +5,6 @@ import {
type Meal,
type PlanningItemView,
type RecipeSummaryView,
type RecipeTab,
type WeekDay,
} from "@batch-cooking/shared";
import { useEffect, useState } from "react";
@ -16,8 +15,9 @@ import { Dialog } from "../../components/ui/Dialog";
import { errorMessageService } from "../../services/error-message.service";
import { DietTagSelect } from "../recipes/DietTagSelect";
import { IngredientPicker } from "../recipes/IngredientPicker";
import { RecipeSourcesPanel } from "../recipes/RecipeSourcesPanel";
import { RecipeTable } from "../recipes/RecipeTable";
import { RecipeTabs } from "../recipes/RecipeTabs";
import { RecipeTabs, type RecipesPageTab } from "../recipes/RecipeTabs";
import "./recipe-picker-dialog.scss";
/** Debounce for the search field — same value as `RecipesPage`'s. */
@ -48,7 +48,13 @@ export interface PlanningSlot {
* with three extra filters layered on top of the plain name search
* (ingredients / regime / "convient à tout le foyer" toggle, all wired to
* `GET /recipes`'s corresponding query params) since browsing here is
* about finding something to cook, not just looking something up.
* about finding something to cook, not just looking something up. The
* "Sources" tab is included too (unlike an earlier version of this dialog
* see `ImportRecipePage`'s `planningSlot`, the review/import flow that
* made including it here worthwhile): picking an already-imported item
* behaves exactly like picking a regular recipe, and picking one that
* isn't imported yet hands off to that review screen, which adds the
* freshly-created recipe straight to this slot once it's saved.
*
* Mounted only while open (see `PlanningPage`, same conditional-mount
* convention as its own `CalendarPopover`) every piece of local state
@ -58,7 +64,10 @@ export interface PlanningSlot {
* Selecting a row doesn't navigate anywhere (unlike `RecipesPage`'s own
* use of `RecipeTable`) it switches this same dialog to a small
* "how many portions?" confirmation step, then calls `POST
* /planning/items` on submit.
* /planning/items` on submit. The one exception is picking a not-yet-
* imported source item, which does navigate away entirely (to
* `/recettes/importer/...`) that flow has its own portions field
* already, on the review screen itself.
*/
export function RecipePickerDialog({
slot,
@ -71,7 +80,7 @@ export function RecipePickerDialog({
}) {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<RecipeTab>("favoris");
const [activeTab, setActiveTab] = useState<RecipesPageTab>("favoris");
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [selectedIngredientIds, setSelectedIngredientIds] = useState<number[]>([]);
@ -83,6 +92,11 @@ export function RecipePickerDialog({
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
const [listState, setListState] = useState<ListState>({ status: "loading" });
// Set when picking an already-imported source item fails to resolve to a
// real recipe (see `handleSelectImportedRecipe`) — a rare race (the
// recipe was deleted between the browse fetch and the click), surfaced
// the same way any other catalog load error is on this dialog.
const [sourceSelectError, setSourceSelectError] = useState(false);
// The recipe picked in step 1 — `null` while still browsing, set once a
// row is clicked to switch this dialog into its confirmation step.
@ -114,6 +128,9 @@ export function RecipePickerDialog({
}, []);
useEffect(() => {
// The "sources" tab doesn't query the recipe table at all — same guard
// as `RecipesPage`'s own identical effect.
if (activeTab === "sources") return;
let cancelled = false;
setListState({ status: "loading" });
@ -140,6 +157,18 @@ export function RecipePickerDialog({
selectedIngredientIds.includes(ingredient.id),
);
/** Picking an already-imported source item (`RecipeSourcesPanel`'s "sources" tab) — resolved to its full recipe, then treated exactly like picking that same recipe from one of the regular tabs, moving straight to the confirm-portions step below. */
function handleSelectImportedRecipe(recipeId: number) {
setSourceSelectError(false);
apiClient
.getRecipe(recipeId)
.then((recipe) => {
setSelectedRecipe(recipe);
setPortions(String(recipe.portions));
})
.catch(() => setSourceSelectError(true));
}
async function handleConfirm() {
if (!selectedRecipe) return;
const parsedPortions = Number(portions);
@ -201,87 +230,113 @@ export function RecipePickerDialog({
return (
<Dialog onClose={onClose} title={t("planning.picker.title")} className="recipe-picker-dialog">
<div className="recipe-picker__filters">
<input
type="search"
className="recipe-picker__search"
placeholder={t("planning.picker.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{activeTab !== "sources" && (
<div className="recipe-picker__filters">
<input
type="search"
className="recipe-picker__search"
placeholder={t("planning.picker.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="recipe-picker__ingredient-filter">
<span className="recipe-picker__filter-label">
{t("planning.picker.ingredientsFilterLabel")}
</span>
<div className="recipe-picker__chips">
{selectedIngredients.map((ingredient) => (
<span key={ingredient.id} className="filter-chip">
{t(`catalog.ingredients.${ingredient.key}`)}
<button
type="button"
onClick={() =>
setSelectedIngredientIds((ids) => ids.filter((id) => id !== ingredient.id))
}
>
</button>
</span>
))}
<button
type="button"
className="recipe-picker__toggle-ingredient-picker"
onClick={() => setIsIngredientPickerOpen((open) => !open)}
aria-expanded={isIngredientPickerOpen}
>
+{" "}
{isIngredientPickerOpen
? t("planning.picker.hideIngredientPicker")
: t("planning.picker.addIngredientFilter")}
</button>
<div className="recipe-picker__ingredient-filter">
<span className="recipe-picker__filter-label">
{t("planning.picker.ingredientsFilterLabel")}
</span>
<div className="recipe-picker__chips">
{selectedIngredients.map((ingredient) => (
<span key={ingredient.id} className="filter-chip">
{t(`catalog.ingredients.${ingredient.key}`)}
<button
type="button"
onClick={() =>
setSelectedIngredientIds((ids) => ids.filter((id) => id !== ingredient.id))
}
>
</button>
</span>
))}
<button
type="button"
className="recipe-picker__toggle-ingredient-picker"
onClick={() => setIsIngredientPickerOpen((open) => !open)}
aria-expanded={isIngredientPickerOpen}
>
+{" "}
{isIngredientPickerOpen
? t("planning.picker.hideIngredientPicker")
: t("planning.picker.addIngredientFilter")}
</button>
</div>
{isIngredientPickerOpen && (
<IngredientPicker
ingredients={ingredientsCatalog}
excludeIds={selectedIngredientIds}
onSelect={(ingredient) =>
setSelectedIngredientIds((ids) => [...ids, ingredient.id])
}
/>
)}
</div>
{isIngredientPickerOpen && (
<IngredientPicker
ingredients={ingredientsCatalog}
excludeIds={selectedIngredientIds}
onSelect={(ingredient) => setSelectedIngredientIds((ids) => [...ids, ingredient.id])}
/>
<DietTagSelect
diets={dietsCatalog}
value={selectedDietIds}
onChange={setSelectedDietIds}
/>
{hasHousehold && (
<CheckboxOption checked={suitableForHousehold} onChange={setSuitableForHousehold}>
{t("planning.picker.suitableForHouseholdLabel")}
</CheckboxOption>
)}
</div>
<DietTagSelect diets={dietsCatalog} value={selectedDietIds} onChange={setSelectedDietIds} />
{hasHousehold && (
<CheckboxOption checked={suitableForHousehold} onChange={setSuitableForHousehold}>
{t("planning.picker.suitableForHouseholdLabel")}
</CheckboxOption>
)}
</div>
)}
<RecipeTabs active={activeTab} onChange={setActiveTab} />
{listState.status === "loading" && (
<p className="recipes-page__status">{t("planning.picker.loading")}</p>
)}
{listState.status === "error" && (
<p className="recipes-page__status recipes-page__status--error">{t("common.loadError")}</p>
)}
{listState.status === "loaded" && listState.recipes.length === 0 && (
<p className="recipes-page__status">{t("planning.picker.empty")}</p>
)}
{listState.status === "loaded" && listState.recipes.length > 0 && (
<RecipeTable
recipes={listState.recipes}
selectedId={null}
onSelect={(id) => {
const recipe = listState.recipes.find((r) => r.id === id) ?? null;
setSelectedRecipe(recipe);
// Pre-fill from the recipe's own written yield rather than
// always starting at 1 — still freely editable below, this is
// just a better starting point (see `Recipe.portions`).
if (recipe) setPortions(String(recipe.portions));
}}
/>
{activeTab === "sources" ? (
<>
{sourceSelectError && (
<p className="recipes-page__status recipes-page__status--error">
{t("common.loadError")}
</p>
)}
<RecipeSourcesPanel
planningSlot={slot}
onSelectImportedRecipe={handleSelectImportedRecipe}
/>
</>
) : (
<>
{listState.status === "loading" && (
<p className="recipes-page__status">{t("planning.picker.loading")}</p>
)}
{listState.status === "error" && (
<p className="recipes-page__status recipes-page__status--error">
{t("common.loadError")}
</p>
)}
{listState.status === "loaded" && listState.recipes.length === 0 && (
<p className="recipes-page__status">{t("planning.picker.empty")}</p>
)}
{listState.status === "loaded" && listState.recipes.length > 0 && (
<RecipeTable
recipes={listState.recipes}
selectedId={null}
onSelect={(id) => {
const recipe = listState.recipes.find((r) => r.id === id) ?? null;
setSelectedRecipe(recipe);
// Pre-fill from the recipe's own written yield rather than
// always starting at 1 — still freely editable below, this
// is just a better starting point (see `Recipe.portions`).
if (recipe) setPortions(String(recipe.portions));
}}
/>
)}
</>
)}
</Dialog>
);

View file

@ -0,0 +1,216 @@
import type { BrowsableSourceItemView, Meal, SourceView, WeekDay } from "@batch-cooking/shared";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { apiClient } from "../../api/client";
import { SourceItemPreviewPanel, type SourceItemPreviewState } from "./SourceItemPreviewPanel";
import { SourceItemTable } from "./SourceItemTable";
import "./recipes.scss";
/** Debounce for the search field — same idea/value as `RecipesPage`'s own search. */
const SEARCH_DEBOUNCE_MS = 300;
type EnabledSourcesState =
| { status: "loading" }
| { status: "loaded"; sources: SourceView[] }
| { status: "error" };
type BrowseState =
| { status: "loading" }
| { status: "loaded"; items: BrowsableSourceItemView[]; nextCursor: string | null }
| { status: "error" };
/**
* "Sources" tab content of the recipe catalog (`RecipesPage`) a
* self-contained master-detail pair of its own (source selector + browsable
* list on the left, `SourceItemPreviewPanel` on the right), independent of
* `RecipeTable`/`RecipeDetailPanel`: it browses a source's *live* catalog
* (`GET /sources/:sourceKey/browse`), not the saved `Recipe` table, so it
* doesn't share their `RecipeTab`-based fetching at all.
*
* Selecting an already-imported item navigates straight to its real
* recipe (`/recettes/:id`, leaving this tab) selecting one that isn't
* imported yet shows a read-only preview here instead. Turning that
* preview into an actual saved recipe (reviewing/fixing unresolved
* ingredients first) is a later stage of the same plan, not built here.
*
* `onSelectImportedRecipe` hands back the id instead of this panel
* navigating anywhere itself what "viewing" an already-imported item
* means depends on the caller: `RecipesPage` switches its own active tab
* away from `"sources"` (its `RecipeDetailPanel`/`RecipeTable` only render
* outside that tab, so without switching first the URL would change but
* this panel would keep rendering over it) and navigates to the recipe's
* detail page, while `RecipePickerDialog` instead treats it exactly like
* picking that recipe from one of the regular tabs moving to its own
* confirm-portions step, no navigation at all.
*/
export function RecipeSourcesPanel({
onSelectImportedRecipe,
planningSlot,
}: {
onSelectImportedRecipe: (recipeId: number) => void;
/** Forwarded as-is to `SourceItemPreviewPanel` — see its own doc comment. Only ever set by `RecipePickerDialog`. */
planningSlot?: { date: string; weekDay: WeekDay; meal: Meal };
}) {
const { t } = useTranslation();
const [enabledSources, setEnabledSources] = useState<EnabledSourcesState>({ status: "loading" });
const [selectedSourceKey, setSelectedSourceKey] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [browseState, setBrowseState] = useState<BrowseState>({ status: "loading" });
const [selectedExternalId, setSelectedExternalId] = useState<string | null>(null);
const [previewState, setPreviewState] = useState<SourceItemPreviewState>({ status: "empty" });
// Loaded once — which sources exist, crossed with which the household
// has enabled (`/parametres/foyer`). Defaults the selector to the first
// enabled one, if any.
useEffect(() => {
let cancelled = false;
Promise.all([apiClient.getSources(), apiClient.getHouseSourceIds()])
.then(([sources, enabledIds]) => {
if (cancelled) return;
const enabled = sources.filter((source) => enabledIds.includes(source.id));
setEnabledSources({ status: "loaded", sources: enabled });
setSelectedSourceKey((current) => current ?? enabled[0]?.key ?? null);
})
.catch(() => {
if (!cancelled) setEnabledSources({ status: "error" });
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS);
return () => window.clearTimeout(timeout);
}, [search]);
useEffect(() => {
if (selectedSourceKey === null) return;
let cancelled = false;
setBrowseState({ status: "loading" });
setSelectedExternalId(null);
setPreviewState({ status: "empty" });
apiClient
.browseSource(selectedSourceKey, { query: debouncedSearch.trim() || undefined })
.then(({ items, nextCursor }) => {
if (!cancelled) setBrowseState({ status: "loaded", items, nextCursor });
})
.catch(() => {
if (!cancelled) setBrowseState({ status: "error" });
});
return () => {
cancelled = true;
};
}, [selectedSourceKey, debouncedSearch]);
function handleLoadMore() {
if (selectedSourceKey === null || browseState.status !== "loaded" || !browseState.nextCursor) {
return;
}
const cursor = browseState.nextCursor;
apiClient
.browseSource(selectedSourceKey, { query: debouncedSearch.trim() || undefined, cursor })
.then(({ items, nextCursor }) => {
setBrowseState((prev) =>
prev.status === "loaded"
? { status: "loaded", items: [...prev.items, ...items], nextCursor }
: prev,
);
})
.catch(() => setBrowseState({ status: "error" }));
}
function handleSelectItem(item: BrowsableSourceItemView) {
if (item.alreadyImported && item.recipeId !== null) {
onSelectImportedRecipe(item.recipeId);
return;
}
if (selectedSourceKey === null) return;
setSelectedExternalId(item.externalId);
setPreviewState({ status: "loading" });
apiClient
.previewSourceItem(selectedSourceKey, item.externalId)
.then((draft) => setPreviewState({ status: "loaded", draft }))
.catch(() => setPreviewState({ status: "error" }));
}
if (enabledSources.status === "loading") {
return <p className="recipes-page__status">{t("recipes.loading")}</p>;
}
if (enabledSources.status === "error") {
return (
<p className="recipes-page__status recipes-page__status--error">{t("common.loadError")}</p>
);
}
if (enabledSources.sources.length === 0) {
return (
<p className="recipes-page__status">
{t("recipes.sources.noneEnabled")}{" "}
<Link to="/parametres/foyer">{t("recipes.sources.noneEnabledLink")}</Link>
</p>
);
}
return (
<>
<div className="recipes-page__header recipes-page__header--sources">
{enabledSources.sources.length > 1 && (
<select
className="source-sources-select"
aria-label={t("recipes.sources.sourceLabel")}
value={selectedSourceKey ?? ""}
onChange={(e) => setSelectedSourceKey(e.target.value)}
>
{enabledSources.sources.map((source) => (
<option key={source.key} value={source.key}>
{source.name}
</option>
))}
</select>
)}
<input
type="search"
className="recipes-page__search"
placeholder={t("recipes.sources.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="recipes-page__catalog">
{browseState.status === "loading" && (
<p className="recipes-page__status">{t("recipes.sources.loading")}</p>
)}
{browseState.status === "error" && (
<p className="recipes-page__status recipes-page__status--error">
{t("recipes.sources.loadError")}
</p>
)}
{browseState.status === "loaded" && browseState.items.length === 0 && (
<p className="recipes-page__status">{t("recipes.sources.empty")}</p>
)}
{browseState.status === "loaded" && browseState.items.length > 0 && (
<div className="source-items-column">
<SourceItemTable
items={browseState.items}
selectedExternalId={selectedExternalId}
onSelect={handleSelectItem}
/>
{browseState.nextCursor && (
<button type="button" className="source-items-load-more" onClick={handleLoadMore}>
{t("recipes.sources.loadMore")}
</button>
)}
</div>
)}
<SourceItemPreviewPanel state={previewState} planningSlot={planningSlot} />
</div>
</>
);
}

View file

@ -1,37 +1,61 @@
import type { RecipeTab } from "@batch-cooking/shared";
import type { LucideIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { AccountIcon, FavoriteIcon, HouseholdIcon, PublicIcon } from "../../layouts/nav-icons";
import {
AccountIcon,
FavoriteIcon,
HouseholdIcon,
PublicIcon,
SourcesIcon,
} from "../../layouts/nav-icons";
import "./recipes.scss";
/** Every functional tab, in display order, with its icon — reuses `AccountIcon`/`HouseholdIcon` from the sidebar's own icon set (see nav-icons.tsx) rather than a second "person"/"house" glyph. */
const TABS: Array<{ value: RecipeTab; Icon: LucideIcon }> = [
/**
* A tab of the recipe catalog either a real {@link RecipeTab} (`GET
* /recipes?tab=`, `recipe.service.ts`'s `listRecipes`) or `"sources"`, a
* web-only mode that doesn't query the recipe table at all: it browses a
* household-enabled external source's own catalog live
* (`GET /sources/:sourceKey/browse`, `RecipeSourcesPanel`) instead of
* listing saved `Recipe` rows. Kept out of the shared `RecipeTab` type on
* purpose the API has no `tab=sources` to validate.
*/
export type RecipesPageTab = RecipeTab | "sources";
/** Every possible tab, in display order, with its icon — reuses `AccountIcon`/`HouseholdIcon` from the sidebar's own icon set (see nav-icons.tsx) rather than a second "person"/"house" glyph. */
const ALL_TABS: Array<{ value: RecipesPageTab; Icon: LucideIcon }> = [
{ value: "favoris", Icon: FavoriteIcon },
{ value: "perso", Icon: AccountIcon },
{ value: "foyer", Icon: HouseholdIcon },
{ value: "publique", Icon: PublicIcon },
{ value: "sources", Icon: SourcesIcon },
];
/**
* Catalog tab bar Favoris / Perso / Foyer / Publique, plus a disabled
* placeholder for external sources (not built yet, see the plan's "hors
* scope" note) so the eventual nav slot is visible without being
* functional. No "toutes" tab: every recipe a viewer can see falls under
* exactly one of perso/foyer/publique (its own visibility) see
* Catalog tab bar Favoris / Perso / Foyer / Publique / Sources by
* default (`/recettes`, `RecipesPage`). `tabs` narrows which of those
* show `RecipePickerDialog` (picking a recipe for a planning slot)
* passes just the four real ones: browsing external sources mid-dialog,
* without the review/import flow, doesn't make sense there yet (its
* `onChange` narrows the result back to `RecipeTab` itself, safe exactly
* because `tabs` guarantees `"sources"` is never clickable there). No
* "toutes" tab among the real ones: every recipe a viewer can see falls
* under exactly one of perso/foyer/publique (its own visibility) see
* `recipe.service.ts`'s `listRecipes`.
*/
export function RecipeTabs({
active,
onChange,
tabs = ALL_TABS.map((tab) => tab.value),
}: {
active: RecipeTab;
onChange: (tab: RecipeTab) => void;
active: RecipesPageTab;
onChange: (tab: RecipesPageTab) => void;
tabs?: readonly RecipesPageTab[];
}) {
const { t } = useTranslation();
return (
<div className="recipe-tabs">
{TABS.map(({ value, Icon }) => (
{ALL_TABS.filter(({ value }) => tabs.includes(value)).map(({ value, Icon }) => (
<button
key={value}
type="button"
@ -42,14 +66,6 @@ export function RecipeTabs({
{t(`recipes.tabs.${value}`)}
</button>
))}
<button
type="button"
className="recipe-tabs__tab placeholder"
disabled
title={t("recipes.tabs.sourcesSoonHint")}
>
{t("recipes.tabs.sourcesSoon")}
</button>
</div>
);
}

View file

@ -0,0 +1,154 @@
import type { Meal, RecipeImportDraftView, WeekDay } from "@batch-cooking/shared";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { StepDescription } from "./StepDescription";
import "./recipes.scss";
/** State {@link SourceItemPreviewPanel} renders — mirrors `RecipeDetailState`'s shape (`RecipeDetailPanel`), one status short (no "not-found": an invalid `externalId` surfaces as `"error"`, there's no separate "id was well-formed but nothing matched it" case here). */
export type SourceItemPreviewState =
| { status: "empty" }
| { status: "loading" }
| { status: "loaded"; draft: RecipeImportDraftView }
| { status: "error" };
/**
* Right-hand panel of the catalog's "Sources" tab (`RecipeSourcesPanel`)
* a read-only preview of a not-yet-imported item: nothing here can be
* edited or saved yet (no favorite/edit/delete actions, unlike
* `RecipeDetailPanel`) turning this into an actual import with a review
* step for unresolved ingredients is a later stage of the same plan.
* Reuses `StepDescription` so a step's detected techniques are already
* highlighted here too, exactly like a saved recipe's detail.
*/
export function SourceItemPreviewPanel({
state,
planningSlot,
}: {
state: SourceItemPreviewState;
/**
* Set only when this panel is rendered from `RecipePickerDialog` (adding a
* recipe to one planning slot) rather than the standalone `/recettes`
* catalog carried along on the "Importer cette recette" link as query
* params so `ImportRecipePage` knows to add the freshly-created recipe to
* this exact slot once the import succeeds, instead of landing on the
* recipe's own detail page. See `ImportRecipePage`'s `planningSlot`.
*/
planningSlot?: { date: string; weekDay: WeekDay; meal: Meal };
}) {
const { t } = useTranslation();
if (state.status === "empty") {
return (
<aside className="recipe-detail-panel">
<p className="recipe-detail-panel__status">{t("recipes.sources.detail.empty")}</p>
</aside>
);
}
if (state.status === "loading") {
return (
<aside className="recipe-detail-panel">
<p className="recipe-detail-panel__status">{t("recipes.sources.detail.loading")}</p>
</aside>
);
}
if (state.status === "error") {
return (
<aside className="recipe-detail-panel">
<p className="recipe-detail-panel__status recipe-detail-panel__status--error">
{t("recipes.sources.detail.loadError")}
</p>
</aside>
);
}
const { draft } = state;
const hasUnresolvedIngredient = draft.ingredients.some(
(ingredient) => ingredient.ingredient === null,
);
return (
<aside className="recipe-detail-panel">
<div className="recipe-detail-panel__header">
<div className="recipe-detail-panel__photo" aria-hidden="true">
{draft.picture ? <img src={draft.picture} alt="" /> : "🍽️"}
</div>
</div>
<div className="recipe-detail-panel__title-row">
<div className="recipe-detail-panel__title-main">
<h2>{draft.name}</h2>
{draft.portions !== null && (
<p className="recipe-detail-panel__portions">
{t("recipes.detail.portions", { count: draft.portions })}
</p>
)}
</div>
</div>
<div className="recipe-detail-panel__actions">
<Link
to={{
pathname: `/recettes/importer/${draft.sourceKey}/${encodeURIComponent(draft.externalId)}`,
search: planningSlot
? `?planningDate=${planningSlot.date}&planningWeekDay=${planningSlot.weekDay}&planningMeal=${planningSlot.meal}`
: undefined,
}}
className="recipes-page__new-button"
>
{t("recipes.sources.detail.importButton")}
</Link>
<a
href={draft.sourceUrl}
target="_blank"
rel="noreferrer"
className="recipes-page__new-button"
>
{t("recipes.sources.detail.viewSource")}
</a>
</div>
{draft.description && (
<section className="recipe-detail-panel__section recipe-detail-panel__section--description">
<p className="recipe-detail-panel__description">{draft.description}</p>
</section>
)}
<section className="recipe-detail-panel__section">
<h3>{t("recipes.sources.detail.ingredientsCount", { count: draft.ingredients.length })}</h3>
{hasUnresolvedIngredient && (
<p className="source-item-preview__hint">
{t("recipes.sources.detail.unresolvedIngredientsHint")}
</p>
)}
<ul className="source-item-preview__ingredients">
{draft.ingredients.map((ingredient, index) => (
// Draft lines have no id of their own (nothing is saved yet) —
// `rawText` alone could collide (a source repeating the same
// line), so it's paired with its position; this list is fully
// regenerated from `draft` on every render, never reordered in
// place, so that's safe here (same reasoning as
// StepDescription.tsx's segment keys).
<li
key={`${index}-${ingredient.rawText}`}
className={ingredient.ingredient === null ? "is-unresolved" : undefined}
>
{ingredient.rawText}
</li>
))}
</ul>
</section>
<section className="recipe-detail-panel__section">
<h3>{t("recipes.sources.detail.stepsCount", { count: draft.steps.length })}</h3>
<ol className="recipe-detail-panel__steps">
{draft.steps.map((step, index) => (
<li key={`${index}-${step.description}`}>
{step.picture && <img src={step.picture} alt="" />}
<StepDescription description={step.description} techSteps={step.techSteps} />
</li>
))}
</ol>
</section>
</aside>
);
}

View file

@ -0,0 +1,67 @@
import type { BrowsableSourceItemView } from "@batch-cooking/shared";
import { useTranslation } from "react-i18next";
import "./recipes.scss";
/**
* List of one source's browsable items (`RecipeSourcesPanel`) same
* "photo + name, click/Enter to select" row shape as `RecipeTable`, plus
* an "already imported" badge in place of allergen/regime columns (a
* source item has neither, it's not resolved against our catalogs until
* previewed).
*/
export function SourceItemTable({
items,
selectedExternalId,
onSelect,
}: {
items: BrowsableSourceItemView[];
selectedExternalId: string | null;
onSelect: (item: BrowsableSourceItemView) => void;
}) {
const { t } = useTranslation();
return (
<div className="recipe-table-wrap">
<table className="recipe-table">
<thead>
<tr>
<th />
<th>{t("recipes.table.name")}</th>
<th />
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr
key={item.externalId}
className={item.externalId === selectedExternalId ? "selected" : undefined}
onClick={() => onSelect(item)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect(item);
}
}}
tabIndex={0}
aria-current={item.externalId === selectedExternalId ? "true" : undefined}
>
<td>
<span className="recipe-table__photo" aria-hidden="true">
{item.picture ? <img src={item.picture} alt="" /> : "🍽️"}
</span>
</td>
<td className="recipe-table__name">{item.title}</td>
<td>
{item.alreadyImported && (
<span className="source-item-table__imported-badge">
{t("recipes.sources.alreadyImported")}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View file

@ -334,6 +334,91 @@
}
}
// --- Sources tab (RecipeSourcesPanel) ---------------------------------------
// Reuses .recipes-page__header/__search/__catalog and .recipe-table(-wrap)
// as-is (see RecipeSourcesPanel.tsx/SourceItemTable.tsx) only what's
// actually new to this tab gets its own rules here.
.recipes-page__header--sources {
// The source <select> only renders when the household has more than one
// enabled source (see RecipeSourcesPanel) this just keeps it visually
// grouped with the search field when it does.
gap: var(--space-sm);
}
.source-sources-select {
flex: none;
padding: 0.5rem var(--space-md);
font-family: var(--font-body);
font-size: var(--font-size-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
}
.source-item-table__imported-badge {
padding: 0.1rem 0.5rem;
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--color-text-muted);
background: var(--color-surface-alt);
border-radius: var(--radius-pill);
white-space: nowrap;
}
.source-items-column {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.source-items-load-more {
flex-shrink: 0;
align-self: center;
padding: 0.4rem var(--space-lg);
font-family: var(--font-body);
font-size: var(--font-size-sm);
color: var(--color-primary);
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
cursor: pointer;
&:hover {
border-color: var(--color-primary);
}
}
.source-item-preview__hint {
margin: 0 0 var(--space-sm);
padding: var(--space-xs) var(--space-sm);
font-size: var(--font-size-sm);
color: var(--color-warning);
background: color-mix(in srgb, var(--color-warning) 12%, var(--color-surface));
border-radius: var(--radius-base);
}
.source-item-preview__ingredients {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.3rem;
li {
font-size: var(--font-size-sm);
}
.is-unresolved {
color: var(--color-text-muted);
font-style: italic;
}
}
// --- Recipe detail panel -----------------------------------------------------
// 100% of the grid row's height (see `.recipes-page__catalog` above): the
// panel itself never scrolls, only its content does past that height
@ -695,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) -------------------------------------
// Checkbox-grid, same visual language as AllergySelect (profile-forms.scss)
// global.scss's `label:has(> input[type="checkbox"])` rule already

View file

@ -25,4 +25,5 @@ export {
ChevronLeft as ChevronLeftIcon,
Star as FavoriteIcon,
Globe as PublicIcon,
Rss as SourcesIcon,
} from "lucide-react";

View file

@ -19,6 +19,7 @@
"INVITE_CODE_NOT_FOUND": "Ce code d'invitation ne correspond à aucun foyer",
"RECIPE_NOT_FOUND": "Cette recette n'existe pas",
"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",
"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",
@ -162,14 +163,48 @@
"perso": "Perso",
"foyer": "Foyer",
"publique": "Publique",
"sourcesSoon": "Sources (bientôt)",
"sourcesSoonHint": "Un onglet par source externe, une fois l'import de recettes construit"
"sources": "Sources"
},
"table": {
"name": "Nom",
"allergens": "Allergènes / intolérances",
"diets": "Régime associé"
},
"sources": {
"sourceLabel": "Source",
"noneEnabled": "Aucune source n'est activée pour votre foyer.",
"noneEnabledLink": "Activez-en une dans les paramètres du foyer",
"searchPlaceholder": "Rechercher…",
"empty": "Aucune recette trouvée.",
"loadMore": "Voir plus",
"alreadyImported": "Déjà importée",
"loading": "Chargement…",
"loadError": "Impossible de charger cette source pour le moment.",
"detail": {
"empty": "Sélectionnez une recette dans la liste pour voir son aperçu ici.",
"loading": "Chargement de l'aperçu…",
"loadError": "Impossible de charger l'aperçu de cette recette.",
"viewSource": "Voir sur le site d'origine",
"importButton": "Importer cette recette",
"ingredientsCount_one": "{{count}} ingrédient",
"ingredientsCount_other": "{{count}} ingrédients",
"unresolvedIngredientsHint": "Certains ingrédients n'ont pas été reconnus automatiquement — ils pourront être corrigés à l'import.",
"stepsCount_one": "{{count}} étape",
"stepsCount_other": "{{count}} étapes"
},
"import": {
"title": "Revoir l'import",
"planningHint": "Cette recette sera automatiquement ajoutée à votre planning une fois importée.",
"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": {
"empty": "Sélectionnez une recette dans le tableau pour voir son détail ici.",
"favorite": "Ajouter aux favoris",

View file

@ -0,0 +1,462 @@
import {
type CreateRecipeInput,
type DietView,
ErrorCode,
type IngredientView,
MEALS,
type Meal,
type RecipeVisibility,
type UnitView,
WEEK_DAYS,
type WeekDay,
createRecipeSchema,
} from "@batch-cooking/shared";
import { type FormEvent, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams, useSearchParams } 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";
/**
* Reads and validates `?planningDate=&planningWeekDay=&planningMeal=` off
* this page's own URL `null` unless all three are present and well-formed
* (a closed-set match against `WEEK_DAYS`/`MEALS`, same validation
* `addPlanningItemSchema` enforces server-side), so a hand-typed or stale
* URL just falls back to this page's normal "land on the recipe" behavior
* rather than throwing. See this component's own doc comment.
*/
function parsePlanningSlot(
searchParams: URLSearchParams,
): { date: string; weekDay: WeekDay; meal: Meal } | null {
const date = searchParams.get("planningDate");
const weekDay = searchParams.get("planningWeekDay");
const meal = searchParams.get("planningMeal");
if (
date === null ||
!/^\d{4}-\d{2}-\d{2}$/.test(date) ||
weekDay === null ||
!(WEEK_DAYS as readonly string[]).includes(weekDay) ||
meal === null ||
!(MEALS as readonly string[]).includes(meal)
) {
return null;
}
return { date, weekDay: weekDay as WeekDay, meal: meal as Meal };
}
/**
* 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.
*
* `?planningDate=&planningWeekDay=&planningMeal=` are set only when this
* page was reached from `RecipePickerDialog`'s "Sources" tab (via
* `SourceItemPreviewPanel`'s import link, see its own `planningSlot` prop)
* picking a not-yet-imported item there hands off to this full review
* screen instead of the dialog's own small "how many portions?" step,
* since an unresolved-ingredient review doesn't fit in that step. When
* present and well-formed, a successful import also adds the freshly
* created recipe straight to that planning slot (`POST /planning/items`,
* using this form's own `portions` field) before landing back on the
* planning page, instead of the recipe's own detail page.
*/
export function ImportRecipePage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { sourceKey, externalId } = useParams<{ sourceKey: string; externalId: string }>();
const [searchParams] = useSearchParams();
const planningSlot = parsePlanningSlot(searchParams);
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);
if (planningSlot) {
try {
await apiClient.addPlanningItem({
date: planningSlot.date,
weekDay: planningSlot.weekDay,
meal: planningSlot.meal,
recipeId: saved.id,
portions: Number(portions),
});
navigate("/");
return;
} catch {
// The recipe itself was already imported successfully — only the
// planning add failed. Land on the new recipe's own page rather
// than stranding the user on a form that already submitted; it
// can still be added to that slot afterwards via the normal
// "déjà importée" picker path.
navigate(`/recettes/${saved.id}`);
return;
}
}
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>
{planningSlot && (
<p className="source-item-preview__hint">{t("recipes.sources.import.planningHint")}</p>
)}
<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

@ -1,11 +1,12 @@
import { ErrorCode, type RecipeSummaryView, type RecipeTab } from "@batch-cooking/shared";
import { ErrorCode, type RecipeSummaryView } from "@batch-cooking/shared";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { ApiError, apiClient } from "../api/client";
import { RecipeDetailPanel, type RecipeDetailState } from "../features/recipes/RecipeDetailPanel";
import { RecipeSourcesPanel } from "../features/recipes/RecipeSourcesPanel";
import { RecipeTable } from "../features/recipes/RecipeTable";
import { RecipeTabs } from "../features/recipes/RecipeTabs";
import { RecipeTabs, type RecipesPageTab } from "../features/recipes/RecipeTabs";
import "../features/recipes/recipes.scss";
/** Debounce for the search field — avoids firing a request on every keystroke, same idea as the household name's autosave (`HouseholdSettingsPage`). */
@ -37,7 +38,7 @@ export function RecipesPage() {
// filter view would).
const [searchParams] = useSearchParams();
const [activeTab, setActiveTab] = useState<RecipeTab>("favoris");
const [activeTab, setActiveTab] = useState<RecipesPageTab>("favoris");
const [search, setSearch] = useState(() => searchParams.get("search") ?? "");
// Seeded from the same initial value as `search` — otherwise the first
// fetch below would fire with an empty term (the debounce effect hasn't
@ -53,6 +54,10 @@ export function RecipesPage() {
}, [search]);
useEffect(() => {
// The "sources" tab doesn't query the recipe table at all — it browses
// a source's own live catalog instead (see `RecipeSourcesPanel`, which
// owns its own fetching entirely).
if (activeTab === "sources") return;
let cancelled = false;
setListState({ status: "loading" });
@ -137,13 +142,15 @@ export function RecipesPage() {
<div className="recipes-page">
<div className="recipes-page__header">
<h1>{t("recipes.title")}</h1>
<input
type="search"
className="recipes-page__search"
placeholder={t("recipes.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{activeTab !== "sources" && (
<input
type="search"
className="recipes-page__search"
placeholder={t("recipes.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
)}
<Link to="/recettes/nouvelle" className="recipes-page__new-button">
{t("recipes.newButton")}
</Link>
@ -151,33 +158,42 @@ export function RecipesPage() {
<RecipeTabs active={activeTab} onChange={setActiveTab} />
<div className="recipes-page__catalog">
{listState.status === "loading" && (
<p className="recipes-page__status">{t("recipes.loading")}</p>
)}
{listState.status === "error" && (
<p className="recipes-page__status recipes-page__status--error">
{t("common.loadError")}
</p>
)}
{listState.status === "loaded" && listState.recipes.length === 0 && (
<p className="recipes-page__status">{t("recipes.empty")}</p>
)}
{listState.status === "loaded" && listState.recipes.length > 0 && (
<RecipeTable
recipes={listState.recipes}
selectedId={selectedId}
onSelect={(recipeId) => navigate(`/recettes/${recipeId}`)}
/>
)}
<RecipeDetailPanel
state={detailState}
dislikedIngredientIds={dislikedIngredientIds}
onFavoriteToggled={handleFavoriteToggled}
onDeleted={handleDeleted}
{activeTab === "sources" ? (
<RecipeSourcesPanel
onSelectImportedRecipe={(recipeId) => {
setActiveTab("favoris");
navigate(`/recettes/${recipeId}`);
}}
/>
</div>
) : (
<div className="recipes-page__catalog">
{listState.status === "loading" && (
<p className="recipes-page__status">{t("recipes.loading")}</p>
)}
{listState.status === "error" && (
<p className="recipes-page__status recipes-page__status--error">
{t("common.loadError")}
</p>
)}
{listState.status === "loaded" && listState.recipes.length === 0 && (
<p className="recipes-page__status">{t("recipes.empty")}</p>
)}
{listState.status === "loaded" && listState.recipes.length > 0 && (
<RecipeTable
recipes={listState.recipes}
selectedId={selectedId}
onSelect={(recipeId) => navigate(`/recettes/${recipeId}`)}
/>
)}
<RecipeDetailPanel
state={detailState}
dislikedIngredientIds={dislikedIngredientIds}
onFavoriteToggled={handleFavoriteToggled}
onDeleted={handleDeleted}
/>
</div>
)}
</div>
);
}

View file

@ -38,6 +38,8 @@ export enum ErrorCode {
ALREADY_HAS_HOUSE = 4020,
/** `DELETE /recipes/:id` attempted on a recipe still referenced by at least one `PlanningItem`. */
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. */
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. */