* fix(recipes): corrige la liste vide de sevenFiftyGAdapter quand le filtre est vide L'endpoint IA que list() utilisait pour toute recherche (SEARCH_URL, /genius/query/) répond avec un corps de réponse vide dès que query est vide — vérifié en direct. Résultat : parcourir la source 750g sans filtre ne remontait jamais aucune recette. Corrigé en lisant un endpoint différent quand query est vide/absent : dernieres-recettes.htm, le vrai catalogue paginé "dernières recettes" de 750g.com (pagination réelle via &page=N, contrairement à l'endpoint de recherche). nextCursor suit désormais cette même distinction : toujours null pour une recherche par texte (l'endpoint ne pagine pas), calculé normalement pour le parcours sans filtre (une page sans aucune carte en est le signal de fin, cet endpoint ne renvoyant ni 404 ni redirection une fois la dernière page dépassée). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(recipes): scroll infini + placeholders sur le parcours des sources externes Remplace le bouton "Voir plus" de RecipeSourcesPanel par un scroll infini : une ligne sentinelle en fin de liste (SourceItemTable), observée via IntersectionObserver scopé au conteneur scrollable de la table, déclenche le chargement de la page suivante quand elle approche du bas. Le panel précharge en plus la page suivante dès que la page courante s'affiche (pas seulement au moment où la sentinelle devient visible), pour qu'un défilement rapide tombe le plus souvent sur une réponse déjà arrivée plutôt que de déclencher un aller-retour réseau à ce moment précis. Pendant un chargement (préchargé ou non), SourceItemTable ajoute des lignes squelettes qui pulsent en bas de la liste au lieu de laisser un vide. Un échec de chargement n'efface plus la liste déjà chargée comme avant (bug corrigé au passage) — un message avec un lien "Réessayer" s'affiche à la place ; ce correctif inclut aussi le nettoyage d'un préchargement en échec qui, sinon, aurait fait rejouer indéfiniment la même promesse déjà rejetée à chaque tentative de réessai. Deux nouveaux scénarios Cypress (recipe-sources.feature) : chargement automatique de pages supplémentaires sans bouton, et réessai après un échec du chargement suivant. Suite e2e complète relancée (78/79, le seul échec restant est un test préexistant sans rapport, recipe-form.feature, signalé séparément). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
334 lines
15 KiB
TypeScript
334 lines
15 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) =>
|
|
prev.status === "loaded"
|
|
? { status: "loaded", items: [...prev.items, ...items], nextCursor }
|
|
: prev,
|
|
);
|
|
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>
|
|
</>
|
|
);
|
|
}
|