Dernière étape du plan « onglet Sources » : le sélecteur de recette du planning (`RecipePickerDialog`) gagne l'onglet « Sources », jusqu'ici volontairement exclu faute d'écran de revue à qui transmettre un item choisi (voir étape 3, #47). - Sélectionner un item déjà importé se comporte exactement comme choisir cette même recette depuis un onglet normal (résolue via `GET /recipes/:id`, direction vers l'étape « combien de portions ? » du dialogue, sans navigation). - Sélectionner un item pas encore importé bascule vers l'écran de revue existant (`ImportRecipePage`), avec le créneau du planning porté par la query string (`?planningDate=&planningWeekDay=&planningMeal=`). Un import réussi y ajoute alors automatiquement la recette fraîchement créée à ce créneau (`POST /planning/items`, avec les portions du formulaire) avant de revenir sur le planning — plutôt que d'atterrir sur la page de la recette comme le fait un import « classique ». - `RecipeSourcesPanel`/`SourceItemPreviewPanel` généralisés en conséquence : la première ne navigue plus elle-même vers la recette déjà importée (`onSelectImportedRecipe` renvoie l'id, chaque appelant décide), la seconde propage le créneau optionnel sur son lien d'import. Aucun changement backend : `POST /sources/:sourceKey/import/:externalId` et `POST /planning/items` existaient déjà et suffisent tels quels — une fois la recette importée, `GET /sources/:sourceKey/browse` la marque déjà `alreadyImported` automatiquement (logique déjà couverte par `sources.test.ts`). 282 tests API toujours au vert, aucune régression. Tests : - Cypress : nouveau scénario Gherkin bout-en-bout (`cypress/e2e/planning.feature`/`planning.ts`) — ouvrir le sélecteur depuis un créneau vide, parcourir Sources, importer un item non résolu (ingrédient à compléter compris), vérifier que la requête d'ajout au planning porte bien le bon créneau/les bonnes portions, que la recette apparaît dans la bonne case de la grille après le retour sur "/", puis que rebrowser la source la marque désormais comme déjà importée. Suite : plan « onglet Sources » terminé (étapes 1 à 4). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
199 lines
7.7 KiB
TypeScript
199 lines
7.7 KiB
TypeScript
import { ErrorCode, type RecipeSummaryView } from "@batch-cooking/shared";
|
|
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
|
import { ApiError, apiClient } from "../api/client";
|
|
import { RecipeDetailPanel, type RecipeDetailState } from "../features/recipes/RecipeDetailPanel";
|
|
import { RecipeSourcesPanel } from "../features/recipes/RecipeSourcesPanel";
|
|
import { RecipeTable } from "../features/recipes/RecipeTable";
|
|
import { RecipeTabs, type RecipesPageTab } from "../features/recipes/RecipeTabs";
|
|
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`). */
|
|
const SEARCH_DEBOUNCE_MS = 300;
|
|
|
|
/** Load state for the catalog table (`GET /recipes?tab=...`) — a discriminated union so a stale/impossible combination (e.g. "loading" with data) can't be represented, same pattern as `PlanningPage`'s `PlanningState`. */
|
|
type RecipeListState =
|
|
| { status: "loading" }
|
|
| { status: "loaded"; recipes: RecipeSummaryView[] }
|
|
| { status: "error" };
|
|
|
|
/**
|
|
* Recipe catalog — routed at both `/recettes` and `/recettes/:id` (the same
|
|
* component either way, see `App.tsx`): a tab bar + table on the left stay
|
|
* mounted at all times, only the right-hand detail panel changes with the
|
|
* `:id` param — a master-detail layout, not a navigation to a separate
|
|
* page (see `RecipeDetailPanel`, which replaces the earlier standalone
|
|
* `RecipeDetailPage`).
|
|
*/
|
|
export function RecipesPage() {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const { id } = useParams<{ id: string }>();
|
|
const selectedId = id !== undefined ? Number(id) : null;
|
|
// `?search=` lets another page (the recipe form's "faisable maison"
|
|
// badge, see `ReproducibleBadge`) deep-link straight into a pre-filled
|
|
// search — read once on mount, not kept in sync on every keystroke
|
|
// afterwards (this page doesn't own the URL the way e.g. a shareable
|
|
// filter view would).
|
|
const [searchParams] = useSearchParams();
|
|
|
|
const [activeTab, setActiveTab] = useState<RecipesPageTab>("favoris");
|
|
const [search, setSearch] = useState(() => searchParams.get("search") ?? "");
|
|
// Seeded from the same initial value as `search` — otherwise the first
|
|
// fetch below would fire with an empty term (the debounce effect hasn't
|
|
// run yet), then a second one 300ms later once it catches up.
|
|
const [debouncedSearch, setDebouncedSearch] = useState(() => searchParams.get("search") ?? "");
|
|
const [listState, setListState] = useState<RecipeListState>({ status: "loading" });
|
|
const [detailState, setDetailState] = useState<RecipeDetailState>({ status: "empty" });
|
|
const [dislikedIngredientIds, setDislikedIngredientIds] = useState<number[]>([]);
|
|
|
|
useEffect(() => {
|
|
const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS);
|
|
return () => window.clearTimeout(timeout);
|
|
}, [search]);
|
|
|
|
useEffect(() => {
|
|
// The "sources" tab doesn't query the recipe table at all — it browses
|
|
// a source's own live catalog instead (see `RecipeSourcesPanel`, which
|
|
// owns its own fetching entirely).
|
|
if (activeTab === "sources") return;
|
|
let cancelled = false;
|
|
setListState({ status: "loading" });
|
|
|
|
apiClient
|
|
.listRecipes(activeTab, { search: debouncedSearch.trim() || undefined })
|
|
.then((recipes) => {
|
|
if (!cancelled) setListState({ status: "loaded", recipes });
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setListState({ status: "error" });
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [activeTab, debouncedSearch]);
|
|
|
|
useEffect(() => {
|
|
if (selectedId === null) {
|
|
setDetailState({ status: "empty" });
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
setDetailState({ status: "loading" });
|
|
|
|
apiClient
|
|
.getRecipe(selectedId)
|
|
.then((recipe) => {
|
|
if (!cancelled) setDetailState({ status: "loaded", recipe });
|
|
})
|
|
.catch((err) => {
|
|
if (cancelled) return;
|
|
if (err instanceof ApiError && err.code === ErrorCode.RECIPE_NOT_FOUND) {
|
|
setDetailState({ status: "not-found" });
|
|
} else {
|
|
setDetailState({ status: "error" });
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [selectedId]);
|
|
|
|
// The viewer's personal "disliked" list only changes from the
|
|
// preferences page, never from here — loaded once, not re-fetched on
|
|
// every tab/selection change.
|
|
useEffect(() => {
|
|
apiClient
|
|
.getDislikedIngredientIds()
|
|
.then(setDislikedIngredientIds)
|
|
.catch(() => setDislikedIngredientIds([]));
|
|
}, []);
|
|
|
|
/** Keeps the table row's fav-mark and the `favoris` tab's membership in sync with a toggle made from the detail panel, without a full reload. */
|
|
function handleFavoriteToggled(recipeId: number, isFavorite: boolean) {
|
|
setDetailState((prev) =>
|
|
prev.status === "loaded" && prev.recipe.id === recipeId
|
|
? { status: "loaded", recipe: { ...prev.recipe, isFavorite } }
|
|
: prev,
|
|
);
|
|
setListState((prev) => {
|
|
if (prev.status !== "loaded") return prev;
|
|
const recipes = prev.recipes
|
|
.map((recipe) => (recipe.id === recipeId ? { ...recipe, isFavorite } : recipe))
|
|
.filter((recipe) => activeTab !== "favoris" || recipe.isFavorite);
|
|
return { status: "loaded", recipes };
|
|
});
|
|
}
|
|
|
|
/** After a delete, the removed recipe can no longer be selected, and the table must drop it too. */
|
|
function handleDeleted(recipeId: number) {
|
|
navigate("/recettes");
|
|
setListState((prev) =>
|
|
prev.status === "loaded"
|
|
? { status: "loaded", recipes: prev.recipes.filter((r) => r.id !== recipeId) }
|
|
: prev,
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="recipes-page">
|
|
<div className="recipes-page__header">
|
|
<h1>{t("recipes.title")}</h1>
|
|
{activeTab !== "sources" && (
|
|
<input
|
|
type="search"
|
|
className="recipes-page__search"
|
|
placeholder={t("recipes.searchPlaceholder")}
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
/>
|
|
)}
|
|
<Link to="/recettes/nouvelle" className="recipes-page__new-button">
|
|
{t("recipes.newButton")}
|
|
</Link>
|
|
</div>
|
|
|
|
<RecipeTabs active={activeTab} onChange={setActiveTab} />
|
|
|
|
{activeTab === "sources" ? (
|
|
<RecipeSourcesPanel
|
|
onSelectImportedRecipe={(recipeId) => {
|
|
setActiveTab("favoris");
|
|
navigate(`/recettes/${recipeId}`);
|
|
}}
|
|
/>
|
|
) : (
|
|
<div className="recipes-page__catalog">
|
|
{listState.status === "loading" && (
|
|
<p className="recipes-page__status">{t("recipes.loading")}</p>
|
|
)}
|
|
{listState.status === "error" && (
|
|
<p className="recipes-page__status recipes-page__status--error">
|
|
{t("common.loadError")}
|
|
</p>
|
|
)}
|
|
{listState.status === "loaded" && listState.recipes.length === 0 && (
|
|
<p className="recipes-page__status">{t("recipes.empty")}</p>
|
|
)}
|
|
{listState.status === "loaded" && listState.recipes.length > 0 && (
|
|
<RecipeTable
|
|
recipes={listState.recipes}
|
|
selectedId={selectedId}
|
|
onSelect={(recipeId) => navigate(`/recettes/${recipeId}`)}
|
|
/>
|
|
)}
|
|
|
|
<RecipeDetailPanel
|
|
state={detailState}
|
|
dislikedIngredientIds={dislikedIngredientIds}
|
|
onFavoriteToggled={handleFavoriteToggled}
|
|
onDeleted={handleDeleted}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|