batchCooking/apps/web/src/features/recipes/sources/SourceItemTable.tsx
kyuno053 ba3c978c25
feat(recipes): scroll infini + placeholders sur le parcours des sources externes (#71)
* 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>
2026-08-22 20:09:23 +02:00

131 lines
5.6 KiB
TypeScript

import type { BrowsableSourceItemView } from "@batch-cooking/shared";
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import "../recipes.scss";
/**
* List of one source's browsable items (`RecipeSourcesPanel`) — same
* "photo + name, click/Enter to select" row shape as `RecipeTable`, plus
* an "already imported" badge in place of allergen/regime columns (a
* source item has neither, it's not resolved against our catalogs until
* previewed).
*
* Infinite scroll, not a "voir plus" button: a zero-content sentinel row
* (`source-item-table__sentinel`) sits right after `items`, watched by an
* `IntersectionObserver` scoped to this table's own scrolling container
* (`.recipe-table-wrap`, not the page) — scrolling it into view calls
* `onLoadMore`, the same callback a button's `onClick` would have. Kept
* entirely inside this component rather than exposed as a prop callback
* signature change on every call site: `RecipeSourcesPanel` doesn't need to
* know *how* "load more" gets triggered, only that it does.
*/
export function SourceItemTable({
items,
selectedExternalId,
onSelect,
placeholderCount = 0,
hasMore = false,
onLoadMore,
}: {
items: BrowsableSourceItemView[];
selectedExternalId: string | null;
onSelect: (item: BrowsableSourceItemView) => void;
/**
* Extra pulsing skeleton rows appended after `items` — `RecipeSourcesPanel`
* sets this while a "load more" fetch is in flight, so the list fills in
* right away instead of looking like nothing happened. Purely decorative:
* never clickable/focusable, unlike a real row.
*/
placeholderCount?: number;
/** Whether a further page exists — renders the sentinel row (and therefore observes it) only when true; the observer would otherwise have nothing meaningful to trigger once the catalog is exhausted. */
hasMore?: boolean;
/** Called once when the sentinel row scrolls into view — `RecipeSourcesPanel`'s own re-entrancy guard (not this component) is what keeps a still-visible sentinel from firing this repeatedly while a fetch is already in flight. */
onLoadMore?: () => void;
}) {
const { t } = useTranslation();
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const sentinelRef = useRef<HTMLTableRowElement | null>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: `onLoadMore` is deliberately excluded — RecipeSourcesPanel passes a fresh function identity on every render, and re-subscribing the observer on every single render (rather than only when hasMore actually flips) would be wasteful busywork for no behavioral difference.
useEffect(() => {
const root = scrollContainerRef.current;
const sentinel = sentinelRef.current;
if (!hasMore || !onLoadMore || !root || !sentinel) return;
// `rootMargin` starts loading the next page slightly before the
// sentinel actually reaches the visible edge — combined with
// RecipeSourcesPanel's own prefetch-ahead-of-time, the goal is that a
// page finishes arriving before anyone actually scrolls far enough to
// need it, not just "as soon as" they do.
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) onLoadMore();
},
{ root, rootMargin: "200px 0px" },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMore]);
return (
<div className="recipe-table-wrap" ref={scrollContainerRef}>
<table className="recipe-table">
<thead>
<tr>
<th />
<th>{t("recipes.table.name")}</th>
<th />
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr
key={item.externalId}
className={item.externalId === selectedExternalId ? "selected" : undefined}
onClick={() => onSelect(item)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect(item);
}
}}
tabIndex={0}
aria-current={item.externalId === selectedExternalId ? "true" : undefined}
>
<td>
<span className="recipe-table__photo" aria-hidden="true">
{item.picture ? <img src={item.picture} alt="" /> : "🍽️"}
</span>
</td>
<td className="recipe-table__name">{item.title}</td>
<td>
{item.alreadyImported && (
<span className="source-item-table__imported-badge">
{t("recipes.sources.alreadyImported")}
</span>
)}
</td>
</tr>
))}
{hasMore && (
<tr ref={sentinelRef} className="source-item-table__sentinel">
<td colSpan={3} />
</tr>
)}
{Array.from({ length: placeholderCount }, (_, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: a fixed-length run of interchangeable, content-less placeholders — there's no stable identity to key on, and the count never reorders.
<tr key={`skeleton-${index}`} className="source-item-table__row--skeleton">
<td>
<span className="recipe-table__photo source-item-table__photo--skeleton" />
</td>
<td>
<span className="source-item-table__skeleton-bar" />
</td>
<td />
</tr>
))}
</tbody>
</table>
</div>
);
}