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. */}
+
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.
+
- {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() {