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>
216 lines
8.3 KiB
TypeScript
216 lines
8.3 KiB
TypeScript
import type { BrowsableSourceItemView, Meal, SourceView, WeekDay } from "@batch-cooking/shared";
|
|
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Link } from "react-router-dom";
|
|
import { apiClient } from "../../api/client";
|
|
import { SourceItemPreviewPanel, type SourceItemPreviewState } from "./SourceItemPreviewPanel";
|
|
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;
|
|
|
|
type EnabledSourcesState =
|
|
| { status: "loading" }
|
|
| { status: "loaded"; sources: SourceView[] }
|
|
| { status: "error" };
|
|
|
|
type BrowseState =
|
|
| { status: "loading" }
|
|
| { status: "loaded"; items: BrowsableSourceItemView[]; nextCursor: string | null }
|
|
| { status: "error" };
|
|
|
|
/**
|
|
* "Sources" tab content of the recipe catalog (`RecipesPage`) — a
|
|
* self-contained master-detail pair of its own (source selector + browsable
|
|
* list on the left, `SourceItemPreviewPanel` on the right), independent of
|
|
* `RecipeTable`/`RecipeDetailPanel`: it browses a source's *live* catalog
|
|
* (`GET /sources/:sourceKey/browse`), not the saved `Recipe` table, so it
|
|
* doesn't share their `RecipeTab`-based fetching at all.
|
|
*
|
|
* Selecting an already-imported item navigates straight to its real
|
|
* recipe (`/recettes/:id`, leaving this tab) — selecting one that isn't
|
|
* imported yet shows a read-only preview here instead. Turning that
|
|
* preview into an actual saved recipe (reviewing/fixing unresolved
|
|
* ingredients first) is a later stage of the same plan, not built here.
|
|
*
|
|
* `onSelectImportedRecipe` hands back the id instead of this panel
|
|
* navigating anywhere itself — what "viewing" an already-imported item
|
|
* means depends on the caller: `RecipesPage` switches its own active tab
|
|
* away from `"sources"` (its `RecipeDetailPanel`/`RecipeTable` only render
|
|
* outside that tab, so without switching first the URL would change but
|
|
* this panel would keep rendering over it) and navigates to the recipe's
|
|
* detail page, while `RecipePickerDialog` instead treats it exactly like
|
|
* picking that recipe from one of the regular tabs — moving to its own
|
|
* confirm-portions step, no navigation at all.
|
|
*/
|
|
export function RecipeSourcesPanel({
|
|
onSelectImportedRecipe,
|
|
planningSlot,
|
|
}: {
|
|
onSelectImportedRecipe: (recipeId: number) => void;
|
|
/** Forwarded as-is to `SourceItemPreviewPanel` — see its own doc comment. Only ever set by `RecipePickerDialog`. */
|
|
planningSlot?: { date: string; weekDay: WeekDay; meal: Meal };
|
|
}) {
|
|
const { t } = useTranslation();
|
|
|
|
const [enabledSources, setEnabledSources] = useState<EnabledSourcesState>({ status: "loading" });
|
|
const [selectedSourceKey, setSelectedSourceKey] = useState<string | null>(null);
|
|
const [search, setSearch] = useState("");
|
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
|
const [browseState, setBrowseState] = useState<BrowseState>({ status: "loading" });
|
|
const [selectedExternalId, setSelectedExternalId] = useState<string | null>(null);
|
|
const [previewState, setPreviewState] = useState<SourceItemPreviewState>({ status: "empty" });
|
|
|
|
// Loaded once — which sources exist, crossed with which the household
|
|
// has enabled (`/parametres/foyer`). Defaults the selector to the first
|
|
// enabled one, if any.
|
|
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;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const timeout = window.setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS);
|
|
return () => window.clearTimeout(timeout);
|
|
}, [search]);
|
|
|
|
useEffect(() => {
|
|
if (selectedSourceKey === null) return;
|
|
let cancelled = false;
|
|
setBrowseState({ status: "loading" });
|
|
setSelectedExternalId(null);
|
|
setPreviewState({ status: "empty" });
|
|
|
|
apiClient
|
|
.browseSource(selectedSourceKey, { query: debouncedSearch.trim() || undefined })
|
|
.then(({ items, nextCursor }) => {
|
|
if (!cancelled) setBrowseState({ status: "loaded", items, nextCursor });
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setBrowseState({ status: "error" });
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [selectedSourceKey, debouncedSearch]);
|
|
|
|
function handleLoadMore() {
|
|
if (selectedSourceKey === null || browseState.status !== "loaded" || !browseState.nextCursor) {
|
|
return;
|
|
}
|
|
const cursor = browseState.nextCursor;
|
|
apiClient
|
|
.browseSource(selectedSourceKey, { query: debouncedSearch.trim() || undefined, cursor })
|
|
.then(({ items, nextCursor }) => {
|
|
setBrowseState((prev) =>
|
|
prev.status === "loaded"
|
|
? { status: "loaded", items: [...prev.items, ...items], nextCursor }
|
|
: prev,
|
|
);
|
|
})
|
|
.catch(() => setBrowseState({ status: "error" }));
|
|
}
|
|
|
|
function handleSelectItem(item: BrowsableSourceItemView) {
|
|
if (item.alreadyImported && item.recipeId !== null) {
|
|
onSelectImportedRecipe(item.recipeId);
|
|
return;
|
|
}
|
|
if (selectedSourceKey === null) return;
|
|
setSelectedExternalId(item.externalId);
|
|
setPreviewState({ status: "loading" });
|
|
apiClient
|
|
.previewSourceItem(selectedSourceKey, item.externalId)
|
|
.then((draft) => setPreviewState({ status: "loaded", draft }))
|
|
.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 (
|
|
<>
|
|
<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)}
|
|
>
|
|
{enabledSources.sources.map((source) => (
|
|
<option key={source.key} value={source.key}>
|
|
{source.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
<input
|
|
type="search"
|
|
className="recipes-page__search"
|
|
placeholder={t("recipes.sources.searchPlaceholder")}
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="recipes-page__catalog">
|
|
{browseState.status === "loading" && (
|
|
<p className="recipes-page__status">{t("recipes.sources.loading")}</p>
|
|
)}
|
|
{browseState.status === "error" && (
|
|
<p className="recipes-page__status recipes-page__status--error">
|
|
{t("recipes.sources.loadError")}
|
|
</p>
|
|
)}
|
|
{browseState.status === "loaded" && browseState.items.length === 0 && (
|
|
<p className="recipes-page__status">{t("recipes.sources.empty")}</p>
|
|
)}
|
|
{browseState.status === "loaded" && browseState.items.length > 0 && (
|
|
<div className="source-items-column">
|
|
<SourceItemTable
|
|
items={browseState.items}
|
|
selectedExternalId={selectedExternalId}
|
|
onSelect={handleSelectItem}
|
|
/>
|
|
{browseState.nextCursor && (
|
|
<button type="button" className="source-items-load-more" onClick={handleLoadMore}>
|
|
{t("recipes.sources.loadMore")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<SourceItemPreviewPanel state={previewState} planningSlot={planningSlot} />
|
|
</div>
|
|
</>
|
|
);
|
|
}
|