diff --git a/apps/web/cypress/e2e/recipe-sources.feature b/apps/web/cypress/e2e/recipe-sources.feature
index 9fc8a56..483f490 100644
--- a/apps/web/cypress/e2e/recipe-sources.feature
+++ b/apps/web/cypress/e2e/recipe-sources.feature
@@ -42,9 +42,20 @@ Feature: Browsing external recipe sources
When I visit "/recettes"
And I click the button "Sources"
And I click the source item "Fish Pie"
- Then the recipe detail panel heading should be "Fish Pie"
+ Then the URL should include "/recettes/sources/theMealDb/9999"
+ 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
+ Given the recipe catalog contains nothing
+ And the sources reference list has options
+ And the household has enabled TheMealDB
+ And browsing TheMealDB returns some items
+ And previewing TheMealDB item "9999" is available
+ When I visit "/recettes/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
diff --git a/apps/web/cypress/support/step_definitions/common.steps.ts b/apps/web/cypress/support/step_definitions/common.steps.ts
index a4ac86a..8084ff2 100644
--- a/apps/web/cypress/support/step_definitions/common.steps.ts
+++ b/apps/web/cypress/support/step_definitions/common.steps.ts
@@ -133,10 +133,10 @@ When("I scroll to the section {string}", (legend: string) => {
cy.contains("legend", legend).scrollIntoView();
});
-// `.recipe-detail-panel` is used by both a saved recipe's real detail
-// (RecipeDetailPanel) and an unsaved source item's read-only preview
-// (SourceItemPreviewPanel) — recipes.feature and recipe-sources.feature
-// both need this.
+// `.recipe-detail-panel` is used by both a saved recipe's real detail and
+// an unsaved source item's read-only preview (RecipeDetailPanel's
+// `"loaded"`/`"loaded-draft"` states, same component for both) —
+// recipes.feature and recipe-sources.feature both need this.
Then("the recipe detail panel heading should be {string}", (text: string) => {
cy.get(".recipe-detail-panel").contains("h2", text).should("be.visible");
});
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 206e7c3..88911dd 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -61,6 +61,7 @@ export function App() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/apps/web/src/features/recipes/RecipeDetailPanel.tsx b/apps/web/src/features/recipes/RecipeDetailPanel.tsx
index 4585041..479b036 100644
--- a/apps/web/src/features/recipes/RecipeDetailPanel.tsx
+++ b/apps/web/src/features/recipes/RecipeDetailPanel.tsx
@@ -1,4 +1,10 @@
-import { ErrorCode, type RecipeView } from "@batch-cooking/shared";
+import {
+ ErrorCode,
+ type Meal,
+ type RecipeImportDraftView,
+ type RecipeView,
+ type WeekDay,
+} from "@batch-cooking/shared";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
@@ -9,11 +15,22 @@ import { FavoriteStarButton } from "./FavoriteStarButton";
import { StepDescription } from "./StepDescription";
import "./recipes.scss";
-/** State {@link RecipeDetailPanel} renders — `"empty"` (no row selected yet) is distinct from `"not-found"` (a selected id that turned out invalid/inaccessible), each with its own message. */
+/**
+ * State {@link RecipeDetailPanel} renders — `"empty"` (no row selected yet)
+ * 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*).
+ */
export type RecipeDetailState =
| { status: "empty" }
| { status: "loading" }
| { status: "loaded"; recipe: RecipeView }
+ | { status: "loaded-draft"; draft: RecipeImportDraftView }
| { status: "not-found" }
| { status: "error" };
@@ -24,18 +41,29 @@ export type RecipeDetailState =
* *viewer's* personal taste-preference list (`GET
* /profile/disliked-ingredients`) — crossed here against this recipe's own
* ingredients to surface just the ones relevant to it, not the viewer's
- * whole list.
+ * whole list. `onFavoriteToggled`/`onDeleted` are optional — only the
+ * `"loaded"` (real recipe) branch ever calls them; callers that only ever
+ * pass `"loaded-draft"`/other states (`RecipeSourcesPanel`) can omit them.
*/
export function RecipeDetailPanel({
state,
- dislikedIngredientIds,
+ dislikedIngredientIds = [],
onFavoriteToggled,
onDeleted,
+ planningSlot,
}: {
state: RecipeDetailState;
- dislikedIngredientIds: number[];
- onFavoriteToggled: (recipeId: number, isFavorite: boolean) => void;
- onDeleted: (recipeId: number) => void;
+ 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();
@@ -72,6 +100,70 @@ export function RecipeDetailPanel({
);
}
+ if (state.status === "loaded-draft") {
+ const { draft } = state;
+ return (
+
+ );
+ }
+
const { recipe } = state;
const dislikedIngredients = recipe.ingredients
.map((line) => line.ingredient)
@@ -86,7 +178,7 @@ export function RecipeDetailPanel({
onFavoriteToggled(recipe.id, isFavorite)}
+ onToggled={(isFavorite) => onFavoriteToggled?.(recipe.id, isFavorite)}
/>
@@ -115,7 +207,7 @@ export function RecipeDetailPanel({
{t("recipes.editButton")}
- onDeleted(recipe.id)} />
+ onDeleted?.(recipe.id)} />
{recipe.description && (
diff --git a/apps/web/src/features/recipes/RecipeSourcesPanel.tsx b/apps/web/src/features/recipes/RecipeSourcesPanel.tsx
index 80d609e..3717448 100644
--- a/apps/web/src/features/recipes/RecipeSourcesPanel.tsx
+++ b/apps/web/src/features/recipes/RecipeSourcesPanel.tsx
@@ -3,13 +3,19 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { apiClient } from "../../api/client";
-import { SourceItemPreviewPanel, type SourceItemPreviewState } from "./SourceItemPreviewPanel";
+import { RecipeDetailPanel, type RecipeDetailState } from "./RecipeDetailPanel";
import { SourceItemTable } from "./SourceItemTable";
import "./recipes.scss";
/** Debounce for the search field — same idea/value as `RecipesPage`'s own search. */
const SEARCH_DEBOUNCE_MS = 300;
+/** One item's identity within a source's browsable catalog — `sourceKey` + `externalId` together, since `externalId` alone is only unique per source. */
+export interface SourceItemSelection {
+ sourceKey: string;
+ externalId: string;
+}
+
type EnabledSourcesState =
| { status: "loading" }
| { status: "loaded"; sources: SourceView[] }
@@ -23,48 +29,77 @@ type BrowseState =
/**
* "Sources" tab content of the recipe catalog (`RecipesPage`) — a
* self-contained master-detail pair of its own (source selector + browsable
- * list on the left, `SourceItemPreviewPanel` on the right), independent of
- * `RecipeTable`/`RecipeDetailPanel`: it browses a source's *live* catalog
- * (`GET /sources/:sourceKey/browse`), not the saved `Recipe` table, so it
- * doesn't share their `RecipeTab`-based fetching at all.
+ * list on the left, a preview on the right), independent of `RecipeTable`'s
+ * own `RecipeTab`-based fetching: it browses a source's *live* catalog
+ * (`GET /sources/:sourceKey/browse`), not the saved `Recipe` table.
+ *
+ * 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).
*
* Selecting an already-imported item navigates straight to its real
- * recipe (`/recettes/:id`, leaving this tab) — selecting one that isn't
- * imported yet shows a read-only preview here instead. Turning that
- * preview into an actual saved recipe (reviewing/fixing unresolved
- * ingredients first) is a later stage of the same plan, not built here.
+ * recipe (`/recettes/:id`, leaving this tab) — `onSelectImportedRecipe`
+ * hands back the id instead of this panel navigating anywhere itself, since
+ * 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.
*
- * `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.
+ * `initialSelection`/`onItemSelected` are how `RecipesPage` keeps a
+ * not-yet-imported item's preview addressable by URL
+ * (`/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.
*/
export function RecipeSourcesPanel({
onSelectImportedRecipe,
planningSlot,
+ initialSelection,
+ onItemSelected,
}: {
onSelectImportedRecipe: (recipeId: number) => void;
- /** Forwarded as-is to `SourceItemPreviewPanel` — see its own doc comment. Only ever set by `RecipePickerDialog`. */
+ /** Forwarded as-is to `RecipeDetailPanel` — see its own doc comment. Only ever set by `RecipePickerDialog`. */
planningSlot?: { date: string; weekDay: WeekDay; meal: Meal };
+ initialSelection?: SourceItemSelection;
+ onItemSelected?: (item: SourceItemSelection | null) => void;
}) {
const { t } = useTranslation();
const [enabledSources, setEnabledSources] = useState({ status: "loading" });
- const [selectedSourceKey, setSelectedSourceKey] = useState(null);
+ const [selectedSourceKey, setSelectedSourceKey] = useState(
+ initialSelection?.sourceKey ?? null,
+ );
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [browseState, setBrowseState] = useState({ status: "loading" });
- const [selectedExternalId, setSelectedExternalId] = useState(null);
- const [previewState, setPreviewState] = useState({ status: "empty" });
+ const [selectedExternalId, setSelectedExternalId] = useState(
+ initialSelection?.externalId ?? null,
+ );
+ const [previewState, setPreviewState] = useState({ status: "empty" });
+ // Which item `previewState` actually reflects (or is in flight for) —
+ // lets the `initialSelection` effect below tell "the URL just changed to
+ // match a selection this component already made itself" (a row click
+ // already fetched/is fetching this exact item; `onItemSelected` only
+ // round-trips that same pair back in as a new `initialSelection` prop)
+ // apart from "the URL changed to point somewhere new" (a deep link, or
+ // the browser's back/forward button) — only the latter needs a fetch.
+ // Always starts at `null`, even when `initialSelection` is already set on
+ // mount — nothing has been fetched yet at that point, that's exactly the
+ // "needs a fetch" case the effect below must still run for.
+ const [previewedItem, setPreviewedItem] = useState(null);
// Loaded once — which sources exist, crossed with which the household
// has enabled (`/parametres/foyer`). Defaults the selector to the first
- // enabled one, if any.
+ // enabled one, if any — but never overrides a source `initialSelection`
+ // already picked (the `current ??` below), so a deep link always wins.
useEffect(() => {
let cancelled = false;
Promise.all([apiClient.getSources(), apiClient.getHouseSourceIds()])
@@ -82,17 +117,58 @@ export function RecipeSourcesPanel({
};
}, []);
+ // Re-previews whenever `initialSelection` itself changes (a fresh deep
+ // link, or the browser's back/forward button landing on a different
+ // item) — not just once on mount. Deliberately doesn't touch
+ // `browseState`/the source dropdown beyond `selectedSourceKey` above: the
+ // item list for whichever source this belongs to loads independently
+ // (see the effect below), on its own schedule. Keyed on the primitive
+ // fields below, not `initialSelection` itself — a fresh object literal
+ // from the caller on every render (see `RecipesPage`) would otherwise
+ // re-run this on every render too.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: see above.
+ useEffect(() => {
+ if (!initialSelection) return;
+ if (
+ previewedItem?.sourceKey === initialSelection.sourceKey &&
+ previewedItem?.externalId === initialSelection.externalId
+ ) {
+ return;
+ }
+ let cancelled = false;
+ setPreviewedItem(initialSelection);
+ setSelectedSourceKey(initialSelection.sourceKey);
+ setSelectedExternalId(initialSelection.externalId);
+ setPreviewState({ status: "loading" });
+ apiClient
+ .previewSourceItem(initialSelection.sourceKey, initialSelection.externalId)
+ .then((draft) => {
+ if (!cancelled) setPreviewState({ status: "loaded-draft", draft });
+ })
+ .catch(() => {
+ if (!cancelled) setPreviewState({ status: "error" });
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [initialSelection?.sourceKey, initialSelection?.externalId]);
+
useEffect(() => {
const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS);
return () => window.clearTimeout(timeout);
}, [search]);
+ // Only manages `browseState` — deliberately doesn't reset the current
+ // item selection/preview when `selectedSourceKey` changes, so that the
+ // `initialSelection` effect above (which also sets `selectedSourceKey`,
+ // to reflect a deep link) isn't immediately undone by this one running
+ // straight after it in the same commit. Switching source via the
+ // dropdown clears the selection explicitly, in its own `onChange` below,
+ // where that reset is actually wanted.
useEffect(() => {
if (selectedSourceKey === null) return;
let cancelled = false;
setBrowseState({ status: "loading" });
- setSelectedExternalId(null);
- setPreviewState({ status: "empty" });
apiClient
.browseSource(selectedSourceKey, { query: debouncedSearch.trim() || undefined })
@@ -131,11 +207,17 @@ export function RecipeSourcesPanel({
return;
}
if (selectedSourceKey === null) return;
+ const selection = { sourceKey: selectedSourceKey, externalId: item.externalId };
setSelectedExternalId(item.externalId);
setPreviewState({ status: "loading" });
+ // Set before `onItemSelected` so the `initialSelection` effect above
+ // recognizes the URL change it triggers as reflecting this same fetch,
+ // not a fresh one to make (see that effect's own doc comment).
+ setPreviewedItem(selection);
+ onItemSelected?.(selection);
apiClient
.previewSourceItem(selectedSourceKey, item.externalId)
- .then((draft) => setPreviewState({ status: "loaded", draft }))
+ .then((draft) => setPreviewState({ status: "loaded-draft", draft }))
.catch(() => setPreviewState({ status: "error" }));
}
@@ -164,7 +246,13 @@ export function RecipeSourcesPanel({
className="source-sources-select"
aria-label={t("recipes.sources.sourceLabel")}
value={selectedSourceKey ?? ""}
- onChange={(e) => setSelectedSourceKey(e.target.value)}
+ onChange={(e) => {
+ setSelectedSourceKey(e.target.value);
+ setSelectedExternalId(null);
+ setPreviewState({ status: "empty" });
+ setPreviewedItem(null);
+ onItemSelected?.(null);
+ }}
>
{enabledSources.sources.map((source) => (