batchCooking/apps/web/src/features/recipes/sources/RecipeSourcesPanel.tsx
Nicolas c48625cb0c
Some checks failed
CI / lint (push) Successful in 3m0s
CI / build (push) Successful in 2m58s
CI / intent-service-test (push) Successful in 4m30s
CI / e2e (push) Failing after 7m41s
CI / test (push) Has been cancelled
fix(recipes): stoppe le scroll infini des sources au lieu de boucler
Deux volets de l'issue #38.

**Bug de bouclage** — l'`IntersectionObserver` de `SourceItemTable` n'a
que `[hasMore]` comme dépendance et ne se réabonne donc jamais pendant un
scroll normal (`hasMore` reste `true`). Il figeait la toute première
closure `onLoadMore`, laquelle rappelait indéfiniment le même curseur
périmé — la liste s'allongeait en boucle avec les mêmes éléments et
`nextCursor` n'atteignait jamais son vrai `null`.
- `SourceItemTable` lit maintenant `onLoadMore` via une ref réassignée à
  chaque rendu : l'observer appelle toujours la closure fraîche, sans
  réabonnement inutile (intention d'origine du `biome-ignore` conservée,
  le commentaire n'étant plus nécessaire).
- `RecipeSourcesPanel` dédoublonne le merge par `externalId` et traite
  « cette page n'a rien apporté de nouveau » comme une fin de liste même
  si la source renvoie encore un `nextCursor` — garde-fou contre une
  éventuelle heuristique de fin imparfaite côté adaptateur.

**Couverture recherche vide** — `theMealDbAdapter.list()` tapait
`/search.php?s=` même sans requête, ce que TheMealDB borne à ~25 recettes
(`nextCursor` toujours `null`). Une requête vide déroule désormais la
liste des catégories (`/list.php?c=list` puis `/filter.php?c=…`), une
catégorie par pas de curseur, pour un vrai « tout parcourir ».

Tests : nouveau scénario Cypress à 3 pages avec curseurs distincts
réellement vérifiés (aucun curseur redemandé) ; nouveaux cas Mocha pour
la marche par catégories (première page + curseur, suivi du curseur,
arrêt sur la dernière catégorie, page vide au-delà, requête blanche =
requête vide).

Closes #38

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 20:20:43 +02:00

345 lines
16 KiB
TypeScript

import type { BrowsableSourceItemView, RecipeView } from "@batch-cooking/shared";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
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;
/** How many pulsing skeleton rows `handleLoadMore` shows while its fetch is in flight — see `loadMoreStatus`. Not tied to any source's real page size (that varies per source, and isn't known client-side); just enough to visibly fill the gap below the list without over-promising. */
const LOAD_MORE_PLACEHOLDER_COUNT = 4;
/** One page of `sourceKey`'s browsable catalog, as returned by `apiClient.browseSource`. */
interface BrowsePage {
items: BrowsableSourceItemView[];
nextCursor: string | null;
}
/** 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 BrowseState =
| { status: "loading" }
| { status: "loaded"; items: BrowsableSourceItemView[]; nextCursor: string | null }
| { status: "error" };
/**
* One household-enabled source's own tab content in the recipe catalog
* (`RecipesPage`/`RecipePickerDialog`) — a master-detail pair of its own
* (browsable list on the left, a preview on the right), independent of
* `RecipeTable`'s own `RecipeTab`-based fetching: it browses this one
* source's *live* catalog (`GET /sources/:sourceKey/browse`), not the
* saved `Recipe` table.
*
* Scoped to exactly one source — every enabled source gets its own tab
* now (`RecipeTabs`), rather than a single generic "Sources" tab
* switching between them internally, so `sourceKey` is a fixed prop, not
* something this component ever changes itself. Callers remount this
* (via a React `key={sourceKey}` on it) when switching which source's tab
* is active, the same "mounted only while relevant" convention as
* `RecipePickerDialog`/`CalendarPopover` elsewhere — simpler than this
* component reacting to its own `sourceKey` prop changing mid-lifetime.
*
* The right-hand preview reuses `RecipeDetailPanel` itself — for a
* not-yet-imported item, its `"loaded-draft"` state; for an already-
* imported one, this panel fetches the real thing (`GET /recipes/:id`)
* and shows it through the exact same `"loaded"` state `RecipesPage` uses,
* `showActions={false}` since editing/deleting isn't a click that belongs
* on a browsing/preview screen. Either way, selecting a row only ever
* previews here — nothing about clicking one imports, saves, or navigates
* by itself; what a selection *means* is entirely up to the caller
* (`onSelectImportedRecipe`/`onDraftSelected` below just report which one
* is currently previewed).
*
* `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 dialog
* has no URL of its own to keep in sync.
*/
export function RecipeSourcesPanel({
sourceKey,
onSelectImportedRecipe,
onDraftSelected,
initialSelection,
onItemSelected,
}: {
sourceKey: string;
/** Fired once an already-imported row's own fetch resolves — the full recipe, already what this panel is itself previewing, handed up so the caller (`RecipePickerDialog`) knows a real recipe is now the pending selection without fetching it again itself. */
onSelectImportedRecipe: (recipe: RecipeView) => void;
/** Fired the moment a not-yet-imported row is clicked (before its own preview fetch even resolves) — same "which one is pending" role as `onSelectImportedRecipe`, just for a draft instead of a real recipe. Only `RecipePickerDialog` sets this; `RecipesPage` has nothing to do with "pending" since browsing there is never building up to a confirm step. */
onDraftSelected?: (selection: SourceItemSelection) => void;
initialSelection?: SourceItemSelection;
onItemSelected?: (item: SourceItemSelection | null) => void;
}) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [browseState, setBrowseState] = useState<BrowseState>({ status: "loading" });
const [loadMoreStatus, setLoadMoreStatus] = useState<"idle" | "loading" | "error">("idle");
// The *next* page, fetched ahead of time as soon as the current one is on
// screen (see the effect below) — a ref, not state, since it's an
// implementation detail `handleLoadMore` consumes, never itself rendered.
// Keyed on exactly what makes a prefetch valid to reuse (source/query/
// cursor all matching) rather than just "is something in flight", so a
// stale prefetch from before a search/source change is never mistaken for
// the page that's actually needed next.
const nextPagePrefetchRef = useRef<{
sourceKey: string;
query: string;
cursor: string;
promise: Promise<BrowsePage>;
} | null>(null);
// Re-entrancy guard for `handleLoadMore` — infinite scroll (unlike a
// button `onClick`) can call it again before the previous call has
// settled (e.g. the sentinel row is still intersecting when the observer
// re-evaluates after a layout shift). A ref, not `loadMoreStatus`: that
// state only exists to drive what's rendered and is read from React's
// closure at call time, which would still read the *previous* render's
// (stale) value inside a handler fired synchronously off a fresh
// browser event — this needs to be checked/set immediately and
// synchronously, which only a ref does correctly here.
const isLoadingMoreRef = useRef(false);
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);
// Re-previews whenever `initialSelection` itself changes (a fresh deep
// link, or the browser's back/forward button landing on a different
// item within this same source) — not just once on mount. Keyed on the
// primitive field 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?.externalId === initialSelection.externalId) return;
let cancelled = false;
setPreviewedItem(initialSelection);
setSelectedExternalId(initialSelection.externalId);
setPreviewState({ status: "loading" });
apiClient
.previewSourceItem(sourceKey, initialSelection.externalId)
.then((draft) => {
if (!cancelled) setPreviewState({ status: "loaded-draft", draft });
})
.catch(() => {
if (!cancelled) setPreviewState({ status: "error" });
});
return () => {
cancelled = true;
};
}, [initialSelection?.externalId]);
useEffect(() => {
const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS);
return () => window.clearTimeout(timeout);
}, [search]);
useEffect(() => {
let cancelled = false;
setBrowseState({ status: "loading" });
// A fresh search/source is a fresh list — any in-flight "load more" or
// stale prefetch from the *previous* one no longer applies to anything.
setLoadMoreStatus("idle");
nextPagePrefetchRef.current = null;
isLoadingMoreRef.current = false;
apiClient
.browseSource(sourceKey, { query: debouncedSearch.trim() || undefined })
.then(({ items, nextCursor }) => {
if (!cancelled) setBrowseState({ status: "loaded", items, nextCursor });
})
.catch(() => {
if (!cancelled) setBrowseState({ status: "error" });
});
return () => {
cancelled = true;
};
}, [sourceKey, debouncedSearch]);
// Prefetches the page after the one currently on screen, so scrolling
// near the bottom (SourceItemTable's sentinel row, which calls
// handleLoadMore) usually just swaps in data that's already arrived
// instead of starting a fresh round-trip right when someone's waiting on
// it — `handleLoadMore` below reuses this when it matches. Re-runs on every
// `browseState` change, so a load-more that appends a new page and a new
// `nextCursor` immediately kicks off prefetching the page *after* that
// one too, keeping the panel permanently one page ahead of what's shown.
useEffect(() => {
if (browseState.status !== "loaded" || !browseState.nextCursor) return;
const cursor = browseState.nextCursor;
const query = debouncedSearch.trim();
const already = nextPagePrefetchRef.current;
if (
already &&
already.sourceKey === sourceKey &&
already.query === query &&
already.cursor === cursor
) {
return; // already prefetching/prefetched exactly this page
}
const promise = apiClient.browseSource(sourceKey, { query: query || undefined, cursor });
nextPagePrefetchRef.current = { sourceKey, query, cursor, promise };
// A failed prefetch is swallowed here on purpose — nobody's actually
// waiting on it yet. If `handleLoadMore` later reuses this same promise
// it awaits/catches the rejection itself at that point; if it's never
// reused (the prefetch just goes stale), this `.catch()` only exists to
// keep the rejection from surfacing as an unhandled one.
promise.catch(() => {});
}, [browseState, sourceKey, debouncedSearch]);
function handleLoadMore() {
if (browseState.status !== "loaded" || !browseState.nextCursor) {
return;
}
if (isLoadingMoreRef.current) {
return; // already fetching this exact next page — see the ref's own doc comment
}
isLoadingMoreRef.current = true;
const cursor = browseState.nextCursor;
const query = debouncedSearch.trim();
setLoadMoreStatus("loading");
const prefetch = nextPagePrefetchRef.current;
const request =
prefetch &&
prefetch.sourceKey === sourceKey &&
prefetch.query === query &&
prefetch.cursor === cursor
? prefetch.promise
: apiClient.browseSource(sourceKey, { query: query || undefined, cursor });
request
.then(({ items, nextCursor }) => {
nextPagePrefetchRef.current = null;
isLoadingMoreRef.current = false;
setBrowseState((prev) => {
if (prev.status !== "loaded") return prev;
// Merge deduplicated by `externalId`, and treat "this page added
// nothing genuinely new" as the end of the list even if the
// source still returned a non-null `nextCursor`. Belt-and-braces
// with `SourceItemTable`'s latest-ref fix against the issue #38
// loop (a stale cursor re-fetching the same page forever), and a
// guard against a source whose own end-of-catalog heuristic is
// imperfect (e.g. 750g's "page with no cards").
const seen = new Set(prev.items.map((item) => item.externalId));
const freshItems = items.filter((item) => !seen.has(item.externalId));
if (freshItems.length === 0) {
return { status: "loaded", items: prev.items, nextCursor: null };
}
return { status: "loaded", items: [...prev.items, ...freshItems], nextCursor };
});
setLoadMoreStatus("idle");
})
.catch(() => {
// Also clears a failed *prefetch*, not just a failed manual retry —
// otherwise a rejected promise would sit in the ref forever, and
// "Réessayer" would just keep reusing (and re-rejecting on) that
// same dead promise instead of ever making a fresh request.
nextPagePrefetchRef.current = null;
isLoadingMoreRef.current = false;
setLoadMoreStatus("error");
});
}
function handleSelectItem(item: BrowsableSourceItemView) {
if (item.alreadyImported && item.recipeId !== null) {
setSelectedExternalId(item.externalId);
setPreviewState({ status: "loading" });
apiClient
.getRecipe(item.recipeId)
.then((recipe) => {
setPreviewState({ status: "loaded", recipe });
onSelectImportedRecipe(recipe);
})
.catch(() => setPreviewState({ status: "error" }));
return;
}
const selection = { sourceKey, 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);
onDraftSelected?.(selection);
apiClient
.previewSourceItem(sourceKey, item.externalId)
.then((draft) => setPreviewState({ status: "loaded-draft", draft }))
.catch(() => setPreviewState({ status: "error" }));
}
return (
<>
<div className="recipes-page__header recipes-page__header--sources">
<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}
placeholderCount={loadMoreStatus === "loading" ? LOAD_MORE_PLACEHOLDER_COUNT : 0}
hasMore={browseState.nextCursor !== null}
onLoadMore={handleLoadMore}
/>
{loadMoreStatus === "error" && (
<p className="recipes-page__status recipes-page__status--error">
{t("recipes.sources.loadMoreError")}{" "}
<button type="button" className="source-items-retry" onClick={handleLoadMore}>
{t("recipes.sources.retry")}
</button>
</p>
)}
</div>
)}
<RecipeDetailPanel state={previewState} showActions={false} />
</div>
</>
);
}