Correction de comportement sur la gestion des recettes de sources externes — l'implémentation précédente avait dérivé d'une lecture erronée du besoin : - Plus aucun bouton d'import nulle part. Parcourir une source (RecipesPage, hors planning) ne fait plus jamais que prévisualiser — RecipeDetailPanel n'affiche plus de lien "Importer cette recette", seulement un bouton icône discret vers la page d'origine quand la recette en a une (nouveau .recipe-detail-panel__source-link, même emplacement que l'étoile favori). - Une recette externe n'est importée dans la base qu'au moment où quelqu'un l'ajoute effectivement à son planning — jamais avant. RecipePickerDialog.handleSelectDraftItem est désormais le seul endroit de toute l'appli qui importe quoi que ce soit : cliquer sur un item pas encore importé y déclenche une tentative d'import transparente (POST /sources/.../import puis POST /planning/items), sans écran intermédiaire, dès que rien ne manque (tryBuildCompleteImport, nouveau apps/web/src/features/recipes/ recipe-import-draft.ts). Seul un ingrédient non résolu (ou une erreur réseau) fait encore basculer vers l'écran de revue existant (ImportRecipePage), pré-rempli, pour compléter ce qui manque. - RecipeSourcesPanel gagne onSelectDraftItem (remplace planningSlot, qui n'a plus de raison d'être puisqu'il n'y a plus de lien d'import à qui le transmettre) : quand ce callback est fourni (RecipePickerDialog uniquement), un item pas encore importé n'est plus prévisualisé sur place, il est remonté tel quel à l'appelant. Tests : - planning.feature : le scénario existant retire l'étape "je clique le lien Importer cette recette" (redirection désormais automatique puisque le draft de test a un ingrédient non résolu) ; nouveau scénario pour le chemin transparent (draft entièrement résolu, aucun écran de revue). - recipe-sources.feature : le scénario qui important depuis /recettes (hors planning) est supprimé — cette capacité n'existe plus hors planning. Le scénario de deep-link vérifie maintenant l'absence du bouton d'import et la présence du lien discret. - pnpm exec tsc -b --force (web) — propre. - pnpm exec biome check — propre. - pnpm --filter web build — propre. - Cypress non exécutable localement sur cette machine (crash GPU Electron connu) — scénarios vérifiés par relecture attentive contre le markup/les clés i18n réels ; CI (GitHub Actions) fera foi à l'exécution. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
446 lines
17 KiB
TypeScript
446 lines
17 KiB
TypeScript
import {
|
|
type BrowsableSourceItemView,
|
|
type DietView,
|
|
ErrorCode,
|
|
type IngredientView,
|
|
type Meal,
|
|
type PlanningItemView,
|
|
type RecipeSummaryView,
|
|
type RecipeView,
|
|
type WeekDay,
|
|
} from "@batch-cooking/shared";
|
|
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { ApiError, apiClient } from "../../api/client";
|
|
import { CheckboxOption } from "../../components/ui/Checkbox";
|
|
import { Dialog } from "../../components/ui/Dialog";
|
|
import { errorMessageService } from "../../services/error-message.service";
|
|
import { DietTagSelect } from "../recipes/DietTagSelect";
|
|
import { IngredientPicker } from "../recipes/IngredientPicker";
|
|
import { RecipeSourcesPanel } from "../recipes/RecipeSourcesPanel";
|
|
import { RecipeTable } from "../recipes/RecipeTable";
|
|
import {
|
|
RecipeTabs,
|
|
type RecipesPageTab,
|
|
isSourceTab,
|
|
parseSourceTabValue,
|
|
} from "../recipes/RecipeTabs";
|
|
import { tryBuildCompleteImport } from "../recipes/recipe-import-draft";
|
|
import { useEnabledSources } from "../recipes/useEnabledSources";
|
|
import "./recipe-picker-dialog.scss";
|
|
|
|
/** Debounce for the search field — same value as `RecipesPage`'s. */
|
|
const SEARCH_DEBOUNCE_MS = 300;
|
|
|
|
/** Load state for the filtered catalog list, same discriminated-union shape as `RecipesPage`'s `RecipeListState`. */
|
|
type ListState =
|
|
| { status: "loading" }
|
|
| { status: "loaded"; recipes: RecipeSummaryView[] }
|
|
| { status: "error" };
|
|
|
|
/**
|
|
* The (day, meal) slot a `RecipePickerDialog` is adding a recipe to —
|
|
* `date` is that day's `YYYY-MM-DD` (the specific date within the
|
|
* displayed week, not just its weekday), needed by `POST /planning/items`
|
|
* to resolve which week's `Planning` row to attach to.
|
|
*/
|
|
export interface PlanningSlot {
|
|
date: string;
|
|
weekDay: WeekDay;
|
|
meal: Meal;
|
|
}
|
|
|
|
/**
|
|
* Recipe-selection dialog opened from a planning grid cell's "+" button
|
|
* (see `PlanningPage.tsx`'s `MealCell`) — the same catalog browsing
|
|
* experience as `/recettes` (`RecipeTabs` + `RecipeTable`, reused as-is),
|
|
* with three extra filters layered on top of the plain name search
|
|
* (ingredients / regime / "convient à tout le foyer" toggle, all wired to
|
|
* `GET /recipes`'s corresponding query params) since browsing here is
|
|
* about finding something to cook, not just looking something up. Each
|
|
* household-enabled source's own tab is included too: picking an
|
|
* already-imported item behaves exactly like picking a regular recipe,
|
|
* and picking one that isn't imported yet is what actually imports it —
|
|
* nowhere else in the app does (see `handleSelectDraftItem`) — since a
|
|
* source item only ever becomes a real, saved `Recipe` as a side effect of
|
|
* someone adding it to their planning.
|
|
*
|
|
* Mounted only while open (see `PlanningPage`, same conditional-mount
|
|
* convention as its own `CalendarPopover`) — every piece of local state
|
|
* below resets for free the next time it's reopened, no manual reset
|
|
* needed.
|
|
*
|
|
* Selecting a row doesn't navigate anywhere (unlike `RecipesPage`'s own
|
|
* use of `RecipeTable`) — it switches this same dialog to a small
|
|
* "how many portions?" confirmation step, then calls `POST
|
|
* /planning/items` on submit. Picking a not-yet-imported source item is
|
|
* handled differently still (`handleSelectDraftItem`): when the draft has
|
|
* everything a real recipe needs, it's imported and added to this slot
|
|
* transparently — no extra screen, same as picking anything else. Only
|
|
* when something's actually missing (an ingredient the automatic matcher
|
|
* couldn't resolve, say) does this navigate away entirely, to the review
|
|
* screen (`/recettes/importer/...`), which has its own portions field
|
|
* already.
|
|
*/
|
|
export function RecipePickerDialog({
|
|
slot,
|
|
onClose,
|
|
onAdded,
|
|
}: {
|
|
slot: PlanningSlot;
|
|
onClose: () => void;
|
|
onAdded: (item: PlanningItemView) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
|
|
const [activeTab, setActiveTab] = useState<RecipesPageTab>("favoris");
|
|
const activeSourceKey = parseSourceTabValue(activeTab);
|
|
const enabledSources = useEnabledSources();
|
|
const [search, setSearch] = useState("");
|
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
|
const [selectedIngredientIds, setSelectedIngredientIds] = useState<number[]>([]);
|
|
const [selectedDietIds, setSelectedDietIds] = useState<number[]>([]);
|
|
const [suitableForHousehold, setSuitableForHousehold] = useState(false);
|
|
const [hasHousehold, setHasHousehold] = useState(false);
|
|
const [isIngredientPickerOpen, setIsIngredientPickerOpen] = useState(false);
|
|
|
|
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
|
|
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
|
|
const [listState, setListState] = useState<ListState>({ status: "loading" });
|
|
// Set when picking an already-imported source item fails to resolve to a
|
|
// real recipe (see `handleSelectImportedRecipe`) — a rare race (the
|
|
// recipe was deleted between the browse fetch and the click), surfaced
|
|
// the same way any other catalog load error is on this dialog.
|
|
const [sourceSelectError, setSourceSelectError] = useState(false);
|
|
// True while `handleSelectDraftItem` below is resolving a not-yet-
|
|
// imported item's transparent-import attempt — replaces the source tab's
|
|
// whole panel with a status message for that brief window (fetch the
|
|
// draft, maybe import it, maybe add it to the slot) rather than leaving
|
|
// the browse list clickable mid-flight.
|
|
const [isAddingDraft, setIsAddingDraft] = useState(false);
|
|
|
|
// The recipe picked in step 1 — `null` while still browsing, set once a
|
|
// row is clicked to switch this dialog into its confirmation step.
|
|
const [selectedRecipe, setSelectedRecipe] = useState<RecipeSummaryView | null>(null);
|
|
const [portions, setPortions] = useState("1");
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS);
|
|
return () => window.clearTimeout(timeout);
|
|
}, [search]);
|
|
|
|
// Reference lists + "does the viewer have a household" — loaded once,
|
|
// they don't change while the dialog is open.
|
|
useEffect(() => {
|
|
apiClient
|
|
.getIngredients()
|
|
.then(setIngredientsCatalog)
|
|
.catch(() => setIngredientsCatalog([]));
|
|
apiClient
|
|
.getDiets()
|
|
.then(setDietsCatalog)
|
|
.catch(() => setDietsCatalog([]));
|
|
apiClient
|
|
.getCurrentHouse()
|
|
.then((house) => setHasHousehold(house !== null))
|
|
.catch(() => setHasHousehold(false));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
// A source's own tab doesn't query the recipe table at all — same
|
|
// guard as `RecipesPage`'s own identical effect (the type-guard, not
|
|
// just `activeSourceKey !== null`, is what narrows `activeTab` to
|
|
// `RecipeTab` below).
|
|
if (isSourceTab(activeTab)) return;
|
|
let cancelled = false;
|
|
setListState({ status: "loading" });
|
|
|
|
apiClient
|
|
.listRecipes(activeTab, {
|
|
search: debouncedSearch.trim() || undefined,
|
|
suitableForHousehold: suitableForHousehold || undefined,
|
|
ingredientIds: selectedIngredientIds,
|
|
dietIds: selectedDietIds,
|
|
})
|
|
.then((recipes) => {
|
|
if (!cancelled) setListState({ status: "loaded", recipes });
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setListState({ status: "error" });
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [activeTab, debouncedSearch, selectedIngredientIds, selectedDietIds, suitableForHousehold]);
|
|
|
|
const selectedIngredients = ingredientsCatalog.filter((ingredient) =>
|
|
selectedIngredientIds.includes(ingredient.id),
|
|
);
|
|
|
|
/** 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) {
|
|
setSourceSelectError(false);
|
|
apiClient
|
|
.getRecipe(recipeId)
|
|
.then((recipe) => {
|
|
setSelectedRecipe(recipe);
|
|
setPortions(String(recipe.portions));
|
|
})
|
|
.catch(() => setSourceSelectError(true));
|
|
}
|
|
|
|
/** Navigates away to the full review/creation screen, pre-filled from this exact item and carrying `slot` along so a successful import there adds straight to it — the fallback `handleSelectDraftItem` below takes whenever a transparent import isn't possible. */
|
|
function goToReviewScreen(sourceKey: string, externalId: string) {
|
|
navigate(
|
|
`/recettes/importer/${sourceKey}/${encodeURIComponent(externalId)}` +
|
|
`?planningDate=${slot.date}&planningWeekDay=${slot.weekDay}&planningMeal=${slot.meal}`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Picking a not-yet-imported source item — the one action in the whole
|
|
* app that actually imports one (see this component's own doc comment).
|
|
* Fetches its full draft, and when {@link tryBuildCompleteImport} finds
|
|
* nothing missing, imports it and adds it to `slot` transparently: no
|
|
* extra screen, same end result as picking any other recipe. Anything
|
|
* short of that — an unresolved ingredient, a network hiccup on any of
|
|
* these three calls — falls back to the full review screen
|
|
* (`goToReviewScreen`) instead, since only a person can supply what's
|
|
* actually missing.
|
|
*/
|
|
async function handleSelectDraftItem(item: BrowsableSourceItemView) {
|
|
if (activeSourceKey === null) return;
|
|
const sourceKey = activeSourceKey;
|
|
setSourceSelectError(false);
|
|
setIsAddingDraft(true);
|
|
|
|
let payload: ReturnType<typeof tryBuildCompleteImport>;
|
|
try {
|
|
payload = tryBuildCompleteImport(
|
|
await apiClient.previewSourceItem(sourceKey, item.externalId),
|
|
);
|
|
} catch {
|
|
goToReviewScreen(sourceKey, item.externalId);
|
|
return;
|
|
}
|
|
if (!payload) {
|
|
setIsAddingDraft(false);
|
|
goToReviewScreen(sourceKey, item.externalId);
|
|
return;
|
|
}
|
|
|
|
let saved: RecipeView;
|
|
try {
|
|
saved = await apiClient.importSourceItem(sourceKey, item.externalId, payload);
|
|
} catch {
|
|
setIsAddingDraft(false);
|
|
goToReviewScreen(sourceKey, item.externalId);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const planningItem = await apiClient.addPlanningItem({
|
|
date: slot.date,
|
|
weekDay: slot.weekDay,
|
|
meal: slot.meal,
|
|
recipeId: saved.id,
|
|
portions: payload.portions,
|
|
});
|
|
onAdded(planningItem);
|
|
onClose();
|
|
} catch {
|
|
// The recipe itself is already saved at this point — only adding it
|
|
// to this slot failed. Land on its own page rather than retrying the
|
|
// whole import through the review form (same fallback
|
|
// `ImportRecipePage`'s own submit takes for the identical failure).
|
|
navigate(`/recettes/${saved.id}`);
|
|
}
|
|
}
|
|
|
|
async function handleConfirm() {
|
|
if (!selectedRecipe) return;
|
|
const parsedPortions = Number(portions);
|
|
if (!Number.isInteger(parsedPortions) || parsedPortions < 1) return;
|
|
|
|
setIsSubmitting(true);
|
|
setSubmitError(null);
|
|
try {
|
|
const item = await apiClient.addPlanningItem({
|
|
date: slot.date,
|
|
weekDay: slot.weekDay,
|
|
meal: slot.meal,
|
|
recipeId: selectedRecipe.id,
|
|
portions: parsedPortions,
|
|
});
|
|
onAdded(item);
|
|
onClose();
|
|
} catch (err) {
|
|
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
|
setSubmitError(errorMessageService.getLabel(code));
|
|
setIsSubmitting(false);
|
|
}
|
|
}
|
|
|
|
if (selectedRecipe) {
|
|
return (
|
|
<Dialog
|
|
onClose={onClose}
|
|
title={t("planning.picker.confirmTitle", { recipe: selectedRecipe.name })}
|
|
>
|
|
<div className="recipe-picker-confirm">
|
|
<label htmlFor="planning-picker-portions">{t("planning.picker.portionsLabel")}</label>
|
|
<input
|
|
id="planning-picker-portions"
|
|
type="number"
|
|
min="1"
|
|
step="1"
|
|
value={portions}
|
|
onChange={(e) => setPortions(e.target.value)}
|
|
/>
|
|
{submitError && <p className="field-error">{submitError}</p>}
|
|
<div className="recipe-picker-confirm__actions">
|
|
<button type="button" onClick={() => setSelectedRecipe(null)} disabled={isSubmitting}>
|
|
{t("planning.picker.backButton")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="recipe-picker-confirm__confirm"
|
|
onClick={handleConfirm}
|
|
disabled={isSubmitting}
|
|
>
|
|
{isSubmitting ? t("planning.picker.adding") : t("planning.picker.confirmButton")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Dialog onClose={onClose} title={t("planning.picker.title")} className="recipe-picker-dialog">
|
|
{activeSourceKey === null && (
|
|
<div className="recipe-picker__filters">
|
|
<input
|
|
type="search"
|
|
className="recipe-picker__search"
|
|
placeholder={t("planning.picker.searchPlaceholder")}
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
/>
|
|
|
|
<div className="recipe-picker__ingredient-filter">
|
|
<span className="recipe-picker__filter-label">
|
|
{t("planning.picker.ingredientsFilterLabel")}
|
|
</span>
|
|
<div className="recipe-picker__chips">
|
|
{selectedIngredients.map((ingredient) => (
|
|
<span key={ingredient.id} className="filter-chip">
|
|
{t(`catalog.ingredients.${ingredient.key}`)}
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setSelectedIngredientIds((ids) => ids.filter((id) => id !== ingredient.id))
|
|
}
|
|
>
|
|
✕
|
|
</button>
|
|
</span>
|
|
))}
|
|
<button
|
|
type="button"
|
|
className="recipe-picker__toggle-ingredient-picker"
|
|
onClick={() => setIsIngredientPickerOpen((open) => !open)}
|
|
aria-expanded={isIngredientPickerOpen}
|
|
>
|
|
+{" "}
|
|
{isIngredientPickerOpen
|
|
? t("planning.picker.hideIngredientPicker")
|
|
: t("planning.picker.addIngredientFilter")}
|
|
</button>
|
|
</div>
|
|
{isIngredientPickerOpen && (
|
|
<IngredientPicker
|
|
ingredients={ingredientsCatalog}
|
|
excludeIds={selectedIngredientIds}
|
|
onSelect={(ingredient) =>
|
|
setSelectedIngredientIds((ids) => [...ids, ingredient.id])
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<DietTagSelect
|
|
diets={dietsCatalog}
|
|
value={selectedDietIds}
|
|
onChange={setSelectedDietIds}
|
|
/>
|
|
|
|
{hasHousehold && (
|
|
<CheckboxOption checked={suitableForHousehold} onChange={setSuitableForHousehold}>
|
|
{t("planning.picker.suitableForHouseholdLabel")}
|
|
</CheckboxOption>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<RecipeTabs
|
|
active={activeTab}
|
|
onChange={setActiveTab}
|
|
sources={enabledSources.status === "loaded" ? enabledSources.sources : []}
|
|
/>
|
|
|
|
{activeSourceKey !== null ? (
|
|
<>
|
|
{sourceSelectError && (
|
|
<p className="recipes-page__status recipes-page__status--error">
|
|
{t("common.loadError")}
|
|
</p>
|
|
)}
|
|
{isAddingDraft ? (
|
|
<p className="recipes-page__status">{t("planning.picker.addingDraft")}</p>
|
|
) : (
|
|
<RecipeSourcesPanel
|
|
key={activeSourceKey}
|
|
sourceKey={activeSourceKey}
|
|
onSelectImportedRecipe={handleSelectImportedRecipe}
|
|
onSelectDraftItem={handleSelectDraftItem}
|
|
/>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
{listState.status === "loading" && (
|
|
<p className="recipes-page__status">{t("planning.picker.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("planning.picker.empty")}</p>
|
|
)}
|
|
{listState.status === "loaded" && listState.recipes.length > 0 && (
|
|
<RecipeTable
|
|
recipes={listState.recipes}
|
|
selectedId={null}
|
|
onSelect={(id) => {
|
|
const recipe = listState.recipes.find((r) => r.id === id) ?? null;
|
|
setSelectedRecipe(recipe);
|
|
// Pre-fill from the recipe's own written yield rather than
|
|
// always starting at 1 — still freely editable below, this
|
|
// is just a better starting point (see `Recipe.portions`).
|
|
if (recipe) setPortions(String(recipe.portions));
|
|
}}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</Dialog>
|
|
);
|
|
}
|