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>
462 lines
17 KiB
TypeScript
462 lines
17 KiB
TypeScript
import {
|
|
type CreateRecipeInput,
|
|
type DietView,
|
|
ErrorCode,
|
|
type IngredientView,
|
|
MEALS,
|
|
type Meal,
|
|
type RecipeVisibility,
|
|
type UnitView,
|
|
WEEK_DAYS,
|
|
type WeekDay,
|
|
createRecipeSchema,
|
|
} from "@batch-cooking/shared";
|
|
import { type FormEvent, useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
|
import { ApiError, apiClient } from "../api/client";
|
|
import { DietTagSelect } from "../features/recipes/DietTagSelect";
|
|
import { IngredientPicker } from "../features/recipes/IngredientPicker";
|
|
import { IngredientRow } from "../features/recipes/IngredientRow";
|
|
import { type StepDraft, StepListEditor } from "../features/recipes/StepListEditor";
|
|
import "../features/recipes/recipes.scss";
|
|
import { makeClientKey } from "../lib/client-key";
|
|
import { errorMessageService } from "../services/error-message.service";
|
|
|
|
/** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). Same list as `RecipeFormPage`. */
|
|
const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"];
|
|
|
|
/** A resolved ingredient line — identical shape to `RecipeFormPage`'s own `IngredientLine`. */
|
|
interface IngredientLine {
|
|
key: string;
|
|
ingredient: IngredientView;
|
|
quantity: string;
|
|
unitId: number | null;
|
|
}
|
|
|
|
/** One draft line that didn't resolve to a real `Ingredient` on preview (`DraftRecipeIngredientView.ingredient === null`) — still needs a person to pick the right one, or discard it, before this recipe can be saved. */
|
|
interface UnresolvedIngredientLine {
|
|
key: string;
|
|
rawText: string;
|
|
quantity: string;
|
|
}
|
|
|
|
type LoadState = "loading" | "loaded" | "error";
|
|
|
|
/**
|
|
* Reads and validates `?planningDate=&planningWeekDay=&planningMeal=` off
|
|
* this page's own URL — `null` unless all three are present and well-formed
|
|
* (a closed-set match against `WEEK_DAYS`/`MEALS`, same validation
|
|
* `addPlanningItemSchema` enforces server-side), so a hand-typed or stale
|
|
* URL just falls back to this page's normal "land on the recipe" behavior
|
|
* rather than throwing. See this component's own doc comment.
|
|
*/
|
|
function parsePlanningSlot(
|
|
searchParams: URLSearchParams,
|
|
): { date: string; weekDay: WeekDay; meal: Meal } | null {
|
|
const date = searchParams.get("planningDate");
|
|
const weekDay = searchParams.get("planningWeekDay");
|
|
const meal = searchParams.get("planningMeal");
|
|
if (
|
|
date === null ||
|
|
!/^\d{4}-\d{2}-\d{2}$/.test(date) ||
|
|
weekDay === null ||
|
|
!(WEEK_DAYS as readonly string[]).includes(weekDay) ||
|
|
meal === null ||
|
|
!(MEALS as readonly string[]).includes(meal)
|
|
) {
|
|
return null;
|
|
}
|
|
return { date, weekDay: weekDay as WeekDay, meal: meal as Meal };
|
|
}
|
|
|
|
/**
|
|
* Review screen for finalizing an import — routed at
|
|
* `/recettes/importer/:sourceKey/:externalId` (reached from
|
|
* `SourceItemPreviewPanel`'s "Importer cette recette" button). Pre-filled
|
|
* from `GET /sources/:sourceKey/preview/:externalId` (the same draft the
|
|
* preview panel already showed), structurally the same form as
|
|
* `RecipeFormPage` — same sub-components (`IngredientRow`,
|
|
* `IngredientPicker`, `StepListEditor`, `DietTagSelect`), same
|
|
* `CreateRecipeInput` submit shape — plus one thing a manual creation
|
|
* never has to handle: ingredient lines the automatic matching
|
|
* (`ingredient-matcher.ts`) couldn't resolve. Those render as their own
|
|
* "à compléter" list, each needing a real ingredient picked (or the line
|
|
* discarded) before the form can submit — never silently drops/guesses one,
|
|
* per the product decision this stage was built against (no invalid
|
|
* recipe is ever persisted).
|
|
*
|
|
* Submits to `POST /sources/:sourceKey/import/:externalId`
|
|
* (`apiClient.importSourceItem`) instead of `POST /recipes` — the only
|
|
* other difference from `RecipeFormPage`'s own submit.
|
|
*
|
|
* `?planningDate=&planningWeekDay=&planningMeal=` are set only when this
|
|
* page was reached from `RecipePickerDialog`'s "Sources" tab (via
|
|
* `SourceItemPreviewPanel`'s import link, see its own `planningSlot` prop)
|
|
* — picking a not-yet-imported item there hands off to this full review
|
|
* screen instead of the dialog's own small "how many portions?" step,
|
|
* since an unresolved-ingredient review doesn't fit in that step. When
|
|
* present and well-formed, a successful import also adds the freshly
|
|
* created recipe straight to that planning slot (`POST /planning/items`,
|
|
* using this form's own `portions` field) before landing back on the
|
|
* planning page, instead of the recipe's own detail page.
|
|
*/
|
|
export function ImportRecipePage() {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const { sourceKey, externalId } = useParams<{ sourceKey: string; externalId: string }>();
|
|
const [searchParams] = useSearchParams();
|
|
const planningSlot = parsePlanningSlot(searchParams);
|
|
|
|
const [loadState, setLoadState] = useState<LoadState>("loading");
|
|
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
|
|
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
|
|
const [unitsCatalog, setUnitsCatalog] = useState<UnitView[]>([]);
|
|
|
|
const [name, setName] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const [picture, setPicture] = useState("");
|
|
const [portions, setPortions] = useState("4");
|
|
const [visibility, setVisibility] = useState<RecipeVisibility>("PERSONAL");
|
|
const [dietIds, setDietIds] = useState<number[]>([]);
|
|
const [ingredientLines, setIngredientLines] = useState<IngredientLine[]>([]);
|
|
const [unresolvedIngredients, setUnresolvedIngredients] = useState<UnresolvedIngredientLine[]>(
|
|
[],
|
|
);
|
|
// Which unresolved line's picker is currently open — at most one at a
|
|
// time (IngredientPicker is a whole browsable grid, not a compact
|
|
// popover; showing one per unresolved line at once would be unwieldy).
|
|
const [resolvingKey, setResolvingKey] = useState<string | null>(null);
|
|
const [steps, setSteps] = useState<StepDraft[]>([]);
|
|
|
|
const [formError, setFormError] = useState<string | null>(null);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (sourceKey === undefined || externalId === undefined) {
|
|
setLoadState("error");
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
setLoadState("loading");
|
|
|
|
Promise.all([
|
|
apiClient.getIngredients(),
|
|
apiClient.getDiets(),
|
|
apiClient.getUnits(),
|
|
apiClient.previewSourceItem(sourceKey, externalId),
|
|
])
|
|
.then(([ingredients, diets, units, draft]) => {
|
|
if (cancelled) return;
|
|
setIngredientsCatalog(ingredients);
|
|
setDietsCatalog(diets);
|
|
setUnitsCatalog(units);
|
|
|
|
setName(draft.name);
|
|
setDescription(draft.description ?? "");
|
|
setPicture(draft.picture ?? "");
|
|
setPortions(draft.portions !== null ? String(draft.portions) : "4");
|
|
|
|
const resolved: IngredientLine[] = [];
|
|
const unresolved: UnresolvedIngredientLine[] = [];
|
|
for (const line of draft.ingredients) {
|
|
if (line.ingredient !== null) {
|
|
resolved.push({
|
|
key: makeClientKey(),
|
|
ingredient: line.ingredient,
|
|
quantity: line.quantity !== null ? String(line.quantity) : "",
|
|
unitId: line.unit?.id ?? null,
|
|
});
|
|
} else {
|
|
unresolved.push({
|
|
key: makeClientKey(),
|
|
rawText: line.rawText,
|
|
quantity: line.quantity !== null ? String(line.quantity) : "",
|
|
});
|
|
}
|
|
}
|
|
setIngredientLines(resolved);
|
|
setUnresolvedIngredients(unresolved);
|
|
|
|
setSteps(
|
|
draft.steps.map((step) => ({
|
|
key: makeClientKey(),
|
|
description: step.description,
|
|
picture: step.picture ?? "",
|
|
})),
|
|
);
|
|
setLoadState("loaded");
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setLoadState("error");
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [sourceKey, externalId]);
|
|
|
|
function addIngredient(ingredient: IngredientView) {
|
|
setIngredientLines((lines) => [
|
|
...lines,
|
|
{ key: makeClientKey(), ingredient, quantity: "", unitId: null },
|
|
]);
|
|
}
|
|
|
|
function updateIngredientLine(
|
|
key: string,
|
|
patch: Partial<Pick<IngredientLine, "quantity" | "unitId">>,
|
|
) {
|
|
setIngredientLines((lines) =>
|
|
lines.map((line) => (line.key === key ? { ...line, ...patch } : line)),
|
|
);
|
|
}
|
|
|
|
function removeIngredientLine(key: string) {
|
|
setIngredientLines((lines) => lines.filter((line) => line.key !== key));
|
|
}
|
|
|
|
/** Promotes an unresolved line into a real ingredient line, carrying its quantity over — its unit still needs picking, same as a freshly-added ingredient. */
|
|
function resolveIngredient(unresolvedKey: string, ingredient: IngredientView) {
|
|
setUnresolvedIngredients((lines) => {
|
|
const line = lines.find((l) => l.key === unresolvedKey);
|
|
if (line) {
|
|
setIngredientLines((resolved) => [
|
|
...resolved,
|
|
{ key: makeClientKey(), ingredient, quantity: line.quantity, unitId: null },
|
|
]);
|
|
}
|
|
return lines.filter((l) => l.key !== unresolvedKey);
|
|
});
|
|
setResolvingKey(null);
|
|
}
|
|
|
|
function discardUnresolvedIngredient(key: string) {
|
|
setUnresolvedIngredients((lines) => lines.filter((line) => line.key !== key));
|
|
setResolvingKey((current) => (current === key ? null : current));
|
|
}
|
|
|
|
const canSubmit =
|
|
name.trim().length > 0 &&
|
|
Number.isInteger(Number(portions)) &&
|
|
Number(portions) > 0 &&
|
|
ingredientLines.length > 0 &&
|
|
ingredientLines.every((line) => Number(line.quantity) > 0 && line.unitId !== null) &&
|
|
unresolvedIngredients.length === 0 &&
|
|
steps.length > 0 &&
|
|
steps.every((step) => step.description.trim().length > 0);
|
|
|
|
async function handleSubmit(e: FormEvent) {
|
|
e.preventDefault();
|
|
setFormError(null);
|
|
if (sourceKey === undefined || externalId === undefined) return;
|
|
|
|
const payload: CreateRecipeInput = {
|
|
name: name.trim(),
|
|
description: description.trim() || null,
|
|
picture: picture.trim() || null,
|
|
portions: Number(portions),
|
|
visibility,
|
|
dietIds,
|
|
ingredients: ingredientLines.map((line) => ({
|
|
ingredientId: line.ingredient.id,
|
|
quantity: Number(line.quantity),
|
|
// `canSubmit` already requires every line to have a unit picked —
|
|
// same "?? 0, the schema rejects it if ever reached" reasoning as
|
|
// RecipeFormPage's identical submit.
|
|
unitId: line.unitId ?? 0,
|
|
})),
|
|
steps: steps.map((step) => ({
|
|
description: step.description.trim(),
|
|
picture: step.picture.trim() || null,
|
|
})),
|
|
};
|
|
|
|
const result = createRecipeSchema.safeParse(payload);
|
|
if (!result.success) {
|
|
setFormError(result.error.issues[0]?.message ?? t("recipes.sources.import.genericError"));
|
|
return;
|
|
}
|
|
|
|
setIsSubmitting(true);
|
|
try {
|
|
const saved = await apiClient.importSourceItem(sourceKey, externalId, result.data);
|
|
if (planningSlot) {
|
|
try {
|
|
await apiClient.addPlanningItem({
|
|
date: planningSlot.date,
|
|
weekDay: planningSlot.weekDay,
|
|
meal: planningSlot.meal,
|
|
recipeId: saved.id,
|
|
portions: Number(portions),
|
|
});
|
|
navigate("/");
|
|
return;
|
|
} catch {
|
|
// The recipe itself was already imported successfully — only the
|
|
// planning add failed. Land on the new recipe's own page rather
|
|
// than stranding the user on a form that already submitted; it
|
|
// can still be added to that slot afterwards via the normal
|
|
// "déjà importée" picker path.
|
|
navigate(`/recettes/${saved.id}`);
|
|
return;
|
|
}
|
|
}
|
|
navigate(`/recettes/${saved.id}`);
|
|
} catch (err) {
|
|
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
|
setFormError(errorMessageService.getLabel(code));
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
}
|
|
|
|
if (loadState === "loading") {
|
|
return (
|
|
<div className="recipe-form">
|
|
<p className="recipes-page__status">{t("recipes.loading")}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (loadState === "error") {
|
|
return (
|
|
<div className="recipe-form">
|
|
<p className="recipes-page__status recipes-page__status--error">
|
|
{t("recipes.sources.import.loadError")}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const selectedIds = ingredientLines.map((line) => line.ingredient.id);
|
|
|
|
return (
|
|
<form className="recipe-form" onSubmit={handleSubmit} noValidate>
|
|
<h1>{t("recipes.sources.import.title")}</h1>
|
|
{planningSlot && (
|
|
<p className="source-item-preview__hint">{t("recipes.sources.import.planningHint")}</p>
|
|
)}
|
|
|
|
<label htmlFor="recipe-name">{t("recipes.form.nameLabel")}</label>
|
|
<input id="recipe-name" value={name} onChange={(e) => setName(e.target.value)} />
|
|
|
|
<label htmlFor="recipe-description">{t("recipes.form.descriptionLabel")}</label>
|
|
<textarea
|
|
id="recipe-description"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
rows={3}
|
|
/>
|
|
|
|
<label htmlFor="recipe-picture">{t("recipes.form.pictureLabel")}</label>
|
|
<input
|
|
id="recipe-picture"
|
|
type="url"
|
|
value={picture}
|
|
onChange={(e) => setPicture(e.target.value)}
|
|
placeholder="https://…"
|
|
/>
|
|
|
|
<label htmlFor="recipe-portions">{t("recipes.form.portionsLabel")}</label>
|
|
<input
|
|
id="recipe-portions"
|
|
type="number"
|
|
min="1"
|
|
step="1"
|
|
value={portions}
|
|
onChange={(e) => setPortions(e.target.value)}
|
|
/>
|
|
|
|
<label htmlFor="recipe-visibility">{t("recipes.form.visibilityLabel")}</label>
|
|
<select
|
|
id="recipe-visibility"
|
|
value={visibility}
|
|
onChange={(e) => setVisibility(e.target.value as RecipeVisibility)}
|
|
>
|
|
{VISIBILITY_OPTIONS.map((option) => (
|
|
<option key={option} value={option}>
|
|
{t(`recipes.form.visibility.${option}`)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
<DietTagSelect diets={dietsCatalog} value={dietIds} onChange={setDietIds} />
|
|
|
|
<section className="recipe-form__section">
|
|
<h2>{t("recipes.ingredientsTitle")}</h2>
|
|
<ul className="recipe-form__ingredient-list">
|
|
{ingredientLines.map((line) => (
|
|
<IngredientRow
|
|
key={line.key}
|
|
ingredient={line.ingredient}
|
|
quantity={line.quantity}
|
|
unitId={line.unitId}
|
|
unitsCatalog={unitsCatalog}
|
|
onQuantityChange={(quantity) => updateIngredientLine(line.key, { quantity })}
|
|
onUnitChange={(unitId) => updateIngredientLine(line.key, { unitId })}
|
|
onRemove={() => removeIngredientLine(line.key)}
|
|
/>
|
|
))}
|
|
</ul>
|
|
|
|
{unresolvedIngredients.length > 0 && (
|
|
<section className="import-recipe__unresolved">
|
|
<h3>{t("recipes.sources.import.unresolvedTitle")}</h3>
|
|
<p className="source-item-preview__hint">
|
|
{t("recipes.sources.import.unresolvedHint")}
|
|
</p>
|
|
<ul className="import-recipe__unresolved-list">
|
|
{unresolvedIngredients.map((line) => (
|
|
<li key={line.key}>
|
|
<div className="import-recipe__unresolved-row">
|
|
<span>{line.rawText}</span>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setResolvingKey((current) => (current === line.key ? null : line.key))
|
|
}
|
|
>
|
|
{t("recipes.sources.import.resolveButton")}
|
|
</button>
|
|
<button type="button" onClick={() => discardUnresolvedIngredient(line.key)}>
|
|
{t("recipes.sources.import.discardButton")}
|
|
</button>
|
|
</div>
|
|
{resolvingKey === line.key && (
|
|
<IngredientPicker
|
|
ingredients={ingredientsCatalog}
|
|
excludeIds={selectedIds}
|
|
onSelect={(ingredient) => resolveIngredient(line.key, ingredient)}
|
|
/>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
)}
|
|
|
|
<IngredientPicker
|
|
ingredients={ingredientsCatalog}
|
|
excludeIds={selectedIds}
|
|
onSelect={addIngredient}
|
|
/>
|
|
</section>
|
|
|
|
<section className="recipe-form__section">
|
|
<h2>{t("recipes.stepsTitle")}</h2>
|
|
<StepListEditor steps={steps} onChange={setSteps} />
|
|
</section>
|
|
|
|
{formError && <p className="form-error">{formError}</p>}
|
|
|
|
<div className="recipe-form__actions">
|
|
<button type="submit" disabled={isSubmitting || !canSubmit}>
|
|
{isSubmitting
|
|
? t("recipes.sources.import.submitting")
|
|
: t("recipes.sources.import.submit")}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|