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>
345 lines
16 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|