Backend : - `createRecipe` refactorisé en fine enveloppe autour d'un nouvel helper interne `createRecipeInternal`, paramétré par une source d'import optionnelle ; nouvelle fonction exportée `createImportedRecipe` qui réutilise toute la validation ingrédients/unités/diets et le matching des tech steps, sans dupliquer cette logique. - La locale de l'adaptateur source est propagée jusqu'au chargement des `TechStepMapping`, pour que le texte anglais (TheMealDB, etc.) soit matché contre le bon jeu de règles au lieu du défaut français. - Nouvel endpoint `POST /sources/:sourceKey/import/:externalId` — valide le payload via `createRecipeSchema` (même schéma qu'une création manuelle) et persiste une vraie `Recipe` liée à la source (`sourceId`/`externalId`). - Nouveau code d'erreur `RECIPE_ALREADY_IMPORTED` (4022) quand l'item a déjà été importé pour ce foyer. Frontend : - `ImportRecipePage` (nouvelle page, `/recettes/importer/:sourceKey/:externalId`) — pré-remplit le formulaire depuis `previewSourceItem`, en miroir de `RecipeFormPage` (mêmes sous-composants : `IngredientRow`, `IngredientPicker`, `StepListEditor`, `DietTagSelect`). Ajoute une section dédiée aux lignes d'ingrédients non résolues automatiquement : l'utilisateur choisit un ingrédient réel via l'`IngredientPicker` existant ou retire la ligne — aucune recette invalide n'est jamais soumise, le bouton d'import reste désactivé tant qu'il en reste. - `SourceItemPreviewPanel` gagne un lien « Importer cette recette » vers cet écran. Tests : - Mocha (`apps/api/test/sources.test.ts`) : 6 nouveaux tests sur `POST /sources/:sourceKey/import/:externalId` (payload valide, ingrédient/unité inconnus, déjà importé, deux foyers distincts, locale de la source respectée pour les tech steps). 282 tests passent au total, aucune régression. - Cypress : nouveau scénario Gherkin bout-en-bout dans `recipe-sources.feature` (parcourir → prévisualiser → importer → résoudre un ingrédient non reconnu → confirmer → atterrir sur la recette sauvegardée). Steps d'édition d'ingrédients/étapes génériques déplacés de `recipe-form.ts` vers `cypress/support/step_definitions/common.steps.ts`, réutilisables par ce nouveau scénario. Suite : étape 4 (ajouter au planning déclenche l'import si nécessaire). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
135 lines
5.1 KiB
TypeScript
135 lines
5.1 KiB
TypeScript
import type { RecipeImportDraftView } 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 }: { state: SourceItemPreviewState }) {
|
|
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={`/recettes/importer/${draft.sourceKey}/${encodeURIComponent(draft.externalId)}`}
|
|
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>
|
|
);
|
|
}
|