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) => (