batchCooking/apps/web/src/pages/recipes/RecipeFormPage.tsx
Nicolas 5cc70d4244
Some checks failed
CI / test (push) Failing after 1s
CI / lint (push) Successful in 5m10s
CI / build (push) Successful in 6m29s
CI / intent-service-test (push) Successful in 13m15s
CI / e2e (push) Successful in 10m8s
feat(recipes): permet d'ajouter des ingredients hors-catalogue
Quand le catalogue seede ne couvre pas un ingredient, l'utilisateur pouvait
etre bloque (creation manuelle) ou perdre silencieusement la ligne (import).
Une ligne de recette accepte desormais `placeholderName` (texte libre) au
lieu de `ingredientId` : l'API cree une ligne `Ingredient` `isPlaceholder`
(cle `placeholder:<uuid>`, `displayName`, `createdById`) dans la transaction
de la recette, et emet `ingredient.placeholder_created`. Ces lignes sont
exclues de `GET /reference/ingredients` et de `ingredient-matcher`.

Front : bouton "Ajouter << ... >>" dans l'etat vide de `IngredientPicker`
(formulaire + import), badge "a completer" sur la ligne, helper
`ingredientLabel` applique partout ou un libelle d'ingredient est rendu.

Admin : `/admin/catalog/*` (+ page `apps/admin-web`) liste les placeholders
regroupes par nom normalise, "marquer traite" (`reviewedAt`) et purge des
orphelins. La promotion en vraie entree catalogue reste manuelle.

Migration `ingredient_placeholder` ecrite a la main (Postgres indisponible).
Suites Mocha DB-backed ecrites, non executees en session ; test pur
`normalizePlaceholderName` + Cypress admin-web/web verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 19:06:15 +02:00

356 lines
13 KiB
TypeScript

import {
type CreateRecipeInput,
createRecipeSchema,
type DietView,
ErrorCode,
type IngredientView,
type RecipeVisibility,
type UnitView,
} from "@batch-cooking/shared";
import { type FormEvent, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import { ApiError, apiClient } from "../../api/client";
import { DietTagSelect } from "../../features/recipes/badges/DietTagSelect";
import { IngredientPicker } from "../../features/recipes/ingredients/IngredientPicker";
import { IngredientRow } from "../../features/recipes/ingredients/IngredientRow";
import {
isUnsavedPlaceholder,
makePlaceholderIngredientView,
} from "../../features/recipes/ingredients/placeholder-ingredient";
import { type StepDraft, StepListEditor } from "../../features/recipes/steps/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). */
const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"];
/**
* One selected ingredient line — `key` is a client-only stable identity,
* same reasoning as `StepDraft`. `unitId` is `null` until the user picks
* one (no default — unlike `portions`, there's no single "usually right"
* unit across every ingredient); `canSubmit` gates on every line having one
* set before allowing save.
*
* `ingredient` is a real `IngredientView` for a catalog pick or an
* edit-loaded placeholder, or a synthetic one (see
* `makePlaceholderIngredientView`) for a brand-new free-text placeholder
* the user added because the catalog fell short — the latter submits as
* `placeholderName`, not `ingredientId` (see {@link isUnsavedPlaceholder}).
*/
interface IngredientLine {
key: string;
ingredient: IngredientView;
quantity: string;
unitId: number | null;
}
/** Load state for the reference ingredient list (+ the existing recipe, when editing) this form needs before it can render. */
type LoadState = "loading" | "loaded" | "error";
/**
* Create/edit form for one recipe — routed at `/recettes/nouvelle` and
* `/recettes/:id/modifier`. Same component for both: edit mode is just
* "there's an `:id` param", which also drives preloading the existing
* recipe's fields. Saving always sends the recipe's *whole* content (name,
* ingredients, steps) — there's no partial-field save here, matching the
* API's `PATCH /recipes/:id` contract (see `recipe.service.ts`).
*/
export function RecipeFormPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const recipeId = id !== undefined ? Number(id) : null;
const isEditing = recipeId !== null;
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("");
// Pre-filled with a sensible default (same posture as `visibility`
// defaulting to `PERSONAL`) rather than starting empty — this is a
// required field, but the user shouldn't have to type a value just to
// get past the gate if 4 is already right for their recipe.
const [portions, setPortions] = useState("4");
const [visibility, setVisibility] = useState<RecipeVisibility>("PERSONAL");
const [dietIds, setDietIds] = useState<number[]>([]);
const [ingredientLines, setIngredientLines] = useState<IngredientLine[]>([]);
const [steps, setSteps] = useState<StepDraft[]>([]);
const [formError, setFormError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
let cancelled = false;
setLoadState("loading");
Promise.all([
apiClient.getIngredients(),
apiClient.getDiets(),
apiClient.getUnits(),
recipeId !== null ? apiClient.getRecipe(recipeId) : Promise.resolve(null),
])
.then(([ingredients, diets, units, recipe]) => {
if (cancelled) return;
setIngredientsCatalog(ingredients);
setDietsCatalog(diets);
setUnitsCatalog(units);
if (recipe) {
setName(recipe.name);
setDescription(recipe.description ?? "");
setPicture(recipe.picture ?? "");
setPortions(String(recipe.portions));
setVisibility(recipe.visibility);
setDietIds(recipe.diets.map((diet) => diet.id));
setIngredientLines(
recipe.ingredients.map((line) => ({
key: makeClientKey(),
ingredient: line.ingredient,
quantity: String(line.quantity),
unitId: line.unit.id,
})),
);
setSteps(
recipe.steps.map((step) => ({
key: makeClientKey(),
description: step.description,
picture: step.picture ?? "",
})),
);
}
setLoadState("loaded");
})
.catch(() => {
if (!cancelled) setLoadState("error");
});
return () => {
cancelled = true;
};
}, [recipeId]);
function addIngredient(ingredient: IngredientView) {
setIngredientLines((lines) => [
...lines,
{ key: makeClientKey(), ingredient, quantity: "", unitId: null },
]);
}
/** Adds a free-text placeholder line — the escape hatch when nothing in the catalog matches (see `IngredientPicker`'s `onAddPlaceholder`). */
function addPlaceholderIngredient(name: string) {
setIngredientLines((lines) => [
...lines,
{
key: makeClientKey(),
ingredient: makePlaceholderIngredientView(name),
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));
}
// Surfaced next to the submit button when it's the reason `canSubmit` is
// false — same "don't leave the button silently disabled" reasoning as
// `RecipeImportForm` (issue #53), just less likely to bite here since a
// manually-added line starts with no unit by design, right where the
// person is already looking.
const hasIngredientMissingUnit = ingredientLines.some((line) => line.unitId === null);
// Gates the submit button — the schema (checked again on submit, see
// `handleSubmit`) is the source of truth, this is just instant feedback
// that doesn't need a round trip through zod on every keystroke.
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) &&
steps.length > 0 &&
steps.every((step) => step.description.trim().length > 0);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setFormError(null);
const payload: CreateRecipeInput = {
name: name.trim(),
description: description.trim() || null,
picture: picture.trim() || null,
portions: Number(portions),
visibility,
dietIds,
ingredients: ingredientLines.map((line) => ({
// A just-added free-text line has no catalog id yet — ask the API to
// create the placeholder row via `placeholderName`. An edit-loaded
// placeholder already has a real id and goes through `ingredientId`
// like any other line (so re-saving never duplicates it).
...(isUnsavedPlaceholder(line.ingredient)
? { placeholderName: line.ingredient.displayName ?? "" }
: { ingredientId: line.ingredient.id }),
quantity: Number(line.quantity),
// `canSubmit` already requires every line to have a unit picked
// before the button is enabled — `?? 0` is just to satisfy the
// type here; if it's ever reached with no unit set, the schema's
// `positive()` check rejects it the same way an invalid quantity
// already does.
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.form.genericError"));
return;
}
setIsSubmitting(true);
try {
const saved =
recipeId !== null
? await apiClient.updateRecipe(recipeId, result.data)
: await apiClient.createRecipe(result.data);
void 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("common.loadError")}</p>
</div>
);
}
// Placeholder lines carry no real catalog id (a brand-new one is id 0, an
// edit-loaded one isn't in the browsable catalog anyway), so they never
// belong in the picker's "already picked, hide it" set.
const selectedIds = ingredientLines
.filter((line) => !line.ingredient.isPlaceholder)
.map((line) => line.ingredient.id);
return (
<form className="recipe-form" onSubmit={handleSubmit} noValidate>
<h1>{isEditing ? t("recipes.form.editTitle") : t("recipes.form.newTitle")}</h1>
<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>
<IngredientPicker
ingredients={ingredientsCatalog}
excludeIds={selectedIds}
onSelect={addIngredient}
onAddPlaceholder={addPlaceholderIngredient}
/>
</section>
<section className="recipe-form__section">
<h2>{t("recipes.stepsTitle")}</h2>
<StepListEditor steps={steps} onChange={setSteps} />
</section>
{formError && <p className="form-error">{formError}</p>}
{!canSubmit && hasIngredientMissingUnit && (
<p className="form-error">{t("recipes.form.incompleteIngredientsHint")}</p>
)}
<div className="recipe-form__actions">
<button type="submit" disabled={isSubmitting || !canSubmit}>
{isSubmitting ? t("recipes.form.submitting") : t("recipes.form.submit")}
</button>
</div>
</form>
);
}