From 73ae8169a1032d26c468406ba05c71f6bb676c55 Mon Sep 17 00:00:00 2001
From: Nicolas
Date: Thu, 20 Aug 2026 21:14:10 +0200
Subject: [PATCH 1/7] fix(recipes): retire l'import manuel, le planning importe
seul
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Correction de comportement sur la gestion des recettes de sources
externes — l'implémentation précédente avait dérivé d'une lecture
erronée du besoin :
- Plus aucun bouton d'import nulle part. Parcourir une source
(RecipesPage, hors planning) ne fait plus jamais que prévisualiser
— RecipeDetailPanel n'affiche plus de lien "Importer cette
recette", seulement un bouton icône discret vers la page d'origine
quand la recette en a une (nouveau .recipe-detail-panel__source-link,
même emplacement que l'étoile favori).
- Une recette externe n'est importée dans la base qu'au moment où
quelqu'un l'ajoute effectivement à son planning — jamais avant.
RecipePickerDialog.handleSelectDraftItem est désormais le seul
endroit de toute l'appli qui importe quoi que ce soit : cliquer sur
un item pas encore importé y déclenche une tentative d'import
transparente (POST /sources/.../import puis POST /planning/items),
sans écran intermédiaire, dès que rien ne manque
(tryBuildCompleteImport, nouveau apps/web/src/features/recipes/
recipe-import-draft.ts). Seul un ingrédient non résolu (ou une
erreur réseau) fait encore basculer vers l'écran de revue existant
(ImportRecipePage), pré-rempli, pour compléter ce qui manque.
- RecipeSourcesPanel gagne onSelectDraftItem (remplace planningSlot,
qui n'a plus de raison d'être puisqu'il n'y a plus de lien d'import
à qui le transmettre) : quand ce callback est fourni
(RecipePickerDialog uniquement), un item pas encore importé n'est
plus prévisualisé sur place, il est remonté tel quel à l'appelant.
Tests :
- planning.feature : le scénario existant retire l'étape "je clique
le lien Importer cette recette" (redirection désormais automatique
puisque le draft de test a un ingrédient non résolu) ; nouveau
scénario pour le chemin transparent (draft entièrement résolu,
aucun écran de revue).
- recipe-sources.feature : le scénario qui important depuis /recettes
(hors planning) est supprimé — cette capacité n'existe plus hors
planning. Le scénario de deep-link vérifie maintenant l'absence du
bouton d'import et la présence du lien discret.
- pnpm exec tsc -b --force (web) — propre.
- pnpm exec biome check — propre.
- pnpm --filter web build — propre.
- Cypress non exécutable localement sur cette machine (crash GPU
Electron connu) — scénarios vérifiés par relecture attentive
contre le markup/les clés i18n réels ; CI (GitHub Actions) fera
foi à l'exécution.
Co-Authored-By: Claude Sonnet 5
---
apps/web/cypress/e2e/planning.feature | 15 ++-
apps/web/cypress/e2e/planning.ts | 82 ++++++++++++
apps/web/cypress/e2e/recipe-sources.feature | 32 +----
apps/web/cypress/e2e/recipe-sources.ts | 67 +---------
.../features/planning/RecipePickerDialog.tsx | 120 +++++++++++++++---
.../features/recipes/RecipeDetailPanel.tsx | 65 ++++------
.../features/recipes/RecipeSourcesPanel.tsx | 32 +++--
.../features/recipes/recipe-import-draft.ts | 60 +++++++++
apps/web/src/features/recipes/recipes.scss | 36 ++++++
apps/web/src/layouts/nav-icons.tsx | 1 +
apps/web/src/locales/fr/translation.json | 6 +-
apps/web/src/pages/ImportRecipePage.tsx | 45 ++++---
12 files changed, 376 insertions(+), 185 deletions(-)
create mode 100644 apps/web/src/features/recipes/recipe-import-draft.ts
diff --git a/apps/web/cypress/e2e/planning.feature b/apps/web/cypress/e2e/planning.feature
index 53692ee..eaa7546 100644
--- a/apps/web/cypress/e2e/planning.feature
+++ b/apps/web/cypress/e2e/planning.feature
@@ -15,7 +15,7 @@ Feature: Adding a recipe to the planning
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
+ Scenario: Adds a not-yet-imported source item to a planning slot, landing on the review screen since an ingredient needs resolving
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
@@ -23,7 +23,6 @@ Feature: Adding a recipe to the planning
And I click the add button for the first empty planning slot
And I click the button "TheMealDB"
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"
@@ -34,3 +33,15 @@ Feature: Adding a recipe to the planning
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
+
+ Scenario: Adds a fully-resolved not-yet-imported item to a planning slot transparently, with no review screen at all
+ Given previewing TheMealDB item "7777" is fully resolved as "Ratatouille"
+ And importing item "7777" will succeed and return id 100
+ And adding recipe 100 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 "TheMealDB"
+ And I click the source item "Ratatouille"
+ Then the import request for "7777" should have included the name "Ratatouille"
+ And the planning add request should have included recipe 100, weekDay "lundi", meal "petit-dejeuner", and portions 4
+ And the URL should be the home page
diff --git a/apps/web/cypress/e2e/planning.ts b/apps/web/cypress/e2e/planning.ts
index dd078bb..2aadca6 100644
--- a/apps/web/cypress/e2e/planning.ts
+++ b/apps/web/cypress/e2e/planning.ts
@@ -49,6 +49,18 @@ Given("browsing TheMealDB returns some items", () => {
alreadyImported: false,
recipeId: null,
},
+ // Draft's own preview ("previewing TheMealDB item ... is fully
+ // resolved") has nothing left for a person to fix — unlike "Fish
+ // Pie" above, exercises the transparent-import path instead of the
+ // review screen.
+ {
+ externalId: "7777",
+ title: "Ratatouille",
+ picture: null,
+ url: "https://www.themealdb.com/meal/7777",
+ alreadyImported: false,
+ recipeId: null,
+ },
],
nextCursor: null,
},
@@ -89,6 +101,76 @@ Given("previewing TheMealDB item {string} is available", (externalId: string) =>
});
});
+// Unlike "previewing TheMealDB item ... is available" above, every
+// ingredient line here already resolved to a real ingredient/unit/quantity
+// — `tryBuildCompleteImport` (RecipePickerDialog.tsx) accepts a draft
+// shaped exactly like this one as-is, no review screen needed.
+Given(
+ "previewing TheMealDB item {string} is fully resolved as {string}",
+ (externalId: string, name: string) => {
+ cy.intercept("GET", `**/sources/theMealDb/preview/${externalId}`, {
+ statusCode: 200,
+ body: {
+ sourceKey: "theMealDb",
+ externalId,
+ name,
+ description: null,
+ picture: null,
+ portions: 4,
+ sourceUrl: `https://www.themealdb.com/meal/${externalId}`,
+ ingredients: [
+ {
+ rawText: "1 onion",
+ quantity: 1,
+ ingredient: {
+ id: 1,
+ key: "onion",
+ icon: "VEGETABLE",
+ category: "freshProduce",
+ subcategory: "vegetables",
+ reproducible: false,
+ allergens: [],
+ diets: [],
+ },
+ unit: { id: 1, key: "piece", type: "COUNT", toBaseFactor: 1 },
+ },
+ ],
+ steps: [{ description: "Cuire à la poêle.", picture: null, techSteps: [] }],
+ },
+ });
+ },
+);
+
+Given(
+ "importing item {string} will succeed and return id {int}",
+ (externalId: string, id: number) => {
+ cy.intercept("POST", `**/sources/theMealDb/import/${externalId}`, {
+ statusCode: 201,
+ body: { id },
+ }).as("importItem");
+ },
+);
+
+Given("adding recipe {int} to the planning will succeed", (recipeId: number) => {
+ cy.intercept("POST", "**/planning/items", {
+ statusCode: 201,
+ body: {
+ id: 2,
+ weekDay: "lundi",
+ meal: "petit-dejeuner",
+ portions: 4,
+ recipe: { id: recipeId, name: "Ratatouille" },
+ },
+ }).as("addPlanningItem");
+});
+
+Then(
+ "the import request for {string} should have included the name {string}",
+ (_externalId: string, name: string) => {
+ cy.wait("@importItem").its("request.body.name").should("eq", name);
+ },
+);
+
// 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.
diff --git a/apps/web/cypress/e2e/recipe-sources.feature b/apps/web/cypress/e2e/recipe-sources.feature
index 85a7379..16dfee8 100644
--- a/apps/web/cypress/e2e/recipe-sources.feature
+++ b/apps/web/cypress/e2e/recipe-sources.feature
@@ -45,7 +45,7 @@ Feature: Browsing external recipe sources
And the recipe detail panel heading should be "Fish Pie"
And I should see the highlighted technique "Cuire"
- Scenario: Deep-links straight to a not-yet-imported item's own page
+ Scenario: Deep-links straight to a not-yet-imported item's own page, with no import affordance at all
Given the recipe catalog contains nothing
And the sources reference list has options
And the household has enabled TheMealDB
@@ -53,31 +53,5 @@ Feature: Browsing external recipe sources
And previewing TheMealDB item "9999" is available
When I visit "/recettes/sources/theMealDb/9999"
Then the recipe detail panel heading should be "Fish Pie"
- And I should see "Importer cette recette"
-
- 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 "TheMealDB"
- 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"
+ And I should not see "Importer cette recette"
+ And I should see a discreet link to the item's original page
diff --git a/apps/web/cypress/e2e/recipe-sources.ts b/apps/web/cypress/e2e/recipe-sources.ts
index c630282..f183bc2 100644
--- a/apps/web/cypress/e2e/recipe-sources.ts
+++ b/apps/web/cypress/e2e/recipe-sources.ts
@@ -135,66 +135,9 @@ Then("the source item {string} should be marked as already imported", (title: st
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 }],
- });
+// Browsing a source (outside of adding-to-planning, `RecipePickerDialog`'s
+// own scenarios in planning.ts) never imports anything — the only action
+// this preview offers is a discreet way out to the item's own page.
+Then("I should see a discreet link to the item's original page", () => {
+ cy.get(".recipe-detail-panel__source-link").should("be.visible");
});
-
-Given("importing the previewed item will succeed and return id {int}", (id: number) => {
- cy.intercept("POST", "**/sources/theMealDb/import/9999", { statusCode: 201, body: { id } }).as(
- "importRecipe",
- );
-});
-
-When("I choose an ingredient for the unresolved line {string}", (rawText: string) => {
- cy.contains(".import-recipe__unresolved-row", rawText)
- .contains("button", "Choisir un ingrédient")
- .click();
-});
-
-Then("the unresolved ingredients section should no longer be shown", () => {
- cy.get(".import-recipe__unresolved").should("not.exist");
-});
-
-Then(
- "the import request should have included ingredient {int} with quantity {int} and unitId {int}",
- (ingredientId: number, quantity: number, unitId: number) => {
- cy.wait("@importRecipe")
- .its("request.body.ingredients")
- .should("include.deep.members", [{ ingredientId, quantity, unitId }]);
- },
-);
diff --git a/apps/web/src/features/planning/RecipePickerDialog.tsx b/apps/web/src/features/planning/RecipePickerDialog.tsx
index 6958790..5177365 100644
--- a/apps/web/src/features/planning/RecipePickerDialog.tsx
+++ b/apps/web/src/features/planning/RecipePickerDialog.tsx
@@ -1,14 +1,17 @@
import {
+ type BrowsableSourceItemView,
type DietView,
ErrorCode,
type IngredientView,
type Meal,
type PlanningItemView,
type RecipeSummaryView,
+ type RecipeView,
type WeekDay,
} from "@batch-cooking/shared";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router-dom";
import { ApiError, apiClient } from "../../api/client";
import { CheckboxOption } from "../../components/ui/Checkbox";
import { Dialog } from "../../components/ui/Dialog";
@@ -23,6 +26,7 @@ import {
isSourceTab,
parseSourceTabValue,
} from "../recipes/RecipeTabs";
+import { tryBuildCompleteImport } from "../recipes/recipe-import-draft";
import { useEnabledSources } from "../recipes/useEnabledSources";
import "./recipe-picker-dialog.scss";
@@ -55,13 +59,12 @@ export interface PlanningSlot {
* (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. Each
- * household-enabled source's own tab is included too (unlike an earlier
- * version of this dialog — see `ImportRecipePage`'s `planningSlot`, the
- * review/import flow that made including them 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.
+ * household-enabled source's own tab is included too: picking an
+ * already-imported item behaves exactly like picking a regular recipe,
+ * and picking one that isn't imported yet is what actually imports it —
+ * nowhere else in the app does (see `handleSelectDraftItem`) — since a
+ * source item only ever becomes a real, saved `Recipe` as a side effect of
+ * someone adding it to their planning.
*
* Mounted only while open (see `PlanningPage`, same conditional-mount
* convention as its own `CalendarPopover`) — every piece of local state
@@ -71,10 +74,14 @@ 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. 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.
+ * /planning/items` on submit. Picking a not-yet-imported source item is
+ * handled differently still (`handleSelectDraftItem`): when the draft has
+ * everything a real recipe needs, it's imported and added to this slot
+ * transparently — no extra screen, same as picking anything else. Only
+ * when something's actually missing (an ingredient the automatic matcher
+ * couldn't resolve, say) does this navigate away entirely, to the review
+ * screen (`/recettes/importer/...`), which has its own portions field
+ * already.
*/
export function RecipePickerDialog({
slot,
@@ -86,6 +93,7 @@ export function RecipePickerDialog({
onAdded: (item: PlanningItemView) => void;
}) {
const { t } = useTranslation();
+ const navigate = useNavigate();
const [activeTab, setActiveTab] = useState("favoris");
const activeSourceKey = parseSourceTabValue(activeTab);
@@ -106,6 +114,12 @@ export function RecipePickerDialog({
// 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);
+ // True while `handleSelectDraftItem` below is resolving a not-yet-
+ // imported item's transparent-import attempt — replaces the source tab's
+ // whole panel with a status message for that brief window (fetch the
+ // draft, maybe import it, maybe add it to the slot) rather than leaving
+ // the browse list clickable mid-flight.
+ const [isAddingDraft, setIsAddingDraft] = 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.
@@ -180,6 +194,74 @@ export function RecipePickerDialog({
.catch(() => setSourceSelectError(true));
}
+ /** Navigates away to the full review/creation screen, pre-filled from this exact item and carrying `slot` along so a successful import there adds straight to it — the fallback `handleSelectDraftItem` below takes whenever a transparent import isn't possible. */
+ function goToReviewScreen(sourceKey: string, externalId: string) {
+ navigate(
+ `/recettes/importer/${sourceKey}/${encodeURIComponent(externalId)}` +
+ `?planningDate=${slot.date}&planningWeekDay=${slot.weekDay}&planningMeal=${slot.meal}`,
+ );
+ }
+
+ /**
+ * Picking a not-yet-imported source item — the one action in the whole
+ * app that actually imports one (see this component's own doc comment).
+ * Fetches its full draft, and when {@link tryBuildCompleteImport} finds
+ * nothing missing, imports it and adds it to `slot` transparently: no
+ * extra screen, same end result as picking any other recipe. Anything
+ * short of that — an unresolved ingredient, a network hiccup on any of
+ * these three calls — falls back to the full review screen
+ * (`goToReviewScreen`) instead, since only a person can supply what's
+ * actually missing.
+ */
+ async function handleSelectDraftItem(item: BrowsableSourceItemView) {
+ if (activeSourceKey === null) return;
+ const sourceKey = activeSourceKey;
+ setSourceSelectError(false);
+ setIsAddingDraft(true);
+
+ let payload: ReturnType;
+ try {
+ payload = tryBuildCompleteImport(
+ await apiClient.previewSourceItem(sourceKey, item.externalId),
+ );
+ } catch {
+ goToReviewScreen(sourceKey, item.externalId);
+ return;
+ }
+ if (!payload) {
+ setIsAddingDraft(false);
+ goToReviewScreen(sourceKey, item.externalId);
+ return;
+ }
+
+ let saved: RecipeView;
+ try {
+ saved = await apiClient.importSourceItem(sourceKey, item.externalId, payload);
+ } catch {
+ setIsAddingDraft(false);
+ goToReviewScreen(sourceKey, item.externalId);
+ return;
+ }
+
+ try {
+ const planningItem = await apiClient.addPlanningItem({
+ date: slot.date,
+ weekDay: slot.weekDay,
+ meal: slot.meal,
+ recipeId: saved.id,
+ portions: payload.portions,
+ });
+ onAdded(planningItem);
+ onClose();
+ } catch {
+ // The recipe itself is already saved at this point — only adding it
+ // to this slot failed. Land on its own page rather than retrying the
+ // whole import through the review form (same fallback
+ // `ImportRecipePage`'s own submit takes for the identical failure).
+ navigate(`/recettes/${saved.id}`);
+ }
+ }
+
async function handleConfirm() {
if (!selectedRecipe) return;
const parsedPortions = Number(portions);
@@ -319,12 +401,16 @@ export function RecipePickerDialog({
{t("common.loadError")}
)}
-
+ {isAddingDraft ? (
+
{t("planning.picker.addingDraft")}
+ ) : (
+
+ )}
>
) : (
<>
diff --git a/apps/web/src/features/recipes/RecipeDetailPanel.tsx b/apps/web/src/features/recipes/RecipeDetailPanel.tsx
index 479b036..32ff476 100644
--- a/apps/web/src/features/recipes/RecipeDetailPanel.tsx
+++ b/apps/web/src/features/recipes/RecipeDetailPanel.tsx
@@ -1,14 +1,9 @@
-import {
- ErrorCode,
- type Meal,
- type RecipeImportDraftView,
- type RecipeView,
- type WeekDay,
-} from "@batch-cooking/shared";
+import { ErrorCode, type RecipeImportDraftView, type RecipeView } from "@batch-cooking/shared";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ApiError, apiClient } from "../../api/client";
+import { SourceLinkIcon } from "../../layouts/nav-icons";
import { errorMessageService } from "../../services/error-message.service";
import { AllergenBadges } from "./AllergenBadges";
import { FavoriteStarButton } from "./FavoriteStarButton";
@@ -20,11 +15,14 @@ import "./recipes.scss";
* is distinct from `"not-found"` (a selected id that turned out invalid/
* inaccessible), each with its own message. `"loaded-draft"` is the one
* variant that isn't a real, saved `Recipe`: a not-yet-imported source
- * item's preview (`RecipeSourcesPanel`'s "Sources" tab) — rendered through
- * this exact same component so viewing one looks and behaves like viewing
- * any other recipe ("comme si c'était importé"), differing only in which
- * actions make sense (there's nothing to favorite/edit/delete yet, but
- * there is something to *import*).
+ * item's read-only preview (`RecipeSourcesPanel`'s source tabs) — rendered
+ * through this exact same component so viewing one looks like viewing any
+ * other recipe, minus every action that doesn't make sense on something
+ * that isn't saved yet (favorite/edit/delete — nor a manual "import"
+ * button: a source item only ever gets saved as a side effect of adding it
+ * to a planning slot, see `RecipePickerDialog`'s `handleSelectDraftItem`,
+ * never from this preview). The one action this state does offer is a
+ * discreet link to the item's original page, if it has one.
*/
export type RecipeDetailState =
| { status: "empty" }
@@ -50,20 +48,11 @@ export function RecipeDetailPanel({
dislikedIngredientIds = [],
onFavoriteToggled,
onDeleted,
- planningSlot,
}: {
state: RecipeDetailState;
dislikedIngredientIds?: number[];
onFavoriteToggled?: (recipeId: number, isFavorite: boolean) => void;
onDeleted?: (recipeId: number) => void;
- /**
- * Set only when this panel is rendered from `RecipePickerDialog` (adding a
- * recipe to one planning slot) — carried along on a `"loaded-draft"`
- * item's "Importer cette recette" link as query params, so `ImportRecipePage`
- * knows to add the freshly-created recipe to this exact slot once the
- * import succeeds. See `ImportRecipePage`'s own `planningSlot`.
- */
- planningSlot?: { date: string; weekDay: WeekDay; meal: Meal };
}) {
const { t } = useTranslation();
@@ -108,6 +97,18 @@ export function RecipeDetailPanel({
diff --git a/apps/web/src/features/recipes/RecipeSourcesPanel.tsx b/apps/web/src/features/recipes/RecipeSourcesPanel.tsx
index 51c59ca..99e2a86 100644
--- a/apps/web/src/features/recipes/RecipeSourcesPanel.tsx
+++ b/apps/web/src/features/recipes/RecipeSourcesPanel.tsx
@@ -1,4 +1,4 @@
-import type { BrowsableSourceItemView, Meal, WeekDay } from "@batch-cooking/shared";
+import type { BrowsableSourceItemView } from "@batch-cooking/shared";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { apiClient } from "../../api/client";
@@ -40,8 +40,9 @@ type BrowseState =
* The right-hand preview reuses `RecipeDetailPanel` itself (its
* `"loaded-draft"` state) rather than a separate component — viewing a
* not-yet-imported item is meant to look and feel exactly like viewing any
- * other recipe, differing only in which actions are offered (there's an
- * "Importer" button where Modifier/Supprimer would be).
+ * other recipe, minus the actions that don't apply to something that isn't
+ * saved yet. This is `RecipesPage`'s own mode: browsing/reading only,
+ * nothing here ever imports anything (see `onSelectDraftItem` below).
*
* Selecting an already-imported item navigates straight to its real
* recipe (`/recettes/:id`, leaving this tab) — `onSelectImportedRecipe`
@@ -58,20 +59,29 @@ type BrowseState =
* (`/recettes/sources/:sourceKey/:externalId`) without this panel needing
* to know anything about routing itself — it reports selection changes
* upward, and re-previews on mount/prop-change if handed one back.
- * `RecipePickerDialog` leaves both unset: previewing inside that modal has
- * no URL of its own to keep in sync.
+ * `RecipePickerDialog` leaves both unset: it never previews a not-yet-
+ * imported item inline at all (see `onSelectDraftItem`).
*/
export function RecipeSourcesPanel({
sourceKey,
onSelectImportedRecipe,
- planningSlot,
+ onSelectDraftItem,
initialSelection,
onItemSelected,
}: {
sourceKey: string;
onSelectImportedRecipe: (recipeId: number) => void;
- /** Forwarded as-is to `RecipeDetailPanel` — see its own doc comment. Only ever set by `RecipePickerDialog`. */
- planningSlot?: { date: string; weekDay: WeekDay; meal: Meal };
+ /**
+ * Set only by `RecipePickerDialog` — picking a not-yet-imported item
+ * while adding to a planning slot isn't something to preview inline
+ * here at all (there's no "import" button on that preview to lead
+ * anywhere any more — see `RecipeDetailPanel`'s own doc comment). When
+ * set, a not-yet-imported row's click hands the item straight to this
+ * instead of previewing it, and the caller takes it from there
+ * (`RecipePickerDialog.handleSelectDraftItem`: import transparently when
+ * nothing's missing, otherwise hand off to the review screen).
+ */
+ onSelectDraftItem?: (item: BrowsableSourceItemView) => void;
initialSelection?: SourceItemSelection;
onItemSelected?: (item: SourceItemSelection | null) => void;
}) {
@@ -168,6 +178,10 @@ export function RecipeSourcesPanel({
onSelectImportedRecipe(item.recipeId);
return;
}
+ if (onSelectDraftItem) {
+ onSelectDraftItem(item);
+ return;
+ }
const selection = { sourceKey, externalId: item.externalId };
setSelectedExternalId(item.externalId);
setPreviewState({ status: "loading" });
@@ -221,7 +235,7 @@ export function RecipeSourcesPanel({
)}
-
+
>
);
diff --git a/apps/web/src/features/recipes/recipe-import-draft.ts b/apps/web/src/features/recipes/recipe-import-draft.ts
new file mode 100644
index 0000000..ebdbff9
--- /dev/null
+++ b/apps/web/src/features/recipes/recipe-import-draft.ts
@@ -0,0 +1,60 @@
+import {
+ type CreateRecipeInput,
+ type RecipeImportDraftView,
+ createRecipeSchema,
+} from "@batch-cooking/shared";
+
+/**
+ * Attempts to turn `draft` straight into a submittable {@link CreateRecipeInput}
+ * — no form, no person involved — for the planning picker's transparent
+ * import path (`RecipePickerDialog`'s `handleSelectDraftItem`): adding a
+ * not-yet-imported source item to a planning slot should just work,
+ * silently, whenever nothing about it actually needs a human's judgment
+ * call. Returns `null` the moment anything does — an ingredient line the
+ * automatic matcher (`ingredient-matcher.ts`) couldn't resolve to a real
+ * ingredient/unit/quantity, or a missing portions count — so the caller can
+ * fall back to the full review screen (`ImportRecipePage`), pre-filled from
+ * this exact same draft, for a person to fill in what's missing.
+ *
+ * Default `visibility`/`dietIds` mirror what a person would otherwise leave
+ * untouched on that same form (`PERSONAL`, no diet tags) — nothing here is
+ * a guess about data the draft doesn't have an opinion on.
+ * `createRecipeSchema.safeParse` is still the actual authority on whether
+ * the result is submittable (a positive-portions check, string lengths,
+ * etc.) — the checks above it exist only for what a schema alone can't
+ * catch: `ingredient`/`unit` being resolved references, not just present
+ * values.
+ */
+export function tryBuildCompleteImport(draft: RecipeImportDraftView): CreateRecipeInput | null {
+ if (draft.portions === null) return null;
+ if (draft.ingredients.length === 0) return null;
+ if (
+ draft.ingredients.some(
+ (line) => line.ingredient === null || line.unit === null || line.quantity === null,
+ )
+ ) {
+ return null;
+ }
+
+ const candidate: CreateRecipeInput = {
+ name: draft.name,
+ description: draft.description,
+ picture: draft.picture,
+ portions: draft.portions,
+ visibility: "PERSONAL",
+ dietIds: [],
+ ingredients: draft.ingredients.map((line) => ({
+ // Every line is fully resolved by this point — guarded above.
+ ingredientId: (line.ingredient as NonNullable).id,
+ quantity: line.quantity as number,
+ unitId: (line.unit as NonNullable).id,
+ })),
+ steps: draft.steps.map((step) => ({
+ description: step.description,
+ picture: step.picture,
+ })),
+ };
+
+ const result = createRecipeSchema.safeParse(candidate);
+ return result.success ? result.data : null;
+}
diff --git a/apps/web/src/features/recipes/recipes.scss b/apps/web/src/features/recipes/recipes.scss
index 49a6e72..025d9c5 100644
--- a/apps/web/src/features/recipes/recipes.scss
+++ b/apps/web/src/features/recipes/recipes.scss
@@ -674,6 +674,42 @@
}
}
+// --- External source link (draft preview header) ----------------------------
+// Same overlay slot/sizing as `.favorite-star-button` above — a draft
+// preview never has both (nothing to favorite yet), so the two never
+// compete for the corner. Deliberately muted/small ("discret" per the
+// product decision this button follows): a way out to the original page,
+// not a call to action the way the buttons it replaced were.
+.recipe-detail-panel__source-link {
+ position: absolute;
+ top: var(--space-sm);
+ right: var(--space-sm);
+ width: 2.2rem;
+ height: 2.2rem;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: color-mix(in srgb, var(--color-surface) 82%, transparent);
+ box-shadow: var(--shadow-sm);
+ color: var(--color-text-muted);
+ transition:
+ transform 0.12s ease,
+ color 0.12s ease;
+
+ svg {
+ width: 1.1rem;
+ height: 1.1rem;
+ }
+
+ &:hover,
+ &:focus-visible {
+ color: var(--color-primary);
+ transform: scale(1.08);
+ outline: none;
+ }
+}
+
// --- Recipe form (create/edit) ----------------------------------------------
// Per-field validation and whole-form error messages — same small rules as
// auth-form.scss/profile-forms.scss, redeclared here rather than shared
diff --git a/apps/web/src/layouts/nav-icons.tsx b/apps/web/src/layouts/nav-icons.tsx
index 0828143..a698dfa 100644
--- a/apps/web/src/layouts/nav-icons.tsx
+++ b/apps/web/src/layouts/nav-icons.tsx
@@ -26,4 +26,5 @@ export {
Star as FavoriteIcon,
Globe as PublicIcon,
Rss as SourcesIcon,
+ ExternalLink as SourceLinkIcon,
} from "lucide-react";
diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json
index e0f4ba9..623735d 100644
--- a/apps/web/src/locales/fr/translation.json
+++ b/apps/web/src/locales/fr/translation.json
@@ -142,7 +142,8 @@
"portionsLabel": "Nombre de portions",
"backButton": "Retour",
"confirmButton": "Ajouter au planning",
- "adding": "Ajout…"
+ "adding": "Ajout…",
+ "addingDraft": "Ajout au planning…"
}
},
"recipes": {
@@ -177,8 +178,7 @@
"loading": "Chargement…",
"loadError": "Impossible de charger cette source pour le moment.",
"detail": {
- "viewSource": "Voir sur le site d'origine",
- "importButton": "Importer cette recette"
+ "viewSource": "Voir sur le site d'origine"
},
"import": {
"title": "Revoir l'import",
diff --git a/apps/web/src/pages/ImportRecipePage.tsx b/apps/web/src/pages/ImportRecipePage.tsx
index e1a3842..1f891b5 100644
--- a/apps/web/src/pages/ImportRecipePage.tsx
+++ b/apps/web/src/pages/ImportRecipePage.tsx
@@ -72,33 +72,38 @@ function parsePlanningSlot(
/**
* Review screen for finalizing an import — routed at
- * `/recettes/importer/:sourceKey/:externalId` (reached from
- * `RecipeDetailPanel`'s "Importer cette recette" button, shown for its
- * `"loaded-draft"` state). 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`,
+ * `/recettes/importer/:sourceKey/:externalId`. Reached only one way now:
+ * `RecipePickerDialog.handleSelectDraftItem`'s fallback, when picking a
+ * not-yet-imported source item to add to a planning slot turns out to need
+ * a person's input (an ingredient line the automatic matching
+ * (`ingredient-matcher.ts`) couldn't resolve, say) — a draft with nothing
+ * missing imports transparently from that dialog instead, without ever
+ * reaching this screen (`tryBuildCompleteImport`). There is no other way
+ * in any more: browsing a source outside of adding-to-planning
+ * (`RecipesPage`/`RecipeSourcesPanel`) only ever previews, on purpose — a
+ * source item isn't imported into the household's own catalog until
+ * someone actually plans it.
+ *
+ * Pre-filled from `GET /sources/:sourceKey/preview/:externalId` (the same
+ * draft the transparent-import attempt already fetched), 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).
+ * never has to handle: ingredient lines the automatic matching 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
- * `RecipeDetailPanel`'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`,
+ * `?planningDate=&planningWeekDay=&planningMeal=` carry the planning slot
+ * `RecipePickerDialog` was adding to along as query params (always present
+ * in practice, given the only entry point above — still parsed
+ * defensively, see `parsePlanningSlot`). A successful import adds the
+ * freshly created recipe straight to that 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.
*/
From f24b289cd4745322fccc3d87bd11701a701a1b82 Mon Sep 17 00:00:00 2001
From: Nicolas
Date: Thu, 20 Aug 2026 23:17:25 +0200
Subject: [PATCH 2/7] fix css
---
apps/web/src/features/recipes/recipes.scss | 13 +++++--------
1 file changed, 5 insertions(+), 8 deletions(-)
diff --git a/apps/web/src/features/recipes/recipes.scss b/apps/web/src/features/recipes/recipes.scss
index 025d9c5..aad17c0 100644
--- a/apps/web/src/features/recipes/recipes.scss
+++ b/apps/web/src/features/recipes/recipes.scss
@@ -430,11 +430,9 @@
&__photo {
height: 9rem;
width: 100%;
- background: linear-gradient(
- 160deg,
- color-mix(in srgb, var(--color-primary) 22%, var(--color-surface-alt)),
- var(--color-surface-alt)
- );
+ background: linear-gradient(160deg,
+ color-mix(in srgb, var(--color-primary) 22%, var(--color-surface-alt)),
+ var(--color-surface-alt));
display: flex;
align-items: center;
justify-content: center;
@@ -541,7 +539,7 @@
flex-shrink: 0;
padding: var(--space-md);
- & + & {
+ &+& {
border-top: 1px solid var(--color-border);
}
@@ -729,7 +727,6 @@
.recipe-form {
display: flex;
flex-direction: column;
- max-width: 40rem;
gap: var(--space-xs);
label {
@@ -1364,4 +1361,4 @@
}
}
}
-}
+}
\ No newline at end of file
From fc8f38afde838495f4a51e401303d2c02dbc6888 Mon Sep 17 00:00:00 2001
From: Nicolas
Date: Thu, 20 Aug 2026 23:19:21 +0200
Subject: [PATCH 3/7] =?UTF-8?q?fix(planning):=20corrige=20le=20d=C3=A9bord?=
=?UTF-8?q?ement=20du=20dialogue=20de=20s=C3=A9lection=20pour=20les=20sour?=
=?UTF-8?q?ces?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Le dialogue de sélection de recette (RecipePickerDialog) affichait bien les
données renvoyées par le serveur, mais rien ne s'affichait dans la fenêtre
tant que le nombre d'items dépassait sa hauteur — reproduit en direct via
le compte de test : la tab TheMealDB (25 items, contrairement aux quelques
recettes perso habituelles) rend le bug visible alors qu'il touchait déjà
le tableau simple des autres onglets, juste sans jamais assez de lignes
pour le déclencher.
Cause : .recipes-page__catalog et .recipe-table-wrap se dimensionnent via
flex: 1; min-height: 0, ce qui ne fonctionne que si leur parent est
display: flex — vrai sur /recettes (.recipes-page l'établit), faux dans ce
dialogue où le contenu est monté directement dans .dialog-panel__body
(Dialog.tsx), un simple conteneur en flux bloc. Sans ce contexte, la zone
catalogue grossissait à la taille de son contenu (1499px mesuré en direct
contre 605px de hauteur réellement disponible) au lieu d'être bornée et
scrollable dans ses propres limites.
Correctif scoping .dialog-panel__body en display: flex; flex-direction:
column uniquement sous .recipe-picker-dialog (tous les autres appelants de
Dialog gardent le flux bloc par défaut), plus flex: 1; min-height: 0 sur
.recipe-table-wrap pour qu'il profite du même contexte.
Vérifié en direct (navigateur, compte de test, tab TheMealDB) : la zone
catalogue passe de 1499px à 450px (borné), les lignes redeviennent
visibles/cliquables sans affecter les autres onglets ni l'étape de
confirmation des portions (rendue par un Dialog séparé, sans la classe
recipe-picker-dialog). Tenu aussi en viewport réduit (1024x640).
pnpm exec tsc -b --force (web) — propre.
pnpm --filter web build — propre.
Co-Authored-By: Claude Sonnet 5
---
.../planning/recipe-picker-dialog.scss | 26 +++++++++++++++++++
1 file changed, 26 insertions(+)
diff --git a/apps/web/src/features/planning/recipe-picker-dialog.scss b/apps/web/src/features/planning/recipe-picker-dialog.scss
index 7eb5d19..9ef18f7 100644
--- a/apps/web/src/features/planning/recipe-picker-dialog.scss
+++ b/apps/web/src/features/planning/recipe-picker-dialog.scss
@@ -6,6 +6,32 @@
.recipe-picker-dialog {
max-width: 56rem;
+
+ // `.dialog-panel__body` (dialog.scss) is a plain block-flow scroll
+ // container by default — fine for every other dialog's small form, but
+ // this one's browsing step embeds the same components `/recettes` uses
+ // (`RecipeTable`'s `.recipe-table-wrap`, `RecipeSourcesPanel`'s
+ // `.recipes-page__catalog`), both of which size themselves with
+ // `flex: 1; min-height: 0` and need a `display: flex` ancestor for that
+ // to mean anything — on the real page that ancestor is `.recipes-page`
+ // itself (see its own doc comment for the identical fix that page
+ // needed once); this dialog never renders that wrapper, so without this
+ // the catalog/table just grew to its full content height instead of
+ // being clipped and independently scrollable within the dialog's own
+ // bounds — harmless for the handful of rows a personal recipe tab
+ // usually has, but a source tab's ~25-item browse list made it obvious:
+ // everything past the dialog's fixed height rendered, technically, just
+ // never inside the visible/scrollable area. Scoped to this dialog only
+ // — every other `Dialog` caller keeps the plain block layout.
+ .dialog-panel__body {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .recipe-table-wrap {
+ flex: 1;
+ min-height: 0;
+ }
}
.recipe-picker__filters {
From 933882a527fdd76b6eed1cec4f883565c21b9dc4 Mon Sep 17 00:00:00 2001
From: Nicolas
Date: Thu, 20 Aug 2026 23:21:08 +0200
Subject: [PATCH 4/7] =?UTF-8?q?style(planning):=20dialogue=20de=20s=C3=A9l?=
=?UTF-8?q?ection=20plus=20large,=20hauteur=20fixe=2080vh?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Suite au correctif précédent (débordement du catalogue) : le dialogue lui-
même passe de 56rem/hauteur-au-contenu à 92vw plafonné à 75rem, avec une
hauteur fixe de 80vh (au lieu du simple plafond hérité de Dialog.tsx) —
la zone de navigation garde désormais une taille généreuse et constante
quel que soit l'onglet/la quantité de contenu, plutôt que de dépendre de
ce qui tient à l'écran.
Vérifié en direct (navigateur, 1280×800) : dialogue 1178×640px (92vw ×
80vh), catalogue/tableau/détail correctement bornés à l'intérieur.
pnpm --filter web build — propre.
Co-Authored-By: Claude Sonnet 5
---
.../features/planning/recipe-picker-dialog.scss | 14 +++++++++++++-
1 file changed, 13 insertions(+), 1 deletion(-)
diff --git a/apps/web/src/features/planning/recipe-picker-dialog.scss b/apps/web/src/features/planning/recipe-picker-dialog.scss
index 9ef18f7..ad6c453 100644
--- a/apps/web/src/features/planning/recipe-picker-dialog.scss
+++ b/apps/web/src/features/planning/recipe-picker-dialog.scss
@@ -5,7 +5,19 @@
// RecipeTabs import it themselves).
.recipe-picker-dialog {
- max-width: 56rem;
+ width: 92vw;
+ max-width: 75rem;
+ // Fixed, not just capped — `.dialog-panel`'s own `max-height` (dialog.scss)
+ // only bounds how tall a shrink-to-fit dialog can grow, which is right
+ // for every other dialog's small form but leaves this one's browsing
+ // step at the mercy of how much content happens to be on screen (a short
+ // personal-recipe tab vs. a ~25-item source browse). Pinning both
+ // `height`/`max-height` to the same `80vh` keeps the browsing area a
+ // consistent, generous size regardless of tab/content, on top of the
+ // `.dialog-panel__body` fix below that makes that area actually use the
+ // space instead of overflowing it.
+ height: 80vh;
+ max-height: 80vh;
// `.dialog-panel__body` (dialog.scss) is a plain block-flow scroll
// container by default — fine for every other dialog's small form, but
From b0c1d46acc8d90dec120538339f3c2fb044e62c5 Mon Sep 17 00:00:00 2001
From: Nicolas
Date: Thu, 20 Aug 2026 23:24:50 +0200
Subject: [PATCH 5/7] =?UTF-8?q?style(planning):=20r=C3=A9partit=20la=20lar?=
=?UTF-8?q?geur=20du=20dialogue=20par=20rapport=20=C3=A0=20lui-m=C3=AAme?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
.recipes-page__catalog dimensionne son panneau de détail avec
minmax(35vw, 38vw) — une fraction du *viewport*, cohérent sur /recettes
(cette grille occupe quasiment toute la page) mais sans rapport avec ce
dialogue, désormais de largeur fixe (92vw plafonné à 75rem, voir commit
précédent) : à un viewport large, 38vw dépasse largement la largeur réelle
du dialogue et déséquilibre la répartition liste/détail.
Remplacé, sous .recipe-picker-dialog uniquement, par un ratio 3fr/2fr
relatif au dialogue lui-même — la répartition (60/40) reste identique
quelle que soit la largeur de l'écran, vérifié en direct à 1280px et
1600px de large (ratio mesuré 1.50 dans les deux cas).
pnpm --filter web build — propre.
Co-Authored-By: Claude Sonnet 5
---
.../src/features/planning/recipe-picker-dialog.scss | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/apps/web/src/features/planning/recipe-picker-dialog.scss b/apps/web/src/features/planning/recipe-picker-dialog.scss
index ad6c453..b036e82 100644
--- a/apps/web/src/features/planning/recipe-picker-dialog.scss
+++ b/apps/web/src/features/planning/recipe-picker-dialog.scss
@@ -44,6 +44,17 @@
flex: 1;
min-height: 0;
}
+
+ // `.recipes-page__catalog`'s own column split (recipes.scss) sizes the
+ // detail pane with `minmax(35vw, 38vw)` — a fraction of the *viewport*,
+ // which tracked `/recettes`' own width there (that grid spans nearly the
+ // whole page) but has nothing to do with this dialog's width, now a
+ // fixed 92vw/75rem of its own. Overridden here as a fraction of the
+ // dialog itself instead, so the two panes stay proportionate to each
+ // other regardless of viewport size.
+ .recipes-page__catalog {
+ grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
+ }
}
.recipe-picker__filters {
From 260bc3dc058d5eafeb4931c2cc79405cc052db4e Mon Sep 17 00:00:00 2001
From: Nicolas
Date: Thu, 20 Aug 2026 23:26:51 +0200
Subject: [PATCH 6/7] =?UTF-8?q?style(planning):=20dialogue=20de=20s=C3=A9l?=
=?UTF-8?q?ection=20encore=20un=20peu=20plus=20large?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
92vw/75rem -> 95vw/85rem — vérifié en direct, dialogue passe de 1178px à
1216px sur un viewport 1280px.
Co-Authored-By: Claude Sonnet 5
---
apps/web/src/features/planning/recipe-picker-dialog.scss | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/web/src/features/planning/recipe-picker-dialog.scss b/apps/web/src/features/planning/recipe-picker-dialog.scss
index b036e82..6e281fb 100644
--- a/apps/web/src/features/planning/recipe-picker-dialog.scss
+++ b/apps/web/src/features/planning/recipe-picker-dialog.scss
@@ -5,8 +5,8 @@
// RecipeTabs import it themselves).
.recipe-picker-dialog {
- width: 92vw;
- max-width: 75rem;
+ width: 95vw;
+ max-width: 85rem;
// Fixed, not just capped — `.dialog-panel`'s own `max-height` (dialog.scss)
// only bounds how tall a shrink-to-fit dialog can grow, which is right
// for every other dialog's small form but leaves this one's browsing
From 85fd9bae7d9da49119720911f7983c2728e8d493 Mon Sep 17 00:00:00 2001
From: Nicolas
Date: Fri, 21 Aug 2026 00:05:17 +0200
Subject: [PATCH 7/7] =?UTF-8?q?feat(planning):=20le=20picker=20pr=C3=A9vis?=
=?UTF-8?q?ualise=20avant=20de=20confirmer,=20formulaire=20de=20revue=20in?=
=?UTF-8?q?t=C3=A9gr=C3=A9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Trois ajustements successifs sur le dialogue de sélection de recette
(RecipePickerDialog), demandés en continu après le premier correctif
de débordement :
1. Dialogue élargi et à hauteur fixe (95vw plafonné à 85rem, 80vh) au
lieu de dépendre du contenu, avec la répartition liste/détail
redéfinie en fractions du dialogue lui-même (3fr/2fr) plutôt qu'en
vw — cette dernière suivait la largeur du viewport, sans rapport
avec la largeur désormais fixe du dialogue.
2. Le formulaire de revue d'import (ex-ImportRecipePage) est extrait
dans un composant partagé, RecipeImportForm — toujours monté en
page autonome (route directe/rechargement), mais désormais aussi
intégré comme une étape du dialogue lui-même quand un item de
source a besoin d'une résolution manuelle, au lieu de naviguer et
perdre le contexte du picker (recherche, filtres, créneau).
3. Cliquer sur une recette dans le dialogue ne fait plus que la
sélectionner/prévisualiser (RecipeDetailPanel, comme /recettes) —
plus de saut automatique vers l'étape suivante. Un nouveau pied de
dialogue (Dialog.tsx gagne une prop ) porte Confirmer/
Fermer : Confirmer agit sur la sélection en cours (recette réelle
→ étape portions existante ; item de source pas encore importé →
import transparent ou formulaire intégré, point 2). Les onglets
réguliers gagnent leur propre paire maître-détail (RecipeTable +
RecipeDetailPanel, showActions=false) sur ce même modèle ; les
onglets source prévisualisent désormais aussi les items déjà
importés en interne (RecipeSourcesPanel), plus de saut direct.
Cypress (planning.feature/planning.ts) mis à jour en conséquence :
sélectionner puis confirmer sont deux étapes distinctes, le clic sur
la ligne ne déclenche plus rien tout seul.
Bug pré-existant trouvé en testant en direct (sans rapport avec ce qui
précède) : l'import d'une recette source plante avec une contrainte
d'unicité Prisma dès que deux lignes d'ingrédient se résolvent au même
ingrédient catalogue — signalé séparément (tâche en arrière-plan), pas
corrigé ici.
Vérifié en direct (navigateur, comptes de test) : sélection sans saut
d'écran, pied de dialogue activé/désactivé correctement, Confirmer sur
un item de source non résolu bascule vers le formulaire intégré,
Fermer ferme bien le dialogue.
pnpm exec tsc -b --force (web) — propre.
pnpm exec biome check — propre.
pnpm --filter web build — propre.
Co-Authored-By: Claude Sonnet 5
---
apps/web/cypress/e2e/planning.feature | 22 +-
apps/web/cypress/e2e/planning.ts | 65 +--
apps/web/src/components/ui/Dialog.tsx | 4 +
apps/web/src/components/ui/dialog.scss | 10 +
.../features/planning/RecipePickerDialog.tsx | 291 ++++++++----
.../planning/recipe-picker-dialog.scss | 26 ++
.../features/recipes/RecipeDetailPanel.tsx | 29 +-
.../src/features/recipes/RecipeImportForm.tsx | 437 +++++++++++++++++
.../features/recipes/RecipeSourcesPanel.tsx | 67 ++-
apps/web/src/locales/fr/translation.json | 3 +-
apps/web/src/pages/ImportRecipePage.tsx | 438 +-----------------
apps/web/src/pages/RecipesPage.tsx | 4 +-
12 files changed, 777 insertions(+), 619 deletions(-)
create mode 100644 apps/web/src/features/recipes/RecipeImportForm.tsx
diff --git a/apps/web/cypress/e2e/planning.feature b/apps/web/cypress/e2e/planning.feature
index eaa7546..93796bb 100644
--- a/apps/web/cypress/e2e/planning.feature
+++ b/apps/web/cypress/e2e/planning.feature
@@ -13,9 +13,18 @@ Feature: Adding a recipe to the planning
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
+ And the planning request returns nothing
- Scenario: Adds a not-yet-imported source item to a planning slot, landing on the review screen since an ingredient needs resolving
+ Scenario: Selecting a source item only previews it, the footer's Confirmer is what acts on it
+ Given previewing TheMealDB item "9999" is available
+ When I visit "/"
+ And I click the add button for the first empty planning slot
+ And I click the button "TheMealDB"
+ And I click the source item "Fish Pie"
+ Then the recipe detail panel heading should be "Fish Pie"
+ And the URL should be the home page
+
+ Scenario: Confirming a not-yet-imported source item lands on the embedded review form since an ingredient needs resolving
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
@@ -23,6 +32,7 @@ Feature: Adding a recipe to the planning
And I click the add button for the first empty planning slot
And I click the button "TheMealDB"
And I click the source item "Fish Pie"
+ And I click the button "Confirmer"
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"
@@ -31,10 +41,10 @@ Feature: Adding a recipe to the planning
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 picker dialog should be closed
And the recipe "Fish Pie" should appear in the first planning slot with 4 portions
- Scenario: Adds a fully-resolved not-yet-imported item to a planning slot transparently, with no review screen at all
+ Scenario: Confirming a fully-resolved not-yet-imported item adds it to the planning transparently, with no review screen at all
Given previewing TheMealDB item "7777" is fully resolved as "Ratatouille"
And importing item "7777" will succeed and return id 100
And adding recipe 100 to the planning will succeed
@@ -42,6 +52,8 @@ Feature: Adding a recipe to the planning
And I click the add button for the first empty planning slot
And I click the button "TheMealDB"
And I click the source item "Ratatouille"
+ And I click the button "Confirmer"
Then the import request for "7777" should have included the name "Ratatouille"
And the planning add request should have included recipe 100, weekDay "lundi", meal "petit-dejeuner", and portions 4
- And the URL should be the home page
+ And the recipe picker dialog should be closed
+ And the recipe "Ratatouille" 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
index 2aadca6..b9ea1d3 100644
--- a/apps/web/cypress/e2e/planning.ts
+++ b/apps/web/cypress/e2e/planning.ts
@@ -13,13 +13,6 @@ import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
// 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: [] });
});
@@ -216,50 +209,26 @@ Given("importing the previewed item will succeed and return id {int}", (id: numb
});
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" },
- },
- });
+ cy.intercept("POST", "**/planning/items", {
+ 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,
- });
- });
+// Every scenario here confirms/imports without ever leaving "/" (the
+// footer's "Confirmer" patches the grid locally via `PlanningPage`'s own
+// `onAdded` — `RecipePickerDialog`'s doc comment — rather than navigating
+// away and back), so unlike a real cross-route remount, this fixture never
+// needs to reflect what's been added: the grid picks it up from local
+// state, not a fresh fetch.
+Then("the recipe picker dialog should be closed", () => {
+ cy.get(".dialog-panel").should("not.exist");
});
// The very first "+" in DOM order is Lundi's Petit-déjeuner cell (`MEALS`'s
diff --git a/apps/web/src/components/ui/Dialog.tsx b/apps/web/src/components/ui/Dialog.tsx
index fb312d9..c451f41 100644
--- a/apps/web/src/components/ui/Dialog.tsx
+++ b/apps/web/src/components/ui/Dialog.tsx
@@ -22,11 +22,14 @@ export function Dialog({
title,
children,
className,
+ footer,
}: {
onClose: () => void;
title?: string;
children: ReactNode;
className?: string;
+ /** Optional action bar pinned below the scrollable body (`.dialog-panel__footer`) — outside `.dialog-panel__body`'s own scroll, same idea as `title`'s header. Omit for a plain dialog with no persistent footer actions. */
+ footer?: ReactNode;
}) {
const dialogRef = useRef(null);
@@ -99,6 +102,7 @@ export function Dialog({
)}
{children}
+ {footer &&
{footer}
}
);
}
diff --git a/apps/web/src/components/ui/dialog.scss b/apps/web/src/components/ui/dialog.scss
index 1bb7dac..8e8b04e 100644
--- a/apps/web/src/components/ui/dialog.scss
+++ b/apps/web/src/components/ui/dialog.scss
@@ -61,3 +61,13 @@
overflow-y: auto;
padding: var(--space-lg);
}
+
+.dialog-panel__footer {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: var(--space-sm);
+ padding: var(--space-md) var(--space-lg);
+ border-top: 1px solid var(--color-border);
+}
diff --git a/apps/web/src/features/planning/RecipePickerDialog.tsx b/apps/web/src/features/planning/RecipePickerDialog.tsx
index 5177365..1f6cb9f 100644
--- a/apps/web/src/features/planning/RecipePickerDialog.tsx
+++ b/apps/web/src/features/planning/RecipePickerDialog.tsx
@@ -1,5 +1,4 @@
import {
- type BrowsableSourceItemView,
type DietView,
ErrorCode,
type IngredientView,
@@ -18,7 +17,9 @@ import { Dialog } from "../../components/ui/Dialog";
import { errorMessageService } from "../../services/error-message.service";
import { DietTagSelect } from "../recipes/DietTagSelect";
import { IngredientPicker } from "../recipes/IngredientPicker";
-import { RecipeSourcesPanel } from "../recipes/RecipeSourcesPanel";
+import { RecipeDetailPanel, type RecipeDetailState } from "../recipes/RecipeDetailPanel";
+import { RecipeImportForm } from "../recipes/RecipeImportForm";
+import { RecipeSourcesPanel, type SourceItemSelection } from "../recipes/RecipeSourcesPanel";
import { RecipeTable } from "../recipes/RecipeTable";
import {
RecipeTabs,
@@ -59,29 +60,36 @@ export interface PlanningSlot {
* (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. Each
- * household-enabled source's own tab is included too: picking an
- * already-imported item behaves exactly like picking a regular recipe,
- * and picking one that isn't imported yet is what actually imports it —
- * nowhere else in the app does (see `handleSelectDraftItem`) — since a
- * source item only ever becomes a real, saved `Recipe` as a side effect of
- * someone adding it to their planning.
+ * household-enabled source's own tab is included too.
*
* Mounted only while open (see `PlanningPage`, same conditional-mount
* convention as its own `CalendarPopover`) — every piece of local state
* below resets for free the next time it's reopened, no manual reset
* needed.
*
- * 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. Picking a not-yet-imported source item is
- * handled differently still (`handleSelectDraftItem`): when the draft has
- * everything a real recipe needs, it's imported and added to this slot
- * transparently — no extra screen, same as picking anything else. Only
- * when something's actually missing (an ingredient the automatic matcher
- * couldn't resolve, say) does this navigate away entirely, to the review
- * screen (`/recettes/importer/...`), which has its own portions field
- * already.
+ * Clicking a row only ever *selects* it — same "preview before you commit"
+ * shape for every kind of row: a regular tab's own master-detail pair
+ * (`RecipeTable` + a `RecipeDetailPanel` fetched here, mirroring
+ * `RecipesPage`'s own layout) fetches and previews a real recipe; a
+ * source tab's `RecipeSourcesPanel` already previews either kind of row it
+ * has (already-imported or not) inline, on its own. Nothing about a click
+ * commits to anything by itself — the pinned footer's "Confirmer" button
+ * (`handleFooterConfirm`) is what acts on whichever preview is currently
+ * pending (`previewedRecipe`/`previewedDraft`, mutually exclusive):
+ * - A real recipe (regular tab, or an already-imported source item) moves
+ * to the small "how many portions?" step (`selectedRecipe`), same as
+ * before this dialog grew a footer.
+ * - A not-yet-imported source item is what actually imports one — nowhere
+ * else in the app does (see `confirmDraftSelection`) — since a source
+ * item only ever becomes a real, saved `Recipe` as a side effect of
+ * someone adding it to their planning. When the draft has everything a
+ * real recipe needs, it's imported and added to this slot transparently
+ * — no extra screen. Only when something's actually missing (an
+ * ingredient the automatic matcher couldn't resolve, say) does this
+ * switch to a third step instead, embedding the full review form
+ * (`RecipeImportForm`) right in this same dialog rather than navigating
+ * away to `ImportRecipePage` and losing the picker's own context (search
+ * term, filters, which slot this even was).
*/
export function RecipePickerDialog({
slot,
@@ -97,6 +105,14 @@ export function RecipePickerDialog({
const [activeTab, setActiveTab] = useState("favoris");
const activeSourceKey = parseSourceTabValue(activeTab);
+
+ /** Switching tabs drops whatever was previewed/pending on the one just left — a stale "Confirmer" target from a different tab would be confusing at best. */
+ function handleTabChange(tab: RecipesPageTab) {
+ setActiveTab(tab);
+ setPreviewedRecipe(null);
+ setPreviewedDraft(null);
+ setRegularPreviewState({ status: "empty" });
+ }
const enabledSources = useEnabledSources();
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
@@ -109,20 +125,37 @@ 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);
- // True while `handleSelectDraftItem` below is resolving a not-yet-
- // imported item's transparent-import attempt — replaces the source tab's
- // whole panel with a status message for that brief window (fetch the
- // draft, maybe import it, maybe add it to the slot) rather than leaving
- // the browse list clickable mid-flight.
- const [isAddingDraft, setIsAddingDraft] = useState(false);
+ // The regular tabs' own master-detail pair — `RecipeTable` on the left,
+ // this on the right, fetched on row click (mirrors `RecipesPage`'s
+ // identical layout). Source tabs don't use this at all: `RecipeSourcesPanel`
+ // previews its own rows internally.
+ const [regularPreviewState, setRegularPreviewState] = useState({
+ status: "empty",
+ });
+ // Which real recipe is the pending selection — from either the regular
+ // tabs' own preview above, or a source tab's already-imported row
+ // (`RecipeSourcesPanel`'s `onSelectImportedRecipe`, which already
+ // previewed it internally). Mutually exclusive with `previewedDraft`
+ // below; the footer's "Confirmer" (`handleFooterConfirm`) acts on
+ // whichever one is set.
+ const [previewedRecipe, setPreviewedRecipe] = useState(null);
+ // Which not-yet-imported source item is the pending selection —
+ // `RecipeSourcesPanel`'s `onDraftSelected`, fired the moment such a row
+ // is clicked (it previews itself internally; this is just "which one").
+ const [previewedDraft, setPreviewedDraft] = useState(null);
+ // True while `confirmDraftSelection` below is resolving the footer's
+ // "Confirmer" for a pending draft (fetch it, maybe import it, maybe add
+ // it to the slot) — disables the footer for that brief window rather
+ // than allowing a second click mid-flight.
+ const [isConfirmingDraft, setIsConfirmingDraft] = useState(false);
+ // Set by `confirmDraftSelection`'s fallback when the pending draft needs
+ // a person's input before it can be imported — switches this whole
+ // dialog to its third step (see the top-level `if` below), embedding
+ // `RecipeImportForm` instead of showing it inline here.
+ const [reviewDraftItem, setReviewDraftItem] = useState(null);
- // The recipe picked in step 1 — `null` while still browsing, set once a
- // row is clicked to switch this dialog into its confirmation step.
+ // The recipe the footer's "Confirmer" moved to this small step for —
+ // `null` while still browsing.
const [selectedRecipe, setSelectedRecipe] = useState(null);
const [portions, setPortions] = useState("1");
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -182,64 +215,84 @@ export function RecipePickerDialog({
selectedIngredientIds.includes(ingredient.id),
);
- /** Picking an already-imported source item (one of the source tabs' `RecipeSourcesPanel`) — 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);
+ /** A regular tab's own row click — fetches the full recipe and previews it in this dialog's own master-detail pair, exactly like `RecipesPage` does. */
+ function handleSelectRegularRecipe(id: number) {
+ setPreviewedDraft(null);
+ setRegularPreviewState({ status: "loading" });
apiClient
- .getRecipe(recipeId)
+ .getRecipe(id)
.then((recipe) => {
- setSelectedRecipe(recipe);
- setPortions(String(recipe.portions));
+ setRegularPreviewState({ status: "loaded", recipe });
+ setPreviewedRecipe(recipe);
})
- .catch(() => setSourceSelectError(true));
+ .catch(() => setRegularPreviewState({ status: "error" }));
}
- /** Navigates away to the full review/creation screen, pre-filled from this exact item and carrying `slot` along so a successful import there adds straight to it — the fallback `handleSelectDraftItem` below takes whenever a transparent import isn't possible. */
- function goToReviewScreen(sourceKey: string, externalId: string) {
- navigate(
- `/recettes/importer/${sourceKey}/${encodeURIComponent(externalId)}` +
- `?planningDate=${slot.date}&planningWeekDay=${slot.weekDay}&planningMeal=${slot.meal}`,
- );
+ /** A source tab's already-imported row — `RecipeSourcesPanel` already fetched and is previewing it itself; this just records it as the pending selection. */
+ function handleSelectImportedRecipe(recipe: RecipeView) {
+ setPreviewedDraft(null);
+ setPreviewedRecipe(recipe);
+ }
+
+ /** A source tab's not-yet-imported row — `RecipeSourcesPanel` previews it itself; this just records it as the pending selection. */
+ function handleDraftSelected(selection: SourceItemSelection) {
+ setPreviewedRecipe(null);
+ setPreviewedDraft(selection);
}
/**
- * Picking a not-yet-imported source item — the one action in the whole
- * app that actually imports one (see this component's own doc comment).
- * Fetches its full draft, and when {@link tryBuildCompleteImport} finds
- * nothing missing, imports it and adds it to `slot` transparently: no
- * extra screen, same end result as picking any other recipe. Anything
- * short of that — an unresolved ingredient, a network hiccup on any of
- * these three calls — falls back to the full review screen
- * (`goToReviewScreen`) instead, since only a person can supply what's
- * actually missing.
+ * The footer's "Confirmer" — acts on whichever preview is currently
+ * pending. A real recipe moves to the small portions step below; a
+ * not-yet-imported draft runs {@link confirmDraftSelection}.
*/
- async function handleSelectDraftItem(item: BrowsableSourceItemView) {
- if (activeSourceKey === null) return;
- const sourceKey = activeSourceKey;
- setSourceSelectError(false);
- setIsAddingDraft(true);
+ function handleFooterConfirm() {
+ if (previewedRecipe) {
+ setSelectedRecipe(previewedRecipe);
+ setPortions(String(previewedRecipe.portions));
+ return;
+ }
+ if (previewedDraft) {
+ void confirmDraftSelection(previewedDraft);
+ }
+ }
+
+ /**
+ * Confirming a not-yet-imported source item — the one action in the
+ * whole app that actually imports one (see this component's own doc
+ * comment). Fetches its full draft, and when {@link tryBuildCompleteImport}
+ * finds nothing missing, imports it and adds it to `slot` transparently:
+ * no extra screen, same end result as picking any other recipe. Anything
+ * short of that — an unresolved ingredient, a network hiccup on any of
+ * these three calls — switches to the embedded review-form step instead
+ * (`setReviewDraftItem`), since only a person can supply what's actually
+ * missing.
+ */
+ async function confirmDraftSelection(selection: SourceItemSelection) {
+ const { sourceKey, externalId } = selection;
+ setIsConfirmingDraft(true);
+
+ function needsReview() {
+ setIsConfirmingDraft(false);
+ setReviewDraftItem({ sourceKey, externalId });
+ }
let payload: ReturnType;
try {
- payload = tryBuildCompleteImport(
- await apiClient.previewSourceItem(sourceKey, item.externalId),
- );
+ payload = tryBuildCompleteImport(await apiClient.previewSourceItem(sourceKey, externalId));
} catch {
- goToReviewScreen(sourceKey, item.externalId);
+ needsReview();
return;
}
if (!payload) {
- setIsAddingDraft(false);
- goToReviewScreen(sourceKey, item.externalId);
+ needsReview();
return;
}
let saved: RecipeView;
try {
- saved = await apiClient.importSourceItem(sourceKey, item.externalId, payload);
+ saved = await apiClient.importSourceItem(sourceKey, externalId, payload);
} catch {
- setIsAddingDraft(false);
- goToReviewScreen(sourceKey, item.externalId);
+ needsReview();
return;
}
@@ -256,8 +309,10 @@ export function RecipePickerDialog({
} catch {
// The recipe itself is already saved at this point — only adding it
// to this slot failed. Land on its own page rather than retrying the
- // whole import through the review form (same fallback
- // `ImportRecipePage`'s own submit takes for the identical failure).
+ // whole import (same fallback `RecipeImportForm`'s own submit takes
+ // for the identical failure — see this dialog's `onImported` handler
+ // below).
+ setIsConfirmingDraft(false);
navigate(`/recettes/${saved.id}`);
}
}
@@ -286,6 +341,34 @@ export function RecipePickerDialog({
}
}
+ if (reviewDraftItem) {
+ return (
+
+ );
+ }
+
if (selectedRecipe) {
return (