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>
154 lines
5.9 KiB
TypeScript
154 lines
5.9 KiB
TypeScript
import type { Meal, RecipeImportDraftView, WeekDay } from "@batch-cooking/shared";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Link } from "react-router-dom";
|
|
import { StepDescription } from "./StepDescription";
|
|
import "./recipes.scss";
|
|
|
|
/** State {@link SourceItemPreviewPanel} renders — mirrors `RecipeDetailState`'s shape (`RecipeDetailPanel`), one status short (no "not-found": an invalid `externalId` surfaces as `"error"`, there's no separate "id was well-formed but nothing matched it" case here). */
|
|
export type SourceItemPreviewState =
|
|
| { status: "empty" }
|
|
| { status: "loading" }
|
|
| { status: "loaded"; draft: RecipeImportDraftView }
|
|
| { status: "error" };
|
|
|
|
/**
|
|
* Right-hand panel of the catalog's "Sources" tab (`RecipeSourcesPanel`) —
|
|
* a read-only preview of a not-yet-imported item: nothing here can be
|
|
* edited or saved yet (no favorite/edit/delete actions, unlike
|
|
* `RecipeDetailPanel`) — turning this into an actual import with a review
|
|
* step for unresolved ingredients is a later stage of the same plan.
|
|
* Reuses `StepDescription` so a step's detected techniques are already
|
|
* highlighted here too, exactly like a saved recipe's detail.
|
|
*/
|
|
export function SourceItemPreviewPanel({
|
|
state,
|
|
planningSlot,
|
|
}: {
|
|
state: SourceItemPreviewState;
|
|
/**
|
|
* Set only when this panel is rendered from `RecipePickerDialog` (adding a
|
|
* recipe to one planning slot) rather than the standalone `/recettes`
|
|
* catalog — carried along on the "Importer cette recette" link as query
|
|
* params so `ImportRecipePage` knows to add the freshly-created recipe to
|
|
* this exact slot once the import succeeds, instead of landing on the
|
|
* recipe's own detail page. See `ImportRecipePage`'s `planningSlot`.
|
|
*/
|
|
planningSlot?: { date: string; weekDay: WeekDay; meal: Meal };
|
|
}) {
|
|
const { t } = useTranslation();
|
|
|
|
if (state.status === "empty") {
|
|
return (
|
|
<aside className="recipe-detail-panel">
|
|
<p className="recipe-detail-panel__status">{t("recipes.sources.detail.empty")}</p>
|
|
</aside>
|
|
);
|
|
}
|
|
if (state.status === "loading") {
|
|
return (
|
|
<aside className="recipe-detail-panel">
|
|
<p className="recipe-detail-panel__status">{t("recipes.sources.detail.loading")}</p>
|
|
</aside>
|
|
);
|
|
}
|
|
if (state.status === "error") {
|
|
return (
|
|
<aside className="recipe-detail-panel">
|
|
<p className="recipe-detail-panel__status recipe-detail-panel__status--error">
|
|
{t("recipes.sources.detail.loadError")}
|
|
</p>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
const { draft } = state;
|
|
const hasUnresolvedIngredient = draft.ingredients.some(
|
|
(ingredient) => ingredient.ingredient === null,
|
|
);
|
|
|
|
return (
|
|
<aside className="recipe-detail-panel">
|
|
<div className="recipe-detail-panel__header">
|
|
<div className="recipe-detail-panel__photo" aria-hidden="true">
|
|
{draft.picture ? <img src={draft.picture} alt="" /> : "🍽️"}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="recipe-detail-panel__title-row">
|
|
<div className="recipe-detail-panel__title-main">
|
|
<h2>{draft.name}</h2>
|
|
{draft.portions !== null && (
|
|
<p className="recipe-detail-panel__portions">
|
|
{t("recipes.detail.portions", { count: draft.portions })}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="recipe-detail-panel__actions">
|
|
<Link
|
|
to={{
|
|
pathname: `/recettes/importer/${draft.sourceKey}/${encodeURIComponent(draft.externalId)}`,
|
|
search: planningSlot
|
|
? `?planningDate=${planningSlot.date}&planningWeekDay=${planningSlot.weekDay}&planningMeal=${planningSlot.meal}`
|
|
: undefined,
|
|
}}
|
|
className="recipes-page__new-button"
|
|
>
|
|
{t("recipes.sources.detail.importButton")}
|
|
</Link>
|
|
<a
|
|
href={draft.sourceUrl}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="recipes-page__new-button"
|
|
>
|
|
{t("recipes.sources.detail.viewSource")}
|
|
</a>
|
|
</div>
|
|
|
|
{draft.description && (
|
|
<section className="recipe-detail-panel__section recipe-detail-panel__section--description">
|
|
<p className="recipe-detail-panel__description">{draft.description}</p>
|
|
</section>
|
|
)}
|
|
|
|
<section className="recipe-detail-panel__section">
|
|
<h3>{t("recipes.sources.detail.ingredientsCount", { count: draft.ingredients.length })}</h3>
|
|
{hasUnresolvedIngredient && (
|
|
<p className="source-item-preview__hint">
|
|
{t("recipes.sources.detail.unresolvedIngredientsHint")}
|
|
</p>
|
|
)}
|
|
<ul className="source-item-preview__ingredients">
|
|
{draft.ingredients.map((ingredient, index) => (
|
|
// Draft lines have no id of their own (nothing is saved yet) —
|
|
// `rawText` alone could collide (a source repeating the same
|
|
// line), so it's paired with its position; this list is fully
|
|
// regenerated from `draft` on every render, never reordered in
|
|
// place, so that's safe here (same reasoning as
|
|
// StepDescription.tsx's segment keys).
|
|
<li
|
|
key={`${index}-${ingredient.rawText}`}
|
|
className={ingredient.ingredient === null ? "is-unresolved" : undefined}
|
|
>
|
|
{ingredient.rawText}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
|
|
<section className="recipe-detail-panel__section">
|
|
<h3>{t("recipes.sources.detail.stepsCount", { count: draft.steps.length })}</h3>
|
|
<ol className="recipe-detail-panel__steps">
|
|
{draft.steps.map((step, index) => (
|
|
<li key={`${index}-${step.description}`}>
|
|
{step.picture && <img src={step.picture} alt="" />}
|
|
<StepDescription description={step.description} techSteps={step.techSteps} />
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</section>
|
|
</aside>
|
|
);
|
|
}
|