batchCooking/apps/web/src/features/recipes/RecipeSourcesPanel.tsx
Nicolas 368ea08960 feat(recipes): unifie l'affichage des recettes externes avec les recettes réelles
Suite au retour utilisateur sur le plan « onglet Sources » livré
précédemment (#44-#48) : navigation transparente, page recette pour un
item externe, comportement d'ajout au planning déjà importé.

L'onglet « Sources » (RecipesPage) reste un onglet à part (décision
explicite : pas de fusion des listes perso/foyer/publique/externe) —
mais son affichage se comporte désormais « comme si c'était importé » :

- `SourceItemPreviewPanel` est supprimé, fusionné dans
  `RecipeDetailPanel` lui-même (nouvel état `"loaded-draft"`) : un item
  pas encore importé se voit exactement comme une vraie recette — même
  en-tête, même mise en page description/étapes — la seule différence
  étant les actions proposées (« Importer cette recette » là où une
  vraie recette montre Modifier/Supprimer). La liste brute des
  ingrédients et l'indice « non résolu » disparaissent de cette vue :
  cette complexité reste réservée à l'écran de revue d'import
  (ImportRecipePage), pas à un simple aperçu.
- Un item pas encore importé gagne une vraie URL adressable —
  `/recettes/sources/:sourceKey/:externalId` (nouvelle route,
  RecipesPage) — au même titre qu'une vraie recette a `/recettes/:id`.
  Avant, le sélectionner ne changeait que de l'état React local dans
  `RecipeSourcesPanel`, sans URL propre : ni lien direct, ni retour
  arrière/rafraîchissement possibles. `RecipeSourcesPanel` gagne
  `initialSelection`/`onItemSelected` pour rester piloté par cette URL
  sans avoir à connaître le routage lui-même — `RecipePickerDialog`
  (qui prévisualise dans une modale sans URL propre) laisse les deux
  non renseignés et garde son comportement inchangé.
- `onSelectImportedRecipe` (déjà présent) continue de traiter un item
  déjà importé exactement comme une vraie recette — c'est justement ce
  qui rend la navigation transparente pour ce cas.

Le troisième point du retour (vérifier si la recette est déjà en base
avant de l'ajouter au planning, ne rien faire si oui, l'importer sinon)
était déjà le comportement de #48 — inchangé ici, aucune régression:
`RecipePickerDialog` résout un item déjà importé vers sa vraie recette
sans ré-import, et n'importe que les items qui ne le sont pas encore.

Aucun changement backend.

Tests :
- Cypress : nouvelle assertion d'URL dans le scénario « Previews a
  not-yet-imported item » de recipe-sources.feature, et nouveau
  scénario « Deep-links straight to a not-yet-imported item's own
  page » — la CI confirmera.
- `pnpm --filter api test` — 282 tests toujours au vert (aucun
  changement backend).
- `pnpm exec tsc -b --force` (web) — propre.
- `pnpm exec biome check` — propre.
- `pnpm -r build` — propre.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 19:30:35 +02:00

304 lines
13 KiB
TypeScript

import type { BrowsableSourceItemView, Meal, SourceView, WeekDay } from "@batch-cooking/shared";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { apiClient } from "../../api/client";
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[] }
| { status: "error" };
type BrowseState =
| { status: "loading" }
| { status: "loaded"; items: BrowsableSourceItemView[]; nextCursor: string | null }
| { status: "error" };
/**
* "Sources" tab content of the recipe catalog (`RecipesPage`) — a
* self-contained master-detail pair of its own (source selector + browsable
* 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) — `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.
*
* `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 `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<EnabledSourcesState>({ status: "loading" });
const [selectedSourceKey, setSelectedSourceKey] = useState<string | null>(
initialSelection?.sourceKey ?? null,
);
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [browseState, setBrowseState] = useState<BrowseState>({ status: "loading" });
const [selectedExternalId, setSelectedExternalId] = useState<string | null>(
initialSelection?.externalId ?? null,
);
const [previewState, setPreviewState] = useState<RecipeDetailState>({ 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<SourceItemSelection | null>(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 — 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()])
.then(([sources, enabledIds]) => {
if (cancelled) return;
const enabled = sources.filter((source) => enabledIds.includes(source.id));
setEnabledSources({ status: "loaded", sources: enabled });
setSelectedSourceKey((current) => current ?? enabled[0]?.key ?? null);
})
.catch(() => {
if (!cancelled) setEnabledSources({ status: "error" });
});
return () => {
cancelled = true;
};
}, []);
// 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" });
apiClient
.browseSource(selectedSourceKey, { query: debouncedSearch.trim() || undefined })
.then(({ items, nextCursor }) => {
if (!cancelled) setBrowseState({ status: "loaded", items, nextCursor });
})
.catch(() => {
if (!cancelled) setBrowseState({ status: "error" });
});
return () => {
cancelled = true;
};
}, [selectedSourceKey, debouncedSearch]);
function handleLoadMore() {
if (selectedSourceKey === null || browseState.status !== "loaded" || !browseState.nextCursor) {
return;
}
const cursor = browseState.nextCursor;
apiClient
.browseSource(selectedSourceKey, { query: debouncedSearch.trim() || undefined, cursor })
.then(({ items, nextCursor }) => {
setBrowseState((prev) =>
prev.status === "loaded"
? { status: "loaded", items: [...prev.items, ...items], nextCursor }
: prev,
);
})
.catch(() => setBrowseState({ status: "error" }));
}
function handleSelectItem(item: BrowsableSourceItemView) {
if (item.alreadyImported && item.recipeId !== null) {
onSelectImportedRecipe(item.recipeId);
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", draft }))
.catch(() => setPreviewState({ status: "error" }));
}
if (enabledSources.status === "loading") {
return <p className="recipes-page__status">{t("recipes.loading")}</p>;
}
if (enabledSources.status === "error") {
return (
<p className="recipes-page__status recipes-page__status--error">{t("common.loadError")}</p>
);
}
if (enabledSources.sources.length === 0) {
return (
<p className="recipes-page__status">
{t("recipes.sources.noneEnabled")}{" "}
<Link to="/parametres/foyer">{t("recipes.sources.noneEnabledLink")}</Link>
</p>
);
}
return (
<>
<div className="recipes-page__header recipes-page__header--sources">
{enabledSources.sources.length > 1 && (
<select
className="source-sources-select"
aria-label={t("recipes.sources.sourceLabel")}
value={selectedSourceKey ?? ""}
onChange={(e) => {
setSelectedSourceKey(e.target.value);
setSelectedExternalId(null);
setPreviewState({ status: "empty" });
setPreviewedItem(null);
onItemSelected?.(null);
}}
>
{enabledSources.sources.map((source) => (
<option key={source.key} value={source.key}>
{source.name}
</option>
))}
</select>
)}
<input
type="search"
className="recipes-page__search"
placeholder={t("recipes.sources.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="recipes-page__catalog">
{browseState.status === "loading" && (
<p className="recipes-page__status">{t("recipes.sources.loading")}</p>
)}
{browseState.status === "error" && (
<p className="recipes-page__status recipes-page__status--error">
{t("recipes.sources.loadError")}
</p>
)}
{browseState.status === "loaded" && browseState.items.length === 0 && (
<p className="recipes-page__status">{t("recipes.sources.empty")}</p>
)}
{browseState.status === "loaded" && browseState.items.length > 0 && (
<div className="source-items-column">
<SourceItemTable
items={browseState.items}
selectedExternalId={selectedExternalId}
onSelect={handleSelectItem}
/>
{browseState.nextCursor && (
<button type="button" className="source-items-load-more" onClick={handleLoadMore}>
{t("recipes.sources.loadMore")}
</button>
)}
</div>
)}
<RecipeDetailPanel state={previewState} planningSlot={planningSlot} />
</div>
</>
);
}