All checks were successful
`RecipeDiet` (tag manuel) est supprimé — table droppée, `dietIds` retiré de `createRecipeSchema`, `DietTagSelect` retiré des formulaires de recette (création + import). `RecipeSummaryView.diets` / `RecipeView.diets` sont désormais **dérivés** via `computeRecipeDietViews` (`@batch-cooking/shared`, pur, testé isolément) : - un régime (vegetarian / vegan / pescatarian) est retenu ssi *tous* les ingrédients y sont compatibles (`IngredientDiet`, exposé par `IngredientView.diets`) ; - `glutenFree` est dérivé des allergènes (aucun ingrédient ne porte `gluten`) — cohérent avec le fait que le seed ne le stocke pas dans `IngredientDiet` ; - `[]` dès qu'une recette contient un ingrédient placeholder (hors-catalogue) : un ingrédient inconnu ne certifie rien. Filtres du sélecteur de planning (`suitableForHousehold`, `dietIds`) : ne peuvent plus être du SQL sur une colonne stockée — devenus des post-filtres sur `diets` calculé dans `listRecipes` ; `omnivore` traité comme « aucune contrainte ». `assertDietsExist` / `ErrorCode.DIET_NOT_FOUND` sur la création recette supprimés (le code d'erreur reste, utilisé par la préférence profil). `DietBadges` inchangé (affiche `diets` tel quel). `RecipePickerDialog` garde son filtre régime (fonctionne via le post-filtre). Tests : `recipe-diets.test.ts` (fonction pure) + `recipe.test.ts` réécrit (régimes calculés attendus, tests DIET_NOT_FOUND retirés) ; Cypress OK (formulaire sans sélecteur de régime, régression recettes/sources/planning). Migration `DROP TABLE recipe_diet` — aucune donnée à conserver. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
379 lines
14 KiB
TypeScript
379 lines
14 KiB
TypeScript
import {
|
|
type CreateRecipeInput,
|
|
createRecipeSchema,
|
|
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 { 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;
|
|
/** `true` = a "selon le goût" seasoning line — submits with no quantity/unit (stored as `null`/`null`), and `canSubmit` doesn't require either. */
|
|
toTaste: boolean;
|
|
}
|
|
|
|
/** 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 [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 [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.getUnits(),
|
|
recipeId !== null ? apiClient.getRecipe(recipeId) : Promise.resolve(null),
|
|
])
|
|
.then(([ingredients, units, recipe]) => {
|
|
if (cancelled) return;
|
|
setIngredientsCatalog(ingredients);
|
|
setUnitsCatalog(units);
|
|
if (recipe) {
|
|
setName(recipe.name);
|
|
setDescription(recipe.description ?? "");
|
|
setPicture(recipe.picture ?? "");
|
|
setPortions(String(recipe.portions));
|
|
setVisibility(recipe.visibility);
|
|
setIngredientLines(
|
|
recipe.ingredients.map((line) => ({
|
|
key: makeClientKey(),
|
|
ingredient: line.ingredient,
|
|
quantity: line.quantity === null ? "" : String(line.quantity),
|
|
unitId: line.unit?.id ?? null,
|
|
toTaste: line.quantity === null,
|
|
})),
|
|
);
|
|
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, toTaste: false },
|
|
]);
|
|
}
|
|
|
|
/** 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,
|
|
toTaste: false,
|
|
},
|
|
]);
|
|
}
|
|
|
|
function updateIngredientLine(
|
|
key: string,
|
|
patch: Partial<Pick<IngredientLine, "quantity" | "unitId" | "toTaste">>,
|
|
) {
|
|
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.toTaste && 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. A "selon
|
|
// le goût" line needs neither quantity nor unit.
|
|
const canSubmit =
|
|
name.trim().length > 0 &&
|
|
Number.isInteger(Number(portions)) &&
|
|
Number(portions) > 0 &&
|
|
ingredientLines.length > 0 &&
|
|
ingredientLines.every(
|
|
(line) => line.toTaste || (Number(line.quantity) > 0 && line.unitId !== null),
|
|
) &&
|
|
steps.length > 0 &&
|
|
steps.every((step) => step.description.trim().length > 0);
|
|
|
|
/**
|
|
* Leaves the form without saving — back to the recipe being edited, or to
|
|
* the catalog for a brand-new one. No "discard changes?" guard on purpose:
|
|
* "Annuler" is an explicit action on a form with no autosave, same posture
|
|
* as every other cancel in the app (the only confirm-guarded action is the
|
|
* irreversible *delete* on `RecipeDetailPanel`, not abandoning a draft).
|
|
*/
|
|
function handleCancel() {
|
|
void navigate(recipeId !== null ? `/recettes/${recipeId}` : "/recettes");
|
|
}
|
|
|
|
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,
|
|
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 }),
|
|
// A "selon le goût" line submits with neither — the schema requires
|
|
// quantity and unit to be both present or both absent.
|
|
...(line.toTaste
|
|
? {}
|
|
: {
|
|
quantity: Number(line.quantity),
|
|
// `canSubmit` already requires every non-toTaste line to have a
|
|
// unit — `?? 0` only satisfies the type; the schema's
|
|
// `positive()` check rejects a 0 the same way it rejects an
|
|
// invalid quantity.
|
|
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>
|
|
|
|
<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}
|
|
toTaste={line.toTaste}
|
|
onQuantityChange={(quantity) => updateIngredientLine(line.key, { quantity })}
|
|
onUnitChange={(unitId) => updateIngredientLine(line.key, { unitId })}
|
|
onToTasteChange={(toTaste) => updateIngredientLine(line.key, { toTaste })}
|
|
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>
|
|
)}
|
|
|
|
{/* Pinned to the bottom of the scroll area (`--sticky`) so a long
|
|
recipe (many ingredients/steps) never needs scrolling back down
|
|
just to save or leave — see `recipes.scss`. */}
|
|
<div className="recipe-form__actions recipe-form__actions--sticky">
|
|
<button type="button" className="recipe-form__cancel" onClick={handleCancel}>
|
|
{t("recipes.form.cancel")}
|
|
</button>
|
|
<button type="submit" disabled={isSubmitting || !canSubmit}>
|
|
{isSubmitting ? t("recipes.form.submitting") : t("recipes.form.submit")}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|