feat(recipes): barre de recherche commune à tous les onglets de sources (#133) #140
8 changed files with 203 additions and 134 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<input
|
||||
type="search"
|
||||
|
|
@ -461,6 +463,8 @@ export function RecipePickerDialog({
|
|||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
|
||||
{activeSourceKey === null && (
|
||||
<>
|
||||
<div className="recipe-picker__ingredient-filter">
|
||||
<span className="recipe-picker__filter-label">
|
||||
{t("planning.picker.ingredientsFilterLabel")}
|
||||
|
|
@ -513,8 +517,9 @@ export function RecipePickerDialog({
|
|||
{t("planning.picker.suitableForHouseholdLabel")}
|
||||
</CheckboxOption>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<RecipeTabs
|
||||
active={activeTab}
|
||||
|
|
@ -526,6 +531,7 @@ export function RecipePickerDialog({
|
|||
<RecipeSourcesPanel
|
||||
key={activeSourceKey}
|
||||
sourceKey={activeSourceKey}
|
||||
debouncedSearch={debouncedSearch}
|
||||
onSelectImportedRecipe={handleSelectImportedRecipe}
|
||||
onDraftSelected={handleDraftSelected}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<BrowseState>({ 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,17 +294,8 @@ export function RecipeSourcesPanel({
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="recipes-page__header recipes-page__header--sources">
|
||||
<input
|
||||
type="search"
|
||||
className="recipes-page__search"
|
||||
placeholder={t("recipes.sources.searchPlaceholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
// The search field lives in `RecipesPage`'s header now (issue #133) —
|
||||
// this panel is just the source's list + preview.
|
||||
<div className="recipes-page__catalog">
|
||||
{browseState.status === "loading" && (
|
||||
<p className="recipes-page__status">{t("recipes.sources.loading")}</p>
|
||||
|
|
@ -340,6 +331,5 @@ export function RecipeSourcesPanel({
|
|||
|
||||
<RecipeDetailPanel state={previewState} showActions={false} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -255,7 +255,6 @@
|
|||
"diets": "Régime associé"
|
||||
},
|
||||
"sources": {
|
||||
"searchPlaceholder": "Rechercher…",
|
||||
"empty": "Aucune recette trouvée.",
|
||||
"alreadyImported": "Déjà importée",
|
||||
"loading": "Chargement…",
|
||||
|
|
|
|||
|
|
@ -179,7 +179,9 @@ export function RecipesPage() {
|
|||
<div className="recipes-page">
|
||||
<div className="recipes-page__header">
|
||||
<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
|
||||
type="search"
|
||||
className="recipes-page__search"
|
||||
|
|
@ -187,7 +189,6 @@ export function RecipesPage() {
|
|||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<Link to="/recettes/nouvelle" className="recipes-page__new-button">
|
||||
{t("recipes.newButton")}
|
||||
</Link>
|
||||
|
|
@ -203,6 +204,7 @@ export function RecipesPage() {
|
|||
<RecipeSourcesPanel
|
||||
key={activeSourceKey}
|
||||
sourceKey={activeSourceKey}
|
||||
debouncedSearch={debouncedSearch}
|
||||
// Only meaningful when it actually belongs to this source — e.g.
|
||||
// clicking straight from TheMealDB's item "9999" to Marmiton's tab
|
||||
// changes `activeSourceKey` before `selectedSourceItem` (URL-driven)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
sentinelle invisible en fin de liste (`SourceItemTable`, un
|
||||
`IntersectionObserver` scopé à son propre conteneur scrollable) déclenche le
|
||||
|
|
|
|||
Loading…
Reference in a new issue