Merge pull request 'feat(recipes): barre de recherche commune à tous les onglets de sources (#133)' (#140) from feat/common-source-search into main
Some checks failed
CI / build (push) Successful in 3m1s
CI / lint (push) Successful in 3m4s
CI / intent-service-test (push) Successful in 4m51s
CI / e2e (push) Successful in 10m50s
CI / test (push) Failing after 17m11s
CI / build-android (push) Has been skipped

This commit is contained in:
kyuno 2026-09-09 19:18:38 +02:00
commit 0c4e8d089c
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