Merge pull request #45 from kyuno053/feat/browse-sources-backend
feat(recipes): parcourir et prévisualiser les sources externes (étape 1/4)
This commit is contained in:
commit
b383abdfdf
38 changed files with 2819 additions and 244 deletions
|
|
@ -10,6 +10,7 @@ import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
|
||||||
import { profileRouter } from "./modules/profile/profile.routes.js";
|
import { profileRouter } from "./modules/profile/profile.routes.js";
|
||||||
import { recipeRouter } from "./modules/recipe/recipe.routes.js";
|
import { recipeRouter } from "./modules/recipe/recipe.routes.js";
|
||||||
import { referenceRouter } from "./modules/reference/reference.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
|
* Builds the API's `ExpressServer`: standard middleware, routes, and the
|
||||||
|
|
@ -34,6 +35,7 @@ export function createServer(): ExpressServer {
|
||||||
server.mountRouter("/profile", profileRouter);
|
server.mountRouter("/profile", profileRouter);
|
||||||
server.mountRouter("/recipes", recipeRouter);
|
server.mountRouter("/recipes", recipeRouter);
|
||||||
server.mountRouter("/reference", referenceRouter);
|
server.mountRouter("/reference", referenceRouter);
|
||||||
|
server.mountRouter("/sources", sourcesRouter);
|
||||||
|
|
||||||
// Serves the built frontend (production Docker image only — see
|
// Serves the built frontend (production Docker image only — see
|
||||||
// FRONTEND_DIST_DIR's doc comment in config/env.ts). Must come after
|
// FRONTEND_DIST_DIR's doc comment in config/env.ts). Must come after
|
||||||
|
|
|
||||||
|
|
@ -41,28 +41,33 @@ export async function syncRecipeSources(prisma: PrismaClient): Promise<void> {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which of `externalIds` already have a `Recipe` imported from the source
|
* Which of `externalIds` already have a `Recipe` imported from the source
|
||||||
* registered under `sourceKey` — the DB-touching counterpart to
|
* registered under `sourceKey`, mapped to that `Recipe`'s id — the
|
||||||
* `markAlreadyImported` (recipe-source-adapter.ts), which stays pure and
|
* DB-touching counterpart to `markAlreadyImported` (recipe-source-adapter.ts),
|
||||||
* takes this set as a plain argument rather than querying itself. Returns
|
* which stays pure and takes a plain `ReadonlySet<string>` (this map's
|
||||||
* an empty set (not an error) for a `sourceKey` with no matching `Source`
|
* `.keys()`) rather than querying itself. The id (not just membership) is
|
||||||
* row — nothing can have been imported from a source we don't even have a
|
* what `sources.service.ts`'s browse endpoint needs to link an
|
||||||
* catalog entry for.
|
* 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,
|
prisma: PrismaClient,
|
||||||
sourceKey: string,
|
sourceKey: string,
|
||||||
externalIds: string[],
|
externalIds: string[],
|
||||||
): Promise<Set<string>> {
|
): Promise<Map<string, number>> {
|
||||||
if (externalIds.length === 0) return new Set();
|
if (externalIds.length === 0) return new Map();
|
||||||
|
|
||||||
const source = await prisma.source.findUnique({ where: { key: sourceKey } });
|
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({
|
const imported = await prisma.recipe.findMany({
|
||||||
where: { sourceId: source.id, externalId: { in: externalIds } },
|
where: { sourceId: source.id, externalId: { in: externalIds } },
|
||||||
select: { externalId: true },
|
select: { id: true, externalId: true },
|
||||||
});
|
});
|
||||||
return new Set(
|
return new Map(
|
||||||
imported.flatMap((recipe) => (recipe.externalId !== null ? [recipe.externalId] : [])),
|
imported.flatMap((recipe) =>
|
||||||
|
recipe.externalId !== null ? [[recipe.externalId, recipe.id]] : [],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,7 @@ export interface ParsedRecipe {
|
||||||
* name: "Some Recipe Site",
|
* name: "Some Recipe Site",
|
||||||
* official: false,
|
* official: false,
|
||||||
* iconUrl: "https://somerecipesite.example/favicon.svg",
|
* iconUrl: "https://somerecipesite.example/favicon.svg",
|
||||||
|
* locale: "fr",
|
||||||
* async list(params) { ... },
|
* async list(params) { ... },
|
||||||
* async fetchDetail(externalId) { ... },
|
* async fetchDetail(externalId) { ... },
|
||||||
* parse(raw) { ... },
|
* parse(raw) { ... },
|
||||||
|
|
@ -171,6 +172,16 @@ export interface RecipeSourceAdapter<TRawDetail = unknown> {
|
||||||
official: boolean;
|
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`. */
|
/** 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;
|
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<RecipeSourceListResult>;
|
list(params: RecipeSourceListParams): Promise<RecipeSourceListResult>;
|
||||||
fetchDetail(externalId: string): Promise<TRawDetail>;
|
fetchDetail(externalId: string): Promise<TRawDetail>;
|
||||||
parse(raw: TRawDetail): ParsedRecipe;
|
parse(raw: TRawDetail): ParsedRecipe;
|
||||||
|
|
|
||||||
|
|
@ -364,11 +364,46 @@ export async function createRecipe(
|
||||||
input: CreateRecipeInput,
|
input: CreateRecipeInput,
|
||||||
authorId: number,
|
authorId: number,
|
||||||
authorHouseId: number | null,
|
authorHouseId: number | null,
|
||||||
|
): Promise<RecipeView> {
|
||||||
|
return createRecipeInternal(input, authorId, authorHouseId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalizes an import from an external source — same validation/creation
|
||||||
|
* path as {@link createRecipe} (by the time this is called, `input` has
|
||||||
|
* already been reviewed and every ingredient resolved to a real catalog
|
||||||
|
* id, same as a manual creation — see `sources.service.ts`'s
|
||||||
|
* `importSourceItem`, the only caller), plus stamping `sourceId`/
|
||||||
|
* `externalId` and matching techniques against `locale` (the source's own
|
||||||
|
* — e.g. `"en"` for TheMealDB) instead of the hardcoded French default,
|
||||||
|
* since the step text is still in whatever language the source wrote it
|
||||||
|
* in.
|
||||||
|
*
|
||||||
|
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
|
||||||
|
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
|
||||||
|
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
|
||||||
|
*/
|
||||||
|
export async function createImportedRecipe(
|
||||||
|
input: CreateRecipeInput,
|
||||||
|
authorId: number,
|
||||||
|
authorHouseId: number | null,
|
||||||
|
source: { sourceId: number; externalId: string; locale: string },
|
||||||
|
): Promise<RecipeView> {
|
||||||
|
return createRecipeInternal(input, authorId, authorHouseId, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRecipeInternal(
|
||||||
|
input: CreateRecipeInput,
|
||||||
|
authorId: number,
|
||||||
|
authorHouseId: number | null,
|
||||||
|
source: { sourceId: number; externalId: string; locale: string } | null,
|
||||||
): Promise<RecipeView> {
|
): Promise<RecipeView> {
|
||||||
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
|
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
|
||||||
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
|
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
|
||||||
await assertDietsExist(input.dietIds);
|
await assertDietsExist(input.dietIds);
|
||||||
const techStepMappings = await loadTechStepMappingRules(DEFAULT_TECH_STEP_LOCALE);
|
const techStepMappings = await loadTechStepMappingRules(
|
||||||
|
source?.locale ?? DEFAULT_TECH_STEP_LOCALE,
|
||||||
|
);
|
||||||
|
|
||||||
const created = await prisma.recipe.create({
|
const created = await prisma.recipe.create({
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -379,6 +414,8 @@ export async function createRecipe(
|
||||||
authorId,
|
authorId,
|
||||||
authorHouseId,
|
authorHouseId,
|
||||||
visibility: input.visibility,
|
visibility: input.visibility,
|
||||||
|
sourceId: source?.sourceId ?? null,
|
||||||
|
externalId: source?.externalId ?? null,
|
||||||
ingredients: {
|
ingredients: {
|
||||||
create: input.ingredients.map((ingredient) => ({
|
create: input.ingredients.map((ingredient) => ({
|
||||||
ingredientId: ingredient.ingredientId,
|
ingredientId: ingredient.ingredientId,
|
||||||
|
|
|
||||||
71
apps/api/src/modules/sources/sources.routes.ts
Normal file
71
apps/api/src/modules/sources/sources.routes.ts
Normal file
|
|
@ -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<unknown, AuthLocals>(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<unknown, AuthLocals>(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<unknown, AuthLocals>(async (req, res) => {
|
||||||
|
const input = createRecipeSchema.parse(req.body);
|
||||||
|
const { id: authorId, houseId } = res.locals.userProfile;
|
||||||
|
res
|
||||||
|
.status(201)
|
||||||
|
.json(
|
||||||
|
await importSourceItem(
|
||||||
|
requireParam(req.params.sourceKey),
|
||||||
|
requireParam(req.params.externalId),
|
||||||
|
input,
|
||||||
|
authorId,
|
||||||
|
houseId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
240
apps/api/src/modules/sources/sources.service.ts
Normal file
240
apps/api/src/modules/sources/sources.service.ts
Normal file
|
|
@ -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<RecipeImportDraftView> {
|
||||||
|
const { adapter } = await assertSourceEnabled(houseId, sourceKey);
|
||||||
|
|
||||||
|
let parsed: ReturnType<typeof adapter.parse>;
|
||||||
|
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<IngredientMatchEntry[]>([]),
|
||||||
|
adapter.locale === "en" ? loadUnitCatalog() : Promise.resolve<UnitMatchEntry[]>([]),
|
||||||
|
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<RecipeView> {
|
||||||
|
const { adapter, sourceId } = await assertSourceEnabled(authorHouseId, sourceKey);
|
||||||
|
|
||||||
|
const alreadyImported = await findImportedRecipeIds(prisma, sourceKey, [externalId]);
|
||||||
|
if (alreadyImported.has(externalId)) {
|
||||||
|
throw new HttpError(
|
||||||
|
409,
|
||||||
|
ErrorCode.RECIPE_ALREADY_IMPORTED,
|
||||||
|
`"${externalId}" from source "${sourceKey}" is already imported`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createImportedRecipe(input, authorId, authorHouseId, {
|
||||||
|
sourceId,
|
||||||
|
externalId,
|
||||||
|
locale: adapter.locale,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -170,6 +170,13 @@ export const jsonLdRecipeAdapter: RecipeSourceAdapter<{ html: string; url: strin
|
||||||
name: "Import générique (JSON-LD)",
|
name: "Import générique (JSON-LD)",
|
||||||
official: false,
|
official: false,
|
||||||
iconUrl: null,
|
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() {
|
async list() {
|
||||||
return { items: [], nextCursor: null };
|
return { items: [], nextCursor: null };
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,10 @@ export const theMealDbAdapter: RecipeSourceAdapter<TheMealDbMeal> = {
|
||||||
name: "TheMealDB",
|
name: "TheMealDB",
|
||||||
official: true,
|
official: true,
|
||||||
iconUrl: "https://www.themealdb.com/images/logo.svg",
|
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<RecipeSourceListResult> {
|
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
|
||||||
const query = params.query ?? "";
|
const query = params.query ?? "";
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter {
|
||||||
name,
|
name,
|
||||||
official: false,
|
official: false,
|
||||||
iconUrl: null,
|
iconUrl: null,
|
||||||
|
locale: "fr",
|
||||||
async list() {
|
async list() {
|
||||||
return { items: [], nextCursor: null };
|
return { items: [], nextCursor: null };
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { expect } from "chai";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import { createApp } from "../src/app.js";
|
import { createApp } from "../src/app.js";
|
||||||
import { prisma } from "../src/db/prisma.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 type { RecipeSourceAdapter } from "../src/lib/recipe-source-adapter.js";
|
||||||
import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js";
|
import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js";
|
||||||
import { resetDatabase } from "../test-support/reset-db.js";
|
import { resetDatabase } from "../test-support/reset-db.js";
|
||||||
|
|
@ -28,6 +28,7 @@ function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter {
|
||||||
name,
|
name,
|
||||||
official: false,
|
official: false,
|
||||||
iconUrl: null,
|
iconUrl: null,
|
||||||
|
locale: "fr",
|
||||||
async list() {
|
async list() {
|
||||||
return { items: [], nextCursor: null };
|
return { items: [], nextCursor: null };
|
||||||
},
|
},
|
||||||
|
|
@ -108,22 +109,22 @@ describe("recipe-source-sync", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("findImportedExternalIds", () => {
|
describe("findImportedRecipeIds", () => {
|
||||||
it("returns an empty set for a sourceKey with no matching Source row", async () => {
|
it("returns an empty map for a sourceKey with no matching Source row", async () => {
|
||||||
expect(await findImportedExternalIds(prisma, "unknown", ["1", "2"])).to.deep.equal(new Set());
|
expect(await findImportedRecipeIds(prisma, "unknown", ["1", "2"])).to.deep.equal(new Map());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns an empty set for an empty externalIds list", async () => {
|
it("returns an empty map for an empty externalIds list", async () => {
|
||||||
expect(await findImportedExternalIds(prisma, "fakeSource", [])).to.deep.equal(new Set());
|
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();
|
const { profileId } = await signup();
|
||||||
registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source"));
|
registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source"));
|
||||||
await syncRecipeSources(prisma);
|
await syncRecipeSources(prisma);
|
||||||
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
|
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
|
||||||
|
|
||||||
await prisma.recipe.create({
|
const imported = await prisma.recipe.create({
|
||||||
data: {
|
data: {
|
||||||
name: "Tarte",
|
name: "Tarte",
|
||||||
authorId: profileId,
|
authorId: profileId,
|
||||||
|
|
@ -135,8 +136,8 @@ describe("recipe-source-sync", () => {
|
||||||
// Manually-authored, not tied to any source — shouldn't ever show up as "imported".
|
// 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 } });
|
await prisma.recipe.create({ data: { name: "Salade", authorId: profileId, portions: 2 } });
|
||||||
|
|
||||||
const result = await findImportedExternalIds(prisma, "fakeSource", ["1", "2", "3"]);
|
const result = await findImportedRecipeIds(prisma, "fakeSource", ["1", "2", "3"]);
|
||||||
expect(result).to.deep.equal(new Set(["1"]));
|
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 () => {
|
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());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ function buildFakeAdapter(key = "fakeSource"): RecipeSourceAdapter<FakeRawRecipe
|
||||||
name: "Fake Source",
|
name: "Fake Source",
|
||||||
official: false,
|
official: false,
|
||||||
iconUrl: null,
|
iconUrl: null,
|
||||||
|
locale: "fr",
|
||||||
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
|
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
|
||||||
const start = params.cursor ? Number(params.cursor) : 0;
|
const start = params.cursor ? Number(params.cursor) : 0;
|
||||||
const page = FAKE_CATALOG.slice(start, start + PAGE_SIZE);
|
const page = FAKE_CATALOG.slice(start, start + PAGE_SIZE);
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter {
|
||||||
name,
|
name,
|
||||||
official: false,
|
official: false,
|
||||||
iconUrl: null,
|
iconUrl: null,
|
||||||
|
locale: "fr",
|
||||||
async list() {
|
async list() {
|
||||||
return { items: [], nextCursor: null };
|
return { items: [], nextCursor: null };
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ function buildFakeAdapter(
|
||||||
name,
|
name,
|
||||||
official,
|
official,
|
||||||
iconUrl,
|
iconUrl,
|
||||||
|
locale: "fr",
|
||||||
async list() {
|
async list() {
|
||||||
return { items: [], nextCursor: null };
|
return { items: [], nextCursor: null };
|
||||||
},
|
},
|
||||||
|
|
|
||||||
385
apps/api/test/sources.test.ts
Normal file
385
apps/api/test/sources.test.ts
Normal file
|
|
@ -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<RecipeSourceListResult> {
|
||||||
|
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<number> {
|
||||||
|
const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { key } });
|
||||||
|
return ingredient.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves a reference unit's id by its `reference-seed-data.ts` uid (also its DB `key`) — same reasoning as {@link ingredientId}. */
|
||||||
|
async function unitId(key: string): Promise<number> {
|
||||||
|
const unit = await prisma.unit.findFirstOrThrow({ where: { key } });
|
||||||
|
return unit.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Sources", () => {
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
/** 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<typeof request.agent>;
|
||||||
|
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<typeof request.agent>;
|
||||||
|
}> {
|
||||||
|
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<typeof request.agent>;
|
||||||
|
sourceId: number;
|
||||||
|
}> {
|
||||||
|
const { agent } = await signupWithHouse();
|
||||||
|
registerRecipeSource(buildFakeAdapter());
|
||||||
|
await syncRecipeSources(prisma);
|
||||||
|
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
|
||||||
|
await agent.patch("/house/current/sources").send({ sourceIds: [source.id] });
|
||||||
|
return { agent, sourceId: source.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fully-resolved payload, as the review screen would submit it — every ingredient already has a real ingredientId/unitId, same shape `POST /recipes` accepts. */
|
||||||
|
async function buildImportPayload() {
|
||||||
|
return {
|
||||||
|
name: "Fake recipe 1 (revue)",
|
||||||
|
portions: 4,
|
||||||
|
dietIds: [],
|
||||||
|
ingredients: [
|
||||||
|
{ ingredientId: await ingredientId("onion"), quantity: 1, unitId: await unitId("piece") },
|
||||||
|
],
|
||||||
|
steps: [{ description: "Chop the onions finely" }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/sources/fakeSource/import/1")
|
||||||
|
.send(await buildImportPayload());
|
||||||
|
expect(res.status).to.equal(401);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a source the household hasn't enabled with 404 SOURCE_NOT_FOUND", async () => {
|
||||||
|
const { agent } = await signupWithHouse();
|
||||||
|
registerRecipeSource(buildFakeAdapter());
|
||||||
|
await syncRecipeSources(prisma);
|
||||||
|
|
||||||
|
const res = await agent.post("/sources/fakeSource/import/1").send(await buildImportPayload());
|
||||||
|
|
||||||
|
expect(res.status).to.equal(404);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.SOURCE_NOT_FOUND);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates the recipe with sourceId/externalId set, matching techniques against the source's own locale", async () => {
|
||||||
|
const { agent, sourceId } = await enableFakeSource();
|
||||||
|
const chop = await prisma.techStep.findFirstOrThrow({ where: { key: "chop" } });
|
||||||
|
|
||||||
|
const res = await agent.post("/sources/fakeSource/import/1").send(await buildImportPayload());
|
||||||
|
|
||||||
|
expect(res.status).to.equal(201);
|
||||||
|
const created = await prisma.recipe.findUniqueOrThrow({ where: { id: res.body.id } });
|
||||||
|
expect(created.sourceId).to.equal(sourceId);
|
||||||
|
expect(created.externalId).to.equal("1");
|
||||||
|
|
||||||
|
// The step text is English ("Chop the onions finely") — this only
|
||||||
|
// matches "chop" if the fake adapter's own locale ("en") was used
|
||||||
|
// for tech-step matching, not the hardcoded French default (which
|
||||||
|
// would find nothing in English text — see recipe-translation.test.ts's
|
||||||
|
// "locales are separate rule sets" test for the same point made the
|
||||||
|
// other way around).
|
||||||
|
const step = await prisma.step.findFirstOrThrow({ where: { recipeId: created.id } });
|
||||||
|
const stepTechSteps = await prisma.stepTechStep.findMany({ where: { stepId: step.id } });
|
||||||
|
expect(stepTechSteps.map((s) => s.techStepId)).to.deep.equal([chop.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a second import of the same item with 409 RECIPE_ALREADY_IMPORTED", async () => {
|
||||||
|
const { agent } = await enableFakeSource();
|
||||||
|
const first = await agent
|
||||||
|
.post("/sources/fakeSource/import/1")
|
||||||
|
.send(await buildImportPayload());
|
||||||
|
expect(first.status).to.equal(201);
|
||||||
|
|
||||||
|
const second = await agent
|
||||||
|
.post("/sources/fakeSource/import/1")
|
||||||
|
.send(await buildImportPayload());
|
||||||
|
|
||||||
|
expect(second.status).to.equal(409);
|
||||||
|
expect(second.body.code).to.equal(ErrorCode.RECIPE_ALREADY_IMPORTED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown ingredientId with 404 INGREDIENT_NOT_FOUND, same as a manual creation", async () => {
|
||||||
|
const { agent } = await enableFakeSource();
|
||||||
|
const payload = await buildImportPayload();
|
||||||
|
payload.ingredients[0].ingredientId = 999_999;
|
||||||
|
|
||||||
|
const res = await agent.post("/sources/fakeSource/import/1").send(payload);
|
||||||
|
|
||||||
|
expect(res.status).to.equal(404);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a second household's import of the same item too — the item's identity is global, not per-household", async () => {
|
||||||
|
// Registers/syncs the adapter once — enableFakeSource() itself does
|
||||||
|
// this too, and registerRecipeSource() throws on a duplicate key, so
|
||||||
|
// calling it twice in one test (once per household) isn't an option.
|
||||||
|
registerRecipeSource(buildFakeAdapter());
|
||||||
|
await syncRecipeSources(prisma);
|
||||||
|
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
|
||||||
|
|
||||||
|
const { agent: firstAgent } = await signupWithHouse();
|
||||||
|
await firstAgent.patch("/house/current/sources").send({ sourceIds: [source.id] });
|
||||||
|
const firstImport = await firstAgent
|
||||||
|
.post("/sources/fakeSource/import/1")
|
||||||
|
.send(await buildImportPayload());
|
||||||
|
expect(firstImport.status).to.equal(201);
|
||||||
|
|
||||||
|
const { agent: secondAgent } = await signupWithHouse();
|
||||||
|
await secondAgent.patch("/house/current/sources").send({ sourceIds: [source.id] });
|
||||||
|
const secondImport = await secondAgent
|
||||||
|
.post("/sources/fakeSource/import/1")
|
||||||
|
.send(await buildImportPayload());
|
||||||
|
|
||||||
|
expect(secondImport.status).to.equal(409);
|
||||||
|
expect(secondImport.body.code).to.equal(ErrorCode.RECIPE_ALREADY_IMPORTED);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
36
apps/web/cypress/e2e/planning.feature
Normal file
36
apps/web/cypress/e2e/planning.feature
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
Feature: Adding a recipe to the planning
|
||||||
|
As a signed-in user
|
||||||
|
I want to add a recipe to a planning slot even when I haven't imported it yet
|
||||||
|
So that browsing an external source and planning it is a single trip
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given I am signed in as "Alice" "Martin"
|
||||||
|
And my household id is 1
|
||||||
|
And today is frozen at "2026-08-17T09:00:00.000Z"
|
||||||
|
And the household request returns no household
|
||||||
|
And the recipe catalog contains nothing
|
||||||
|
And the ingredient and diet catalog is available for import
|
||||||
|
And the sources reference list has options
|
||||||
|
And the household has enabled TheMealDB
|
||||||
|
And browsing TheMealDB returns some items
|
||||||
|
And the planning request reflects whatever's been added so far
|
||||||
|
|
||||||
|
Scenario: Adds a not-yet-imported source item to a planning slot, importing it on the way
|
||||||
|
Given previewing TheMealDB item "9999" is available
|
||||||
|
And importing the previewed item will succeed and return id 99
|
||||||
|
And adding the imported recipe to the planning will succeed
|
||||||
|
When I visit "/"
|
||||||
|
And I click the add button for the first empty planning slot
|
||||||
|
And I click the button "Sources"
|
||||||
|
And I click the source item "Fish Pie"
|
||||||
|
And I click the link "Importer cette recette"
|
||||||
|
Then I should see "Cette recette sera automatiquement ajoutée à votre planning une fois importée."
|
||||||
|
|
||||||
|
When I choose an ingredient for the unresolved line "some mystery paste"
|
||||||
|
And I select the ingredient "Sel" from the picker
|
||||||
|
And I select unit "unité" for the first ingredient
|
||||||
|
And I fill in the last ingredient's quantity with "1" and unit "unité"
|
||||||
|
And I click the button "Importer"
|
||||||
|
Then the planning add request should have included recipe 99, weekDay "lundi", meal "petit-dejeuner", and portions 4
|
||||||
|
And the URL should be the home page
|
||||||
|
And the recipe "Fish Pie" should appear in the first planning slot with 4 portions
|
||||||
219
apps/web/cypress/e2e/planning.ts
Normal file
219
apps/web/cypress/e2e/planning.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
|
||||||
|
|
||||||
|
// Mocks the API via cy.intercept — this job doesn't run a live backend (see
|
||||||
|
// .github/workflows/ci.yml); apps/api's own Mocha suite covers real API
|
||||||
|
// behavior against a real database (see test/sources.test.ts,
|
||||||
|
// test/planning.test.ts).
|
||||||
|
//
|
||||||
|
// planning.feature's journey crosses both `RecipePickerDialog` (browsing an
|
||||||
|
// external source from a planning slot) and the import review screen
|
||||||
|
// (`ImportRecipePage`) it hands off to — same "each spec's own
|
||||||
|
// self-contained fixtures" precedent recipe-sources.ts already sets (the
|
||||||
|
// Cucumber preprocessor's step lookup isn't global across cypress/e2e/, see
|
||||||
|
// its own comment for the full reasoning), so most of what's below mirrors
|
||||||
|
// recipe-sources.ts's fixtures rather than importing them.
|
||||||
|
|
||||||
|
// Flips once, from `false` to `true`, as the single scenario in this file
|
||||||
|
// actually performs the planning-add — module-level `let` rather than
|
||||||
|
// something reset per-scenario, since there's only ever the one here (see
|
||||||
|
// household-settings.ts for the same pattern used across several scenarios
|
||||||
|
// instead).
|
||||||
|
let fishPiePlanned = false;
|
||||||
|
|
||||||
|
Given("the recipe catalog contains nothing", () => {
|
||||||
|
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
Given("the household has enabled TheMealDB", () => {
|
||||||
|
cy.intercept("GET", "**/house/current/sources", { statusCode: 200, body: [1] });
|
||||||
|
});
|
||||||
|
|
||||||
|
Given("browsing TheMealDB returns some items", () => {
|
||||||
|
cy.intercept("GET", "**/sources/theMealDb/browse*", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
externalId: "52795",
|
||||||
|
title: "Chicken Handi",
|
||||||
|
picture: null,
|
||||||
|
url: "https://www.themealdb.com/meal/52795",
|
||||||
|
alreadyImported: true,
|
||||||
|
recipeId: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
externalId: "9999",
|
||||||
|
title: "Fish Pie",
|
||||||
|
picture: null,
|
||||||
|
url: "https://www.themealdb.com/meal/9999",
|
||||||
|
alreadyImported: false,
|
||||||
|
recipeId: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nextCursor: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Given("previewing TheMealDB item {string} is available", (externalId: string) => {
|
||||||
|
cy.intercept("GET", `**/sources/theMealDb/preview/${externalId}`, {
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
sourceKey: "theMealDb",
|
||||||
|
externalId,
|
||||||
|
name: "Fish Pie",
|
||||||
|
description: null,
|
||||||
|
picture: null,
|
||||||
|
portions: 4,
|
||||||
|
sourceUrl: "https://www.themealdb.com/meal/9999",
|
||||||
|
ingredients: [
|
||||||
|
{
|
||||||
|
rawText: "1 onion",
|
||||||
|
quantity: 1,
|
||||||
|
ingredient: {
|
||||||
|
id: 1,
|
||||||
|
key: "onion",
|
||||||
|
icon: "VEGETABLE",
|
||||||
|
category: "freshProduce",
|
||||||
|
subcategory: "vegetables",
|
||||||
|
reproducible: false,
|
||||||
|
allergens: [],
|
||||||
|
diets: [],
|
||||||
|
},
|
||||||
|
unit: null,
|
||||||
|
},
|
||||||
|
{ rawText: "some mystery paste", quantity: null, ingredient: null, unit: null },
|
||||||
|
],
|
||||||
|
steps: [{ description: "Cuire à la poêle.", picture: null, techSteps: [] }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Covers every reference catalog both `RecipePickerDialog` (ingredients/
|
||||||
|
// diets, for its own filters) and `ImportRecipePage` (ingredients/diets/
|
||||||
|
// units, for the review form) fetch — same endpoints, one fixture for both.
|
||||||
|
Given("the ingredient and diet catalog is available for import", () => {
|
||||||
|
cy.intercept("GET", "**/reference/ingredients", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
key: "onion",
|
||||||
|
icon: "VEGETABLE",
|
||||||
|
category: "freshProduce",
|
||||||
|
subcategory: "vegetables",
|
||||||
|
allergens: [],
|
||||||
|
diets: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
key: "salt",
|
||||||
|
icon: "SPICE",
|
||||||
|
category: "condimentsAndSpices",
|
||||||
|
subcategory: "spices",
|
||||||
|
allergens: [],
|
||||||
|
diets: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
cy.intercept("GET", "**/reference/diets", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: [{ id: 1, key: "omnivore" }],
|
||||||
|
});
|
||||||
|
cy.intercept("GET", "**/reference/units", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: [{ id: 1, key: "piece", type: "COUNT", toBaseFactor: 1 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Given("importing the previewed item will succeed and return id {int}", (id: number) => {
|
||||||
|
cy.intercept("POST", "**/sources/theMealDb/import/9999", {
|
||||||
|
statusCode: 201,
|
||||||
|
body: { id },
|
||||||
|
}).as("importRecipe");
|
||||||
|
});
|
||||||
|
|
||||||
|
Given("adding the imported recipe to the planning will succeed", () => {
|
||||||
|
cy.intercept("POST", "**/planning/items", (req) => {
|
||||||
|
fishPiePlanned = true;
|
||||||
|
req.reply({
|
||||||
|
statusCode: 201,
|
||||||
|
body: {
|
||||||
|
id: 1,
|
||||||
|
weekDay: "lundi",
|
||||||
|
meal: "petit-dejeuner",
|
||||||
|
portions: 4,
|
||||||
|
recipe: { id: 99, name: "Fish Pie" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}).as("addPlanningItem");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stateful — landing back on "/" after the import journey remounts
|
||||||
|
// `PlanningPage` from scratch (a real cross-route navigation, not a
|
||||||
|
// same-component state update: see `ImportRecipePage`'s `navigate("/")`),
|
||||||
|
// so only a fresh `GET /planning?date=` that reflects the just-added item
|
||||||
|
// makes it show up there — nothing client-side survives that remount to
|
||||||
|
// patch it in locally the way `PlanningPage`'s own `patchPlanningItems`
|
||||||
|
// does for an add made without leaving the page.
|
||||||
|
Given("the planning request reflects whatever's been added so far", () => {
|
||||||
|
cy.intercept("GET", /\/planning\?/, (req) => {
|
||||||
|
req.reply({
|
||||||
|
statusCode: 200,
|
||||||
|
body: fishPiePlanned
|
||||||
|
? {
|
||||||
|
id: 1,
|
||||||
|
startDate: "2026-08-17T00:00:00.000Z",
|
||||||
|
finishDate: "2026-08-23T00:00:00.000Z",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
weekDay: "lundi",
|
||||||
|
meal: "petit-dejeuner",
|
||||||
|
portions: 4,
|
||||||
|
recipe: { id: 99, name: "Fish Pie" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The very first "+" in DOM order is Lundi's Petit-déjeuner cell (`MEALS`'s
|
||||||
|
// first entry × `WEEK_DAYS`'s first entry, see `PlanningGrid`) — the exact
|
||||||
|
// slot this feature's fixtures above (weekDay "lundi", meal
|
||||||
|
// "petit-dejeuner") are written against.
|
||||||
|
When("I click the add button for the first empty planning slot", () => {
|
||||||
|
cy.get(".add-recipe-btn").first().click();
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I click the source item {string}", (title: string) => {
|
||||||
|
cy.contains(".recipe-table__name", title).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I choose an ingredient for the unresolved line {string}", (rawText: string) => {
|
||||||
|
cy.contains(".import-recipe__unresolved-row", rawText)
|
||||||
|
.contains("button", "Choisir un ingrédient")
|
||||||
|
.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then(
|
||||||
|
"the planning add request should have included recipe {int}, weekDay {string}, meal {string}, and portions {int}",
|
||||||
|
(recipeId: number, weekDay: string, meal: string, portions: number) => {
|
||||||
|
cy.wait("@addPlanningItem")
|
||||||
|
.its("request.body")
|
||||||
|
.should("deep.include", { recipeId, weekDay, meal, portions });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Then(
|
||||||
|
"the recipe {string} should appear in the first planning slot with {int} portions",
|
||||||
|
(name: string, portions: number) => {
|
||||||
|
cy.get(".planning-grid tbody tr")
|
||||||
|
.first()
|
||||||
|
.within(() => {
|
||||||
|
cy.contains(".recipe-chip", `${name} · ×${portions}`).should("be.visible");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -57,66 +57,6 @@ When("I visit the new recipe form without a secure random UUID", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
When("I search the ingredient picker for {string}", (text: string) => {
|
|
||||||
cy.get("input[placeholder='Rechercher un ingrédient…']").type(text);
|
|
||||||
});
|
|
||||||
|
|
||||||
When("I select the ingredient {string} from the picker", (name: string) => {
|
|
||||||
cy.contains(".ingredient-picker__card", name).click();
|
|
||||||
});
|
|
||||||
|
|
||||||
Then("the ingredient {string} should no longer be in the picker", (name: string) => {
|
|
||||||
cy.contains(".ingredient-picker__card", name).should("not.exist");
|
|
||||||
});
|
|
||||||
|
|
||||||
Then("the ingredient {string} should be visible in the picker", (name: string) => {
|
|
||||||
cy.contains(".ingredient-picker__card", name).should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
Then("the recipe should include the ingredient {string}", (name: string) => {
|
|
||||||
cy.contains(".ingredient-row__name", name).should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
Then("there should be {int} ingredient rows", (count: number) => {
|
|
||||||
cy.get(".ingredient-row").should("have.length", count);
|
|
||||||
});
|
|
||||||
|
|
||||||
When("I remove the ingredient {string} from the recipe", (name: string) => {
|
|
||||||
cy.contains(".ingredient-row", name).find("button[title='Retirer cet ingrédient']").click();
|
|
||||||
});
|
|
||||||
|
|
||||||
When(
|
|
||||||
"I fill in the ingredient's quantity with {string} and unit {string}",
|
|
||||||
(quantity: string, unit: string) => {
|
|
||||||
cy.get(".ingredient-row .ingredient-row__quantity").type(quantity);
|
|
||||||
cy.get(".ingredient-row .ingredient-row__unit").select(unit);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
Then("the ingredient's quantity should be {string}", (quantity: string) => {
|
|
||||||
cy.get(".ingredient-row .ingredient-row__quantity").should("have.value", quantity);
|
|
||||||
});
|
|
||||||
|
|
||||||
When(
|
|
||||||
"I fill in the last ingredient's quantity with {string} and unit {string}",
|
|
||||||
(quantity: string, unit: string) => {
|
|
||||||
cy.get(".ingredient-row .ingredient-row__quantity").last().type(quantity);
|
|
||||||
cy.get(".ingredient-row .ingredient-row__unit").last().select(unit);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
When("I add a step", () => {
|
|
||||||
cy.contains("button", "Ajouter une étape").click();
|
|
||||||
});
|
|
||||||
|
|
||||||
When("I fill in the step description with {string}", (text: string) => {
|
|
||||||
cy.get(".step-list-editor__item textarea").type(text);
|
|
||||||
});
|
|
||||||
|
|
||||||
Then("there should be {int} step editor items", (count: number) => {
|
|
||||||
cy.get(".step-list-editor__item").should("have.length", count);
|
|
||||||
});
|
|
||||||
|
|
||||||
Then(
|
Then(
|
||||||
"the recipe creation request should have included name {string}, portions {int}, and ingredient {int} with quantity {int} and unitId {int}",
|
"the recipe creation request should have included name {string}, portions {int}, and ingredient {int} with quantity {int} and unitId {int}",
|
||||||
(name: string, portions: number, ingredientId: number, quantity: number, unitId: number) => {
|
(name: string, portions: number, ingredientId: number, quantity: number, unitId: number) => {
|
||||||
|
|
|
||||||
73
apps/web/cypress/e2e/recipe-sources.feature
Normal file
73
apps/web/cypress/e2e/recipe-sources.feature
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
Feature: Browsing external recipe sources
|
||||||
|
As a signed-in user
|
||||||
|
I want to browse the recipes available from my household's enabled sources
|
||||||
|
So that I can find new recipes to import, or jump straight to ones I already have
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given I am signed in as "Alice" "Martin"
|
||||||
|
And my household id is 1
|
||||||
|
And the disliked ingredients list is empty
|
||||||
|
And the planning request returns nothing
|
||||||
|
|
||||||
|
Scenario: Prompts to enable a source when the household hasn't enabled any
|
||||||
|
Given the recipe catalog contains nothing
|
||||||
|
And the sources reference list has options
|
||||||
|
And the household's enabled sources are empty
|
||||||
|
When I visit "/recettes"
|
||||||
|
And I click the button "Sources"
|
||||||
|
Then I should see "Aucune source n'est activée"
|
||||||
|
|
||||||
|
Scenario: Browses an enabled source, distinguishing already-imported items from new ones
|
||||||
|
Given the recipe catalog contains nothing
|
||||||
|
And the sources reference list has options
|
||||||
|
And the household has enabled TheMealDB
|
||||||
|
And browsing TheMealDB returns some items
|
||||||
|
And recipe 2's detail is available
|
||||||
|
When I visit "/recettes"
|
||||||
|
And I click the button "Sources"
|
||||||
|
Then I should see the source item "Chicken Handi"
|
||||||
|
And I should see the source item "Fish Pie"
|
||||||
|
And the source item "Chicken Handi" should be marked as already imported
|
||||||
|
|
||||||
|
When I click the source item "Chicken Handi"
|
||||||
|
Then the URL should include "/recettes/2"
|
||||||
|
And the recipe detail panel heading should be "Omelette"
|
||||||
|
|
||||||
|
Scenario: Previews a not-yet-imported item, highlighting its detected techniques
|
||||||
|
Given the recipe catalog contains nothing
|
||||||
|
And the sources reference list has options
|
||||||
|
And the household has enabled TheMealDB
|
||||||
|
And browsing TheMealDB returns some items
|
||||||
|
And previewing TheMealDB item "9999" is available
|
||||||
|
When I visit "/recettes"
|
||||||
|
And I click the button "Sources"
|
||||||
|
And I click the source item "Fish Pie"
|
||||||
|
Then the recipe detail panel heading should be "Fish Pie"
|
||||||
|
And I should see the highlighted technique "Cuire"
|
||||||
|
|
||||||
|
Scenario: Reviews an import, resolving an unrecognized ingredient before confirming
|
||||||
|
Given the recipe catalog contains nothing
|
||||||
|
And the sources reference list has options
|
||||||
|
And the household has enabled TheMealDB
|
||||||
|
And browsing TheMealDB returns some items
|
||||||
|
And previewing TheMealDB item "9999" is available
|
||||||
|
And the ingredient and diet catalog is available for import
|
||||||
|
And importing the previewed item will succeed and return id 99
|
||||||
|
When I visit "/recettes"
|
||||||
|
And I click the button "Sources"
|
||||||
|
And I click the source item "Fish Pie"
|
||||||
|
And I click the link "Importer cette recette"
|
||||||
|
Then the "recipe-name" field should have the value "Fish Pie"
|
||||||
|
And the recipe should include the ingredient "Oignon"
|
||||||
|
|
||||||
|
When I choose an ingredient for the unresolved line "some mystery paste"
|
||||||
|
And I select the ingredient "Sel" from the picker
|
||||||
|
Then the unresolved ingredients section should no longer be shown
|
||||||
|
And there should be 2 ingredient rows
|
||||||
|
|
||||||
|
When I select unit "unité" for the first ingredient
|
||||||
|
And I fill in the last ingredient's quantity with "1" and unit "unité"
|
||||||
|
Then the "Importer" button should not be disabled
|
||||||
|
When I click the button "Importer"
|
||||||
|
Then the import request should have included ingredient 2 with quantity 1 and unitId 1
|
||||||
|
And the URL should include "/recettes/99"
|
||||||
200
apps/web/cypress/e2e/recipe-sources.ts
Normal file
200
apps/web/cypress/e2e/recipe-sources.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
|
||||||
|
|
||||||
|
// Mocks the API via cy.intercept — this job doesn't run a live backend (see
|
||||||
|
// .github/workflows/ci.yml); apps/api's own Mocha suite covers real API
|
||||||
|
// behavior against a real database (see test/sources.test.ts).
|
||||||
|
//
|
||||||
|
// "the sources reference list has options"/"the household's enabled
|
||||||
|
// sources are empty" resolve from cypress/support/step_definitions/ (the
|
||||||
|
// preprocessor's step-lookup is *not* global across cypress/e2e/ — only a
|
||||||
|
// feature's own same-named file/directory plus that shared folder are
|
||||||
|
// searched, see its error message when a step isn't found). recipes.ts
|
||||||
|
// sits directly in cypress/e2e/ (not that shared folder), so its own
|
||||||
|
// "the disliked ingredients list is empty"/"the recipe catalog
|
||||||
|
// contains"/"recipe 2's detail is available" are scoped to recipes.feature
|
||||||
|
// only — this file redeclares its own minimal equivalents rather than
|
||||||
|
// relocating shared infra, the same "each spec's own self-contained
|
||||||
|
// fixtures" precedent recipes.cy.ts already sets alongside recipes.ts.
|
||||||
|
|
||||||
|
Given("the disliked ingredients list is empty", () => {
|
||||||
|
cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
Given("the recipe catalog contains nothing", () => {
|
||||||
|
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
Given("recipe 2's detail is available", () => {
|
||||||
|
cy.intercept("GET", "**/recipes/2", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
id: 2,
|
||||||
|
name: "Omelette",
|
||||||
|
description: null,
|
||||||
|
picture: null,
|
||||||
|
portions: 2,
|
||||||
|
authorId: 1,
|
||||||
|
visibility: "PERSONAL",
|
||||||
|
allergens: [],
|
||||||
|
diets: [],
|
||||||
|
isFavorite: false,
|
||||||
|
ingredients: [],
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
description: "Cuire à la poêle.",
|
||||||
|
picture: null,
|
||||||
|
order: 1,
|
||||||
|
techSteps: [{ techStep: { id: 1, key: "cook" }, start: 0, end: 5 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Given("the household has enabled TheMealDB", () => {
|
||||||
|
cy.intercept("GET", "**/house/current/sources", { statusCode: 200, body: [1] });
|
||||||
|
});
|
||||||
|
|
||||||
|
Given("browsing TheMealDB returns some items", () => {
|
||||||
|
cy.intercept("GET", "**/sources/theMealDb/browse*", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
externalId: "52795",
|
||||||
|
title: "Chicken Handi",
|
||||||
|
picture: null,
|
||||||
|
url: "https://www.themealdb.com/meal/52795",
|
||||||
|
alreadyImported: true,
|
||||||
|
recipeId: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
externalId: "9999",
|
||||||
|
title: "Fish Pie",
|
||||||
|
picture: null,
|
||||||
|
url: "https://www.themealdb.com/meal/9999",
|
||||||
|
alreadyImported: false,
|
||||||
|
recipeId: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nextCursor: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Given("previewing TheMealDB item {string} is available", (externalId: string) => {
|
||||||
|
cy.intercept("GET", `**/sources/theMealDb/preview/${externalId}`, {
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
sourceKey: "theMealDb",
|
||||||
|
externalId,
|
||||||
|
name: "Fish Pie",
|
||||||
|
description: null,
|
||||||
|
picture: null,
|
||||||
|
portions: 4,
|
||||||
|
sourceUrl: "https://www.themealdb.com/meal/9999",
|
||||||
|
ingredients: [
|
||||||
|
{
|
||||||
|
rawText: "1 onion",
|
||||||
|
quantity: 1,
|
||||||
|
ingredient: {
|
||||||
|
id: 1,
|
||||||
|
key: "onion",
|
||||||
|
icon: "VEGETABLE",
|
||||||
|
category: "freshProduce",
|
||||||
|
subcategory: "vegetables",
|
||||||
|
reproducible: false,
|
||||||
|
allergens: [],
|
||||||
|
diets: [],
|
||||||
|
},
|
||||||
|
unit: null,
|
||||||
|
},
|
||||||
|
{ rawText: "some mystery paste", quantity: null, ingredient: null, unit: null },
|
||||||
|
],
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
description: "Cuire à la poêle.",
|
||||||
|
picture: null,
|
||||||
|
techSteps: [{ techStep: { id: 1, key: "cook" }, start: 0, end: 5 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("I should see the source item {string}", (title: string) => {
|
||||||
|
cy.contains(".recipe-table__name", title).should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I click the source item {string}", (title: string) => {
|
||||||
|
cy.contains(".recipe-table__name", title).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the source item {string} should be marked as already imported", (title: string) => {
|
||||||
|
cy.contains("tr", title).find(".source-item-table__imported-badge").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ImportRecipePage (the review screen) loads its own ingredient/diet/unit
|
||||||
|
// catalogs the same way RecipeFormPage does — "onion" matches the resolved
|
||||||
|
// line in "previewing TheMealDB item ... is available" above, "salt" is
|
||||||
|
// what "some mystery paste" (unresolved in that same fixture) gets
|
||||||
|
// corrected to in the review-and-import scenario.
|
||||||
|
Given("the ingredient and diet catalog is available for import", () => {
|
||||||
|
cy.intercept("GET", "**/reference/ingredients", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
key: "onion",
|
||||||
|
icon: "VEGETABLE",
|
||||||
|
category: "freshProduce",
|
||||||
|
subcategory: "vegetables",
|
||||||
|
allergens: [],
|
||||||
|
diets: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
key: "salt",
|
||||||
|
icon: "SPICE",
|
||||||
|
category: "condimentsAndSpices",
|
||||||
|
subcategory: "spices",
|
||||||
|
allergens: [],
|
||||||
|
diets: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
cy.intercept("GET", "**/reference/diets", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: [{ id: 1, key: "omnivore" }],
|
||||||
|
});
|
||||||
|
cy.intercept("GET", "**/reference/units", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: [{ id: 1, key: "piece", type: "COUNT", toBaseFactor: 1 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Given("importing the previewed item will succeed and return id {int}", (id: number) => {
|
||||||
|
cy.intercept("POST", "**/sources/theMealDb/import/9999", { statusCode: 201, body: { id } }).as(
|
||||||
|
"importRecipe",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I choose an ingredient for the unresolved line {string}", (rawText: string) => {
|
||||||
|
cy.contains(".import-recipe__unresolved-row", rawText)
|
||||||
|
.contains("button", "Choisir un ingrédient")
|
||||||
|
.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the unresolved ingredients section should no longer be shown", () => {
|
||||||
|
cy.get(".import-recipe__unresolved").should("not.exist");
|
||||||
|
});
|
||||||
|
|
||||||
|
Then(
|
||||||
|
"the import request should have included ingredient {int} with quantity {int} and unitId {int}",
|
||||||
|
(ingredientId: number, quantity: number, unitId: number) => {
|
||||||
|
cy.wait("@importRecipe")
|
||||||
|
.its("request.body.ingredients")
|
||||||
|
.should("include.deep.members", [{ ingredientId, quantity, unitId }]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -136,9 +136,6 @@ describe("Recipe catalog", () => {
|
||||||
cy.get(".recipe-tabs__tab.active").should("contain.text", "Perso");
|
cy.get(".recipe-tabs__tab.active").should("contain.text", "Perso");
|
||||||
cy.contains(".recipe-table__name", "Omelette").should("be.visible");
|
cy.contains(".recipe-table__name", "Omelette").should("be.visible");
|
||||||
cy.contains(".recipe-table__name", "Ratatouille").should("not.exist");
|
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", () => {
|
it("searches within the active tab, debounced", () => {
|
||||||
|
|
|
||||||
|
|
@ -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");
|
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", () => {
|
When("I click the favorite star", () => {
|
||||||
cy.get(".favorite-star-button").click();
|
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", () => {
|
Then("the URL should match the recipes list", () => {
|
||||||
cy.url().should("match", /\/recettes\/?$/);
|
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");
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,100 @@ When("I scroll to the section {string}", (legend: string) => {
|
||||||
cy.contains("legend", legend).scrollIntoView();
|
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) => {
|
Then("the checkbox {string} should be checked", (label: string) => {
|
||||||
cy.contains("label", label).find("input[type=checkbox]").should("be.checked");
|
cy.contains("label", label).find("input[type=checkbox]").should("be.checked");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
import { RedirectIfAuthenticated } from "./features/auth/RedirectIfAuthenticated";
|
import { RedirectIfAuthenticated } from "./features/auth/RedirectIfAuthenticated";
|
||||||
import { RequireAuth } from "./features/auth/RequireAuth";
|
import { RequireAuth } from "./features/auth/RequireAuth";
|
||||||
import { AppLayout } from "./layouts/AppLayout";
|
import { AppLayout } from "./layouts/AppLayout";
|
||||||
|
import { ImportRecipePage } from "./pages/ImportRecipePage";
|
||||||
import { LoginPage } from "./pages/LoginPage";
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
import { PlanningPage } from "./pages/PlanningPage";
|
import { PlanningPage } from "./pages/PlanningPage";
|
||||||
import { RecipeFormPage } from "./pages/RecipeFormPage";
|
import { RecipeFormPage } from "./pages/RecipeFormPage";
|
||||||
|
|
@ -59,6 +60,7 @@ export function App() {
|
||||||
(see RecipesPage.tsx). */}
|
(see RecipesPage.tsx). */}
|
||||||
<Route path="/recettes" element={<RecipesPage />} />
|
<Route path="/recettes" element={<RecipesPage />} />
|
||||||
<Route path="/recettes/nouvelle" element={<RecipeFormPage />} />
|
<Route path="/recettes/nouvelle" element={<RecipeFormPage />} />
|
||||||
|
<Route path="/recettes/importer/:sourceKey/:externalId" element={<ImportRecipePage />} />
|
||||||
<Route path="/recettes/:id" element={<RecipesPage />} />
|
<Route path="/recettes/:id" element={<RecipesPage />} />
|
||||||
<Route path="/recettes/:id/modifier" element={<RecipeFormPage />} />
|
<Route path="/recettes/:id/modifier" element={<RecipeFormPage />} />
|
||||||
<Route path="/liste-de-courses" element={<ShoppingListPage />} />
|
<Route path="/liste-de-courses" element={<ShoppingListPage />} />
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import {
|
||||||
type AddPlanningItemInput,
|
type AddPlanningItemInput,
|
||||||
type AllergyView,
|
type AllergyView,
|
||||||
type ApiErrorResponse,
|
type ApiErrorResponse,
|
||||||
|
type BrowsableSourceItemView,
|
||||||
type CreateRecipeInput,
|
type CreateRecipeInput,
|
||||||
type DietView,
|
type DietView,
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
|
|
@ -11,6 +12,7 @@ import {
|
||||||
type PlanningItemView,
|
type PlanningItemView,
|
||||||
type PlanningView,
|
type PlanningView,
|
||||||
type PreferencesView,
|
type PreferencesView,
|
||||||
|
type RecipeImportDraftView,
|
||||||
type RecipeSummaryView,
|
type RecipeSummaryView,
|
||||||
type RecipeTab,
|
type RecipeTab,
|
||||||
type RecipeView,
|
type RecipeView,
|
||||||
|
|
@ -173,6 +175,35 @@ export class ApiClient {
|
||||||
return this.request("/reference/sources");
|
return this.request("/reference/sources");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One page of `sourceKey`'s own catalog (recipe catalog's "Sources" tab), each item flagged with whether it's already been imported. Rejects with `SOURCE_NOT_FOUND` unless the viewer's household has this source enabled (`/parametres/foyer`). */
|
||||||
|
public browseSource(
|
||||||
|
sourceKey: string,
|
||||||
|
params: { query?: string; cursor?: string } = {},
|
||||||
|
): Promise<{ items: BrowsableSourceItemView[]; nextCursor: string | null }> {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
if (params.query) search.set("query", params.query);
|
||||||
|
if (params.cursor) search.set("cursor", params.cursor);
|
||||||
|
const queryString = search.toString();
|
||||||
|
return this.request(`/sources/${sourceKey}/browse${queryString ? `?${queryString}` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fully translates one not-yet-saved source item (ingredients/units/techniques resolved where possible) — nothing is persisted. Rejects with `RECIPE_NOT_FOUND` if the source couldn't fetch/parse it. */
|
||||||
|
public previewSourceItem(sourceKey: string, externalId: string): Promise<RecipeImportDraftView> {
|
||||||
|
return this.request(`/sources/${sourceKey}/preview/${encodeURIComponent(externalId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Finalizes an import — `input` is a fully-resolved `CreateRecipeInput`, exactly like a manual `createRecipe()` call (the review screen, `ImportRecipePage`, is what makes sure of that before calling this). Rejects with `RECIPE_ALREADY_IMPORTED` if this item was imported since the preview was fetched. */
|
||||||
|
public importSourceItem(
|
||||||
|
sourceKey: string,
|
||||||
|
externalId: string,
|
||||||
|
input: CreateRecipeInput,
|
||||||
|
): Promise<RecipeView> {
|
||||||
|
return this.request(`/sources/${sourceKey}/import/${encodeURIComponent(externalId)}`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One catalog tab (favoris/perso/foyer/publique — see `RecipeTab`),
|
* One catalog tab (favoris/perso/foyer/publique — see `RecipeTab`),
|
||||||
* optionally narrowed further — `search` (name substring),
|
* optionally narrowed further — `search` (name substring),
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import {
|
||||||
type Meal,
|
type Meal,
|
||||||
type PlanningItemView,
|
type PlanningItemView,
|
||||||
type RecipeSummaryView,
|
type RecipeSummaryView,
|
||||||
type RecipeTab,
|
|
||||||
type WeekDay,
|
type WeekDay,
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
@ -16,8 +15,9 @@ import { Dialog } from "../../components/ui/Dialog";
|
||||||
import { errorMessageService } from "../../services/error-message.service";
|
import { errorMessageService } from "../../services/error-message.service";
|
||||||
import { DietTagSelect } from "../recipes/DietTagSelect";
|
import { DietTagSelect } from "../recipes/DietTagSelect";
|
||||||
import { IngredientPicker } from "../recipes/IngredientPicker";
|
import { IngredientPicker } from "../recipes/IngredientPicker";
|
||||||
|
import { RecipeSourcesPanel } from "../recipes/RecipeSourcesPanel";
|
||||||
import { RecipeTable } from "../recipes/RecipeTable";
|
import { RecipeTable } from "../recipes/RecipeTable";
|
||||||
import { RecipeTabs } from "../recipes/RecipeTabs";
|
import { RecipeTabs, type RecipesPageTab } from "../recipes/RecipeTabs";
|
||||||
import "./recipe-picker-dialog.scss";
|
import "./recipe-picker-dialog.scss";
|
||||||
|
|
||||||
/** Debounce for the search field — same value as `RecipesPage`'s. */
|
/** 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
|
* with three extra filters layered on top of the plain name search
|
||||||
* (ingredients / regime / "convient à tout le foyer" toggle, all wired to
|
* (ingredients / regime / "convient à tout le foyer" toggle, all wired to
|
||||||
* `GET /recipes`'s corresponding query params) since browsing here is
|
* `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
|
* Mounted only while open (see `PlanningPage`, same conditional-mount
|
||||||
* convention as its own `CalendarPopover`) — every piece of local state
|
* 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
|
* Selecting a row doesn't navigate anywhere (unlike `RecipesPage`'s own
|
||||||
* use of `RecipeTable`) — it switches this same dialog to a small
|
* use of `RecipeTable`) — it switches this same dialog to a small
|
||||||
* "how many portions?" confirmation step, then calls `POST
|
* "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({
|
export function RecipePickerDialog({
|
||||||
slot,
|
slot,
|
||||||
|
|
@ -71,7 +80,7 @@ export function RecipePickerDialog({
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<RecipeTab>("favoris");
|
const [activeTab, setActiveTab] = useState<RecipesPageTab>("favoris");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||||
const [selectedIngredientIds, setSelectedIngredientIds] = useState<number[]>([]);
|
const [selectedIngredientIds, setSelectedIngredientIds] = useState<number[]>([]);
|
||||||
|
|
@ -83,6 +92,11 @@ export function RecipePickerDialog({
|
||||||
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
|
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
|
||||||
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
|
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
|
||||||
const [listState, setListState] = useState<ListState>({ status: "loading" });
|
const [listState, setListState] = useState<ListState>({ status: "loading" });
|
||||||
|
// Set when picking an already-imported source item fails to resolve to a
|
||||||
|
// real recipe (see `handleSelectImportedRecipe`) — a rare race (the
|
||||||
|
// recipe was deleted between the browse fetch and the click), surfaced
|
||||||
|
// the same way any other catalog load error is on this dialog.
|
||||||
|
const [sourceSelectError, setSourceSelectError] = useState(false);
|
||||||
|
|
||||||
// The recipe picked in step 1 — `null` while still browsing, set once a
|
// The recipe picked in step 1 — `null` while still browsing, set once a
|
||||||
// row is clicked to switch this dialog into its confirmation step.
|
// row is clicked to switch this dialog into its confirmation step.
|
||||||
|
|
@ -114,6 +128,9 @@ export function RecipePickerDialog({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
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;
|
let cancelled = false;
|
||||||
setListState({ status: "loading" });
|
setListState({ status: "loading" });
|
||||||
|
|
||||||
|
|
@ -140,6 +157,18 @@ export function RecipePickerDialog({
|
||||||
selectedIngredientIds.includes(ingredient.id),
|
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() {
|
async function handleConfirm() {
|
||||||
if (!selectedRecipe) return;
|
if (!selectedRecipe) return;
|
||||||
const parsedPortions = Number(portions);
|
const parsedPortions = Number(portions);
|
||||||
|
|
@ -201,87 +230,113 @@ export function RecipePickerDialog({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog onClose={onClose} title={t("planning.picker.title")} className="recipe-picker-dialog">
|
<Dialog onClose={onClose} title={t("planning.picker.title")} className="recipe-picker-dialog">
|
||||||
<div className="recipe-picker__filters">
|
{activeTab !== "sources" && (
|
||||||
<input
|
<div className="recipe-picker__filters">
|
||||||
type="search"
|
<input
|
||||||
className="recipe-picker__search"
|
type="search"
|
||||||
placeholder={t("planning.picker.searchPlaceholder")}
|
className="recipe-picker__search"
|
||||||
value={search}
|
placeholder={t("planning.picker.searchPlaceholder")}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
value={search}
|
||||||
/>
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="recipe-picker__ingredient-filter">
|
<div className="recipe-picker__ingredient-filter">
|
||||||
<span className="recipe-picker__filter-label">
|
<span className="recipe-picker__filter-label">
|
||||||
{t("planning.picker.ingredientsFilterLabel")}
|
{t("planning.picker.ingredientsFilterLabel")}
|
||||||
</span>
|
</span>
|
||||||
<div className="recipe-picker__chips">
|
<div className="recipe-picker__chips">
|
||||||
{selectedIngredients.map((ingredient) => (
|
{selectedIngredients.map((ingredient) => (
|
||||||
<span key={ingredient.id} className="filter-chip">
|
<span key={ingredient.id} className="filter-chip">
|
||||||
{t(`catalog.ingredients.${ingredient.key}`)}
|
{t(`catalog.ingredients.${ingredient.key}`)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setSelectedIngredientIds((ids) => ids.filter((id) => id !== ingredient.id))
|
setSelectedIngredientIds((ids) => ids.filter((id) => id !== ingredient.id))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="recipe-picker__toggle-ingredient-picker"
|
className="recipe-picker__toggle-ingredient-picker"
|
||||||
onClick={() => setIsIngredientPickerOpen((open) => !open)}
|
onClick={() => setIsIngredientPickerOpen((open) => !open)}
|
||||||
aria-expanded={isIngredientPickerOpen}
|
aria-expanded={isIngredientPickerOpen}
|
||||||
>
|
>
|
||||||
+{" "}
|
+{" "}
|
||||||
{isIngredientPickerOpen
|
{isIngredientPickerOpen
|
||||||
? t("planning.picker.hideIngredientPicker")
|
? t("planning.picker.hideIngredientPicker")
|
||||||
: t("planning.picker.addIngredientFilter")}
|
: t("planning.picker.addIngredientFilter")}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
{isIngredientPickerOpen && (
|
||||||
|
<IngredientPicker
|
||||||
|
ingredients={ingredientsCatalog}
|
||||||
|
excludeIds={selectedIngredientIds}
|
||||||
|
onSelect={(ingredient) =>
|
||||||
|
setSelectedIngredientIds((ids) => [...ids, ingredient.id])
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isIngredientPickerOpen && (
|
|
||||||
<IngredientPicker
|
<DietTagSelect
|
||||||
ingredients={ingredientsCatalog}
|
diets={dietsCatalog}
|
||||||
excludeIds={selectedIngredientIds}
|
value={selectedDietIds}
|
||||||
onSelect={(ingredient) => setSelectedIngredientIds((ids) => [...ids, ingredient.id])}
|
onChange={setSelectedDietIds}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{hasHousehold && (
|
||||||
|
<CheckboxOption checked={suitableForHousehold} onChange={setSuitableForHousehold}>
|
||||||
|
{t("planning.picker.suitableForHouseholdLabel")}
|
||||||
|
</CheckboxOption>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<DietTagSelect diets={dietsCatalog} value={selectedDietIds} onChange={setSelectedDietIds} />
|
|
||||||
|
|
||||||
{hasHousehold && (
|
|
||||||
<CheckboxOption checked={suitableForHousehold} onChange={setSuitableForHousehold}>
|
|
||||||
{t("planning.picker.suitableForHouseholdLabel")}
|
|
||||||
</CheckboxOption>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<RecipeTabs active={activeTab} onChange={setActiveTab} />
|
<RecipeTabs active={activeTab} onChange={setActiveTab} />
|
||||||
|
|
||||||
{listState.status === "loading" && (
|
{activeTab === "sources" ? (
|
||||||
<p className="recipes-page__status">{t("planning.picker.loading")}</p>
|
<>
|
||||||
)}
|
{sourceSelectError && (
|
||||||
{listState.status === "error" && (
|
<p className="recipes-page__status recipes-page__status--error">
|
||||||
<p className="recipes-page__status recipes-page__status--error">{t("common.loadError")}</p>
|
{t("common.loadError")}
|
||||||
)}
|
</p>
|
||||||
{listState.status === "loaded" && listState.recipes.length === 0 && (
|
)}
|
||||||
<p className="recipes-page__status">{t("planning.picker.empty")}</p>
|
<RecipeSourcesPanel
|
||||||
)}
|
planningSlot={slot}
|
||||||
{listState.status === "loaded" && listState.recipes.length > 0 && (
|
onSelectImportedRecipe={handleSelectImportedRecipe}
|
||||||
<RecipeTable
|
/>
|
||||||
recipes={listState.recipes}
|
</>
|
||||||
selectedId={null}
|
) : (
|
||||||
onSelect={(id) => {
|
<>
|
||||||
const recipe = listState.recipes.find((r) => r.id === id) ?? null;
|
{listState.status === "loading" && (
|
||||||
setSelectedRecipe(recipe);
|
<p className="recipes-page__status">{t("planning.picker.loading")}</p>
|
||||||
// Pre-fill from the recipe's own written yield rather than
|
)}
|
||||||
// always starting at 1 — still freely editable below, this is
|
{listState.status === "error" && (
|
||||||
// just a better starting point (see `Recipe.portions`).
|
<p className="recipes-page__status recipes-page__status--error">
|
||||||
if (recipe) setPortions(String(recipe.portions));
|
{t("common.loadError")}
|
||||||
}}
|
</p>
|
||||||
/>
|
)}
|
||||||
|
{listState.status === "loaded" && listState.recipes.length === 0 && (
|
||||||
|
<p className="recipes-page__status">{t("planning.picker.empty")}</p>
|
||||||
|
)}
|
||||||
|
{listState.status === "loaded" && listState.recipes.length > 0 && (
|
||||||
|
<RecipeTable
|
||||||
|
recipes={listState.recipes}
|
||||||
|
selectedId={null}
|
||||||
|
onSelect={(id) => {
|
||||||
|
const recipe = listState.recipes.find((r) => r.id === id) ?? null;
|
||||||
|
setSelectedRecipe(recipe);
|
||||||
|
// Pre-fill from the recipe's own written yield rather than
|
||||||
|
// always starting at 1 — still freely editable below, this
|
||||||
|
// is just a better starting point (see `Recipe.portions`).
|
||||||
|
if (recipe) setPortions(String(recipe.portions));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
216
apps/web/src/features/recipes/RecipeSourcesPanel.tsx
Normal file
216
apps/web/src/features/recipes/RecipeSourcesPanel.tsx
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
import type { BrowsableSourceItemView, Meal, SourceView, WeekDay } from "@batch-cooking/shared";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { apiClient } from "../../api/client";
|
||||||
|
import { SourceItemPreviewPanel, type SourceItemPreviewState } from "./SourceItemPreviewPanel";
|
||||||
|
import { SourceItemTable } from "./SourceItemTable";
|
||||||
|
import "./recipes.scss";
|
||||||
|
|
||||||
|
/** Debounce for the search field — same idea/value as `RecipesPage`'s own search. */
|
||||||
|
const SEARCH_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
type EnabledSourcesState =
|
||||||
|
| { status: "loading" }
|
||||||
|
| { status: "loaded"; sources: SourceView[] }
|
||||||
|
| { status: "error" };
|
||||||
|
|
||||||
|
type BrowseState =
|
||||||
|
| { status: "loading" }
|
||||||
|
| { status: "loaded"; items: BrowsableSourceItemView[]; nextCursor: string | null }
|
||||||
|
| { status: "error" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Sources" tab content of the recipe catalog (`RecipesPage`) — a
|
||||||
|
* self-contained master-detail pair of its own (source selector + browsable
|
||||||
|
* list on the left, `SourceItemPreviewPanel` on the right), independent of
|
||||||
|
* `RecipeTable`/`RecipeDetailPanel`: it browses a source's *live* catalog
|
||||||
|
* (`GET /sources/:sourceKey/browse`), not the saved `Recipe` table, so it
|
||||||
|
* doesn't share their `RecipeTab`-based fetching at all.
|
||||||
|
*
|
||||||
|
* Selecting an already-imported item navigates straight to its real
|
||||||
|
* recipe (`/recettes/:id`, leaving this tab) — selecting one that isn't
|
||||||
|
* imported yet shows a read-only preview here instead. Turning that
|
||||||
|
* preview into an actual saved recipe (reviewing/fixing unresolved
|
||||||
|
* ingredients first) is a later stage of the same plan, not built here.
|
||||||
|
*
|
||||||
|
* `onSelectImportedRecipe` hands back the id instead of this panel
|
||||||
|
* navigating anywhere itself — what "viewing" an already-imported item
|
||||||
|
* means depends on the caller: `RecipesPage` switches its own active tab
|
||||||
|
* away from `"sources"` (its `RecipeDetailPanel`/`RecipeTable` only render
|
||||||
|
* outside that tab, so without switching first the URL would change but
|
||||||
|
* this panel would keep rendering over it) and navigates to the recipe's
|
||||||
|
* detail page, while `RecipePickerDialog` instead treats it exactly like
|
||||||
|
* picking that recipe from one of the regular tabs — moving to its own
|
||||||
|
* confirm-portions step, no navigation at all.
|
||||||
|
*/
|
||||||
|
export function RecipeSourcesPanel({
|
||||||
|
onSelectImportedRecipe,
|
||||||
|
planningSlot,
|
||||||
|
}: {
|
||||||
|
onSelectImportedRecipe: (recipeId: number) => void;
|
||||||
|
/** Forwarded as-is to `SourceItemPreviewPanel` — see its own doc comment. Only ever set by `RecipePickerDialog`. */
|
||||||
|
planningSlot?: { date: string; weekDay: WeekDay; meal: Meal };
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [enabledSources, setEnabledSources] = useState<EnabledSourcesState>({ status: "loading" });
|
||||||
|
const [selectedSourceKey, setSelectedSourceKey] = useState<string | null>(null);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||||
|
const [browseState, setBrowseState] = useState<BrowseState>({ status: "loading" });
|
||||||
|
const [selectedExternalId, setSelectedExternalId] = useState<string | null>(null);
|
||||||
|
const [previewState, setPreviewState] = useState<SourceItemPreviewState>({ status: "empty" });
|
||||||
|
|
||||||
|
// Loaded once — which sources exist, crossed with which the household
|
||||||
|
// has enabled (`/parametres/foyer`). Defaults the selector to the first
|
||||||
|
// enabled one, if any.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
Promise.all([apiClient.getSources(), apiClient.getHouseSourceIds()])
|
||||||
|
.then(([sources, enabledIds]) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const enabled = sources.filter((source) => enabledIds.includes(source.id));
|
||||||
|
setEnabledSources({ status: "loaded", sources: enabled });
|
||||||
|
setSelectedSourceKey((current) => current ?? enabled[0]?.key ?? null);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setEnabledSources({ status: "error" });
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS);
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedSourceKey === null) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setBrowseState({ status: "loading" });
|
||||||
|
setSelectedExternalId(null);
|
||||||
|
setPreviewState({ status: "empty" });
|
||||||
|
|
||||||
|
apiClient
|
||||||
|
.browseSource(selectedSourceKey, { query: debouncedSearch.trim() || undefined })
|
||||||
|
.then(({ items, nextCursor }) => {
|
||||||
|
if (!cancelled) setBrowseState({ status: "loaded", items, nextCursor });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setBrowseState({ status: "error" });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [selectedSourceKey, debouncedSearch]);
|
||||||
|
|
||||||
|
function handleLoadMore() {
|
||||||
|
if (selectedSourceKey === null || browseState.status !== "loaded" || !browseState.nextCursor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cursor = browseState.nextCursor;
|
||||||
|
apiClient
|
||||||
|
.browseSource(selectedSourceKey, { query: debouncedSearch.trim() || undefined, cursor })
|
||||||
|
.then(({ items, nextCursor }) => {
|
||||||
|
setBrowseState((prev) =>
|
||||||
|
prev.status === "loaded"
|
||||||
|
? { status: "loaded", items: [...prev.items, ...items], nextCursor }
|
||||||
|
: prev,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => setBrowseState({ status: "error" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectItem(item: BrowsableSourceItemView) {
|
||||||
|
if (item.alreadyImported && item.recipeId !== null) {
|
||||||
|
onSelectImportedRecipe(item.recipeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedSourceKey === null) return;
|
||||||
|
setSelectedExternalId(item.externalId);
|
||||||
|
setPreviewState({ status: "loading" });
|
||||||
|
apiClient
|
||||||
|
.previewSourceItem(selectedSourceKey, item.externalId)
|
||||||
|
.then((draft) => setPreviewState({ status: "loaded", draft }))
|
||||||
|
.catch(() => setPreviewState({ status: "error" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enabledSources.status === "loading") {
|
||||||
|
return <p className="recipes-page__status">{t("recipes.loading")}</p>;
|
||||||
|
}
|
||||||
|
if (enabledSources.status === "error") {
|
||||||
|
return (
|
||||||
|
<p className="recipes-page__status recipes-page__status--error">{t("common.loadError")}</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (enabledSources.sources.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="recipes-page__status">
|
||||||
|
{t("recipes.sources.noneEnabled")}{" "}
|
||||||
|
<Link to="/parametres/foyer">{t("recipes.sources.noneEnabledLink")}</Link>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="recipes-page__header recipes-page__header--sources">
|
||||||
|
{enabledSources.sources.length > 1 && (
|
||||||
|
<select
|
||||||
|
className="source-sources-select"
|
||||||
|
aria-label={t("recipes.sources.sourceLabel")}
|
||||||
|
value={selectedSourceKey ?? ""}
|
||||||
|
onChange={(e) => setSelectedSourceKey(e.target.value)}
|
||||||
|
>
|
||||||
|
{enabledSources.sources.map((source) => (
|
||||||
|
<option key={source.key} value={source.key}>
|
||||||
|
{source.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="recipes-page__search"
|
||||||
|
placeholder={t("recipes.sources.searchPlaceholder")}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="recipes-page__catalog">
|
||||||
|
{browseState.status === "loading" && (
|
||||||
|
<p className="recipes-page__status">{t("recipes.sources.loading")}</p>
|
||||||
|
)}
|
||||||
|
{browseState.status === "error" && (
|
||||||
|
<p className="recipes-page__status recipes-page__status--error">
|
||||||
|
{t("recipes.sources.loadError")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{browseState.status === "loaded" && browseState.items.length === 0 && (
|
||||||
|
<p className="recipes-page__status">{t("recipes.sources.empty")}</p>
|
||||||
|
)}
|
||||||
|
{browseState.status === "loaded" && browseState.items.length > 0 && (
|
||||||
|
<div className="source-items-column">
|
||||||
|
<SourceItemTable
|
||||||
|
items={browseState.items}
|
||||||
|
selectedExternalId={selectedExternalId}
|
||||||
|
onSelect={handleSelectItem}
|
||||||
|
/>
|
||||||
|
{browseState.nextCursor && (
|
||||||
|
<button type="button" className="source-items-load-more" onClick={handleLoadMore}>
|
||||||
|
{t("recipes.sources.loadMore")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SourceItemPreviewPanel state={previewState} planningSlot={planningSlot} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,37 +1,61 @@
|
||||||
import type { RecipeTab } from "@batch-cooking/shared";
|
import type { RecipeTab } from "@batch-cooking/shared";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
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";
|
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: "favoris", Icon: FavoriteIcon },
|
||||||
{ value: "perso", Icon: AccountIcon },
|
{ value: "perso", Icon: AccountIcon },
|
||||||
{ value: "foyer", Icon: HouseholdIcon },
|
{ value: "foyer", Icon: HouseholdIcon },
|
||||||
{ value: "publique", Icon: PublicIcon },
|
{ value: "publique", Icon: PublicIcon },
|
||||||
|
{ value: "sources", Icon: SourcesIcon },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Catalog tab bar — Favoris / Perso / Foyer / Publique, plus a disabled
|
* Catalog tab bar — Favoris / Perso / Foyer / Publique / Sources by
|
||||||
* placeholder for external sources (not built yet, see the plan's "hors
|
* default (`/recettes`, `RecipesPage`). `tabs` narrows which of those
|
||||||
* scope" note) so the eventual nav slot is visible without being
|
* show — `RecipePickerDialog` (picking a recipe for a planning slot)
|
||||||
* functional. No "toutes" tab: every recipe a viewer can see falls under
|
* passes just the four real ones: browsing external sources mid-dialog,
|
||||||
* exactly one of perso/foyer/publique (its own visibility) — see
|
* 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`.
|
* `recipe.service.ts`'s `listRecipes`.
|
||||||
*/
|
*/
|
||||||
export function RecipeTabs({
|
export function RecipeTabs({
|
||||||
active,
|
active,
|
||||||
onChange,
|
onChange,
|
||||||
|
tabs = ALL_TABS.map((tab) => tab.value),
|
||||||
}: {
|
}: {
|
||||||
active: RecipeTab;
|
active: RecipesPageTab;
|
||||||
onChange: (tab: RecipeTab) => void;
|
onChange: (tab: RecipesPageTab) => void;
|
||||||
|
tabs?: readonly RecipesPageTab[];
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="recipe-tabs">
|
<div className="recipe-tabs">
|
||||||
{TABS.map(({ value, Icon }) => (
|
{ALL_TABS.filter(({ value }) => tabs.includes(value)).map(({ value, Icon }) => (
|
||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -42,14 +66,6 @@ export function RecipeTabs({
|
||||||
{t(`recipes.tabs.${value}`)}
|
{t(`recipes.tabs.${value}`)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="recipe-tabs__tab placeholder"
|
|
||||||
disabled
|
|
||||||
title={t("recipes.tabs.sourcesSoonHint")}
|
|
||||||
>
|
|
||||||
{t("recipes.tabs.sourcesSoon")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
154
apps/web/src/features/recipes/SourceItemPreviewPanel.tsx
Normal file
154
apps/web/src/features/recipes/SourceItemPreviewPanel.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
import type { Meal, RecipeImportDraftView, WeekDay } from "@batch-cooking/shared";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { StepDescription } from "./StepDescription";
|
||||||
|
import "./recipes.scss";
|
||||||
|
|
||||||
|
/** State {@link SourceItemPreviewPanel} renders — mirrors `RecipeDetailState`'s shape (`RecipeDetailPanel`), one status short (no "not-found": an invalid `externalId` surfaces as `"error"`, there's no separate "id was well-formed but nothing matched it" case here). */
|
||||||
|
export type SourceItemPreviewState =
|
||||||
|
| { status: "empty" }
|
||||||
|
| { status: "loading" }
|
||||||
|
| { status: "loaded"; draft: RecipeImportDraftView }
|
||||||
|
| { status: "error" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Right-hand panel of the catalog's "Sources" tab (`RecipeSourcesPanel`) —
|
||||||
|
* a read-only preview of a not-yet-imported item: nothing here can be
|
||||||
|
* edited or saved yet (no favorite/edit/delete actions, unlike
|
||||||
|
* `RecipeDetailPanel`) — turning this into an actual import with a review
|
||||||
|
* step for unresolved ingredients is a later stage of the same plan.
|
||||||
|
* Reuses `StepDescription` so a step's detected techniques are already
|
||||||
|
* highlighted here too, exactly like a saved recipe's detail.
|
||||||
|
*/
|
||||||
|
export function SourceItemPreviewPanel({
|
||||||
|
state,
|
||||||
|
planningSlot,
|
||||||
|
}: {
|
||||||
|
state: SourceItemPreviewState;
|
||||||
|
/**
|
||||||
|
* Set only when this panel is rendered from `RecipePickerDialog` (adding a
|
||||||
|
* recipe to one planning slot) rather than the standalone `/recettes`
|
||||||
|
* catalog — carried along on the "Importer cette recette" link as query
|
||||||
|
* params so `ImportRecipePage` knows to add the freshly-created recipe to
|
||||||
|
* this exact slot once the import succeeds, instead of landing on the
|
||||||
|
* recipe's own detail page. See `ImportRecipePage`'s `planningSlot`.
|
||||||
|
*/
|
||||||
|
planningSlot?: { date: string; weekDay: WeekDay; meal: Meal };
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (state.status === "empty") {
|
||||||
|
return (
|
||||||
|
<aside className="recipe-detail-panel">
|
||||||
|
<p className="recipe-detail-panel__status">{t("recipes.sources.detail.empty")}</p>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state.status === "loading") {
|
||||||
|
return (
|
||||||
|
<aside className="recipe-detail-panel">
|
||||||
|
<p className="recipe-detail-panel__status">{t("recipes.sources.detail.loading")}</p>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state.status === "error") {
|
||||||
|
return (
|
||||||
|
<aside className="recipe-detail-panel">
|
||||||
|
<p className="recipe-detail-panel__status recipe-detail-panel__status--error">
|
||||||
|
{t("recipes.sources.detail.loadError")}
|
||||||
|
</p>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { draft } = state;
|
||||||
|
const hasUnresolvedIngredient = draft.ingredients.some(
|
||||||
|
(ingredient) => ingredient.ingredient === null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="recipe-detail-panel">
|
||||||
|
<div className="recipe-detail-panel__header">
|
||||||
|
<div className="recipe-detail-panel__photo" aria-hidden="true">
|
||||||
|
{draft.picture ? <img src={draft.picture} alt="" /> : "🍽️"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="recipe-detail-panel__title-row">
|
||||||
|
<div className="recipe-detail-panel__title-main">
|
||||||
|
<h2>{draft.name}</h2>
|
||||||
|
{draft.portions !== null && (
|
||||||
|
<p className="recipe-detail-panel__portions">
|
||||||
|
{t("recipes.detail.portions", { count: draft.portions })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="recipe-detail-panel__actions">
|
||||||
|
<Link
|
||||||
|
to={{
|
||||||
|
pathname: `/recettes/importer/${draft.sourceKey}/${encodeURIComponent(draft.externalId)}`,
|
||||||
|
search: planningSlot
|
||||||
|
? `?planningDate=${planningSlot.date}&planningWeekDay=${planningSlot.weekDay}&planningMeal=${planningSlot.meal}`
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
className="recipes-page__new-button"
|
||||||
|
>
|
||||||
|
{t("recipes.sources.detail.importButton")}
|
||||||
|
</Link>
|
||||||
|
<a
|
||||||
|
href={draft.sourceUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="recipes-page__new-button"
|
||||||
|
>
|
||||||
|
{t("recipes.sources.detail.viewSource")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{draft.description && (
|
||||||
|
<section className="recipe-detail-panel__section recipe-detail-panel__section--description">
|
||||||
|
<p className="recipe-detail-panel__description">{draft.description}</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="recipe-detail-panel__section">
|
||||||
|
<h3>{t("recipes.sources.detail.ingredientsCount", { count: draft.ingredients.length })}</h3>
|
||||||
|
{hasUnresolvedIngredient && (
|
||||||
|
<p className="source-item-preview__hint">
|
||||||
|
{t("recipes.sources.detail.unresolvedIngredientsHint")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<ul className="source-item-preview__ingredients">
|
||||||
|
{draft.ingredients.map((ingredient, index) => (
|
||||||
|
// Draft lines have no id of their own (nothing is saved yet) —
|
||||||
|
// `rawText` alone could collide (a source repeating the same
|
||||||
|
// line), so it's paired with its position; this list is fully
|
||||||
|
// regenerated from `draft` on every render, never reordered in
|
||||||
|
// place, so that's safe here (same reasoning as
|
||||||
|
// StepDescription.tsx's segment keys).
|
||||||
|
<li
|
||||||
|
key={`${index}-${ingredient.rawText}`}
|
||||||
|
className={ingredient.ingredient === null ? "is-unresolved" : undefined}
|
||||||
|
>
|
||||||
|
{ingredient.rawText}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="recipe-detail-panel__section">
|
||||||
|
<h3>{t("recipes.sources.detail.stepsCount", { count: draft.steps.length })}</h3>
|
||||||
|
<ol className="recipe-detail-panel__steps">
|
||||||
|
{draft.steps.map((step, index) => (
|
||||||
|
<li key={`${index}-${step.description}`}>
|
||||||
|
{step.picture && <img src={step.picture} alt="" />}
|
||||||
|
<StepDescription description={step.description} techSteps={step.techSteps} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
apps/web/src/features/recipes/SourceItemTable.tsx
Normal file
67
apps/web/src/features/recipes/SourceItemTable.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import type { BrowsableSourceItemView } from "@batch-cooking/shared";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import "./recipes.scss";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of one source's browsable items (`RecipeSourcesPanel`) — same
|
||||||
|
* "photo + name, click/Enter to select" row shape as `RecipeTable`, plus
|
||||||
|
* an "already imported" badge in place of allergen/regime columns (a
|
||||||
|
* source item has neither, it's not resolved against our catalogs until
|
||||||
|
* previewed).
|
||||||
|
*/
|
||||||
|
export function SourceItemTable({
|
||||||
|
items,
|
||||||
|
selectedExternalId,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
items: BrowsableSourceItemView[];
|
||||||
|
selectedExternalId: string | null;
|
||||||
|
onSelect: (item: BrowsableSourceItemView) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="recipe-table-wrap">
|
||||||
|
<table className="recipe-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th />
|
||||||
|
<th>{t("recipes.table.name")}</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((item) => (
|
||||||
|
<tr
|
||||||
|
key={item.externalId}
|
||||||
|
className={item.externalId === selectedExternalId ? "selected" : undefined}
|
||||||
|
onClick={() => onSelect(item)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onSelect(item);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
aria-current={item.externalId === selectedExternalId ? "true" : undefined}
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<span className="recipe-table__photo" aria-hidden="true">
|
||||||
|
{item.picture ? <img src={item.picture} alt="" /> : "🍽️"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="recipe-table__name">{item.title}</td>
|
||||||
|
<td>
|
||||||
|
{item.alreadyImported && (
|
||||||
|
<span className="source-item-table__imported-badge">
|
||||||
|
{t("recipes.sources.alreadyImported")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -334,6 +334,91 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Sources tab (RecipeSourcesPanel) ---------------------------------------
|
||||||
|
// Reuses .recipes-page__header/__search/__catalog and .recipe-table(-wrap)
|
||||||
|
// as-is (see RecipeSourcesPanel.tsx/SourceItemTable.tsx) — only what's
|
||||||
|
// actually new to this tab gets its own rules here.
|
||||||
|
|
||||||
|
.recipes-page__header--sources {
|
||||||
|
// The source <select> only renders when the household has more than one
|
||||||
|
// enabled source (see RecipeSourcesPanel) — this just keeps it visually
|
||||||
|
// grouped with the search field when it does.
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-sources-select {
|
||||||
|
flex: none;
|
||||||
|
padding: 0.5rem var(--space-md);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-item-table__imported-badge {
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-items-column {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-items-load-more {
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: center;
|
||||||
|
padding: 0.4rem var(--space-lg);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-primary);
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-item-preview__hint {
|
||||||
|
margin: 0 0 var(--space-sm);
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-warning);
|
||||||
|
background: color-mix(in srgb, var(--color-warning) 12%, var(--color-surface));
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-item-preview__ingredients {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
|
||||||
|
li {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-unresolved {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Recipe detail panel -----------------------------------------------------
|
// --- Recipe detail panel -----------------------------------------------------
|
||||||
// 100% of the grid row's height (see `.recipes-page__catalog` above): the
|
// 100% of the grid row's height (see `.recipes-page__catalog` above): the
|
||||||
// panel itself never scrolls, only its content does past that height
|
// panel itself never scrolls, only its content does past that height
|
||||||
|
|
@ -695,6 +780,59 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Unresolved-ingredient review (ImportRecipePage) ------------------------
|
||||||
|
// Amber-tinted callout, same "warning" language as .source-item-preview__hint
|
||||||
|
// — each line needs a person to pick the right ingredient (or drop it)
|
||||||
|
// before the form can submit at all (see ImportRecipePage.tsx's canSubmit).
|
||||||
|
.import-recipe__unresolved {
|
||||||
|
margin: var(--space-sm) 0;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
background: color-mix(in srgb, var(--color-warning) 8%, var(--color-surface));
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-warning) 30%, var(--color-border));
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 var(--space-xs);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-recipe__unresolved-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: var(--space-sm) 0 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-recipe__unresolved-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
span {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
flex: none;
|
||||||
|
padding: 0.3rem var(--space-sm);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Diet tag multi-select (recipe form) -------------------------------------
|
// --- Diet tag multi-select (recipe form) -------------------------------------
|
||||||
// Checkbox-grid, same visual language as AllergySelect (profile-forms.scss)
|
// Checkbox-grid, same visual language as AllergySelect (profile-forms.scss)
|
||||||
// — global.scss's `label:has(> input[type="checkbox"])` rule already
|
// — global.scss's `label:has(> input[type="checkbox"])` rule already
|
||||||
|
|
|
||||||
|
|
@ -25,4 +25,5 @@ export {
|
||||||
ChevronLeft as ChevronLeftIcon,
|
ChevronLeft as ChevronLeftIcon,
|
||||||
Star as FavoriteIcon,
|
Star as FavoriteIcon,
|
||||||
Globe as PublicIcon,
|
Globe as PublicIcon,
|
||||||
|
Rss as SourcesIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
"INVITE_CODE_NOT_FOUND": "Ce code d'invitation ne correspond à aucun foyer",
|
"INVITE_CODE_NOT_FOUND": "Ce code d'invitation ne correspond à aucun foyer",
|
||||||
"RECIPE_NOT_FOUND": "Cette recette n'existe pas",
|
"RECIPE_NOT_FOUND": "Cette recette n'existe pas",
|
||||||
"RECIPE_IN_USE": "Cette recette est encore utilisée dans un planning",
|
"RECIPE_IN_USE": "Cette recette est encore utilisée dans un planning",
|
||||||
|
"RECIPE_ALREADY_IMPORTED": "Cette recette a déjà été importée",
|
||||||
"INGREDIENT_NOT_FOUND": "Un des ingrédients sélectionnés n'existe pas",
|
"INGREDIENT_NOT_FOUND": "Un des ingrédients sélectionnés n'existe pas",
|
||||||
"UNIT_NOT_FOUND": "Une des unités sélectionnées n'existe pas",
|
"UNIT_NOT_FOUND": "Une des unités sélectionnées n'existe pas",
|
||||||
"SOURCE_NOT_FOUND": "Une des sources sélectionnées n'existe pas",
|
"SOURCE_NOT_FOUND": "Une des sources sélectionnées n'existe pas",
|
||||||
|
|
@ -162,14 +163,48 @@
|
||||||
"perso": "Perso",
|
"perso": "Perso",
|
||||||
"foyer": "Foyer",
|
"foyer": "Foyer",
|
||||||
"publique": "Publique",
|
"publique": "Publique",
|
||||||
"sourcesSoon": "Sources (bientôt)",
|
"sources": "Sources"
|
||||||
"sourcesSoonHint": "Un onglet par source externe, une fois l'import de recettes construit"
|
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"name": "Nom",
|
"name": "Nom",
|
||||||
"allergens": "Allergènes / intolérances",
|
"allergens": "Allergènes / intolérances",
|
||||||
"diets": "Régime associé"
|
"diets": "Régime associé"
|
||||||
},
|
},
|
||||||
|
"sources": {
|
||||||
|
"sourceLabel": "Source",
|
||||||
|
"noneEnabled": "Aucune source n'est activée pour votre foyer.",
|
||||||
|
"noneEnabledLink": "Activez-en une dans les paramètres du foyer",
|
||||||
|
"searchPlaceholder": "Rechercher…",
|
||||||
|
"empty": "Aucune recette trouvée.",
|
||||||
|
"loadMore": "Voir plus",
|
||||||
|
"alreadyImported": "Déjà importée",
|
||||||
|
"loading": "Chargement…",
|
||||||
|
"loadError": "Impossible de charger cette source pour le moment.",
|
||||||
|
"detail": {
|
||||||
|
"empty": "Sélectionnez une recette dans la liste pour voir son aperçu ici.",
|
||||||
|
"loading": "Chargement de l'aperçu…",
|
||||||
|
"loadError": "Impossible de charger l'aperçu de cette recette.",
|
||||||
|
"viewSource": "Voir sur le site d'origine",
|
||||||
|
"importButton": "Importer cette recette",
|
||||||
|
"ingredientsCount_one": "{{count}} ingrédient",
|
||||||
|
"ingredientsCount_other": "{{count}} ingrédients",
|
||||||
|
"unresolvedIngredientsHint": "Certains ingrédients n'ont pas été reconnus automatiquement — ils pourront être corrigés à l'import.",
|
||||||
|
"stepsCount_one": "{{count}} étape",
|
||||||
|
"stepsCount_other": "{{count}} étapes"
|
||||||
|
},
|
||||||
|
"import": {
|
||||||
|
"title": "Revoir l'import",
|
||||||
|
"planningHint": "Cette recette sera automatiquement ajoutée à votre planning une fois importée.",
|
||||||
|
"loadError": "Impossible de charger cette recette pour le moment.",
|
||||||
|
"unresolvedTitle": "Ingrédients à compléter",
|
||||||
|
"unresolvedHint": "Ces lignes n'ont pas été reconnues automatiquement — choisissez le bon ingrédient, ou retirez-les.",
|
||||||
|
"resolveButton": "Choisir un ingrédient",
|
||||||
|
"discardButton": "Retirer cette ligne",
|
||||||
|
"submit": "Importer",
|
||||||
|
"submitting": "Import en cours…",
|
||||||
|
"genericError": "Le formulaire contient des erreurs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"empty": "Sélectionnez une recette dans le tableau pour voir son détail ici.",
|
"empty": "Sélectionnez une recette dans le tableau pour voir son détail ici.",
|
||||||
"favorite": "Ajouter aux favoris",
|
"favorite": "Ajouter aux favoris",
|
||||||
|
|
|
||||||
462
apps/web/src/pages/ImportRecipePage.tsx
Normal file
462
apps/web/src/pages/ImportRecipePage.tsx
Normal file
|
|
@ -0,0 +1,462 @@
|
||||||
|
import {
|
||||||
|
type CreateRecipeInput,
|
||||||
|
type DietView,
|
||||||
|
ErrorCode,
|
||||||
|
type IngredientView,
|
||||||
|
MEALS,
|
||||||
|
type Meal,
|
||||||
|
type RecipeVisibility,
|
||||||
|
type UnitView,
|
||||||
|
WEEK_DAYS,
|
||||||
|
type WeekDay,
|
||||||
|
createRecipeSchema,
|
||||||
|
} from "@batch-cooking/shared";
|
||||||
|
import { type FormEvent, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
import { ApiError, apiClient } from "../api/client";
|
||||||
|
import { DietTagSelect } from "../features/recipes/DietTagSelect";
|
||||||
|
import { IngredientPicker } from "../features/recipes/IngredientPicker";
|
||||||
|
import { IngredientRow } from "../features/recipes/IngredientRow";
|
||||||
|
import { type StepDraft, StepListEditor } from "../features/recipes/StepListEditor";
|
||||||
|
import "../features/recipes/recipes.scss";
|
||||||
|
import { makeClientKey } from "../lib/client-key";
|
||||||
|
import { errorMessageService } from "../services/error-message.service";
|
||||||
|
|
||||||
|
/** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). Same list as `RecipeFormPage`. */
|
||||||
|
const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"];
|
||||||
|
|
||||||
|
/** A resolved ingredient line — identical shape to `RecipeFormPage`'s own `IngredientLine`. */
|
||||||
|
interface IngredientLine {
|
||||||
|
key: string;
|
||||||
|
ingredient: IngredientView;
|
||||||
|
quantity: string;
|
||||||
|
unitId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One draft line that didn't resolve to a real `Ingredient` on preview (`DraftRecipeIngredientView.ingredient === null`) — still needs a person to pick the right one, or discard it, before this recipe can be saved. */
|
||||||
|
interface UnresolvedIngredientLine {
|
||||||
|
key: string;
|
||||||
|
rawText: string;
|
||||||
|
quantity: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoadState = "loading" | "loaded" | "error";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads and validates `?planningDate=&planningWeekDay=&planningMeal=` off
|
||||||
|
* this page's own URL — `null` unless all three are present and well-formed
|
||||||
|
* (a closed-set match against `WEEK_DAYS`/`MEALS`, same validation
|
||||||
|
* `addPlanningItemSchema` enforces server-side), so a hand-typed or stale
|
||||||
|
* URL just falls back to this page's normal "land on the recipe" behavior
|
||||||
|
* rather than throwing. See this component's own doc comment.
|
||||||
|
*/
|
||||||
|
function parsePlanningSlot(
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
): { date: string; weekDay: WeekDay; meal: Meal } | null {
|
||||||
|
const date = searchParams.get("planningDate");
|
||||||
|
const weekDay = searchParams.get("planningWeekDay");
|
||||||
|
const meal = searchParams.get("planningMeal");
|
||||||
|
if (
|
||||||
|
date === null ||
|
||||||
|
!/^\d{4}-\d{2}-\d{2}$/.test(date) ||
|
||||||
|
weekDay === null ||
|
||||||
|
!(WEEK_DAYS as readonly string[]).includes(weekDay) ||
|
||||||
|
meal === null ||
|
||||||
|
!(MEALS as readonly string[]).includes(meal)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { date, weekDay: weekDay as WeekDay, meal: meal as Meal };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Review screen for finalizing an import — routed at
|
||||||
|
* `/recettes/importer/:sourceKey/:externalId` (reached from
|
||||||
|
* `SourceItemPreviewPanel`'s "Importer cette recette" button). Pre-filled
|
||||||
|
* from `GET /sources/:sourceKey/preview/:externalId` (the same draft the
|
||||||
|
* preview panel already showed), structurally the same form as
|
||||||
|
* `RecipeFormPage` — same sub-components (`IngredientRow`,
|
||||||
|
* `IngredientPicker`, `StepListEditor`, `DietTagSelect`), same
|
||||||
|
* `CreateRecipeInput` submit shape — plus one thing a manual creation
|
||||||
|
* never has to handle: ingredient lines the automatic matching
|
||||||
|
* (`ingredient-matcher.ts`) couldn't resolve. Those render as their own
|
||||||
|
* "à compléter" list, each needing a real ingredient picked (or the line
|
||||||
|
* discarded) before the form can submit — never silently drops/guesses one,
|
||||||
|
* per the product decision this stage was built against (no invalid
|
||||||
|
* recipe is ever persisted).
|
||||||
|
*
|
||||||
|
* Submits to `POST /sources/:sourceKey/import/:externalId`
|
||||||
|
* (`apiClient.importSourceItem`) instead of `POST /recipes` — the only
|
||||||
|
* other difference from `RecipeFormPage`'s own submit.
|
||||||
|
*
|
||||||
|
* `?planningDate=&planningWeekDay=&planningMeal=` are set only when this
|
||||||
|
* page was reached from `RecipePickerDialog`'s "Sources" tab (via
|
||||||
|
* `SourceItemPreviewPanel`'s import link, see its own `planningSlot` prop)
|
||||||
|
* — picking a not-yet-imported item there hands off to this full review
|
||||||
|
* screen instead of the dialog's own small "how many portions?" step,
|
||||||
|
* since an unresolved-ingredient review doesn't fit in that step. When
|
||||||
|
* present and well-formed, a successful import also adds the freshly
|
||||||
|
* created recipe straight to that planning slot (`POST /planning/items`,
|
||||||
|
* using this form's own `portions` field) before landing back on the
|
||||||
|
* planning page, instead of the recipe's own detail page.
|
||||||
|
*/
|
||||||
|
export function ImportRecipePage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { sourceKey, externalId } = useParams<{ sourceKey: string; externalId: string }>();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const planningSlot = parsePlanningSlot(searchParams);
|
||||||
|
|
||||||
|
const [loadState, setLoadState] = useState<LoadState>("loading");
|
||||||
|
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
|
||||||
|
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
|
||||||
|
const [unitsCatalog, setUnitsCatalog] = useState<UnitView[]>([]);
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [picture, setPicture] = useState("");
|
||||||
|
const [portions, setPortions] = useState("4");
|
||||||
|
const [visibility, setVisibility] = useState<RecipeVisibility>("PERSONAL");
|
||||||
|
const [dietIds, setDietIds] = useState<number[]>([]);
|
||||||
|
const [ingredientLines, setIngredientLines] = useState<IngredientLine[]>([]);
|
||||||
|
const [unresolvedIngredients, setUnresolvedIngredients] = useState<UnresolvedIngredientLine[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
// Which unresolved line's picker is currently open — at most one at a
|
||||||
|
// time (IngredientPicker is a whole browsable grid, not a compact
|
||||||
|
// popover; showing one per unresolved line at once would be unwieldy).
|
||||||
|
const [resolvingKey, setResolvingKey] = useState<string | null>(null);
|
||||||
|
const [steps, setSteps] = useState<StepDraft[]>([]);
|
||||||
|
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sourceKey === undefined || externalId === undefined) {
|
||||||
|
setLoadState("error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setLoadState("loading");
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
apiClient.getIngredients(),
|
||||||
|
apiClient.getDiets(),
|
||||||
|
apiClient.getUnits(),
|
||||||
|
apiClient.previewSourceItem(sourceKey, externalId),
|
||||||
|
])
|
||||||
|
.then(([ingredients, diets, units, draft]) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setIngredientsCatalog(ingredients);
|
||||||
|
setDietsCatalog(diets);
|
||||||
|
setUnitsCatalog(units);
|
||||||
|
|
||||||
|
setName(draft.name);
|
||||||
|
setDescription(draft.description ?? "");
|
||||||
|
setPicture(draft.picture ?? "");
|
||||||
|
setPortions(draft.portions !== null ? String(draft.portions) : "4");
|
||||||
|
|
||||||
|
const resolved: IngredientLine[] = [];
|
||||||
|
const unresolved: UnresolvedIngredientLine[] = [];
|
||||||
|
for (const line of draft.ingredients) {
|
||||||
|
if (line.ingredient !== null) {
|
||||||
|
resolved.push({
|
||||||
|
key: makeClientKey(),
|
||||||
|
ingredient: line.ingredient,
|
||||||
|
quantity: line.quantity !== null ? String(line.quantity) : "",
|
||||||
|
unitId: line.unit?.id ?? null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
unresolved.push({
|
||||||
|
key: makeClientKey(),
|
||||||
|
rawText: line.rawText,
|
||||||
|
quantity: line.quantity !== null ? String(line.quantity) : "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIngredientLines(resolved);
|
||||||
|
setUnresolvedIngredients(unresolved);
|
||||||
|
|
||||||
|
setSteps(
|
||||||
|
draft.steps.map((step) => ({
|
||||||
|
key: makeClientKey(),
|
||||||
|
description: step.description,
|
||||||
|
picture: step.picture ?? "",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
setLoadState("loaded");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setLoadState("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [sourceKey, externalId]);
|
||||||
|
|
||||||
|
function addIngredient(ingredient: IngredientView) {
|
||||||
|
setIngredientLines((lines) => [
|
||||||
|
...lines,
|
||||||
|
{ key: makeClientKey(), ingredient, quantity: "", unitId: null },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateIngredientLine(
|
||||||
|
key: string,
|
||||||
|
patch: Partial<Pick<IngredientLine, "quantity" | "unitId">>,
|
||||||
|
) {
|
||||||
|
setIngredientLines((lines) =>
|
||||||
|
lines.map((line) => (line.key === key ? { ...line, ...patch } : line)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeIngredientLine(key: string) {
|
||||||
|
setIngredientLines((lines) => lines.filter((line) => line.key !== key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Promotes an unresolved line into a real ingredient line, carrying its quantity over — its unit still needs picking, same as a freshly-added ingredient. */
|
||||||
|
function resolveIngredient(unresolvedKey: string, ingredient: IngredientView) {
|
||||||
|
setUnresolvedIngredients((lines) => {
|
||||||
|
const line = lines.find((l) => l.key === unresolvedKey);
|
||||||
|
if (line) {
|
||||||
|
setIngredientLines((resolved) => [
|
||||||
|
...resolved,
|
||||||
|
{ key: makeClientKey(), ingredient, quantity: line.quantity, unitId: null },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return lines.filter((l) => l.key !== unresolvedKey);
|
||||||
|
});
|
||||||
|
setResolvingKey(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function discardUnresolvedIngredient(key: string) {
|
||||||
|
setUnresolvedIngredients((lines) => lines.filter((line) => line.key !== key));
|
||||||
|
setResolvingKey((current) => (current === key ? null : current));
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSubmit =
|
||||||
|
name.trim().length > 0 &&
|
||||||
|
Number.isInteger(Number(portions)) &&
|
||||||
|
Number(portions) > 0 &&
|
||||||
|
ingredientLines.length > 0 &&
|
||||||
|
ingredientLines.every((line) => Number(line.quantity) > 0 && line.unitId !== null) &&
|
||||||
|
unresolvedIngredients.length === 0 &&
|
||||||
|
steps.length > 0 &&
|
||||||
|
steps.every((step) => step.description.trim().length > 0);
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setFormError(null);
|
||||||
|
if (sourceKey === undefined || externalId === undefined) return;
|
||||||
|
|
||||||
|
const payload: CreateRecipeInput = {
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim() || null,
|
||||||
|
picture: picture.trim() || null,
|
||||||
|
portions: Number(portions),
|
||||||
|
visibility,
|
||||||
|
dietIds,
|
||||||
|
ingredients: ingredientLines.map((line) => ({
|
||||||
|
ingredientId: line.ingredient.id,
|
||||||
|
quantity: Number(line.quantity),
|
||||||
|
// `canSubmit` already requires every line to have a unit picked —
|
||||||
|
// same "?? 0, the schema rejects it if ever reached" reasoning as
|
||||||
|
// RecipeFormPage's identical submit.
|
||||||
|
unitId: line.unitId ?? 0,
|
||||||
|
})),
|
||||||
|
steps: steps.map((step) => ({
|
||||||
|
description: step.description.trim(),
|
||||||
|
picture: step.picture.trim() || null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = createRecipeSchema.safeParse(payload);
|
||||||
|
if (!result.success) {
|
||||||
|
setFormError(result.error.issues[0]?.message ?? t("recipes.sources.import.genericError"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const saved = await apiClient.importSourceItem(sourceKey, externalId, result.data);
|
||||||
|
if (planningSlot) {
|
||||||
|
try {
|
||||||
|
await apiClient.addPlanningItem({
|
||||||
|
date: planningSlot.date,
|
||||||
|
weekDay: planningSlot.weekDay,
|
||||||
|
meal: planningSlot.meal,
|
||||||
|
recipeId: saved.id,
|
||||||
|
portions: Number(portions),
|
||||||
|
});
|
||||||
|
navigate("/");
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// The recipe itself was already imported successfully — only the
|
||||||
|
// planning add failed. Land on the new recipe's own page rather
|
||||||
|
// than stranding the user on a form that already submitted; it
|
||||||
|
// can still be added to that slot afterwards via the normal
|
||||||
|
// "déjà importée" picker path.
|
||||||
|
navigate(`/recettes/${saved.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
navigate(`/recettes/${saved.id}`);
|
||||||
|
} catch (err) {
|
||||||
|
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
||||||
|
setFormError(errorMessageService.getLabel(code));
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadState === "loading") {
|
||||||
|
return (
|
||||||
|
<div className="recipe-form">
|
||||||
|
<p className="recipes-page__status">{t("recipes.loading")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadState === "error") {
|
||||||
|
return (
|
||||||
|
<div className="recipe-form">
|
||||||
|
<p className="recipes-page__status recipes-page__status--error">
|
||||||
|
{t("recipes.sources.import.loadError")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedIds = ingredientLines.map((line) => line.ingredient.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="recipe-form" onSubmit={handleSubmit} noValidate>
|
||||||
|
<h1>{t("recipes.sources.import.title")}</h1>
|
||||||
|
{planningSlot && (
|
||||||
|
<p className="source-item-preview__hint">{t("recipes.sources.import.planningHint")}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label htmlFor="recipe-name">{t("recipes.form.nameLabel")}</label>
|
||||||
|
<input id="recipe-name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
|
||||||
|
<label htmlFor="recipe-description">{t("recipes.form.descriptionLabel")}</label>
|
||||||
|
<textarea
|
||||||
|
id="recipe-description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label htmlFor="recipe-picture">{t("recipes.form.pictureLabel")}</label>
|
||||||
|
<input
|
||||||
|
id="recipe-picture"
|
||||||
|
type="url"
|
||||||
|
value={picture}
|
||||||
|
onChange={(e) => setPicture(e.target.value)}
|
||||||
|
placeholder="https://…"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label htmlFor="recipe-portions">{t("recipes.form.portionsLabel")}</label>
|
||||||
|
<input
|
||||||
|
id="recipe-portions"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
value={portions}
|
||||||
|
onChange={(e) => setPortions(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label htmlFor="recipe-visibility">{t("recipes.form.visibilityLabel")}</label>
|
||||||
|
<select
|
||||||
|
id="recipe-visibility"
|
||||||
|
value={visibility}
|
||||||
|
onChange={(e) => setVisibility(e.target.value as RecipeVisibility)}
|
||||||
|
>
|
||||||
|
{VISIBILITY_OPTIONS.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{t(`recipes.form.visibility.${option}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<DietTagSelect diets={dietsCatalog} value={dietIds} onChange={setDietIds} />
|
||||||
|
|
||||||
|
<section className="recipe-form__section">
|
||||||
|
<h2>{t("recipes.ingredientsTitle")}</h2>
|
||||||
|
<ul className="recipe-form__ingredient-list">
|
||||||
|
{ingredientLines.map((line) => (
|
||||||
|
<IngredientRow
|
||||||
|
key={line.key}
|
||||||
|
ingredient={line.ingredient}
|
||||||
|
quantity={line.quantity}
|
||||||
|
unitId={line.unitId}
|
||||||
|
unitsCatalog={unitsCatalog}
|
||||||
|
onQuantityChange={(quantity) => updateIngredientLine(line.key, { quantity })}
|
||||||
|
onUnitChange={(unitId) => updateIngredientLine(line.key, { unitId })}
|
||||||
|
onRemove={() => removeIngredientLine(line.key)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{unresolvedIngredients.length > 0 && (
|
||||||
|
<section className="import-recipe__unresolved">
|
||||||
|
<h3>{t("recipes.sources.import.unresolvedTitle")}</h3>
|
||||||
|
<p className="source-item-preview__hint">
|
||||||
|
{t("recipes.sources.import.unresolvedHint")}
|
||||||
|
</p>
|
||||||
|
<ul className="import-recipe__unresolved-list">
|
||||||
|
{unresolvedIngredients.map((line) => (
|
||||||
|
<li key={line.key}>
|
||||||
|
<div className="import-recipe__unresolved-row">
|
||||||
|
<span>{line.rawText}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setResolvingKey((current) => (current === line.key ? null : line.key))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("recipes.sources.import.resolveButton")}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => discardUnresolvedIngredient(line.key)}>
|
||||||
|
{t("recipes.sources.import.discardButton")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{resolvingKey === line.key && (
|
||||||
|
<IngredientPicker
|
||||||
|
ingredients={ingredientsCatalog}
|
||||||
|
excludeIds={selectedIds}
|
||||||
|
onSelect={(ingredient) => resolveIngredient(line.key, ingredient)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<IngredientPicker
|
||||||
|
ingredients={ingredientsCatalog}
|
||||||
|
excludeIds={selectedIds}
|
||||||
|
onSelect={addIngredient}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="recipe-form__section">
|
||||||
|
<h2>{t("recipes.stepsTitle")}</h2>
|
||||||
|
<StepListEditor steps={steps} onChange={setSteps} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{formError && <p className="form-error">{formError}</p>}
|
||||||
|
|
||||||
|
<div className="recipe-form__actions">
|
||||||
|
<button type="submit" disabled={isSubmitting || !canSubmit}>
|
||||||
|
{isSubmitting
|
||||||
|
? t("recipes.sources.import.submitting")
|
||||||
|
: t("recipes.sources.import.submit")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { ErrorCode, type RecipeSummaryView, type RecipeTab } from "@batch-cooking/shared";
|
import { ErrorCode, type RecipeSummaryView } from "@batch-cooking/shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
import { ApiError, apiClient } from "../api/client";
|
import { ApiError, apiClient } from "../api/client";
|
||||||
import { RecipeDetailPanel, type RecipeDetailState } from "../features/recipes/RecipeDetailPanel";
|
import { RecipeDetailPanel, type RecipeDetailState } from "../features/recipes/RecipeDetailPanel";
|
||||||
|
import { RecipeSourcesPanel } from "../features/recipes/RecipeSourcesPanel";
|
||||||
import { RecipeTable } from "../features/recipes/RecipeTable";
|
import { RecipeTable } from "../features/recipes/RecipeTable";
|
||||||
import { RecipeTabs } from "../features/recipes/RecipeTabs";
|
import { RecipeTabs, type RecipesPageTab } from "../features/recipes/RecipeTabs";
|
||||||
import "../features/recipes/recipes.scss";
|
import "../features/recipes/recipes.scss";
|
||||||
|
|
||||||
/** Debounce for the search field — avoids firing a request on every keystroke, same idea as the household name's autosave (`HouseholdSettingsPage`). */
|
/** Debounce for the search field — avoids firing a request on every keystroke, same idea as the household name's autosave (`HouseholdSettingsPage`). */
|
||||||
|
|
@ -37,7 +38,7 @@ export function RecipesPage() {
|
||||||
// filter view would).
|
// filter view would).
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<RecipeTab>("favoris");
|
const [activeTab, setActiveTab] = useState<RecipesPageTab>("favoris");
|
||||||
const [search, setSearch] = useState(() => searchParams.get("search") ?? "");
|
const [search, setSearch] = useState(() => searchParams.get("search") ?? "");
|
||||||
// Seeded from the same initial value as `search` — otherwise the first
|
// Seeded from the same initial value as `search` — otherwise the first
|
||||||
// fetch below would fire with an empty term (the debounce effect hasn't
|
// fetch below would fire with an empty term (the debounce effect hasn't
|
||||||
|
|
@ -53,6 +54,10 @@ export function RecipesPage() {
|
||||||
}, [search]);
|
}, [search]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// The "sources" tab doesn't query the recipe table at all — it browses
|
||||||
|
// a source's own live catalog instead (see `RecipeSourcesPanel`, which
|
||||||
|
// owns its own fetching entirely).
|
||||||
|
if (activeTab === "sources") return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setListState({ status: "loading" });
|
setListState({ status: "loading" });
|
||||||
|
|
||||||
|
|
@ -137,13 +142,15 @@ export function RecipesPage() {
|
||||||
<div className="recipes-page">
|
<div className="recipes-page">
|
||||||
<div className="recipes-page__header">
|
<div className="recipes-page__header">
|
||||||
<h1>{t("recipes.title")}</h1>
|
<h1>{t("recipes.title")}</h1>
|
||||||
<input
|
{activeTab !== "sources" && (
|
||||||
type="search"
|
<input
|
||||||
className="recipes-page__search"
|
type="search"
|
||||||
placeholder={t("recipes.searchPlaceholder")}
|
className="recipes-page__search"
|
||||||
value={search}
|
placeholder={t("recipes.searchPlaceholder")}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
value={search}
|
||||||
/>
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Link to="/recettes/nouvelle" className="recipes-page__new-button">
|
<Link to="/recettes/nouvelle" className="recipes-page__new-button">
|
||||||
{t("recipes.newButton")}
|
{t("recipes.newButton")}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -151,33 +158,42 @@ export function RecipesPage() {
|
||||||
|
|
||||||
<RecipeTabs active={activeTab} onChange={setActiveTab} />
|
<RecipeTabs active={activeTab} onChange={setActiveTab} />
|
||||||
|
|
||||||
<div className="recipes-page__catalog">
|
{activeTab === "sources" ? (
|
||||||
{listState.status === "loading" && (
|
<RecipeSourcesPanel
|
||||||
<p className="recipes-page__status">{t("recipes.loading")}</p>
|
onSelectImportedRecipe={(recipeId) => {
|
||||||
)}
|
setActiveTab("favoris");
|
||||||
{listState.status === "error" && (
|
navigate(`/recettes/${recipeId}`);
|
||||||
<p className="recipes-page__status recipes-page__status--error">
|
}}
|
||||||
{t("common.loadError")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{listState.status === "loaded" && listState.recipes.length === 0 && (
|
|
||||||
<p className="recipes-page__status">{t("recipes.empty")}</p>
|
|
||||||
)}
|
|
||||||
{listState.status === "loaded" && listState.recipes.length > 0 && (
|
|
||||||
<RecipeTable
|
|
||||||
recipes={listState.recipes}
|
|
||||||
selectedId={selectedId}
|
|
||||||
onSelect={(recipeId) => navigate(`/recettes/${recipeId}`)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<RecipeDetailPanel
|
|
||||||
state={detailState}
|
|
||||||
dislikedIngredientIds={dislikedIngredientIds}
|
|
||||||
onFavoriteToggled={handleFavoriteToggled}
|
|
||||||
onDeleted={handleDeleted}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="recipes-page__catalog">
|
||||||
|
{listState.status === "loading" && (
|
||||||
|
<p className="recipes-page__status">{t("recipes.loading")}</p>
|
||||||
|
)}
|
||||||
|
{listState.status === "error" && (
|
||||||
|
<p className="recipes-page__status recipes-page__status--error">
|
||||||
|
{t("common.loadError")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{listState.status === "loaded" && listState.recipes.length === 0 && (
|
||||||
|
<p className="recipes-page__status">{t("recipes.empty")}</p>
|
||||||
|
)}
|
||||||
|
{listState.status === "loaded" && listState.recipes.length > 0 && (
|
||||||
|
<RecipeTable
|
||||||
|
recipes={listState.recipes}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={(recipeId) => navigate(`/recettes/${recipeId}`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<RecipeDetailPanel
|
||||||
|
state={detailState}
|
||||||
|
dislikedIngredientIds={dislikedIngredientIds}
|
||||||
|
onFavoriteToggled={handleFavoriteToggled}
|
||||||
|
onDeleted={handleDeleted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ export enum ErrorCode {
|
||||||
ALREADY_HAS_HOUSE = 4020,
|
ALREADY_HAS_HOUSE = 4020,
|
||||||
/** `DELETE /recipes/:id` attempted on a recipe still referenced by at least one `PlanningItem`. */
|
/** `DELETE /recipes/:id` attempted on a recipe still referenced by at least one `PlanningItem`. */
|
||||||
RECIPE_IN_USE = 4021,
|
RECIPE_IN_USE = 4021,
|
||||||
|
/** `POST /sources/:sourceKey/import/:externalId` attempted on an item already imported (a `Recipe` already exists for that `sourceId`/`externalId` pair). */
|
||||||
|
RECIPE_ALREADY_IMPORTED = 4022,
|
||||||
/** A household action reserved to its admin (delete the household, remove a member) attempted by a non-admin member. */
|
/** A household action reserved to its admin (delete the household, remove a member) attempted by a non-admin member. */
|
||||||
NOT_HOUSE_ADMIN = 4030,
|
NOT_HOUSE_ADMIN = 4030,
|
||||||
/** `PATCH /recipes/:id` or `DELETE /recipes/:id` attempted by someone other than the recipe's author — visibility controls reading, not writing. */
|
/** `PATCH /recipes/:id` or `DELETE /recipes/:id` attempted by someone other than the recipe's author — visibility controls reading, not writing. */
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,12 @@ export * from "./schemas/planning.js";
|
||||||
export * from "./schemas/preferences.js";
|
export * from "./schemas/preferences.js";
|
||||||
export * from "./schemas/profile.js";
|
export * from "./schemas/profile.js";
|
||||||
export * from "./schemas/recipe.js";
|
export * from "./schemas/recipe.js";
|
||||||
|
export * from "./schemas/sources.js";
|
||||||
export * from "./tools/assert-is-never.js";
|
export * from "./tools/assert-is-never.js";
|
||||||
export * from "./types/household.js";
|
export * from "./types/household.js";
|
||||||
export * from "./types/planning.js";
|
export * from "./types/planning.js";
|
||||||
export * from "./types/preferences.js";
|
export * from "./types/preferences.js";
|
||||||
export * from "./types/recipe.js";
|
export * from "./types/recipe.js";
|
||||||
export * from "./types/reference.js";
|
export * from "./types/reference.js";
|
||||||
|
export * from "./types/sources.js";
|
||||||
export * from "./types/user-profile.js";
|
export * from "./types/user-profile.js";
|
||||||
|
|
|
||||||
9
packages/shared/src/schemas/sources.ts
Normal file
9
packages/shared/src/schemas/sources.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/** Payload accepted by `GET /sources/:sourceKey/browse`'s query params — `query` free-text searches the source's own catalog (support varies by adapter, see `RecipeSourceListParams`), `cursor` continues a previous page (`RecipeSourceListResult.nextCursor`), omitted starts from the first page. */
|
||||||
|
export const browseSourceSchema = z.object({
|
||||||
|
query: z.string().trim().min(1).optional(),
|
||||||
|
cursor: z.string().trim().min(1).optional(),
|
||||||
|
});
|
||||||
|
/** Inferred TS type for {@link browseSourceSchema}'s validated output. */
|
||||||
|
export type BrowseSourceInput = z.infer<typeof browseSourceSchema>;
|
||||||
63
packages/shared/src/types/sources.ts
Normal file
63
packages/shared/src/types/sources.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import type { StepTechStepView } from "./recipe.js";
|
||||||
|
import type { IngredientView, UnitView } from "./reference.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One item from a source's own catalog (`RecipeSourceAdapter.list()`),
|
||||||
|
* browsable regardless of whether it's already been imported —
|
||||||
|
* `GET /sources/:sourceKey/browse`. Mirrors `BrowsableRecipeItem`
|
||||||
|
* (apps/api's `recipe-source-adapter.ts`), plus `recipeId`: the already-
|
||||||
|
* imported `Recipe`'s id when `alreadyImported` is true, so a caller (the
|
||||||
|
* browse UI) can navigate straight to it without a second lookup — `null`
|
||||||
|
* otherwise.
|
||||||
|
*/
|
||||||
|
export interface BrowsableSourceItemView {
|
||||||
|
externalId: string;
|
||||||
|
title: string;
|
||||||
|
picture: string | null;
|
||||||
|
url: string;
|
||||||
|
alreadyImported: boolean;
|
||||||
|
recipeId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One ingredient line of an unsaved import draft (`RecipeImportDraftView`)
|
||||||
|
* — same spirit as `RecipeIngredientView`, but `ingredient`/`unit` can be
|
||||||
|
* `null` (nothing in the catalog matched — see `ingredient-matcher.ts`) and
|
||||||
|
* `quantity` can be missing entirely, since this hasn't been reviewed/fixed
|
||||||
|
* up by a person yet.
|
||||||
|
*/
|
||||||
|
export interface DraftRecipeIngredientView {
|
||||||
|
/** Exactly what the source wrote for this line — kept even once ingredient/unit resolve, so a review screen can show what the match was made from. */
|
||||||
|
rawText: string;
|
||||||
|
quantity: number | null;
|
||||||
|
ingredient: IngredientView | null;
|
||||||
|
unit: UnitView | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One step of an unsaved import draft — `techSteps` is detected the same way a real save computes it (`matchTechStepSpans`), just not persisted yet. */
|
||||||
|
export interface DraftRecipeStepView {
|
||||||
|
description: string;
|
||||||
|
picture: string | null;
|
||||||
|
techSteps: StepTechStepView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An unsaved preview of one source item, fully translated (ingredients/
|
||||||
|
* units/techniques resolved against our catalogs where possible) —
|
||||||
|
* `GET /sources/:sourceKey/preview/:externalId`. What a future import
|
||||||
|
* review screen pre-fills its form from. Deliberately distinct from
|
||||||
|
* `RecipeView`: nothing here has an id (nothing is saved), and ingredient/
|
||||||
|
* unit resolution can be incomplete — nothing about this type assumes it's
|
||||||
|
* ready to persist as-is.
|
||||||
|
*/
|
||||||
|
export interface RecipeImportDraftView {
|
||||||
|
sourceKey: string;
|
||||||
|
externalId: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
picture: string | null;
|
||||||
|
portions: number | null;
|
||||||
|
sourceUrl: string;
|
||||||
|
ingredients: DraftRecipeIngredientView[];
|
||||||
|
steps: DraftRecipeStepView[];
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue