feat(recipes): chaque source activée devient sa propre tab

Nouvelle correction demandée sur cette PR : l'onglet générique
« Sources » (avec un <select> interne quand le foyer en a activé
plusieurs) devient une tab à part entière par source activée — au même
niveau que Favoris/Perso/Foyer/Publique, plus transparent qu'un
sélecteur caché dans un sous-menu.

- `RecipeTabs` accepte désormais `sources: SourceView[]` et rend une
  tab par source (icône propre à la source si elle en a une, sinon
  l'icône générique `SourcesIcon` ; libellé = le nom réel de la
  source, pas une clé i18n). Nouveau type `RecipesPageTab` en
  `RecipeTab | "source:<key>"`, avec `sourceTabValue`/
  `parseSourceTabValue`/`isSourceTab` comme seul point d'assemblage/
  lecture de ce format.
- Nouveau hook partagé `useEnabledSources` (déplacé hors de
  `RecipeSourcesPanel`, maintenant utilisé par `RecipesPage` ET
  `RecipePickerDialog` pour construire leurs tabs).
- `RecipeSourcesPanel` simplifié : `sourceKey` devient une prop requise
  (fournie par la tab elle-même) au lieu d'un état interne avec son
  propre sélecteur — plus de `<select>`, plus de message « aucune
  source activée » (une tab qui n'existe pas ne peut plus être
  cliquée). Remonté via `key={sourceKey}` par l'appelant au changement
  de tab, même convention que `RecipePickerDialog`/`CalendarPopover`
  ailleurs dans l'app.
- Un bug distinct trouvé en écrivant ce changement : passer tel quel
  `initialSelection` (dérivé de l'URL) au panneau nouvellement monté
  en changeant directement de tab source à tab source aurait fait
  prévisualiser l'ancien item contre la nouvelle source. Gardé en ne
  transmettant `initialSelection` que lorsqu'il appartient réellement
  à `activeSourceKey`.

Aucun changement backend.

Tests :
- Vérifié manuellement en local (foyer avec TheMealDB activé) :
  tab dédiée dans /recettes et dans le sélecteur du planning, parcours
  d'un item, aperçu unifié, aucune régression console.
- Cypress : `recipe-sources.feature`/`planning.feature` mis à jour
  (« I click the button "Sources" » → « ... "TheMealDB" »), scénario
  « aucune source activée » réécrit pour vérifier l'absence de tab
  plutôt qu'un message dans un onglet qui n'existe plus.
- `pnpm exec tsc -b --force` (web) — propre.
- `pnpm exec biome check` — propre.
- `pnpm --filter web build` — propre.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-08-20 20:31:09 +02:00
parent d5172c2c66
commit 4381e63045
9 changed files with 236 additions and 192 deletions

View file

@ -21,7 +21,7 @@ Feature: Adding a recipe to the planning
And adding the imported recipe to the planning will succeed And adding the imported recipe to the planning will succeed
When I visit "/" When I visit "/"
And I click the add button for the first empty planning slot And I click the add button for the first empty planning slot
And I click the button "Sources" And I click the button "TheMealDB"
And I click the source item "Fish Pie" And I click the source item "Fish Pie"
And I click the link "Importer cette recette" And I click the link "Importer cette recette"
Then I should see "Cette recette sera automatiquement ajoutée à votre planning une fois importée." Then I should see "Cette recette sera automatiquement ajoutée à votre planning une fois importée."

View file

@ -9,13 +9,12 @@ Feature: Browsing external recipe sources
And the disliked ingredients list is empty And the disliked ingredients list is empty
And the planning request returns nothing And the planning request returns nothing
Scenario: Prompts to enable a source when the household hasn't enabled any Scenario: Shows no source tab when the household hasn't enabled any
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
And the household's enabled sources are empty And the household's enabled sources are empty
When I visit "/recettes" When I visit "/recettes"
And I click the button "Sources" Then I should not see "TheMealDB"
Then I should see "Aucune source n'est activée"
Scenario: Browses an enabled source, distinguishing already-imported items from new ones Scenario: Browses an enabled source, distinguishing already-imported items from new ones
Given the recipe catalog contains nothing Given the recipe catalog contains nothing
@ -24,7 +23,7 @@ Feature: Browsing external recipe sources
And browsing TheMealDB returns some items And browsing TheMealDB returns some items
And recipe 2's detail is available And recipe 2's detail is available
When I visit "/recettes" When I visit "/recettes"
And I click the button "Sources" And I click the button "TheMealDB"
Then I should see the source item "Chicken Handi" Then I should see the source item "Chicken Handi"
And I should see the source item "Fish Pie" And I should see the source item "Fish Pie"
And the source item "Chicken Handi" should be marked as already imported And the source item "Chicken Handi" should be marked as already imported
@ -40,7 +39,7 @@ Feature: Browsing external recipe sources
And browsing TheMealDB returns some items And browsing TheMealDB returns some items
And previewing TheMealDB item "9999" is available And previewing TheMealDB item "9999" is available
When I visit "/recettes" When I visit "/recettes"
And I click the button "Sources" And I click the button "TheMealDB"
And I click the source item "Fish Pie" And I click the source item "Fish Pie"
Then the URL should include "/recettes/sources/theMealDb/9999" Then the URL should include "/recettes/sources/theMealDb/9999"
And the recipe detail panel heading should be "Fish Pie" And the recipe detail panel heading should be "Fish Pie"
@ -65,7 +64,7 @@ Feature: Browsing external recipe sources
And the ingredient and diet catalog is available for import And the ingredient and diet catalog is available for import
And importing the previewed item will succeed and return id 99 And importing the previewed item will succeed and return id 99
When I visit "/recettes" When I visit "/recettes"
And I click the button "Sources" And I click the button "TheMealDB"
And I click the source item "Fish Pie" And I click the source item "Fish Pie"
And I click the link "Importer cette recette" And I click the link "Importer cette recette"
Then the "recipe-name" field should have the value "Fish Pie" Then the "recipe-name" field should have the value "Fish Pie"

View file

@ -17,7 +17,13 @@ import { DietTagSelect } from "../recipes/DietTagSelect";
import { IngredientPicker } from "../recipes/IngredientPicker"; import { IngredientPicker } from "../recipes/IngredientPicker";
import { RecipeSourcesPanel } from "../recipes/RecipeSourcesPanel"; import { RecipeSourcesPanel } from "../recipes/RecipeSourcesPanel";
import { RecipeTable } from "../recipes/RecipeTable"; import { RecipeTable } from "../recipes/RecipeTable";
import { RecipeTabs, type RecipesPageTab } from "../recipes/RecipeTabs"; import {
RecipeTabs,
type RecipesPageTab,
isSourceTab,
parseSourceTabValue,
} from "../recipes/RecipeTabs";
import { useEnabledSources } from "../recipes/useEnabledSources";
import "./recipe-picker-dialog.scss"; import "./recipe-picker-dialog.scss";
/** Debounce for the search field — same value as `RecipesPage`'s. */ /** Debounce for the search field — same value as `RecipesPage`'s. */
@ -48,13 +54,14 @@ export interface PlanningSlot {
* with three extra filters layered on top of the plain name search * with three extra filters layered on top of the plain name search
* (ingredients / regime / "convient à tout le foyer" toggle, all wired to * (ingredients / regime / "convient à tout le foyer" toggle, all wired to
* `GET /recipes`'s corresponding query params) since browsing here is * `GET /recipes`'s corresponding query params) since browsing here is
* about finding something to cook, not just looking something up. The * about finding something to cook, not just looking something up. Each
* "Sources" tab is included too (unlike an earlier version of this dialog * household-enabled source's own tab is included too (unlike an earlier
* see `ImportRecipePage`'s `planningSlot`, the review/import flow that * version of this dialog see `ImportRecipePage`'s `planningSlot`, the
* made including it here worthwhile): picking an already-imported item * review/import flow that made including them here worthwhile): picking
* behaves exactly like picking a regular recipe, and picking one that * an already-imported item behaves exactly like picking a regular recipe,
* isn't imported yet hands off to that review screen, which adds the * and picking one that isn't imported yet hands off to that review
* freshly-created recipe straight to this slot once it's saved. * screen, which adds the freshly-created recipe straight to this slot
* once it's saved.
* *
* Mounted only while open (see `PlanningPage`, same conditional-mount * Mounted only while open (see `PlanningPage`, same conditional-mount
* convention as its own `CalendarPopover`) every piece of local state * convention as its own `CalendarPopover`) every piece of local state
@ -81,6 +88,8 @@ export function RecipePickerDialog({
const { t } = useTranslation(); const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<RecipesPageTab>("favoris"); const [activeTab, setActiveTab] = useState<RecipesPageTab>("favoris");
const activeSourceKey = parseSourceTabValue(activeTab);
const enabledSources = useEnabledSources();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState("");
const [selectedIngredientIds, setSelectedIngredientIds] = useState<number[]>([]); const [selectedIngredientIds, setSelectedIngredientIds] = useState<number[]>([]);
@ -128,9 +137,11 @@ export function RecipePickerDialog({
}, []); }, []);
useEffect(() => { useEffect(() => {
// The "sources" tab doesn't query the recipe table at all — same guard // A source's own tab doesn't query the recipe table at all — same
// as `RecipesPage`'s own identical effect. // guard as `RecipesPage`'s own identical effect (the type-guard, not
if (activeTab === "sources") return; // just `activeSourceKey !== null`, is what narrows `activeTab` to
// `RecipeTab` below).
if (isSourceTab(activeTab)) return;
let cancelled = false; let cancelled = false;
setListState({ status: "loading" }); setListState({ status: "loading" });
@ -157,7 +168,7 @@ export function RecipePickerDialog({
selectedIngredientIds.includes(ingredient.id), selectedIngredientIds.includes(ingredient.id),
); );
/** Picking an already-imported source item (`RecipeSourcesPanel`'s "sources" tab) — resolved to its full recipe, then treated exactly like picking that same recipe from one of the regular tabs, moving straight to the confirm-portions step below. */ /** Picking an already-imported source item (one of the source tabs' `RecipeSourcesPanel`) — resolved to its full recipe, then treated exactly like picking that same recipe from one of the regular tabs, moving straight to the confirm-portions step below. */
function handleSelectImportedRecipe(recipeId: number) { function handleSelectImportedRecipe(recipeId: number) {
setSourceSelectError(false); setSourceSelectError(false);
apiClient apiClient
@ -230,7 +241,7 @@ export function RecipePickerDialog({
return ( return (
<Dialog onClose={onClose} title={t("planning.picker.title")} className="recipe-picker-dialog"> <Dialog onClose={onClose} title={t("planning.picker.title")} className="recipe-picker-dialog">
{activeTab !== "sources" && ( {activeSourceKey === null && (
<div className="recipe-picker__filters"> <div className="recipe-picker__filters">
<input <input
type="search" type="search"
@ -295,9 +306,13 @@ export function RecipePickerDialog({
</div> </div>
)} )}
<RecipeTabs active={activeTab} onChange={setActiveTab} /> <RecipeTabs
active={activeTab}
onChange={setActiveTab}
sources={enabledSources.status === "loaded" ? enabledSources.sources : []}
/>
{activeTab === "sources" ? ( {activeSourceKey !== null ? (
<> <>
{sourceSelectError && ( {sourceSelectError && (
<p className="recipes-page__status recipes-page__status--error"> <p className="recipes-page__status recipes-page__status--error">
@ -305,6 +320,8 @@ export function RecipePickerDialog({
</p> </p>
)} )}
<RecipeSourcesPanel <RecipeSourcesPanel
key={activeSourceKey}
sourceKey={activeSourceKey}
planningSlot={slot} planningSlot={slot}
onSelectImportedRecipe={handleSelectImportedRecipe} onSelectImportedRecipe={handleSelectImportedRecipe}
/> />

View file

@ -1,7 +1,6 @@
import type { BrowsableSourceItemView, Meal, SourceView, WeekDay } from "@batch-cooking/shared"; import type { BrowsableSourceItemView, Meal, WeekDay } from "@batch-cooking/shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { apiClient } from "../../api/client"; import { apiClient } from "../../api/client";
import { RecipeDetailPanel, type RecipeDetailState } from "./RecipeDetailPanel"; import { RecipeDetailPanel, type RecipeDetailState } from "./RecipeDetailPanel";
import { SourceItemTable } from "./SourceItemTable"; import { SourceItemTable } from "./SourceItemTable";
@ -16,22 +15,27 @@ export interface SourceItemSelection {
externalId: string; externalId: string;
} }
type EnabledSourcesState =
| { status: "loading" }
| { status: "loaded"; sources: SourceView[] }
| { status: "error" };
type BrowseState = type BrowseState =
| { status: "loading" } | { status: "loading" }
| { status: "loaded"; items: BrowsableSourceItemView[]; nextCursor: string | null } | { status: "loaded"; items: BrowsableSourceItemView[]; nextCursor: string | null }
| { status: "error" }; | { status: "error" };
/** /**
* "Sources" tab content of the recipe catalog (`RecipesPage`) a * One household-enabled source's own tab content in the recipe catalog
* self-contained master-detail pair of its own (source selector + browsable * (`RecipesPage`/`RecipePickerDialog`) a master-detail pair of its own
* list on the left, a preview on the right), independent of `RecipeTable`'s * (browsable list on the left, a preview on the right), independent of
* own `RecipeTab`-based fetching: it browses a source's *live* catalog * `RecipeTable`'s own `RecipeTab`-based fetching: it browses this one
* (`GET /sources/:sourceKey/browse`), not the saved `Recipe` table. * source's *live* catalog (`GET /sources/:sourceKey/browse`), not the
* saved `Recipe` table.
*
* Scoped to exactly one source every enabled source gets its own tab
* now (`RecipeTabs`), rather than a single generic "Sources" tab
* switching between them internally, so `sourceKey` is a fixed prop, not
* something this component ever changes itself. Callers remount this
* (via a React `key={sourceKey}` on it) when switching which source's tab
* is active, the same "mounted only while relevant" convention as
* `RecipePickerDialog`/`CalendarPopover` elsewhere simpler than this
* component reacting to its own `sourceKey` prop changing mid-lifetime.
* *
* The right-hand preview reuses `RecipeDetailPanel` itself (its * The right-hand preview reuses `RecipeDetailPanel` itself (its
* `"loaded-draft"` state) rather than a separate component viewing a * `"loaded-draft"` state) rather than a separate component viewing a
@ -43,13 +47,11 @@ type BrowseState =
* recipe (`/recettes/:id`, leaving this tab) `onSelectImportedRecipe` * recipe (`/recettes/:id`, leaving this tab) `onSelectImportedRecipe`
* hands back the id instead of this panel navigating anywhere itself, since * hands back the id instead of this panel navigating anywhere itself, since
* what "viewing" an already-imported item means depends on the caller: * what "viewing" an already-imported item means depends on the caller:
* `RecipesPage` switches its own active tab away from `"sources"` (its * `RecipesPage` navigates to the recipe's detail page (switching its own
* `RecipeDetailPanel`/`RecipeTable` only render outside that tab, so * active tab first see its own doc comment), while `RecipePickerDialog`
* without switching first the URL would change but this panel would keep * instead treats it exactly like picking that recipe from one of the
* rendering over it) and navigates to the recipe's detail page, while * regular tabs moving to its own confirm-portions step, no navigation
* `RecipePickerDialog` instead treats it exactly like picking that recipe * at all.
* from one of the regular tabs moving to its own confirm-portions step,
* no navigation at all.
* *
* `initialSelection`/`onItemSelected` are how `RecipesPage` keeps a * `initialSelection`/`onItemSelected` are how `RecipesPage` keeps a
* not-yet-imported item's preview addressable by URL * not-yet-imported item's preview addressable by URL
@ -60,11 +62,13 @@ type BrowseState =
* no URL of its own to keep in sync. * no URL of its own to keep in sync.
*/ */
export function RecipeSourcesPanel({ export function RecipeSourcesPanel({
sourceKey,
onSelectImportedRecipe, onSelectImportedRecipe,
planningSlot, planningSlot,
initialSelection, initialSelection,
onItemSelected, onItemSelected,
}: { }: {
sourceKey: string;
onSelectImportedRecipe: (recipeId: number) => void; onSelectImportedRecipe: (recipeId: number) => void;
/** Forwarded as-is to `RecipeDetailPanel` — see its own doc comment. Only ever set by `RecipePickerDialog`. */ /** Forwarded as-is to `RecipeDetailPanel` — see its own doc comment. Only ever set by `RecipePickerDialog`. */
planningSlot?: { date: string; weekDay: WeekDay; meal: Meal }; planningSlot?: { date: string; weekDay: WeekDay; meal: Meal };
@ -73,10 +77,6 @@ export function RecipeSourcesPanel({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [enabledSources, setEnabledSources] = useState<EnabledSourcesState>({ status: "loading" });
const [selectedSourceKey, setSelectedSourceKey] = useState<string | null>(
initialSelection?.sourceKey ?? null,
);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState("");
const [browseState, setBrowseState] = useState<BrowseState>({ status: "loading" }); const [browseState, setBrowseState] = useState<BrowseState>({ status: "loading" });
@ -96,52 +96,22 @@ export function RecipeSourcesPanel({
// "needs a fetch" case the effect below must still run for. // "needs a fetch" case the effect below must still run for.
const [previewedItem, setPreviewedItem] = useState<SourceItemSelection | null>(null); const [previewedItem, setPreviewedItem] = useState<SourceItemSelection | null>(null);
// Loaded once — which sources exist, crossed with which the household
// has enabled (`/parametres/foyer`). Defaults the selector to the first
// enabled one, if any — but never overrides a source `initialSelection`
// already picked (the `current ??` below), so a deep link always wins.
useEffect(() => {
let cancelled = false;
Promise.all([apiClient.getSources(), apiClient.getHouseSourceIds()])
.then(([sources, enabledIds]) => {
if (cancelled) return;
const enabled = sources.filter((source) => enabledIds.includes(source.id));
setEnabledSources({ status: "loaded", sources: enabled });
setSelectedSourceKey((current) => current ?? enabled[0]?.key ?? null);
})
.catch(() => {
if (!cancelled) setEnabledSources({ status: "error" });
});
return () => {
cancelled = true;
};
}, []);
// Re-previews whenever `initialSelection` itself changes (a fresh deep // Re-previews whenever `initialSelection` itself changes (a fresh deep
// link, or the browser's back/forward button landing on a different // link, or the browser's back/forward button landing on a different
// item) — not just once on mount. Deliberately doesn't touch // item within this same source) — not just once on mount. Keyed on the
// `browseState`/the source dropdown beyond `selectedSourceKey` above: the // primitive field below, not `initialSelection` itself — a fresh object
// item list for whichever source this belongs to loads independently // literal from the caller on every render (see `RecipesPage`) would
// (see the effect below), on its own schedule. Keyed on the primitive // otherwise re-run this on every render too.
// fields below, not `initialSelection` itself — a fresh object literal
// from the caller on every render (see `RecipesPage`) would otherwise
// re-run this on every render too.
// biome-ignore lint/correctness/useExhaustiveDependencies: see above. // biome-ignore lint/correctness/useExhaustiveDependencies: see above.
useEffect(() => { useEffect(() => {
if (!initialSelection) return; if (!initialSelection) return;
if ( if (previewedItem?.externalId === initialSelection.externalId) return;
previewedItem?.sourceKey === initialSelection.sourceKey &&
previewedItem?.externalId === initialSelection.externalId
) {
return;
}
let cancelled = false; let cancelled = false;
setPreviewedItem(initialSelection); setPreviewedItem(initialSelection);
setSelectedSourceKey(initialSelection.sourceKey);
setSelectedExternalId(initialSelection.externalId); setSelectedExternalId(initialSelection.externalId);
setPreviewState({ status: "loading" }); setPreviewState({ status: "loading" });
apiClient apiClient
.previewSourceItem(initialSelection.sourceKey, initialSelection.externalId) .previewSourceItem(sourceKey, initialSelection.externalId)
.then((draft) => { .then((draft) => {
if (!cancelled) setPreviewState({ status: "loaded-draft", draft }); if (!cancelled) setPreviewState({ status: "loaded-draft", draft });
}) })
@ -151,27 +121,19 @@ export function RecipeSourcesPanel({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [initialSelection?.sourceKey, initialSelection?.externalId]); }, [initialSelection?.externalId]);
useEffect(() => { useEffect(() => {
const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS); const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS);
return () => window.clearTimeout(timeout); return () => window.clearTimeout(timeout);
}, [search]); }, [search]);
// Only manages `browseState` — deliberately doesn't reset the current
// item selection/preview when `selectedSourceKey` changes, so that the
// `initialSelection` effect above (which also sets `selectedSourceKey`,
// to reflect a deep link) isn't immediately undone by this one running
// straight after it in the same commit. Switching source via the
// dropdown clears the selection explicitly, in its own `onChange` below,
// where that reset is actually wanted.
useEffect(() => { useEffect(() => {
if (selectedSourceKey === null) return;
let cancelled = false; let cancelled = false;
setBrowseState({ status: "loading" }); setBrowseState({ status: "loading" });
apiClient apiClient
.browseSource(selectedSourceKey, { query: debouncedSearch.trim() || undefined }) .browseSource(sourceKey, { query: debouncedSearch.trim() || undefined })
.then(({ items, nextCursor }) => { .then(({ items, nextCursor }) => {
if (!cancelled) setBrowseState({ status: "loaded", items, nextCursor }); if (!cancelled) setBrowseState({ status: "loaded", items, nextCursor });
}) })
@ -182,15 +144,15 @@ export function RecipeSourcesPanel({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [selectedSourceKey, debouncedSearch]); }, [sourceKey, debouncedSearch]);
function handleLoadMore() { function handleLoadMore() {
if (selectedSourceKey === null || browseState.status !== "loaded" || !browseState.nextCursor) { if (browseState.status !== "loaded" || !browseState.nextCursor) {
return; return;
} }
const cursor = browseState.nextCursor; const cursor = browseState.nextCursor;
apiClient apiClient
.browseSource(selectedSourceKey, { query: debouncedSearch.trim() || undefined, cursor }) .browseSource(sourceKey, { query: debouncedSearch.trim() || undefined, cursor })
.then(({ items, nextCursor }) => { .then(({ items, nextCursor }) => {
setBrowseState((prev) => setBrowseState((prev) =>
prev.status === "loaded" prev.status === "loaded"
@ -206,8 +168,7 @@ export function RecipeSourcesPanel({
onSelectImportedRecipe(item.recipeId); onSelectImportedRecipe(item.recipeId);
return; return;
} }
if (selectedSourceKey === null) return; const selection = { sourceKey, externalId: item.externalId };
const selection = { sourceKey: selectedSourceKey, externalId: item.externalId };
setSelectedExternalId(item.externalId); setSelectedExternalId(item.externalId);
setPreviewState({ status: "loading" }); setPreviewState({ status: "loading" });
// Set before `onItemSelected` so the `initialSelection` effect above // Set before `onItemSelected` so the `initialSelection` effect above
@ -216,51 +177,14 @@ export function RecipeSourcesPanel({
setPreviewedItem(selection); setPreviewedItem(selection);
onItemSelected?.(selection); onItemSelected?.(selection);
apiClient apiClient
.previewSourceItem(selectedSourceKey, item.externalId) .previewSourceItem(sourceKey, item.externalId)
.then((draft) => setPreviewState({ status: "loaded-draft", draft })) .then((draft) => setPreviewState({ status: "loaded-draft", draft }))
.catch(() => setPreviewState({ status: "error" })); .catch(() => setPreviewState({ status: "error" }));
} }
if (enabledSources.status === "loading") {
return <p className="recipes-page__status">{t("recipes.loading")}</p>;
}
if (enabledSources.status === "error") {
return (
<p className="recipes-page__status recipes-page__status--error">{t("common.loadError")}</p>
);
}
if (enabledSources.sources.length === 0) {
return (
<p className="recipes-page__status">
{t("recipes.sources.noneEnabled")}{" "}
<Link to="/parametres/foyer">{t("recipes.sources.noneEnabledLink")}</Link>
</p>
);
}
return ( return (
<> <>
<div className="recipes-page__header recipes-page__header--sources"> <div className="recipes-page__header recipes-page__header--sources">
{enabledSources.sources.length > 1 && (
<select
className="source-sources-select"
aria-label={t("recipes.sources.sourceLabel")}
value={selectedSourceKey ?? ""}
onChange={(e) => {
setSelectedSourceKey(e.target.value);
setSelectedExternalId(null);
setPreviewState({ status: "empty" });
setPreviewedItem(null);
onItemSelected?.(null);
}}
>
{enabledSources.sources.map((source) => (
<option key={source.key} value={source.key}>
{source.name}
</option>
))}
</select>
)}
<input <input
type="search" type="search"
className="recipes-page__search" className="recipes-page__search"

View file

@ -1,4 +1,4 @@
import type { RecipeTab } from "@batch-cooking/shared"; import type { RecipeTab, SourceView } from "@batch-cooking/shared";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
@ -10,52 +10,79 @@ import {
} from "../../layouts/nav-icons"; } from "../../layouts/nav-icons";
import "./recipes.scss"; import "./recipes.scss";
/** Prefix marking a tab value as "browse this one enabled source's live catalog" rather than a real `RecipeTab` — see {@link sourceTabValue}/{@link parseSourceTabValue}, the one place this shape is assembled/read apart. */
const SOURCE_TAB_PREFIX = "source:";
/** /**
* A tab of the recipe catalog either a real {@link RecipeTab} (`GET * A tab of the recipe catalog either a real {@link RecipeTab} (`GET
* /recipes?tab=`, `recipe.service.ts`'s `listRecipes`) or `"sources"`, a * /recipes?tab=`, `recipe.service.ts`'s `listRecipes`) or a `"source:<key>"`
* web-only mode that doesn't query the recipe table at all: it browses a * value identifying one household-enabled external source, browsed live
* household-enabled external source's own catalog live
* (`GET /sources/:sourceKey/browse`, `RecipeSourcesPanel`) instead of * (`GET /sources/:sourceKey/browse`, `RecipeSourcesPanel`) instead of
* listing saved `Recipe` rows. Kept out of the shared `RecipeTab` type on * listing saved `Recipe` rows one tab per enabled source (see
* purpose the API has no `tab=sources` to validate. * `RecipeTabs` below), so switching between sources is as direct as
* switching between Perso/Foyer/Publique, not a single generic "Sources"
* tab hiding a second selector inside it. Kept out of the shared
* `RecipeTab` type on purpose the API has no such `tab=` value to
* validate, this is a web-only browsing mode.
*/ */
export type RecipesPageTab = RecipeTab | "sources"; export type RecipesPageTab = RecipeTab | `${typeof SOURCE_TAB_PREFIX}${string}`;
/** Every possible tab, in display order, with its icon — reuses `AccountIcon`/`HouseholdIcon` from the sidebar's own icon set (see nav-icons.tsx) rather than a second "person"/"house" glyph. */ /** Builds the tab value identifying `sourceKey`'s own tab. */
const ALL_TABS: Array<{ value: RecipesPageTab; Icon: LucideIcon }> = [ export function sourceTabValue(sourceKey: string): RecipesPageTab {
return `${SOURCE_TAB_PREFIX}${sourceKey}`;
}
/** The reverse of {@link sourceTabValue} — `null` for any tab that isn't a source tab (a real `RecipeTab`). */
export function parseSourceTabValue(tab: RecipesPageTab): string | null {
return isSourceTab(tab) ? tab.slice(SOURCE_TAB_PREFIX.length) : null;
}
/** Type guard version of the same check — narrows `tab` to a real `RecipeTab` in the `false` branch, which a plain `parseSourceTabValue(tab) === null` check can't (TS can't see through the function call). Needed wherever the narrowed value gets passed on to something typed as `RecipeTab`, e.g. `apiClient.listRecipes`. */
export function isSourceTab(tab: RecipesPageTab): tab is `${typeof SOURCE_TAB_PREFIX}${string}` {
return tab.startsWith(SOURCE_TAB_PREFIX);
}
/** The four real, DB-backed tabs, in display order, with their icon — reuses `AccountIcon`/`HouseholdIcon` from the sidebar's own icon set (see nav-icons.tsx) rather than a second "person"/"house" glyph. No "toutes" tab among them: every recipe a viewer can see falls under exactly one of perso/foyer/publique (its own visibility) — see `recipe.service.ts`'s `listRecipes`. */
const REAL_TABS: Array<{ value: RecipeTab; Icon: LucideIcon }> = [
{ value: "favoris", Icon: FavoriteIcon }, { value: "favoris", Icon: FavoriteIcon },
{ value: "perso", Icon: AccountIcon }, { value: "perso", Icon: AccountIcon },
{ value: "foyer", Icon: HouseholdIcon }, { value: "foyer", Icon: HouseholdIcon },
{ value: "publique", Icon: PublicIcon }, { value: "publique", Icon: PublicIcon },
{ value: "sources", Icon: SourcesIcon },
]; ];
/** /**
* Catalog tab bar Favoris / Perso / Foyer / Publique / Sources by * Catalog tab bar Favoris / Perso / Foyer / Publique, plus one tab per
* default (`/recettes`, `RecipesPage`). `tabs` narrows which of those * household-enabled source (e.g. "TheMealDB"), in that order. A source's
* show `RecipePickerDialog` (picking a recipe for a planning slot) * own icon (`SourceView.iconUrl`) is used when it has one, `SourcesIcon`
* passes just the four real ones: browsing external sources mid-dialog, * otherwise unlike the four real tabs, whose label comes from an i18n
* without the review/import flow, doesn't make sense there yet (its * key, a source tab's label is its own name as-is (there's no translation
* `onChange` narrows the result back to `RecipeTab` itself, safe exactly * for an arbitrary household-picked source's name).
* because `tabs` guarantees `"sources"` is never clickable there). No *
* "toutes" tab among the real ones: every recipe a viewer can see falls * `tabs` narrows which of the four *real* tabs show every enabled
* under exactly one of perso/foyer/publique (its own visibility) see * source still gets its own tab regardless (narrowing individual sources
* `recipe.service.ts`'s `listRecipes`. * doesn't make sense the way narrowing the four real ones does).
* `RecipePickerDialog` used to pass just the four real ones, before its
* own review/import flow existed to hand a picked source item off to; now
* every caller shows the full set, but the narrowing stays available for
* a future caller that still wants it.
*/ */
export function RecipeTabs({ export function RecipeTabs({
active, active,
onChange, onChange,
tabs = ALL_TABS.map((tab) => tab.value), sources,
tabs = REAL_TABS.map((tab) => tab.value),
}: { }: {
active: RecipesPageTab; active: RecipesPageTab;
onChange: (tab: RecipesPageTab) => void; onChange: (tab: RecipesPageTab) => void;
tabs?: readonly RecipesPageTab[]; /** Household-enabled sources, one tab each. Pass `[]` while still loading (see `useEnabledSources`) — that's indistinguishable from "none enabled" for this bar, which simply renders no source tabs either way. */
sources: readonly SourceView[];
tabs?: readonly RecipeTab[];
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="recipe-tabs"> <div className="recipe-tabs">
{ALL_TABS.filter(({ value }) => tabs.includes(value)).map(({ value, Icon }) => ( {REAL_TABS.filter(({ value }) => tabs.includes(value)).map(({ value, Icon }) => (
<button <button
key={value} key={value}
type="button" type="button"
@ -66,6 +93,24 @@ export function RecipeTabs({
{t(`recipes.tabs.${value}`)} {t(`recipes.tabs.${value}`)}
</button> </button>
))} ))}
{sources.map((source) => {
const value = sourceTabValue(source.key);
return (
<button
key={source.key}
type="button"
className={`recipe-tabs__tab${value === active ? " active" : ""}`}
onClick={() => onChange(value)}
>
{source.iconUrl ? (
<img src={source.iconUrl} alt="" className="recipe-tabs__source-icon" />
) : (
<SourcesIcon aria-hidden="true" />
)}
{source.name}
</button>
);
})}
</div> </div>
); );
} }

View file

@ -190,8 +190,9 @@
gap: var(--space-xs); gap: var(--space-xs);
// Never lets a tab overflow the page (which would force the whole body // Never lets a tab overflow the page (which would force the whole body
// to scroll horizontally, see global.scss's rule against that) — scrolls // to scroll horizontally, see global.scss's rule against that) — scrolls
// within itself instead once the tabs (including the disabled "Sources" // within itself instead once the tabs (favoris/perso/foyer/publique,
// placeholder) don't all fit, same pattern as the sidebar's own nav. // plus one per household-enabled source) don't all fit, same pattern as
// the sidebar's own nav.
overflow-x: auto; overflow-x: auto;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
margin-bottom: var(--space-md); margin-bottom: var(--space-md);
@ -222,6 +223,16 @@
flex: none; flex: none;
} }
// A source tab's own icon (`SourceView.iconUrl`) — sized to match the
// Lucide `svg` icons above so a source tab doesn't stand out from the
// four real ones.
.recipe-tabs__source-icon {
width: 1.05rem;
height: 1.05rem;
flex: none;
object-fit: contain;
}
&:hover { &:hover {
color: var(--color-text); color: var(--color-text);
} }
@ -340,23 +351,9 @@
// actually new to this tab gets its own rules here. // actually new to this tab gets its own rules here.
.recipes-page__header--sources { .recipes-page__header--sources {
// The source <select> only renders when the household has more than one
// enabled source (see RecipeSourcesPanel) this just keeps it visually
// grouped with the search field when it does.
gap: var(--space-sm); gap: var(--space-sm);
} }
.source-sources-select {
flex: none;
padding: 0.5rem var(--space-md);
font-family: var(--font-body);
font-size: var(--font-size-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
}
.source-item-table__imported-badge { .source-item-table__imported-badge {
padding: 0.1rem 0.5rem; padding: 0.1rem 0.5rem;
font-size: var(--font-size-xs); font-size: var(--font-size-xs);

View file

@ -0,0 +1,40 @@
import type { SourceView } from "@batch-cooking/shared";
import { useEffect, useState } from "react";
import { apiClient } from "../../api/client";
export type EnabledSourcesState =
| { status: "loading" }
| { status: "loaded"; sources: SourceView[] }
| { status: "error" };
/**
* Which sources exist, crossed with which the household has enabled
* (`/parametres/foyer`) shared by `RecipesPage` and `RecipePickerDialog`,
* both of which need this to render one `RecipeTabs` tab per enabled
* source (see `RecipeTabs`' own doc comment). Loaded once per mount, not
* re-fetched on every render a household's enabled sources only change
* from the settings page, never from here.
*/
export function useEnabledSources(): EnabledSourcesState {
const [state, setState] = useState<EnabledSourcesState>({ status: "loading" });
useEffect(() => {
let cancelled = false;
Promise.all([apiClient.getSources(), apiClient.getHouseSourceIds()])
.then(([sources, enabledIds]) => {
if (cancelled) return;
setState({
status: "loaded",
sources: sources.filter((source) => enabledIds.includes(source.id)),
});
})
.catch(() => {
if (!cancelled) setState({ status: "error" });
});
return () => {
cancelled = true;
};
}, []);
return state;
}

View file

@ -162,8 +162,7 @@
"favoris": "Favoris", "favoris": "Favoris",
"perso": "Perso", "perso": "Perso",
"foyer": "Foyer", "foyer": "Foyer",
"publique": "Publique", "publique": "Publique"
"sources": "Sources"
}, },
"table": { "table": {
"name": "Nom", "name": "Nom",
@ -171,9 +170,6 @@
"diets": "Régime associé" "diets": "Régime associé"
}, },
"sources": { "sources": {
"sourceLabel": "Source",
"noneEnabled": "Aucune source n'est activée pour votre foyer.",
"noneEnabledLink": "Activez-en une dans les paramètres du foyer",
"searchPlaceholder": "Rechercher…", "searchPlaceholder": "Rechercher…",
"empty": "Aucune recette trouvée.", "empty": "Aucune recette trouvée.",
"loadMore": "Voir plus", "loadMore": "Voir plus",

View file

@ -9,7 +9,14 @@ import {
type SourceItemSelection, type SourceItemSelection,
} from "../features/recipes/RecipeSourcesPanel"; } from "../features/recipes/RecipeSourcesPanel";
import { RecipeTable } from "../features/recipes/RecipeTable"; import { RecipeTable } from "../features/recipes/RecipeTable";
import { RecipeTabs, type RecipesPageTab } from "../features/recipes/RecipeTabs"; import {
RecipeTabs,
type RecipesPageTab,
isSourceTab,
parseSourceTabValue,
sourceTabValue,
} from "../features/recipes/RecipeTabs";
import { useEnabledSources } from "../features/recipes/useEnabledSources";
import "../features/recipes/recipes.scss"; import "../features/recipes/recipes.scss";
/** Debounce for the search field — avoids firing a request on every keystroke, same idea as the household name's autosave (`HouseholdSettingsPage`). */ /** Debounce for the search field — avoids firing a request on every keystroke, same idea as the household name's autosave (`HouseholdSettingsPage`). */
@ -32,8 +39,8 @@ type RecipeListState =
* *
* The `sources` route exists so a not-yet-imported item is just as * The `sources` route exists so a not-yet-imported item is just as
* addressable/deep-linkable as a real recipe's `/recettes/:id` without * addressable/deep-linkable as a real recipe's `/recettes/:id` without
* it, selecting one inside the "Sources" tab only changed local component * it, selecting one inside its source's own tab only changed local
* state, with no URL of its own (see `RecipeSourcesPanel`'s * component state, with no URL of its own (see `RecipeSourcesPanel`'s
* `initialSelection`/`onItemSelected`, which this page drives). * `initialSelection`/`onItemSelected`, which this page drives).
*/ */
export function RecipesPage() { export function RecipesPage() {
@ -47,6 +54,7 @@ export function RecipesPage() {
const selectedId = id !== undefined ? Number(id) : null; const selectedId = id !== undefined ? Number(id) : null;
const selectedSourceItem: SourceItemSelection | undefined = const selectedSourceItem: SourceItemSelection | undefined =
sourceKey !== undefined && externalId !== undefined ? { sourceKey, externalId } : undefined; sourceKey !== undefined && externalId !== undefined ? { sourceKey, externalId } : undefined;
const enabledSources = useEnabledSources();
// `?search=` lets another page (the recipe form's "faisable maison" // `?search=` lets another page (the recipe form's "faisable maison"
// badge, see `ReproducibleBadge`) deep-link straight into a pre-filled // badge, see `ReproducibleBadge`) deep-link straight into a pre-filled
// search — read once on mount, not kept in sync on every keystroke // search — read once on mount, not kept in sync on every keystroke
@ -55,13 +63,14 @@ export function RecipesPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
// Deep-linking straight into `/recettes/sources/:sourceKey/:externalId` // Deep-linking straight into `/recettes/sources/:sourceKey/:externalId`
// must land on the "Sources" tab — otherwise `RecipeSourcesPanel` (which // must land on that source's own tab — otherwise `RecipeSourcesPanel`
// reads this URL via `selectedSourceItem` below) wouldn't even be // (which reads this URL via `selectedSourceItem` below) wouldn't even be
// mounted to show it. Lazy initializer: only matters for this page's // mounted to show it. Lazy initializer: only matters for this page's
// very first render, same reasoning as `search`'s below. // very first render, same reasoning as `search`'s below.
const [activeTab, setActiveTab] = useState<RecipesPageTab>(() => const [activeTab, setActiveTab] = useState<RecipesPageTab>(() =>
selectedSourceItem ? "sources" : "favoris", selectedSourceItem ? sourceTabValue(selectedSourceItem.sourceKey) : "favoris",
); );
const activeSourceKey = parseSourceTabValue(activeTab);
const [search, setSearch] = useState(() => searchParams.get("search") ?? ""); const [search, setSearch] = useState(() => searchParams.get("search") ?? "");
// Seeded from the same initial value as `search` — otherwise the first // Seeded from the same initial value as `search` — otherwise the first
// fetch below would fire with an empty term (the debounce effect hasn't // fetch below would fire with an empty term (the debounce effect hasn't
@ -77,10 +86,12 @@ export function RecipesPage() {
}, [search]); }, [search]);
useEffect(() => { useEffect(() => {
// The "sources" tab doesn't query the recipe table at all — it browses // A source's own tab doesn't query the recipe table at all — it
// a source's own live catalog instead (see `RecipeSourcesPanel`, which // browses that source's live catalog instead (see `RecipeSourcesPanel`,
// owns its own fetching entirely). // which owns its own fetching entirely). The type-guard (not just
if (activeTab === "sources") return; // `activeSourceKey !== null`) is what lets `activeTab` narrow to
// `RecipeTab` below, for `apiClient.listRecipes`.
if (isSourceTab(activeTab)) return;
let cancelled = false; let cancelled = false;
setListState({ status: "loading" }); setListState({ status: "loading" });
@ -165,7 +176,7 @@ 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>
{activeTab !== "sources" && ( {activeSourceKey === null && (
<input <input
type="search" type="search"
className="recipes-page__search" className="recipes-page__search"
@ -179,11 +190,26 @@ export function RecipesPage() {
</Link> </Link>
</div> </div>
<RecipeTabs active={activeTab} onChange={setActiveTab} /> <RecipeTabs
active={activeTab}
onChange={setActiveTab}
sources={enabledSources.status === "loaded" ? enabledSources.sources : []}
/>
{activeTab === "sources" ? ( {activeSourceKey !== null ? (
<RecipeSourcesPanel <RecipeSourcesPanel
initialSelection={selectedSourceItem} key={activeSourceKey}
sourceKey={activeSourceKey}
// 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)
// catches up, since only picking a *row* navigates, not switching
// tabs. Passing it through unguarded would have the freshly
// (`key`-forced) remounted panel try to preview "9999" against the
// wrong source.
initialSelection={
selectedSourceItem?.sourceKey === activeSourceKey ? selectedSourceItem : undefined
}
onItemSelected={(item) => onItemSelected={(item) =>
navigate( navigate(
item item