From 51b7b8eec3572c404ef4a9c5a0061dfddfe509e5 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 9 Sep 2026 17:10:09 +0200 Subject: [PATCH] =?UTF-8?q?feat(recipes):=20barre=20de=20recherche=20commu?= =?UTF-8?q?ne=20=C3=A0=20tous=20les=20onglets=20de=20sources=20(#133)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ``, 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 --- apps/web/cypress/e2e/recipe-sources.feature | 13 ++ apps/web/cypress/e2e/recipe-sources.ts | 54 ++++++++ .../features/planning/RecipePickerDialog.tsx | 128 +++++++++--------- apps/web/src/features/recipes/recipes.scss | 11 +- .../recipes/sources/RecipeSourcesPanel.tsx | 102 +++++++------- apps/web/src/locales/fr/translation.json | 1 - apps/web/src/pages/recipes/RecipesPage.tsx | 20 +-- specs/frontend-architecture.md | 8 ++ 8 files changed, 203 insertions(+), 134 deletions(-) diff --git a/apps/web/cypress/e2e/recipe-sources.feature b/apps/web/cypress/e2e/recipe-sources.feature index fc03862..f17ec22 100644 --- a/apps/web/cypress/e2e/recipe-sources.feature +++ b/apps/web/cypress/e2e/recipe-sources.feature @@ -80,6 +80,19 @@ Feature: Browsing external recipe sources When I click the button "Réessayer" 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 Given the recipe catalog contains nothing And the sources reference list has options diff --git a/apps/web/cypress/e2e/recipe-sources.ts b/apps/web/cypress/e2e/recipe-sources.ts index 54af571..d0fa3e2 100644 --- a/apps/web/cypress/e2e/recipe-sources.ts +++ b/apps/web/cypress/e2e/recipe-sources.ts @@ -56,6 +56,60 @@ Given("the household has enabled TheMealDB", () => { 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", () => { cy.intercept("GET", "**/sources/theMealDb/browse*", { statusCode: 200, diff --git a/apps/web/src/features/planning/RecipePickerDialog.tsx b/apps/web/src/features/planning/RecipePickerDialog.tsx index 494247f..93c6b3b 100644 --- a/apps/web/src/features/planning/RecipePickerDialog.tsx +++ b/apps/web/src/features/planning/RecipePickerDialog.tsx @@ -451,70 +451,75 @@ export function RecipePickerDialog({ } > - {activeSourceKey === null && ( -
- setSearch(e.target.value)} - /> + {/* 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. */} +
+ setSearch(e.target.value)} + /> -
- - {t("planning.picker.ingredientsFilterLabel")} - -
- {selectedIngredients.map((ingredient) => ( - - {ingredientLabel(ingredient, t)} - - - ))} - + {activeSourceKey === null && ( + <> +
+ + {t("planning.picker.ingredientsFilterLabel")} + +
+ {selectedIngredients.map((ingredient) => ( + + {ingredientLabel(ingredient, t)} + + + ))} + +
+ {isIngredientPickerOpen && ( + + setSelectedIngredientIds((ids) => [...ids, ingredient.id]) + } + /> + )}
- {isIngredientPickerOpen && ( - - setSelectedIngredientIds((ids) => [...ids, ingredient.id]) - } - /> + + + + {hasHousehold && ( + + {t("planning.picker.suitableForHouseholdLabel")} + )} -
- - - - {hasHousehold && ( - - {t("planning.picker.suitableForHouseholdLabel")} - - )} -
- )} + + )} +
diff --git a/apps/web/src/features/recipes/recipes.scss b/apps/web/src/features/recipes/recipes.scss index 74bf2ef..90ecc2b 100644 --- a/apps/web/src/features/recipes/recipes.scss +++ b/apps/web/src/features/recipes/recipes.scss @@ -360,13 +360,10 @@ } // --- Sources tab (RecipeSourcesPanel) --------------------------------------- -// Reuses .recipes-page__header/__search/__catalog and .recipe-table(-wrap) -// as-is (see RecipeSourcesPanel.tsx/SourceItemTable.tsx) — only what's -// actually new to this tab gets its own rules here. - -.recipes-page__header--sources { - gap: var(--space-sm); -} +// Reuses .recipes-page__catalog and .recipe-table(-wrap) as-is (see +// RecipeSourcesPanel.tsx/SourceItemTable.tsx); the search field is the +// page header's own now (issue #133) — only what's actually new to this +// tab gets its own rules here. .source-item-table__imported-badge { padding: 0.1rem 0.5rem; diff --git a/apps/web/src/features/recipes/sources/RecipeSourcesPanel.tsx b/apps/web/src/features/recipes/sources/RecipeSourcesPanel.tsx index f38e7ae..d114ac5 100644 --- a/apps/web/src/features/recipes/sources/RecipeSourcesPanel.tsx +++ b/apps/web/src/features/recipes/sources/RecipeSourcesPanel.tsx @@ -6,9 +6,6 @@ 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; @@ -64,15 +61,25 @@ type BrowseState = * 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. + * + * `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({ sourceKey, + debouncedSearch, onSelectImportedRecipe, onDraftSelected, initialSelection, onItemSelected, }: { 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. */ 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. */ @@ -82,8 +89,6 @@ export function RecipeSourcesPanel({ }) { const { t } = useTranslation(); - const [search, setSearch] = useState(""); - const [debouncedSearch, setDebouncedSearch] = useState(""); const [browseState, setBrowseState] = useState({ 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 @@ -152,11 +157,6 @@ export function RecipeSourcesPanel({ }; }, [initialSelection?.externalId]); - useEffect(() => { - const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS); - return () => window.clearTimeout(timeout); - }, [search]); - useEffect(() => { let cancelled = false; setBrowseState({ status: "loading" }); @@ -294,52 +294,42 @@ export function RecipeSourcesPanel({ } return ( - <> -
- setSearch(e.target.value)} - /> -
+ // The search field lives in `RecipesPage`'s header now (issue #133) — + // this panel is just the source's list + preview. +
+ {browseState.status === "loading" && ( +

{t("recipes.sources.loading")}

+ )} + {browseState.status === "error" && ( +

+ {t("recipes.sources.loadError")} +

+ )} + {browseState.status === "loaded" && browseState.items.length === 0 && ( +

{t("recipes.sources.empty")}

+ )} + {browseState.status === "loaded" && browseState.items.length > 0 && ( +
+ + {loadMoreStatus === "error" && ( +

+ {t("recipes.sources.loadMoreError")}{" "} + +

+ )} +
+ )} -
- {browseState.status === "loading" && ( -

{t("recipes.sources.loading")}

- )} - {browseState.status === "error" && ( -

- {t("recipes.sources.loadError")} -

- )} - {browseState.status === "loaded" && browseState.items.length === 0 && ( -

{t("recipes.sources.empty")}

- )} - {browseState.status === "loaded" && browseState.items.length > 0 && ( -
- - {loadMoreStatus === "error" && ( -

- {t("recipes.sources.loadMoreError")}{" "} - -

- )} -
- )} - - -
- + +
); } diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 16c01fc..e368d26 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -255,7 +255,6 @@ "diets": "Régime associé" }, "sources": { - "searchPlaceholder": "Rechercher…", "empty": "Aucune recette trouvée.", "alreadyImported": "Déjà importée", "loading": "Chargement…", diff --git a/apps/web/src/pages/recipes/RecipesPage.tsx b/apps/web/src/pages/recipes/RecipesPage.tsx index 5abd041..3fba6a0 100644 --- a/apps/web/src/pages/recipes/RecipesPage.tsx +++ b/apps/web/src/pages/recipes/RecipesPage.tsx @@ -179,15 +179,16 @@ export function RecipesPage() {

{t("recipes.title")}

- {activeSourceKey === null && ( - setSearch(e.target.value)} - /> - )} + {/* 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. */} + setSearch(e.target.value)} + /> {t("recipes.newButton")} @@ -203,6 +204,7 @@ export function RecipesPage() {