diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index a1161e3..b57a5ad 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -10,6 +10,7 @@ import { preferencesRouter } from "./modules/preferences/preferences.routes.js"; import { profileRouter } from "./modules/profile/profile.routes.js"; import { recipeRouter } from "./modules/recipe/recipe.routes.js"; import { referenceRouter } from "./modules/reference/reference.routes.js"; +import { sourcesRouter } from "./modules/sources/sources.routes.js"; /** * Builds the API's `ExpressServer`: standard middleware, routes, and the @@ -34,6 +35,7 @@ export function createServer(): ExpressServer { server.mountRouter("/profile", profileRouter); server.mountRouter("/recipes", recipeRouter); server.mountRouter("/reference", referenceRouter); + server.mountRouter("/sources", sourcesRouter); // Serves the built frontend (production Docker image only — see // FRONTEND_DIST_DIR's doc comment in config/env.ts). Must come after diff --git a/apps/api/src/db/recipe-source-sync.ts b/apps/api/src/db/recipe-source-sync.ts index f6ae4aa..8ad5a55 100644 --- a/apps/api/src/db/recipe-source-sync.ts +++ b/apps/api/src/db/recipe-source-sync.ts @@ -41,28 +41,33 @@ export async function syncRecipeSources(prisma: PrismaClient): Promise { /** * Which of `externalIds` already have a `Recipe` imported from the source - * registered under `sourceKey` — the DB-touching counterpart to - * `markAlreadyImported` (recipe-source-adapter.ts), which stays pure and - * takes this set as a plain argument rather than querying itself. Returns - * an empty set (not an error) for a `sourceKey` with no matching `Source` - * row — nothing can have been imported from a source we don't even have a - * catalog entry for. + * registered under `sourceKey`, mapped to that `Recipe`'s id — the + * DB-touching counterpart to `markAlreadyImported` (recipe-source-adapter.ts), + * which stays pure and takes a plain `ReadonlySet` (this map's + * `.keys()`) rather than querying itself. The id (not just membership) is + * what `sources.service.ts`'s browse endpoint needs to link an + * already-imported item straight to its real `Recipe`, instead of a + * caller having to look it up again. Returns an empty map (not an error) + * for a `sourceKey` with no matching `Source` row — nothing can have been + * imported from a source we don't even have a catalog entry for. */ -export async function findImportedExternalIds( +export async function findImportedRecipeIds( prisma: PrismaClient, sourceKey: string, externalIds: string[], -): Promise> { - if (externalIds.length === 0) return new Set(); +): Promise> { + if (externalIds.length === 0) return new Map(); const source = await prisma.source.findUnique({ where: { key: sourceKey } }); - if (!source) return new Set(); + if (!source) return new Map(); const imported = await prisma.recipe.findMany({ where: { sourceId: source.id, externalId: { in: externalIds } }, - select: { externalId: true }, + select: { id: true, externalId: true }, }); - return new Set( - imported.flatMap((recipe) => (recipe.externalId !== null ? [recipe.externalId] : [])), + return new Map( + imported.flatMap((recipe) => + recipe.externalId !== null ? [[recipe.externalId, recipe.id]] : [], + ), ); } diff --git a/apps/api/src/lib/recipe-source-adapter.ts b/apps/api/src/lib/recipe-source-adapter.ts index 2db6816..d663855 100644 --- a/apps/api/src/lib/recipe-source-adapter.ts +++ b/apps/api/src/lib/recipe-source-adapter.ts @@ -146,6 +146,7 @@ export interface ParsedRecipe { * name: "Some Recipe Site", * official: false, * iconUrl: "https://somerecipesite.example/favicon.svg", + * locale: "fr", * async list(params) { ... }, * async fetchDetail(externalId) { ... }, * parse(raw) { ... }, @@ -171,6 +172,16 @@ export interface RecipeSourceAdapter { official: boolean; /** URL of the source's own logo/favicon, for `SourceSelect` (apps/web) to display next to its name — `null` if the source has none worth showing. Synced to `Source.iconUrl` the same way as `name`/`official`. */ iconUrl: string | null; + /** + * Language of the text this source produces (`ParsedRecipe.description`/ + * `steps[].description`/`ingredients[].name`) — e.g. `"en"` for + * TheMealDB. Not a user preference: the language the source's own + * content is actually written in, regardless of who's browsing it. + * Determines which `TechStepMapping`/ingredient-label locale + * `translateRecipe` (`recipe-translation.ts`) resolves this source's + * recipes against when previewing/importing one. + */ + locale: string; list(params: RecipeSourceListParams): Promise; fetchDetail(externalId: string): Promise; parse(raw: TRawDetail): ParsedRecipe; diff --git a/apps/api/src/modules/recipe/recipe.service.ts b/apps/api/src/modules/recipe/recipe.service.ts index a4a3dcb..e29b1fb 100644 --- a/apps/api/src/modules/recipe/recipe.service.ts +++ b/apps/api/src/modules/recipe/recipe.service.ts @@ -364,11 +364,46 @@ export async function createRecipe( input: CreateRecipeInput, authorId: number, authorHouseId: number | null, +): Promise { + 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 { + 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 { 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, diff --git a/apps/api/src/modules/sources/sources.routes.ts b/apps/api/src/modules/sources/sources.routes.ts new file mode 100644 index 0000000..edba393 --- /dev/null +++ b/apps/api/src/modules/sources/sources.routes.ts @@ -0,0 +1,71 @@ +import { HttpError } from "@batch-cooking/error-tools"; +import { wrapAsyncHandler } from "@batch-cooking/express-tools"; +import { ErrorCode, browseSourceSchema, createRecipeSchema } from "@batch-cooking/shared"; +import { Router } from "express"; +import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; +import { browseSource, importSourceItem, previewSourceItem } from "./sources.service.js"; + +/** + * Router mounted at `/sources` in app.ts — browsing/previewing a + * household's *enabled* external recipe sources (see `sources.service.ts`). + * Every route requires a session, same posture as `/recipes`/`/house`: this + * is app content scoped to the viewer's household, not signup-time + * reference data (contrast `/reference/sources`, which just lists what + * exists, public, no auth needed). + */ +export const sourcesRouter = Router(); + +/** Route params are typed `string | undefined` by Express even for a segment that always matches when the route does — this just satisfies TS, the branch is unreachable in practice. */ +function requireParam(value: string | undefined): string { + if (value === undefined) { + throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Missing route parameter"); + } + return value; +} + +sourcesRouter.get( + "/:sourceKey/browse", + requireAuth, + wrapAsyncHandler(async (req, res) => { + const input = browseSourceSchema.parse(req.query); + const { houseId } = res.locals.userProfile; + res.status(200).json(await browseSource(requireParam(req.params.sourceKey), houseId, input)); + }), +); + +sourcesRouter.get( + "/:sourceKey/preview/:externalId", + requireAuth, + wrapAsyncHandler(async (req, res) => { + const { houseId } = res.locals.userProfile; + res + .status(200) + .json( + await previewSourceItem( + requireParam(req.params.sourceKey), + requireParam(req.params.externalId), + houseId, + ), + ); + }), +); + +sourcesRouter.post( + "/:sourceKey/import/:externalId", + requireAuth, + wrapAsyncHandler(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, + ), + ); + }), +); diff --git a/apps/api/src/modules/sources/sources.service.ts b/apps/api/src/modules/sources/sources.service.ts new file mode 100644 index 0000000..0fb533b --- /dev/null +++ b/apps/api/src/modules/sources/sources.service.ts @@ -0,0 +1,240 @@ +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"; +import { + type IngredientMatchEntry, + type UnitMatchEntry, + loadIngredientCatalog, + loadUnitCatalog, +} from "../../lib/ingredient-matcher.js"; +import { type RecipeSourceAdapter, markAlreadyImported } from "../../lib/recipe-source-adapter.js"; +import { RecipeSourceError } from "../../lib/recipe-source-errors.js"; +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, 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`. + */ + +/** + * `sourceKey` must both exist as a `Source` (household-enabled, via + * `HouseSource`) *and* still be a registered adapter (`recipe-source-registry.ts`) + * — the two can drift apart (a `Source` row outlives its adapter being + * unregistered, exactly what `jsonLdRecipe` was cleaned up from — see + * `sources/index.ts`), so both are checked. Either failure looks like "this + * source doesn't exist" to the caller (404 `SOURCE_NOT_FOUND`), same + * "don't distinguish not-found from not-visible" posture `recipe.service.ts` + * takes for a recipe the viewer can't see. + * + * @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. + */ +async function assertSourceEnabled( + houseId: number | null, + sourceKey: string, +): 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)) { + throw new HttpError( + 404, + ErrorCode.SOURCE_NOT_FOUND, + `Source "${sourceKey}" is not enabled for this household`, + ); + } + const adapter = getRecipeSource(sourceKey); + if (!adapter) { + throw new HttpError( + 404, + ErrorCode.SOURCE_NOT_FOUND, + `Source "${sourceKey}" has no registered adapter`, + ); + } + return { adapter, sourceId: source.id }; +} + +/** + * One page of `sourceKey`'s own catalog, each item flagged with whether + * it's already been imported (and, if so, its real `Recipe` id — see + * `findImportedRecipeIds`). + * + * @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. + */ +export async function browseSource( + sourceKey: string, + houseId: number | null, + params: { query?: string; cursor?: string }, +): Promise<{ items: BrowsableSourceItemView[]; nextCursor: string | null }> { + const { adapter } = await assertSourceEnabled(houseId, sourceKey); + const result = await adapter.list({ query: params.query, cursor: params.cursor }); + + const importedRecipeIds = await findImportedRecipeIds( + prisma, + sourceKey, + result.items.map((item) => item.externalId), + ); + const marked = markAlreadyImported(result.items, new Set(importedRecipeIds.keys())); + + return { + items: marked.map((item) => ({ + externalId: item.externalId, + title: item.title, + picture: item.picture, + url: item.url, + alreadyImported: item.alreadyImported, + recipeId: importedRecipeIds.get(item.externalId) ?? null, + })), + nextCursor: result.nextCursor, + }; +} + +/** + * Fully translates one source item into an unsaved {@link RecipeImportDraftView} + * — fetches + parses it (`fetchDetail`/`parse`), then resolves its + * ingredients/units (`translateRecipeIngredients`) and detects each step's + * techniques with their exact matched span (`matchTechStepSpans`, the same + * function `recipe.service.ts` uses at real save time — see its doc + * comment), all against `adapter.locale`'s catalogs. Ingredient/unit + * matching itself only has English data today (see `ingredient-matcher.ts`); + * a non-English-locale source simply gets `ingredient`/`unit: null` on + * every line, the same graceful "no matching-language data" degradation + * `translateRecipe` already has. + * + * @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} `404 RECIPE_NOT_FOUND` if `externalId` couldn't be fetched or parsed into a usable recipe (a `RecipeSourceError` — `recipe-source-errors.ts` — from the adapter). + */ +export async function previewSourceItem( + sourceKey: string, + externalId: string, + houseId: number | null, +): Promise { + const { adapter } = await assertSourceEnabled(houseId, sourceKey); + + let parsed: ReturnType; + try { + const raw = await adapter.fetchDetail(externalId); + parsed = adapter.parse(raw); + } catch (err) { + if (err instanceof RecipeSourceError) { + throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, err.message); + } + throw err; + } + + const [techStepMappings, ingredientCatalog, unitCatalog, techStepsByKey] = await Promise.all([ + loadTechStepMappingRules(adapter.locale), + adapter.locale === "en" ? loadIngredientCatalog() : Promise.resolve([]), + adapter.locale === "en" ? loadUnitCatalog() : Promise.resolve([]), + prisma.techStep.findMany({ select: { id: true, key: true } }), + ]); + const techStepById = new Map(techStepsByKey.map((techStep) => [techStep.id, techStep])); + + const translatedIngredients = translateRecipeIngredients( + parsed.ingredients, + ingredientCatalog, + unitCatalog, + ); + const [ingredientViews, unitViews] = await Promise.all([getIngredients(), getUnits()]); + const ingredientById = new Map(ingredientViews.map((view) => [view.id, view])); + const unitById = new Map(unitViews.map((view) => [view.id, view])); + + const ingredients: DraftRecipeIngredientView[] = translatedIngredients.map((ingredient) => ({ + rawText: ingredient.rawText, + quantity: ingredient.quantity, + ingredient: + ingredient.ingredientId !== null + ? (ingredientById.get(ingredient.ingredientId) ?? null) + : null, + unit: ingredient.unitId !== null ? (unitById.get(ingredient.unitId) ?? null) : null, + })); + + const steps: DraftRecipeStepView[] = parsed.steps.map((step) => ({ + description: step.description, + picture: step.picture, + techSteps: matchTechStepSpans(step.description, techStepMappings).flatMap((match) => { + const techStep = techStepById.get(match.techStepId); + return techStep ? [{ techStep, start: match.start, end: match.end }] : []; + }), + })); + + return { + sourceKey, + externalId, + name: parsed.name, + description: parsed.description, + picture: parsed.picture, + portions: parsed.portions, + sourceUrl: parsed.sourceUrl, + ingredients, + 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 { + 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, + }); +} diff --git a/apps/api/src/sources/index.ts b/apps/api/src/sources/index.ts index ea2f9a1..3ff3159 100644 --- a/apps/api/src/sources/index.ts +++ b/apps/api/src/sources/index.ts @@ -1,16 +1,17 @@ import { registerRecipeSource } from "../lib/recipe-source-registry.js"; -import { jsonLdRecipeAdapter } from "./json-ld-recipe.js"; import { theMealDbAdapter } from "./the-meal-db.js"; /** - * Registers every concrete `RecipeSourceAdapter` this app ships with into - * the shared in-memory registry (`recipe-source-registry.ts`) — currently - * `theMealDbAdapter` and `jsonLdRecipeAdapter`. Called once, explicitly, by - * the two real entry points that need the registry populated: + * Registers every concrete, *browsable* `RecipeSourceAdapter` this app + * ships with into the shared in-memory registry + * (`recipe-source-registry.ts`) — currently just `theMealDbAdapter`. + * Called once, explicitly, by the two real entry points that need the + * registry populated: * * - `server.ts` — the running API process, before it starts listening. * - `prisma/seed.ts` — so `syncRecipeSources` has something to mirror into - * the `sources` table. + * the `sources` table (`Source`, household-toggleable — see + * `HouseSource`). * * Deliberately **not** imported by `app.ts`: `createApp()` is what every * test file gets via supertest, and registering a real adapter there would @@ -19,8 +20,16 @@ import { theMealDbAdapter } from "./the-meal-db.js"; * `clearRecipeSources()` clears it out) instead of each test's own * explicit setup. Tests that need a source in the registry register their * own throwaway fake instead (see e.g. `test/recipe-source-sync.test.ts`). + * + * `jsonLdRecipeAdapter` (json-ld-recipe.ts) is deliberately **not** + * registered here — it's a generic schema.org-JSON-LD parser meant to be + * specialized per scraped website (a concrete adapter for a specific site + * would use it internally), not a household-toggleable `Source` in its own + * right: nobody can meaningfully "trust" or "enable" a generic parsing + * mechanism the way they can a named website. Until real per-site adapters + * exist, it's called directly (e.g. a future "import from a pasted URL" + * flow), never through this registry. */ export function registerAllRecipeSources(): void { registerRecipeSource(theMealDbAdapter); - registerRecipeSource(jsonLdRecipeAdapter); } diff --git a/apps/api/src/sources/json-ld-recipe.ts b/apps/api/src/sources/json-ld-recipe.ts index 3023027..10971a2 100644 --- a/apps/api/src/sources/json-ld-recipe.ts +++ b/apps/api/src/sources/json-ld-recipe.ts @@ -170,6 +170,13 @@ export const jsonLdRecipeAdapter: RecipeSourceAdapter<{ html: string; url: strin name: "Import générique (JSON-LD)", official: false, iconUrl: null, + // Genuinely varies per scraped site (this adapter has no fixed content + // language of its own) — "fr" as a placeholder since it's never actually + // consulted: this adapter isn't registered into the source registry (see + // sources/index.ts), so nothing calls translateRecipe against it today. + // A concrete per-site adapter built on top of this one would declare its + // own real locale. + locale: "fr", async list() { return { items: [], nextCursor: null }; diff --git a/apps/api/src/sources/the-meal-db.ts b/apps/api/src/sources/the-meal-db.ts index b43d6d6..4789c4f 100644 --- a/apps/api/src/sources/the-meal-db.ts +++ b/apps/api/src/sources/the-meal-db.ts @@ -76,6 +76,10 @@ export const theMealDbAdapter: RecipeSourceAdapter = { name: "TheMealDB", official: true, iconUrl: "https://www.themealdb.com/images/logo.svg", + // TheMealDB's content (names, ingredients, instructions) is English — + // determines which locale translateRecipe (recipe-translation.ts) + // resolves this source's recipes against when previewing/importing one. + locale: "en", async list(params: RecipeSourceListParams): Promise { const query = params.query ?? ""; diff --git a/apps/api/test/house.test.ts b/apps/api/test/house.test.ts index f25fa60..f621b56 100644 --- a/apps/api/test/house.test.ts +++ b/apps/api/test/house.test.ts @@ -28,6 +28,7 @@ function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter { name, official: false, iconUrl: null, + locale: "fr", async list() { return { items: [], nextCursor: null }; }, diff --git a/apps/api/test/recipe-source-sync.test.ts b/apps/api/test/recipe-source-sync.test.ts index 05f9c4e..e366af4 100644 --- a/apps/api/test/recipe-source-sync.test.ts +++ b/apps/api/test/recipe-source-sync.test.ts @@ -4,7 +4,7 @@ import { expect } from "chai"; import request from "supertest"; import { createApp } from "../src/app.js"; import { prisma } from "../src/db/prisma.js"; -import { findImportedExternalIds, syncRecipeSources } from "../src/db/recipe-source-sync.js"; +import { findImportedRecipeIds, syncRecipeSources } from "../src/db/recipe-source-sync.js"; import type { RecipeSourceAdapter } from "../src/lib/recipe-source-adapter.js"; import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js"; import { resetDatabase } from "../test-support/reset-db.js"; @@ -28,6 +28,7 @@ function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter { name, official: false, iconUrl: null, + locale: "fr", async list() { return { items: [], nextCursor: null }; }, @@ -108,22 +109,22 @@ describe("recipe-source-sync", () => { }); }); - describe("findImportedExternalIds", () => { - it("returns an empty set for a sourceKey with no matching Source row", async () => { - expect(await findImportedExternalIds(prisma, "unknown", ["1", "2"])).to.deep.equal(new Set()); + describe("findImportedRecipeIds", () => { + it("returns an empty map for a sourceKey with no matching Source row", async () => { + expect(await findImportedRecipeIds(prisma, "unknown", ["1", "2"])).to.deep.equal(new Map()); }); - it("returns an empty set for an empty externalIds list", async () => { - expect(await findImportedExternalIds(prisma, "fakeSource", [])).to.deep.equal(new Set()); + it("returns an empty map for an empty externalIds list", async () => { + expect(await findImportedRecipeIds(prisma, "fakeSource", [])).to.deep.equal(new Map()); }); - it("returns exactly the externalIds already imported from that source", async () => { + it("returns exactly the externalIds already imported from that source, mapped to their Recipe id", async () => { const { profileId } = await signup(); registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source")); await syncRecipeSources(prisma); const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } }); - await prisma.recipe.create({ + const imported = await prisma.recipe.create({ data: { name: "Tarte", authorId: profileId, @@ -135,8 +136,8 @@ describe("recipe-source-sync", () => { // Manually-authored, not tied to any source — shouldn't ever show up as "imported". await prisma.recipe.create({ data: { name: "Salade", authorId: profileId, portions: 2 } }); - const result = await findImportedExternalIds(prisma, "fakeSource", ["1", "2", "3"]); - expect(result).to.deep.equal(new Set(["1"])); + const result = await findImportedRecipeIds(prisma, "fakeSource", ["1", "2", "3"]); + expect(result).to.deep.equal(new Map([["1", imported.id]])); }); it("scopes matches to the given source — the same externalId from a different source doesn't count", async () => { @@ -156,7 +157,7 @@ describe("recipe-source-sync", () => { }, }); - expect(await findImportedExternalIds(prisma, "fakeSource", ["1"])).to.deep.equal(new Set()); + expect(await findImportedRecipeIds(prisma, "fakeSource", ["1"])).to.deep.equal(new Map()); }); }); diff --git a/apps/api/test/recipe-source.test.ts b/apps/api/test/recipe-source.test.ts index 45d0c36..e10b3c1 100644 --- a/apps/api/test/recipe-source.test.ts +++ b/apps/api/test/recipe-source.test.ts @@ -60,6 +60,7 @@ function buildFakeAdapter(key = "fakeSource"): RecipeSourceAdapter { const start = params.cursor ? Number(params.cursor) : 0; const page = FAKE_CATALOG.slice(start, start + PAGE_SIZE); diff --git a/apps/api/test/recipe.test.ts b/apps/api/test/recipe.test.ts index 1ad66cb..d35c720 100644 --- a/apps/api/test/recipe.test.ts +++ b/apps/api/test/recipe.test.ts @@ -17,6 +17,7 @@ function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter { name, official: false, iconUrl: null, + locale: "fr", async list() { return { items: [], nextCursor: null }; }, diff --git a/apps/api/test/reference.test.ts b/apps/api/test/reference.test.ts index 128c949..77ed7ba 100644 --- a/apps/api/test/reference.test.ts +++ b/apps/api/test/reference.test.ts @@ -20,6 +20,7 @@ function buildFakeAdapter( name, official, iconUrl, + locale: "fr", async list() { return { items: [], nextCursor: null }; }, diff --git a/apps/api/test/sources-index.test.ts b/apps/api/test/sources-index.test.ts index 1ba4e42..f13bcb3 100644 --- a/apps/api/test/sources-index.test.ts +++ b/apps/api/test/sources-index.test.ts @@ -25,12 +25,10 @@ describe("registerAllRecipeSources", () => { expect(listRecipeSources().map((adapter) => adapter.key)).to.include("theMealDb"); }); - it("registers the generic JSON-LD source into the shared registry", () => { + it("does not register the generic JSON-LD adapter — it's not a household-toggleable source in its own right", () => { registerAllRecipeSources(); - const jsonLd = getRecipeSource("jsonLdRecipe"); - expect(jsonLd).to.not.be.undefined; - expect(jsonLd?.official).to.equal(false); - expect(listRecipeSources().map((adapter) => adapter.key)).to.include("jsonLdRecipe"); + expect(getRecipeSource("jsonLdRecipe")).to.be.undefined; + expect(listRecipeSources().map((adapter) => adapter.key)).to.not.include("jsonLdRecipe"); }); }); diff --git a/apps/api/test/sources.test.ts b/apps/api/test/sources.test.ts new file mode 100644 index 0000000..9107128 --- /dev/null +++ b/apps/api/test/sources.test.ts @@ -0,0 +1,385 @@ +import type { SignupInput } from "@batch-cooking/shared"; +import { ErrorCode } from "@batch-cooking/shared"; +import { faker } from "@faker-js/faker"; +import { expect } from "chai"; +import request from "supertest"; +import { createApp } from "../src/app.js"; +import { prisma } from "../src/db/prisma.js"; +import { syncRecipeSources } from "../src/db/recipe-source-sync.js"; +import type { + ParsedRecipe, + RecipeSourceAdapter, + RecipeSourceListParams, + RecipeSourceListResult, +} from "../src/lib/recipe-source-adapter.js"; +import { RecipeSourceFetchError } from "../src/lib/recipe-source-errors.js"; +import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js"; +import { resetDatabase } from "../test-support/reset-db.js"; + +/** See `recipe.test.ts` — generated rather than hardcoded, no test fixture looks like a real person's data. */ +function buildSignupPayload(): SignupInput { + const firstName = faker.person.firstName(); + const lastName = faker.person.lastName(); + return { + firstName, + lastName, + email: faker.internet.email({ firstName, lastName }).toLowerCase(), + password: faker.internet.password({ length: 16 }), + }; +} + +/** + * A minimal, real English-content fake adapter — `parse()` deliberately + * mixes one ingredient that resolves against the real seeded catalog + * ("onion") with one that doesn't ("mystery paste"), and a step whose + * text matches a real seeded English tech-step mapping ("chop") — same + * "exercise the real catalog, not a mock of it" approach the ingredient/ + * tech-step matcher tests already use. + */ +function buildFakeAdapter(key = "fakeSource"): RecipeSourceAdapter<{ externalId: string }> { + return { + key, + name: "Fake Source", + official: true, + iconUrl: null, + locale: "en", + async list(_params: RecipeSourceListParams): Promise { + return { + items: [ + { externalId: "1", title: "Onion soup", picture: null, url: "https://fake.test/1" }, + { externalId: "2", title: "Mystery stew", picture: null, url: "https://fake.test/2" }, + ], + nextCursor: null, + }; + }, + async fetchDetail(externalId: string): Promise<{ externalId: string }> { + if (externalId === "missing") { + throw new RecipeSourceFetchError(key, `No item found for id "${externalId}"`); + } + return { externalId }; + }, + parse(raw: { externalId: string }): ParsedRecipe { + return { + name: `Fake recipe ${raw.externalId}`, + description: null, + picture: null, + portions: 4, + sourceUrl: `https://fake.test/${raw.externalId}`, + ingredients: [ + { rawText: "1 onion", quantity: null, unit: null, name: "onion" }, + // No leading number and no recognizable unit word — exercises + // quantity/unit staying null alongside the ingredient itself not + // resolving, not just the ingredient. + { rawText: "some mystery paste", quantity: null, unit: null, name: "mystery paste" }, + ], + steps: [{ description: "Chop the onions finely", picture: null }], + }; + }, + }; +} + +/** 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 { + 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 { + const unit = await prisma.unit.findFirstOrThrow({ where: { key } }); + return unit.id; +} + +describe("Sources", () => { + const app = createApp(); + + /** Signs up a fresh profile, creates a household for it, and returns the session `agent` alongside the household id. */ + async function signupWithHouse(): Promise<{ + agent: ReturnType; + houseId: number; + }> { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + const houseRes = await agent.post("/house").send({ name: "Chez moi" }); + return { agent, houseId: houseRes.body.id }; + } + + beforeEach(async () => { + await resetDatabase(); + }); + + afterEach(() => { + clearRecipeSources(); + }); + + after(async () => { + await prisma.$disconnect(); + }); + + describe("GET /sources/:sourceKey/browse", () => { + it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { + const res = await request(app).get("/sources/fakeSource/browse"); + expect(res.status).to.equal(401); + expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); + }); + + it("rejects a profile with no household with 404 HOUSE_NOT_FOUND", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + + const res = await agent.get("/sources/fakeSource/browse"); + + expect(res.status).to.equal(404); + expect(res.body.code).to.equal(ErrorCode.HOUSE_NOT_FOUND); + }); + + it("rejects an unknown sourceKey with 404 SOURCE_NOT_FOUND", async () => { + const { agent } = await signupWithHouse(); + + const res = await agent.get("/sources/unknown/browse"); + + expect(res.status).to.equal(404); + expect(res.body.code).to.equal(ErrorCode.SOURCE_NOT_FOUND); + }); + + it("rejects a real 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.get("/sources/fakeSource/browse"); + + expect(res.status).to.equal(404); + expect(res.body.code).to.equal(ErrorCode.SOURCE_NOT_FOUND); + }); + + it("returns each item flagged with alreadyImported/recipeId once the source is enabled", async () => { + const { agent, houseId } = 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] }); + + const importedRecipe = await prisma.recipe.create({ + data: { + name: "Already imported", + authorId: (await prisma.userProfile.findFirstOrThrow({ where: { houseId } })).id, + portions: 4, + sourceId: source.id, + externalId: "1", + }, + }); + + const res = await agent.get("/sources/fakeSource/browse"); + + expect(res.status).to.equal(200); + expect(res.body.nextCursor).to.equal(null); + expect(res.body.items).to.deep.equal([ + { + externalId: "1", + title: "Onion soup", + picture: null, + url: "https://fake.test/1", + alreadyImported: true, + recipeId: importedRecipe.id, + }, + { + externalId: "2", + title: "Mystery stew", + picture: null, + url: "https://fake.test/2", + alreadyImported: false, + recipeId: null, + }, + ]); + }); + }); + + describe("GET /sources/:sourceKey/preview/:externalId", () => { + async function enableFakeSource(): Promise<{ + agent: ReturnType; + }> { + 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 }; + } + + 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.get("/sources/fakeSource/preview/1"); + + expect(res.status).to.equal(404); + expect(res.body.code).to.equal(ErrorCode.SOURCE_NOT_FOUND); + }); + + it("translates the item against the real catalog: resolves what it can, leaves the rest null", async () => { + const { agent } = await enableFakeSource(); + const onion = await prisma.ingredient.findFirstOrThrow({ where: { key: "onion" } }); + const chop = await prisma.techStep.findFirstOrThrow({ where: { key: "chop" } }); + + const res = await agent.get("/sources/fakeSource/preview/1"); + + expect(res.status).to.equal(200); + expect(res.body).to.deep.include({ + sourceKey: "fakeSource", + externalId: "1", + name: "Fake recipe 1", + description: null, + picture: null, + portions: 4, + sourceUrl: "https://fake.test/1", + }); + + const [resolved, unresolved] = res.body.ingredients; + expect(resolved.rawText).to.equal("1 onion"); + expect(resolved.ingredient).to.deep.include({ id: onion.id, key: "onion" }); + expect(unresolved.rawText).to.equal("some mystery paste"); + expect(unresolved.ingredient).to.equal(null); + expect(unresolved.unit).to.equal(null); + expect(unresolved.quantity).to.equal(null); + + expect(res.body.steps).to.have.length(1); + const [step] = res.body.steps; + expect(step.description).to.equal("Chop the onions finely"); + expect(step.techSteps).to.have.length(1); + expect(step.techSteps[0].techStep).to.deep.equal({ id: chop.id, key: "chop" }); + expect( + step.description.slice(step.techSteps[0].start, step.techSteps[0].end).toLowerCase(), + ).to.equal("chop"); + }); + + it("returns 404 RECIPE_NOT_FOUND when the adapter can't fetch the item", async () => { + const { agent } = await enableFakeSource(); + + const res = await agent.get("/sources/fakeSource/preview/missing"); + + expect(res.status).to.equal(404); + expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND); + }); + }); + + describe("POST /sources/:sourceKey/import/:externalId", () => { + async function enableFakeSource(): Promise<{ + agent: ReturnType; + 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); + }); + }); +}); diff --git a/apps/web/cypress/e2e/household-settings.feature b/apps/web/cypress/e2e/household-settings.feature index d3bc168..17b9b55 100644 --- a/apps/web/cypress/e2e/household-settings.feature +++ b/apps/web/cypress/e2e/household-settings.feature @@ -65,7 +65,7 @@ Feature: Household settings Then I should see the section "Sources disponibles" And I should see "TheMealDB" And I should see "Officielle" - And I should see "Import générique (JSON-LD)" + And I should see "Marmiton" And I should see "Non officielle" When I check the checkbox "TheMealDB" Then the source selection update request should have been made with source id 1 diff --git a/apps/web/cypress/e2e/planning.feature b/apps/web/cypress/e2e/planning.feature new file mode 100644 index 0000000..3b3b30a --- /dev/null +++ b/apps/web/cypress/e2e/planning.feature @@ -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 diff --git a/apps/web/cypress/e2e/planning.ts b/apps/web/cypress/e2e/planning.ts new file mode 100644 index 0000000..dd078bb --- /dev/null +++ b/apps/web/cypress/e2e/planning.ts @@ -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"); + }); + }, +); diff --git a/apps/web/cypress/e2e/recipe-form.ts b/apps/web/cypress/e2e/recipe-form.ts index 2d22a99..03cb085 100644 --- a/apps/web/cypress/e2e/recipe-form.ts +++ b/apps/web/cypress/e2e/recipe-form.ts @@ -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) => { diff --git a/apps/web/cypress/e2e/recipe-sources.feature b/apps/web/cypress/e2e/recipe-sources.feature new file mode 100644 index 0000000..9fc8a56 --- /dev/null +++ b/apps/web/cypress/e2e/recipe-sources.feature @@ -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" diff --git a/apps/web/cypress/e2e/recipe-sources.ts b/apps/web/cypress/e2e/recipe-sources.ts new file mode 100644 index 0000000..c630282 --- /dev/null +++ b/apps/web/cypress/e2e/recipe-sources.ts @@ -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 }]); + }, +); diff --git a/apps/web/cypress/e2e/recipes.cy.ts b/apps/web/cypress/e2e/recipes.cy.ts index 8b52664..da874e1 100644 --- a/apps/web/cypress/e2e/recipes.cy.ts +++ b/apps/web/cypress/e2e/recipes.cy.ts @@ -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", () => { diff --git a/apps/web/cypress/e2e/recipes.ts b/apps/web/cypress/e2e/recipes.ts index 42e3f34..14a75f5 100644 --- a/apps/web/cypress/e2e/recipes.ts +++ b/apps/web/cypress/e2e/recipes.ts @@ -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"); -}); diff --git a/apps/web/cypress/support/step_definitions/common.steps.ts b/apps/web/cypress/support/step_definitions/common.steps.ts index 86fd61a..a4ac86a 100644 --- a/apps/web/cypress/support/step_definitions/common.steps.ts +++ b/apps/web/cypress/support/step_definitions/common.steps.ts @@ -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"); }); diff --git a/apps/web/cypress/support/step_definitions/reference-data.steps.ts b/apps/web/cypress/support/step_definitions/reference-data.steps.ts index edcb805..f105815 100644 --- a/apps/web/cypress/support/step_definitions/reference-data.steps.ts +++ b/apps/web/cypress/support/step_definitions/reference-data.steps.ts @@ -40,11 +40,15 @@ Given("the sources reference list is empty", () => { cy.intercept("GET", "**/reference/sources", { statusCode: 200, body: [] }); }); -// Mirrors what's actually seeded (`reference-seed-data.ts`'s -// `registerAllRecipeSources`/`syncRecipeSources`) — one official API source -// with an icon, one unofficial scraper without one — so the sources step -// (onboarding and `/parametres/foyer` alike) has something real to show -// instead of self-skipping. +// The real first entry (`theMealDb`) mirrors what's actually seeded +// (`reference-seed-data.ts`'s `registerAllRecipeSources`/ +// `syncRecipeSources`); the second is illustrative only — a future +// per-site scraper adapter, not tied to any real one that exists yet (the +// generic `jsonLdRecipeAdapter` is deliberately never registered as a +// household-toggleable source in its own right — see `sources/index.ts`). +// Two entries so the sources step (onboarding and `/parametres/foyer` +// alike) has more than one thing to show/select, instead of self-skipping +// or exercising only a single-item list. Given("the sources reference list has options", () => { cy.intercept("GET", "**/reference/sources", { statusCode: 200, @@ -58,8 +62,8 @@ Given("the sources reference list has options", () => { }, { id: 2, - key: "jsonLdRecipe", - name: "Import générique (JSON-LD)", + key: "marmiton", + name: "Marmiton", official: false, iconUrl: null, }, diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 2750708..206e7c3 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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). */} } /> } /> + } /> } /> } /> } /> diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index dabc2ae..38403c7 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -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 { + 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 { + 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), diff --git a/apps/web/src/features/planning/RecipePickerDialog.tsx b/apps/web/src/features/planning/RecipePickerDialog.tsx index cd0f4c4..feaf62e 100644 --- a/apps/web/src/features/planning/RecipePickerDialog.tsx +++ b/apps/web/src/features/planning/RecipePickerDialog.tsx @@ -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("favoris"); + const [activeTab, setActiveTab] = useState("favoris"); const [search, setSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); const [selectedIngredientIds, setSelectedIngredientIds] = useState([]); @@ -83,6 +92,11 @@ export function RecipePickerDialog({ const [ingredientsCatalog, setIngredientsCatalog] = useState([]); const [dietsCatalog, setDietsCatalog] = useState([]); const [listState, setListState] = useState({ 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 ( -
- setSearch(e.target.value)} - /> + {activeTab !== "sources" && ( +
+ setSearch(e.target.value)} + /> -
- - {t("planning.picker.ingredientsFilterLabel")} - -
- {selectedIngredients.map((ingredient) => ( - - {t(`catalog.ingredients.${ingredient.key}`)} - - - ))} - +
+ + {t("planning.picker.ingredientsFilterLabel")} + +
+ {selectedIngredients.map((ingredient) => ( + + {t(`catalog.ingredients.${ingredient.key}`)} + + + ))} + +
+ {isIngredientPickerOpen && ( + + setSelectedIngredientIds((ids) => [...ids, ingredient.id]) + } + /> + )}
- {isIngredientPickerOpen && ( - setSelectedIngredientIds((ids) => [...ids, ingredient.id])} - /> + + + + {hasHousehold && ( + + {t("planning.picker.suitableForHouseholdLabel")} + )}
- - - - {hasHousehold && ( - - {t("planning.picker.suitableForHouseholdLabel")} - - )} -
+ )} - {listState.status === "loading" && ( -

{t("planning.picker.loading")}

- )} - {listState.status === "error" && ( -

{t("common.loadError")}

- )} - {listState.status === "loaded" && listState.recipes.length === 0 && ( -

{t("planning.picker.empty")}

- )} - {listState.status === "loaded" && listState.recipes.length > 0 && ( - { - 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 && ( +

+ {t("common.loadError")} +

+ )} + + + ) : ( + <> + {listState.status === "loading" && ( +

{t("planning.picker.loading")}

+ )} + {listState.status === "error" && ( +

+ {t("common.loadError")} +

+ )} + {listState.status === "loaded" && listState.recipes.length === 0 && ( +

{t("planning.picker.empty")}

+ )} + {listState.status === "loaded" && listState.recipes.length > 0 && ( + { + 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)); + }} + /> + )} + )}
); diff --git a/apps/web/src/features/recipes/RecipeSourcesPanel.tsx b/apps/web/src/features/recipes/RecipeSourcesPanel.tsx new file mode 100644 index 0000000..80d609e --- /dev/null +++ b/apps/web/src/features/recipes/RecipeSourcesPanel.tsx @@ -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({ status: "loading" }); + const [selectedSourceKey, setSelectedSourceKey] = useState(null); + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [browseState, setBrowseState] = useState({ status: "loading" }); + const [selectedExternalId, setSelectedExternalId] = useState(null); + const [previewState, setPreviewState] = useState({ 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

{t("recipes.loading")}

; + } + if (enabledSources.status === "error") { + return ( +

{t("common.loadError")}

+ ); + } + if (enabledSources.sources.length === 0) { + return ( +

+ {t("recipes.sources.noneEnabled")}{" "} + {t("recipes.sources.noneEnabledLink")} +

+ ); + } + + return ( + <> +
+ {enabledSources.sources.length > 1 && ( + + )} + setSearch(e.target.value)} + /> +
+ +
+ {browseState.status === "loading" && ( +

{t("recipes.sources.loading")}

+ )} + {browseState.status === "error" && ( +

+ {t("recipes.sources.loadError")} +

+ )} + {browseState.status === "loaded" && browseState.items.length === 0 && ( +

{t("recipes.sources.empty")}

+ )} + {browseState.status === "loaded" && browseState.items.length > 0 && ( +
+ + {browseState.nextCursor && ( + + )} +
+ )} + + +
+ + ); +} diff --git a/apps/web/src/features/recipes/RecipeTabs.tsx b/apps/web/src/features/recipes/RecipeTabs.tsx index 6826eba..8fb8399 100644 --- a/apps/web/src/features/recipes/RecipeTabs.tsx +++ b/apps/web/src/features/recipes/RecipeTabs.tsx @@ -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 (
- {TABS.map(({ value, Icon }) => ( + {ALL_TABS.filter(({ value }) => tabs.includes(value)).map(({ value, Icon }) => (
); } diff --git a/apps/web/src/features/recipes/SourceItemPreviewPanel.tsx b/apps/web/src/features/recipes/SourceItemPreviewPanel.tsx new file mode 100644 index 0000000..e6b89fa --- /dev/null +++ b/apps/web/src/features/recipes/SourceItemPreviewPanel.tsx @@ -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 ( + + ); + } + if (state.status === "loading") { + return ( + + ); + } + if (state.status === "error") { + return ( + + ); + } + + const { draft } = state; + const hasUnresolvedIngredient = draft.ingredients.some( + (ingredient) => ingredient.ingredient === null, + ); + + return ( + + ); +} diff --git a/apps/web/src/features/recipes/SourceItemTable.tsx b/apps/web/src/features/recipes/SourceItemTable.tsx new file mode 100644 index 0000000..16b46dd --- /dev/null +++ b/apps/web/src/features/recipes/SourceItemTable.tsx @@ -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 ( +
+ + + + + + + + {items.map((item) => ( + onSelect(item)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect(item); + } + }} + tabIndex={0} + aria-current={item.externalId === selectedExternalId ? "true" : undefined} + > + + + + + ))} + +
+ {t("recipes.table.name")} +
+ + {item.title} + {item.alreadyImported && ( + + {t("recipes.sources.alreadyImported")} + + )} +
+
+ ); +} diff --git a/apps/web/src/features/recipes/recipes.scss b/apps/web/src/features/recipes/recipes.scss index 4523b00..93a939f 100644 --- a/apps/web/src/features/recipes/recipes.scss +++ b/apps/web/src/features/recipes/recipes.scss @@ -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 setName(e.target.value)} /> + + +