diff --git a/apps/web/cypress/e2e/planning.feature b/apps/web/cypress/e2e/planning.feature new file mode 100644 index 0000000..3b3b30a --- /dev/null +++ b/apps/web/cypress/e2e/planning.feature @@ -0,0 +1,36 @@ +Feature: Adding a recipe to the planning + As a signed-in user + I want to add a recipe to a planning slot even when I haven't imported it yet + So that browsing an external source and planning it is a single trip + + Background: + Given I am signed in as "Alice" "Martin" + And my household id is 1 + And today is frozen at "2026-08-17T09:00:00.000Z" + And the household request returns no household + And the recipe catalog contains nothing + And the ingredient and diet catalog is available for import + And the sources reference list has options + And the household has enabled TheMealDB + And browsing TheMealDB returns some items + And the planning request reflects whatever's been added so far + + Scenario: Adds a not-yet-imported source item to a planning slot, importing it on the way + Given previewing TheMealDB item "9999" is available + And importing the previewed item will succeed and return id 99 + And adding the imported recipe to the planning will succeed + When I visit "/" + And I click the add button for the first empty planning slot + And I click the button "Sources" + And I click the source item "Fish Pie" + And I click the link "Importer cette recette" + Then I should see "Cette recette sera automatiquement ajoutée à votre planning une fois importée." + + When I choose an ingredient for the unresolved line "some mystery paste" + And I select the ingredient "Sel" from the picker + And I select unit "unité" for the first ingredient + And I fill in the last ingredient's quantity with "1" and unit "unité" + And I click the button "Importer" + Then the planning add request should have included recipe 99, weekDay "lundi", meal "petit-dejeuner", and portions 4 + And the URL should be the home page + And the recipe "Fish Pie" should appear in the first planning slot with 4 portions diff --git a/apps/web/cypress/e2e/planning.ts b/apps/web/cypress/e2e/planning.ts new file mode 100644 index 0000000..dd078bb --- /dev/null +++ b/apps/web/cypress/e2e/planning.ts @@ -0,0 +1,219 @@ +import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor"; + +// Mocks the API via cy.intercept — this job doesn't run a live backend (see +// .github/workflows/ci.yml); apps/api's own Mocha suite covers real API +// behavior against a real database (see test/sources.test.ts, +// test/planning.test.ts). +// +// planning.feature's journey crosses both `RecipePickerDialog` (browsing an +// external source from a planning slot) and the import review screen +// (`ImportRecipePage`) it hands off to — same "each spec's own +// self-contained fixtures" precedent recipe-sources.ts already sets (the +// Cucumber preprocessor's step lookup isn't global across cypress/e2e/, see +// its own comment for the full reasoning), so most of what's below mirrors +// recipe-sources.ts's fixtures rather than importing them. + +// Flips once, from `false` to `true`, as the single scenario in this file +// actually performs the planning-add — module-level `let` rather than +// something reset per-scenario, since there's only ever the one here (see +// household-settings.ts for the same pattern used across several scenarios +// instead). +let fishPiePlanned = false; + +Given("the recipe catalog contains nothing", () => { + cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] }); +}); + +Given("the household has enabled TheMealDB", () => { + cy.intercept("GET", "**/house/current/sources", { statusCode: 200, body: [1] }); +}); + +Given("browsing TheMealDB returns some items", () => { + cy.intercept("GET", "**/sources/theMealDb/browse*", { + statusCode: 200, + body: { + items: [ + { + externalId: "52795", + title: "Chicken Handi", + picture: null, + url: "https://www.themealdb.com/meal/52795", + alreadyImported: true, + recipeId: 2, + }, + { + externalId: "9999", + title: "Fish Pie", + picture: null, + url: "https://www.themealdb.com/meal/9999", + alreadyImported: false, + recipeId: null, + }, + ], + nextCursor: null, + }, + }); +}); + +Given("previewing TheMealDB item {string} is available", (externalId: string) => { + cy.intercept("GET", `**/sources/theMealDb/preview/${externalId}`, { + statusCode: 200, + body: { + sourceKey: "theMealDb", + externalId, + name: "Fish Pie", + description: null, + picture: null, + portions: 4, + sourceUrl: "https://www.themealdb.com/meal/9999", + ingredients: [ + { + rawText: "1 onion", + quantity: 1, + ingredient: { + id: 1, + key: "onion", + icon: "VEGETABLE", + category: "freshProduce", + subcategory: "vegetables", + reproducible: false, + allergens: [], + diets: [], + }, + unit: null, + }, + { rawText: "some mystery paste", quantity: null, ingredient: null, unit: null }, + ], + steps: [{ description: "Cuire à la poêle.", picture: null, techSteps: [] }], + }, + }); +}); + +// Covers every reference catalog both `RecipePickerDialog` (ingredients/ +// diets, for its own filters) and `ImportRecipePage` (ingredients/diets/ +// units, for the review form) fetch — same endpoints, one fixture for both. +Given("the ingredient and diet catalog is available for import", () => { + cy.intercept("GET", "**/reference/ingredients", { + statusCode: 200, + body: [ + { + id: 1, + key: "onion", + icon: "VEGETABLE", + category: "freshProduce", + subcategory: "vegetables", + allergens: [], + diets: [], + }, + { + id: 2, + key: "salt", + icon: "SPICE", + category: "condimentsAndSpices", + subcategory: "spices", + allergens: [], + diets: [], + }, + ], + }); + cy.intercept("GET", "**/reference/diets", { + statusCode: 200, + body: [{ id: 1, key: "omnivore" }], + }); + cy.intercept("GET", "**/reference/units", { + statusCode: 200, + body: [{ id: 1, key: "piece", type: "COUNT", toBaseFactor: 1 }], + }); +}); + +Given("importing the previewed item will succeed and return id {int}", (id: number) => { + cy.intercept("POST", "**/sources/theMealDb/import/9999", { + statusCode: 201, + body: { id }, + }).as("importRecipe"); +}); + +Given("adding the imported recipe to the planning will succeed", () => { + cy.intercept("POST", "**/planning/items", (req) => { + fishPiePlanned = true; + req.reply({ + statusCode: 201, + body: { + id: 1, + weekDay: "lundi", + meal: "petit-dejeuner", + portions: 4, + recipe: { id: 99, name: "Fish Pie" }, + }, + }); + }).as("addPlanningItem"); +}); + +// Stateful — landing back on "/" after the import journey remounts +// `PlanningPage` from scratch (a real cross-route navigation, not a +// same-component state update: see `ImportRecipePage`'s `navigate("/")`), +// so only a fresh `GET /planning?date=` that reflects the just-added item +// makes it show up there — nothing client-side survives that remount to +// patch it in locally the way `PlanningPage`'s own `patchPlanningItems` +// does for an add made without leaving the page. +Given("the planning request reflects whatever's been added so far", () => { + cy.intercept("GET", /\/planning\?/, (req) => { + req.reply({ + statusCode: 200, + body: fishPiePlanned + ? { + id: 1, + startDate: "2026-08-17T00:00:00.000Z", + finishDate: "2026-08-23T00:00:00.000Z", + items: [ + { + id: 1, + weekDay: "lundi", + meal: "petit-dejeuner", + portions: 4, + recipe: { id: 99, name: "Fish Pie" }, + }, + ], + } + : null, + }); + }); +}); + +// The very first "+" in DOM order is Lundi's Petit-déjeuner cell (`MEALS`'s +// first entry × `WEEK_DAYS`'s first entry, see `PlanningGrid`) — the exact +// slot this feature's fixtures above (weekDay "lundi", meal +// "petit-dejeuner") are written against. +When("I click the add button for the first empty planning slot", () => { + cy.get(".add-recipe-btn").first().click(); +}); + +When("I click the source item {string}", (title: string) => { + cy.contains(".recipe-table__name", title).click(); +}); + +When("I choose an ingredient for the unresolved line {string}", (rawText: string) => { + cy.contains(".import-recipe__unresolved-row", rawText) + .contains("button", "Choisir un ingrédient") + .click(); +}); + +Then( + "the planning add request should have included recipe {int}, weekDay {string}, meal {string}, and portions {int}", + (recipeId: number, weekDay: string, meal: string, portions: number) => { + cy.wait("@addPlanningItem") + .its("request.body") + .should("deep.include", { recipeId, weekDay, meal, portions }); + }, +); + +Then( + "the recipe {string} should appear in the first planning slot with {int} portions", + (name: string, portions: number) => { + cy.get(".planning-grid tbody tr") + .first() + .within(() => { + cy.contains(".recipe-chip", `${name} · ×${portions}`).should("be.visible"); + }); + }, +); diff --git a/apps/web/src/features/planning/RecipePickerDialog.tsx b/apps/web/src/features/planning/RecipePickerDialog.tsx index 050b387..feaf62e 100644 --- a/apps/web/src/features/planning/RecipePickerDialog.tsx +++ b/apps/web/src/features/planning/RecipePickerDialog.tsx @@ -5,7 +5,6 @@ import { type Meal, type PlanningItemView, type RecipeSummaryView, - type RecipeTab, type WeekDay, } from "@batch-cooking/shared"; import { useEffect, useState } from "react"; @@ -16,16 +15,14 @@ import { Dialog } from "../../components/ui/Dialog"; import { errorMessageService } from "../../services/error-message.service"; import { DietTagSelect } from "../recipes/DietTagSelect"; import { IngredientPicker } from "../recipes/IngredientPicker"; +import { RecipeSourcesPanel } from "../recipes/RecipeSourcesPanel"; import { RecipeTable } from "../recipes/RecipeTable"; -import { RecipeTabs } from "../recipes/RecipeTabs"; +import { RecipeTabs, type RecipesPageTab } from "../recipes/RecipeTabs"; import "./recipe-picker-dialog.scss"; /** Debounce for the search field — same value as `RecipesPage`'s. */ const SEARCH_DEBOUNCE_MS = 300; -/** Restricts this dialog's `RecipeTabs` to the four real, DB-backed tabs — browsing external sources mid-dialog (`RecipeTabs`' "sources" tab) doesn't make sense here yet, with no review/import flow to hand a picked item off to. */ -const REAL_RECIPE_TABS: readonly RecipeTab[] = ["favoris", "perso", "foyer", "publique"]; - /** Load state for the filtered catalog list, same discriminated-union shape as `RecipesPage`'s `RecipeListState`. */ type ListState = | { status: "loading" } @@ -51,7 +48,13 @@ export interface PlanningSlot { * with three extra filters layered on top of the plain name search * (ingredients / regime / "convient à tout le foyer" toggle, all wired to * `GET /recipes`'s corresponding query params) since browsing here is - * about finding something to cook, not just looking something up. + * about finding something to cook, not just looking something up. The + * "Sources" tab is included too (unlike an earlier version of this dialog + * — see `ImportRecipePage`'s `planningSlot`, the review/import flow that + * made including it here worthwhile): picking an already-imported item + * behaves exactly like picking a regular recipe, and picking one that + * isn't imported yet hands off to that review screen, which adds the + * freshly-created recipe straight to this slot once it's saved. * * Mounted only while open (see `PlanningPage`, same conditional-mount * convention as its own `CalendarPopover`) — every piece of local state @@ -61,7 +64,10 @@ export interface PlanningSlot { * Selecting a row doesn't navigate anywhere (unlike `RecipesPage`'s own * use of `RecipeTable`) — it switches this same dialog to a small * "how many portions?" confirmation step, then calls `POST - * /planning/items` on submit. + * /planning/items` on submit. The one exception is picking a not-yet- + * imported source item, which does navigate away entirely (to + * `/recettes/importer/...`) — that flow has its own portions field + * already, on the review screen itself. */ export function RecipePickerDialog({ slot, @@ -74,7 +80,7 @@ export function RecipePickerDialog({ }) { const { t } = useTranslation(); - const [activeTab, setActiveTab] = useState("favoris"); + const [activeTab, setActiveTab] = useState("favoris"); const [search, setSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); const [selectedIngredientIds, setSelectedIngredientIds] = useState([]); @@ -86,6 +92,11 @@ export function RecipePickerDialog({ const [ingredientsCatalog, setIngredientsCatalog] = useState([]); const [dietsCatalog, setDietsCatalog] = useState([]); const [listState, setListState] = useState({ status: "loading" }); + // Set when picking an already-imported source item fails to resolve to a + // real recipe (see `handleSelectImportedRecipe`) — a rare race (the + // recipe was deleted between the browse fetch and the click), surfaced + // the same way any other catalog load error is on this dialog. + const [sourceSelectError, setSourceSelectError] = useState(false); // The recipe picked in step 1 — `null` while still browsing, set once a // row is clicked to switch this dialog into its confirmation step. @@ -117,6 +128,9 @@ export function RecipePickerDialog({ }, []); useEffect(() => { + // The "sources" tab doesn't query the recipe table at all — same guard + // as `RecipesPage`'s own identical effect. + if (activeTab === "sources") return; let cancelled = false; setListState({ status: "loading" }); @@ -143,6 +157,18 @@ export function RecipePickerDialog({ selectedIngredientIds.includes(ingredient.id), ); + /** Picking an already-imported source item (`RecipeSourcesPanel`'s "sources" tab) — resolved to its full recipe, then treated exactly like picking that same recipe from one of the regular tabs, moving straight to the confirm-portions step below. */ + function handleSelectImportedRecipe(recipeId: number) { + setSourceSelectError(false); + apiClient + .getRecipe(recipeId) + .then((recipe) => { + setSelectedRecipe(recipe); + setPortions(String(recipe.portions)); + }) + .catch(() => setSourceSelectError(true)); + } + async function handleConfirm() { if (!selectedRecipe) return; const parsedPortions = Number(portions); @@ -204,91 +230,113 @@ export function RecipePickerDialog({ return ( -
- setSearch(e.target.value)} - /> + {activeTab !== "sources" && ( +
+ setSearch(e.target.value)} + /> -
- - {t("planning.picker.ingredientsFilterLabel")} - -
- {selectedIngredients.map((ingredient) => ( - - {t(`catalog.ingredients.${ingredient.key}`)} - - - ))} - +
+ + {t("planning.picker.ingredientsFilterLabel")} + +
+ {selectedIngredients.map((ingredient) => ( + + {t(`catalog.ingredients.${ingredient.key}`)} + + + ))} + +
+ {isIngredientPickerOpen && ( + + setSelectedIngredientIds((ids) => [...ids, ingredient.id]) + } + /> + )}
- {isIngredientPickerOpen && ( - setSelectedIngredientIds((ids) => [...ids, ingredient.id])} - /> + + + + {hasHousehold && ( + + {t("planning.picker.suitableForHouseholdLabel")} + )}
- - - - {hasHousehold && ( - - {t("planning.picker.suitableForHouseholdLabel")} - - )} -
- - setActiveTab(tab as RecipeTab)} - tabs={REAL_RECIPE_TABS} - /> - - {listState.status === "loading" && ( -

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

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

{t("common.loadError")}

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

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

- )} - {listState.status === "loaded" && listState.recipes.length > 0 && ( - { - const recipe = listState.recipes.find((r) => r.id === id) ?? null; - setSelectedRecipe(recipe); - // Pre-fill from the recipe's own written yield rather than - // always starting at 1 — still freely editable below, this is - // just a better starting point (see `Recipe.portions`). - if (recipe) setPortions(String(recipe.portions)); - }} - /> + + + + {activeTab === "sources" ? ( + <> + {sourceSelectError && ( +

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

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

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

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

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

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

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

+ )} + {listState.status === "loaded" && listState.recipes.length > 0 && ( + { + const recipe = listState.recipes.find((r) => r.id === id) ?? null; + setSelectedRecipe(recipe); + // Pre-fill from the recipe's own written yield rather than + // always starting at 1 — still freely editable below, this + // is just a better starting point (see `Recipe.portions`). + if (recipe) setPortions(String(recipe.portions)); + }} + /> + )} + )}
); diff --git a/apps/web/src/features/recipes/RecipeSourcesPanel.tsx b/apps/web/src/features/recipes/RecipeSourcesPanel.tsx index e98c789..80d609e 100644 --- a/apps/web/src/features/recipes/RecipeSourcesPanel.tsx +++ b/apps/web/src/features/recipes/RecipeSourcesPanel.tsx @@ -1,7 +1,7 @@ -import type { BrowsableSourceItemView, SourceView } from "@batch-cooking/shared"; +import type { BrowsableSourceItemView, Meal, SourceView, WeekDay } from "@batch-cooking/shared"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Link, useNavigate } from "react-router-dom"; +import { Link } from "react-router-dom"; import { apiClient } from "../../api/client"; import { SourceItemPreviewPanel, type SourceItemPreviewState } from "./SourceItemPreviewPanel"; import { SourceItemTable } from "./SourceItemTable"; @@ -34,23 +34,25 @@ type BrowseState = * preview into an actual saved recipe (reviewing/fixing unresolved * ingredients first) is a later stage of the same plan, not built here. * - * `onViewImportedRecipe` must switch the caller's active tab away from - * `"sources"` before/alongside navigating — `RecipesPage` only renders - * `RecipeDetailPanel` (and fetches the real recipe list `RecipeTable` - * needs) outside the `"sources"` tab, so without this the URL would change - * but the sources panel would keep rendering over it. Which real tab it - * lands on doesn't have to include the recipe (the table and the detail - * panel are only loosely coupled via the URL's `:id` everywhere else in - * the app too — a deep link to a recipe outside the active tab's own list - * already just shows the detail without highlighting a row). + * `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({ - onViewImportedRecipe, + onSelectImportedRecipe, + planningSlot, }: { - onViewImportedRecipe: () => void; + 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 navigate = useNavigate(); const [enabledSources, setEnabledSources] = useState({ status: "loading" }); const [selectedSourceKey, setSelectedSourceKey] = useState(null); @@ -125,8 +127,7 @@ export function RecipeSourcesPanel({ function handleSelectItem(item: BrowsableSourceItemView) { if (item.alreadyImported && item.recipeId !== null) { - onViewImportedRecipe(); - navigate(`/recettes/${item.recipeId}`); + onSelectImportedRecipe(item.recipeId); return; } if (selectedSourceKey === null) return; @@ -208,7 +209,7 @@ export function RecipeSourcesPanel({ )} - + ); diff --git a/apps/web/src/features/recipes/SourceItemPreviewPanel.tsx b/apps/web/src/features/recipes/SourceItemPreviewPanel.tsx index c9efbdc..e6b89fa 100644 --- a/apps/web/src/features/recipes/SourceItemPreviewPanel.tsx +++ b/apps/web/src/features/recipes/SourceItemPreviewPanel.tsx @@ -1,4 +1,4 @@ -import type { RecipeImportDraftView } from "@batch-cooking/shared"; +import type { Meal, RecipeImportDraftView, WeekDay } from "@batch-cooking/shared"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { StepDescription } from "./StepDescription"; @@ -20,7 +20,21 @@ export type SourceItemPreviewState = * Reuses `StepDescription` so a step's detected techniques are already * highlighted here too, exactly like a saved recipe's detail. */ -export function SourceItemPreviewPanel({ state }: { state: SourceItemPreviewState }) { +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") { @@ -73,7 +87,12 @@ export function SourceItemPreviewPanel({ state }: { state: SourceItemPreviewStat
{t("recipes.sources.detail.importButton")} diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 2ba70e5..9018503 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -194,6 +194,7 @@ }, "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.", diff --git a/apps/web/src/pages/ImportRecipePage.tsx b/apps/web/src/pages/ImportRecipePage.tsx index 88fe25a..5f569bd 100644 --- a/apps/web/src/pages/ImportRecipePage.tsx +++ b/apps/web/src/pages/ImportRecipePage.tsx @@ -3,13 +3,17 @@ import { 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 } from "react-router-dom"; +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"; @@ -39,6 +43,33 @@ interface UnresolvedIngredientLine { 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 @@ -58,11 +89,24 @@ type LoadState = "loading" | "loaded" | "error"; * 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("loading"); const [ingredientsCatalog, setIngredientsCatalog] = useState([]); @@ -237,6 +281,27 @@ export function ImportRecipePage() { 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; @@ -269,6 +334,9 @@ export function ImportRecipePage() { return (

{t("recipes.sources.import.title")}

+ {planningSlot && ( +

{t("recipes.sources.import.planningHint")}

+ )} setName(e.target.value)} /> diff --git a/apps/web/src/pages/RecipesPage.tsx b/apps/web/src/pages/RecipesPage.tsx index c8d19a4..07ffbef 100644 --- a/apps/web/src/pages/RecipesPage.tsx +++ b/apps/web/src/pages/RecipesPage.tsx @@ -159,7 +159,12 @@ export function RecipesPage() { {activeTab === "sources" ? ( - setActiveTab("favoris")} /> + { + setActiveTab("favoris"); + navigate(`/recettes/${recipeId}`); + }} + /> ) : (
{listState.status === "loading" && (