Merge pull request #48 from kyuno053/feat/plan-import-flow

feat(planning): ajouter au planning déclenche l'import si besoin (étape 4/4)
This commit is contained in:
kyuno053 2026-08-20 18:32:58 +02:00 committed by GitHub
commit 45661f58b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 507 additions and 110 deletions

View file

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

View file

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

View file

@ -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<RecipeTab>("favoris");
const [activeTab, setActiveTab] = useState<RecipesPageTab>("favoris");
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [selectedIngredientIds, setSelectedIngredientIds] = useState<number[]>([]);
@ -86,6 +92,11 @@ export function RecipePickerDialog({
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
const [listState, setListState] = useState<ListState>({ status: "loading" });
// Set when picking an already-imported source item fails to resolve to a
// real recipe (see `handleSelectImportedRecipe`) — a rare race (the
// recipe was deleted between the browse fetch and the click), surfaced
// the same way any other catalog load error is on this dialog.
const [sourceSelectError, setSourceSelectError] = useState(false);
// The recipe picked in step 1 — `null` while still browsing, set once a
// row is clicked to switch this dialog into its confirmation step.
@ -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 (
<Dialog onClose={onClose} title={t("planning.picker.title")} className="recipe-picker-dialog">
<div className="recipe-picker__filters">
<input
type="search"
className="recipe-picker__search"
placeholder={t("planning.picker.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{activeTab !== "sources" && (
<div className="recipe-picker__filters">
<input
type="search"
className="recipe-picker__search"
placeholder={t("planning.picker.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="recipe-picker__ingredient-filter">
<span className="recipe-picker__filter-label">
{t("planning.picker.ingredientsFilterLabel")}
</span>
<div className="recipe-picker__chips">
{selectedIngredients.map((ingredient) => (
<span key={ingredient.id} className="filter-chip">
{t(`catalog.ingredients.${ingredient.key}`)}
<button
type="button"
onClick={() =>
setSelectedIngredientIds((ids) => ids.filter((id) => id !== ingredient.id))
}
>
</button>
</span>
))}
<button
type="button"
className="recipe-picker__toggle-ingredient-picker"
onClick={() => setIsIngredientPickerOpen((open) => !open)}
aria-expanded={isIngredientPickerOpen}
>
+{" "}
{isIngredientPickerOpen
? t("planning.picker.hideIngredientPicker")
: t("planning.picker.addIngredientFilter")}
</button>
<div className="recipe-picker__ingredient-filter">
<span className="recipe-picker__filter-label">
{t("planning.picker.ingredientsFilterLabel")}
</span>
<div className="recipe-picker__chips">
{selectedIngredients.map((ingredient) => (
<span key={ingredient.id} className="filter-chip">
{t(`catalog.ingredients.${ingredient.key}`)}
<button
type="button"
onClick={() =>
setSelectedIngredientIds((ids) => ids.filter((id) => id !== ingredient.id))
}
>
</button>
</span>
))}
<button
type="button"
className="recipe-picker__toggle-ingredient-picker"
onClick={() => setIsIngredientPickerOpen((open) => !open)}
aria-expanded={isIngredientPickerOpen}
>
+{" "}
{isIngredientPickerOpen
? t("planning.picker.hideIngredientPicker")
: t("planning.picker.addIngredientFilter")}
</button>
</div>
{isIngredientPickerOpen && (
<IngredientPicker
ingredients={ingredientsCatalog}
excludeIds={selectedIngredientIds}
onSelect={(ingredient) =>
setSelectedIngredientIds((ids) => [...ids, ingredient.id])
}
/>
)}
</div>
{isIngredientPickerOpen && (
<IngredientPicker
ingredients={ingredientsCatalog}
excludeIds={selectedIngredientIds}
onSelect={(ingredient) => setSelectedIngredientIds((ids) => [...ids, ingredient.id])}
/>
<DietTagSelect
diets={dietsCatalog}
value={selectedDietIds}
onChange={setSelectedDietIds}
/>
{hasHousehold && (
<CheckboxOption checked={suitableForHousehold} onChange={setSuitableForHousehold}>
{t("planning.picker.suitableForHouseholdLabel")}
</CheckboxOption>
)}
</div>
<DietTagSelect diets={dietsCatalog} value={selectedDietIds} onChange={setSelectedDietIds} />
{hasHousehold && (
<CheckboxOption checked={suitableForHousehold} onChange={setSuitableForHousehold}>
{t("planning.picker.suitableForHouseholdLabel")}
</CheckboxOption>
)}
</div>
<RecipeTabs
active={activeTab}
onChange={(tab) => setActiveTab(tab as RecipeTab)}
tabs={REAL_RECIPE_TABS}
/>
{listState.status === "loading" && (
<p className="recipes-page__status">{t("planning.picker.loading")}</p>
)}
{listState.status === "error" && (
<p className="recipes-page__status recipes-page__status--error">{t("common.loadError")}</p>
)}
{listState.status === "loaded" && listState.recipes.length === 0 && (
<p className="recipes-page__status">{t("planning.picker.empty")}</p>
)}
{listState.status === "loaded" && listState.recipes.length > 0 && (
<RecipeTable
recipes={listState.recipes}
selectedId={null}
onSelect={(id) => {
const recipe = listState.recipes.find((r) => r.id === id) ?? null;
setSelectedRecipe(recipe);
// Pre-fill from the recipe's own written yield rather than
// always starting at 1 — still freely editable below, this is
// just a better starting point (see `Recipe.portions`).
if (recipe) setPortions(String(recipe.portions));
}}
/>
<RecipeTabs active={activeTab} onChange={setActiveTab} />
{activeTab === "sources" ? (
<>
{sourceSelectError && (
<p className="recipes-page__status recipes-page__status--error">
{t("common.loadError")}
</p>
)}
<RecipeSourcesPanel
planningSlot={slot}
onSelectImportedRecipe={handleSelectImportedRecipe}
/>
</>
) : (
<>
{listState.status === "loading" && (
<p className="recipes-page__status">{t("planning.picker.loading")}</p>
)}
{listState.status === "error" && (
<p className="recipes-page__status recipes-page__status--error">
{t("common.loadError")}
</p>
)}
{listState.status === "loaded" && listState.recipes.length === 0 && (
<p className="recipes-page__status">{t("planning.picker.empty")}</p>
)}
{listState.status === "loaded" && listState.recipes.length > 0 && (
<RecipeTable
recipes={listState.recipes}
selectedId={null}
onSelect={(id) => {
const recipe = listState.recipes.find((r) => r.id === id) ?? null;
setSelectedRecipe(recipe);
// Pre-fill from the recipe's own written yield rather than
// always starting at 1 — still freely editable below, this
// is just a better starting point (see `Recipe.portions`).
if (recipe) setPortions(String(recipe.portions));
}}
/>
)}
</>
)}
</Dialog>
);

View file

@ -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<EnabledSourcesState>({ status: "loading" });
const [selectedSourceKey, setSelectedSourceKey] = useState<string | null>(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({
</div>
)}
<SourceItemPreviewPanel state={previewState} />
<SourceItemPreviewPanel state={previewState} planningSlot={planningSlot} />
</div>
</>
);

View file

@ -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
<div className="recipe-detail-panel__actions">
<Link
to={`/recettes/importer/${draft.sourceKey}/${encodeURIComponent(draft.externalId)}`}
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")}

View file

@ -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.",

View file

@ -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<LoadState>("loading");
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
@ -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 (
<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)} />

View file

@ -159,7 +159,12 @@ export function RecipesPage() {
<RecipeTabs active={activeTab} onChange={setActiveTab} />
{activeTab === "sources" ? (
<RecipeSourcesPanel onViewImportedRecipe={() => setActiveTab("favoris")} />
<RecipeSourcesPanel
onSelectImportedRecipe={(recipeId) => {
setActiveTab("favoris");
navigate(`/recettes/${recipeId}`);
}}
/>
) : (
<div className="recipes-page__catalog">
{listState.status === "loading" && (