feat(recipes): barre de recherche commune à tous les onglets de sources (#133)
All checks were successful
CI / lint (push) Successful in 3m39s
CI / build (push) Successful in 3m32s
CI / intent-service-test (push) Successful in 5m51s
CI / e2e (push) Successful in 8m32s
CI / test (push) Successful in 22m26s
CI / build-android (push) Has been skipped

Avant : chaque onglet de source rendait son propre champ recherche, avec
son propre state, et `RecipeSourcesPanel` est remonté (`key={sourceKey}`)
à chaque changement de source — la requête tapée était donc perdue en
passant d'une source à une autre.

Maintenant : un seul champ, dans l'en-tête de `RecipesPage`. `search` /
`debouncedSearch` (déjà présents pour les onglets réels) sont rendus en
permanence et `debouncedSearch` est passé en prop à `RecipeSourcesPanel`,
qui n'a plus ni `useState` de recherche, ni `<input>`, ni effet de
debounce internes — son effet de `browse` dépend de la prop. La requête
survit donc au changement de source (le `key` remonte toujours la liste +
le préchargement, mais le terme vit au-dessus), et elle est commune aux
onglets réels comme aux sources.

`RecipePickerDialog` : même chose — le champ recherche sort du bloc de
filtres `activeSourceKey === null` (les filtres ingrédients/régimes/foyer
y restent, ils ne concernent que les onglets en base) et `debouncedSearch`
est passé au panneau.

- SCSS : `.recipes-page__header--sources` supprimé (le panneau n'a plus
  d'en-tête). Clé i18n morte `recipes.sources.searchPlaceholder` retirée.
- `specs/frontend-architecture.md` : recherche au niveau page.

Tests : `recipe-sources.feature` — nouveau scénario « la requête est
conservée en changeant d'onglet de source » (tape « poulet » sur TheMealDB,
bascule sur Marmiton, le champ garde « poulet » et Marmiton est interrogé
avec `query=poulet`). `pnpm lint` / `pnpm build` verts ; Cypress local :
recipe-sources 8/8, recipes 5/5, recipes.cy 12/12, planning 3/3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-09 17:10:09 +02:00
parent 36d26c2739
commit 51b7b8eec3
8 changed files with 203 additions and 134 deletions

View file

@ -80,6 +80,19 @@ Feature: Browsing external recipe sources
When I click the button "Réessayer" When I click the button "Réessayer"
Then I should see the source item "Beef Wellington" Then I should see the source item "Beef Wellington"
Scenario: Keeps the search term when switching between source tabs
Given the recipe catalog contains nothing
And the sources reference list has options
And the household has enabled TheMealDB and Marmiton
And browsing any enabled source echoes back its query
When I visit "/recettes"
And I click the button "TheMealDB"
And I type "poulet" into the recipe search field
Then "TheMealDB" should have been browsed with the query "poulet"
When I click the button "Marmiton"
Then the recipe search field should contain "poulet"
And "Marmiton" should have been browsed with the query "poulet"
Scenario: Deep-links straight to a not-yet-imported item's own page, with no import affordance at all Scenario: Deep-links straight to a not-yet-imported item's own page, with no import affordance at all
Given the recipe catalog contains nothing Given the recipe catalog contains nothing
And the sources reference list has options And the sources reference list has options

View file

@ -56,6 +56,60 @@ Given("the household has enabled TheMealDB", () => {
cy.intercept("GET", "**/house/current/sources", { statusCode: 200, body: [1] }); cy.intercept("GET", "**/house/current/sources", { statusCode: 200, body: [1] });
}); });
Given("the household has enabled TheMealDB and Marmiton", () => {
cy.intercept("GET", "**/house/current/sources", { statusCode: 200, body: [1, 2] });
});
// Issue #133 — the search field is shared across every source tab. Both
// sources answer `browse` the same way and each request is aliased so a
// scenario can assert which `query` reached which source.
Given("browsing any enabled source echoes back its query", () => {
const reply = (req: { url: string; reply: (r: unknown) => void }) => {
const query = new URL(req.url).searchParams.get("query") ?? "";
req.reply({
statusCode: 200,
body: {
items: [
{
externalId: `${query || "all"}-1`,
title: query ? `Résultat « ${query} »` : "Tout le catalogue",
picture: null,
url: "https://example.test/1",
alreadyImported: false,
recipeId: null,
},
],
nextCursor: null,
},
});
};
cy.intercept("GET", "**/sources/theMealDb/browse*", reply).as("browseTheMealDb");
cy.intercept("GET", "**/sources/marmiton/browse*", reply).as("browseMarmiton");
});
When("I type {string} into the recipe search field", (text: string) => {
cy.get(".recipes-page__search").clear().type(text);
});
Then("the recipe search field should contain {string}", (text: string) => {
cy.get(".recipes-page__search").should("have.value", text);
});
Then(
"{string} should have been browsed with the query {string}",
(source: string, query: string) => {
const alias = source === "Marmiton" ? "@browseMarmiton.all" : "@browseTheMealDb.all";
// `.all` + a retried `.should` — an empty-query browse fires first (tab
// opened before anything was typed) and React StrictMode double-invokes
// the fetch effect in the dev server, so assert "one of the requests
// carried the term" rather than waiting on a specific occurrence.
cy.get(alias).should((calls: { request: { url: string } }[]) => {
const hit = calls.some((c) => c.request.url.includes(`query=${encodeURIComponent(query)}`));
expect(hit, `a ${source} browse used query="${query}"`).to.equal(true);
});
},
);
Given("browsing TheMealDB returns some items", () => { Given("browsing TheMealDB returns some items", () => {
cy.intercept("GET", "**/sources/theMealDb/browse*", { cy.intercept("GET", "**/sources/theMealDb/browse*", {
statusCode: 200, statusCode: 200,

View file

@ -451,7 +451,9 @@ export function RecipePickerDialog({
</> </>
} }
> >
{activeSourceKey === null && ( {/* The search field shows on every tab — real ones and each source's
(issue #133), matching `RecipesPage`; the ingredient/diet/household
filters below it only apply to the saved-recipe tabs. */}
<div className="recipe-picker__filters"> <div className="recipe-picker__filters">
<input <input
type="search" type="search"
@ -461,6 +463,8 @@ export function RecipePickerDialog({
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
{activeSourceKey === null && (
<>
<div className="recipe-picker__ingredient-filter"> <div className="recipe-picker__ingredient-filter">
<span className="recipe-picker__filter-label"> <span className="recipe-picker__filter-label">
{t("planning.picker.ingredientsFilterLabel")} {t("planning.picker.ingredientsFilterLabel")}
@ -513,8 +517,9 @@ export function RecipePickerDialog({
{t("planning.picker.suitableForHouseholdLabel")} {t("planning.picker.suitableForHouseholdLabel")}
</CheckboxOption> </CheckboxOption>
)} )}
</div> </>
)} )}
</div>
<RecipeTabs <RecipeTabs
active={activeTab} active={activeTab}
@ -526,6 +531,7 @@ export function RecipePickerDialog({
<RecipeSourcesPanel <RecipeSourcesPanel
key={activeSourceKey} key={activeSourceKey}
sourceKey={activeSourceKey} sourceKey={activeSourceKey}
debouncedSearch={debouncedSearch}
onSelectImportedRecipe={handleSelectImportedRecipe} onSelectImportedRecipe={handleSelectImportedRecipe}
onDraftSelected={handleDraftSelected} onDraftSelected={handleDraftSelected}
/> />

View file

@ -360,13 +360,10 @@
} }
// --- Sources tab (RecipeSourcesPanel) --------------------------------------- // --- Sources tab (RecipeSourcesPanel) ---------------------------------------
// Reuses .recipes-page__header/__search/__catalog and .recipe-table(-wrap) // Reuses .recipes-page__catalog and .recipe-table(-wrap) as-is (see
// as-is (see RecipeSourcesPanel.tsx/SourceItemTable.tsx) — only what's // RecipeSourcesPanel.tsx/SourceItemTable.tsx); the search field is the
// actually new to this tab gets its own rules here. // page header's own now (issue #133) — only what's actually new to this
// tab gets its own rules here.
.recipes-page__header--sources {
gap: var(--space-sm);
}
.source-item-table__imported-badge { .source-item-table__imported-badge {
padding: 0.1rem 0.5rem; padding: 0.1rem 0.5rem;

View file

@ -6,9 +6,6 @@ import { RecipeDetailPanel, type RecipeDetailState } from "../RecipeDetailPanel"
import { SourceItemTable } from "./SourceItemTable"; import { SourceItemTable } from "./SourceItemTable";
import "../recipes.scss"; 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. */ /** 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; const LOAD_MORE_PLACEHOLDER_COUNT = 4;
@ -64,15 +61,25 @@ type BrowseState =
* upward, and re-previews on mount/prop-change if handed one back. * upward, and re-previews on mount/prop-change if handed one back.
* `RecipePickerDialog` leaves both unset: previewing inside that dialog * `RecipePickerDialog` leaves both unset: previewing inside that dialog
* has no URL of its own to keep in sync. * has no URL of its own to keep in sync.
*
* `debouncedSearch` is owned by the caller (issue #133): one search field
* in `RecipesPage`'s header serves every tab — real ones and each
* source's — so the query survives switching between sources (this panel
* still remounts on `sourceKey` via its `key`, but the term lives above
* that). The debounce happens in the caller too; this panel just refetches
* whenever the prop changes.
*/ */
export function RecipeSourcesPanel({ export function RecipeSourcesPanel({
sourceKey, sourceKey,
debouncedSearch,
onSelectImportedRecipe, onSelectImportedRecipe,
onDraftSelected, onDraftSelected,
initialSelection, initialSelection,
onItemSelected, onItemSelected,
}: { }: {
sourceKey: string; sourceKey: string;
/** Already-debounced search term from `RecipesPage`'s shared field — drives `browseSource`'s `query`. */
debouncedSearch: 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. */ /** 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; 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. */ /** 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. */
@ -82,8 +89,6 @@ export function RecipeSourcesPanel({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [browseState, setBrowseState] = useState<BrowseState>({ status: "loading" }); const [browseState, setBrowseState] = useState<BrowseState>({ status: "loading" });
const [loadMoreStatus, setLoadMoreStatus] = useState<"idle" | "loading" | "error">("idle"); const [loadMoreStatus, setLoadMoreStatus] = useState<"idle" | "loading" | "error">("idle");
// The *next* page, fetched ahead of time as soon as the current one is on // The *next* page, fetched ahead of time as soon as the current one is on
@ -152,11 +157,6 @@ export function RecipeSourcesPanel({
}; };
}, [initialSelection?.externalId]); }, [initialSelection?.externalId]);
useEffect(() => {
const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS);
return () => window.clearTimeout(timeout);
}, [search]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setBrowseState({ status: "loading" }); setBrowseState({ status: "loading" });
@ -294,17 +294,8 @@ export function RecipeSourcesPanel({
} }
return ( return (
<> // The search field lives in `RecipesPage`'s header now (issue #133) —
<div className="recipes-page__header recipes-page__header--sources"> // this panel is just the source's list + preview.
<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"> <div className="recipes-page__catalog">
{browseState.status === "loading" && ( {browseState.status === "loading" && (
<p className="recipes-page__status">{t("recipes.sources.loading")}</p> <p className="recipes-page__status">{t("recipes.sources.loading")}</p>
@ -340,6 +331,5 @@ export function RecipeSourcesPanel({
<RecipeDetailPanel state={previewState} showActions={false} /> <RecipeDetailPanel state={previewState} showActions={false} />
</div> </div>
</>
); );
} }

View file

@ -255,7 +255,6 @@
"diets": "Régime associé" "diets": "Régime associé"
}, },
"sources": { "sources": {
"searchPlaceholder": "Rechercher…",
"empty": "Aucune recette trouvée.", "empty": "Aucune recette trouvée.",
"alreadyImported": "Déjà importée", "alreadyImported": "Déjà importée",
"loading": "Chargement…", "loading": "Chargement…",

View file

@ -179,7 +179,9 @@ export function RecipesPage() {
<div className="recipes-page"> <div className="recipes-page">
<div className="recipes-page__header"> <div className="recipes-page__header">
<h1>{t("recipes.title")}</h1> <h1>{t("recipes.title")}</h1>
{activeSourceKey === null && ( {/* One field for every tab — real ones and each source's (issue
#133). The term is kept here, above `RecipeSourcesPanel`'s
`key`-forced remount, so switching sources keeps the query. */}
<input <input
type="search" type="search"
className="recipes-page__search" className="recipes-page__search"
@ -187,7 +189,6 @@ export function RecipesPage() {
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
)}
<Link to="/recettes/nouvelle" className="recipes-page__new-button"> <Link to="/recettes/nouvelle" className="recipes-page__new-button">
{t("recipes.newButton")} {t("recipes.newButton")}
</Link> </Link>
@ -203,6 +204,7 @@ export function RecipesPage() {
<RecipeSourcesPanel <RecipeSourcesPanel
key={activeSourceKey} key={activeSourceKey}
sourceKey={activeSourceKey} sourceKey={activeSourceKey}
debouncedSearch={debouncedSearch}
// Only meaningful when it actually belongs to this source — e.g. // Only meaningful when it actually belongs to this source — e.g.
// clicking straight from TheMealDB's item "9999" to Marmiton's tab // clicking straight from TheMealDB's item "9999" to Marmiton's tab
// changes `activeSourceKey` before `selectedSourceItem` (URL-driven) // changes `activeSourceKey` before `selectedSourceItem` (URL-driven)

View file

@ -575,6 +575,14 @@ Contenu de l'onglet d'une source : sa propre paire maître-détail —
`sourceKey` (prop fixe) — remonté avec `key={sourceKey}` en changeant de `sourceKey` (prop fixe) — remonté avec `key={sourceKey}` en changeant de
source, même convention "monté seulement tant que pertinent" que `Dialog`. source, même convention "monté seulement tant que pertinent" que `Dialog`.
Le champ de recherche est **commun à tous les onglets** (issue #133) : il vit
dans l'en-tête de `RecipesPage` (et de `RecipePickerDialog`), pas dans le
panneau. `search`/`debouncedSearch` sont portés par la page ; `RecipeSourcesPanel`
reçoit `debouncedSearch` en prop et refait son `browse` quand elle change. La
requête tapée survit donc au changement de source (le `key={sourceKey}` remonte
toujours la liste + le préchargement, mais le terme est au-dessus). Sur les
onglets réels le même terme alimente `listRecipes({ search })`.
Pagination en scroll infini, pas de bouton "voir plus" : une ligne Pagination en scroll infini, pas de bouton "voir plus" : une ligne
sentinelle invisible en fin de liste (`SourceItemTable`, un sentinelle invisible en fin de liste (`SourceItemTable`, un
`IntersectionObserver` scopé à son propre conteneur scrollable) déclenche le `IntersectionObserver` scopé à son propre conteneur scrollable) déclenche le