feat(recipes): ingrédients d'assaisonnement « selon le goût » sans quantité (#128)
All checks were successful
All checks were successful
`RecipeIngredient.quantity`/`unitId` deviennent nullable (migration purement additive) : une ligne de recette peut n'avoir ni quantité ni unité — sel, poivre… ajoutés selon le goût. Le schéma partagé impose « quantité et unité, ou aucune des deux » ; `toRecipeView` renvoie `null`/`null`. `aggregateShoppingList` fait apparaître une telle ligne une seule fois, sans quantité ni unité, jamais sommée ni mise à l'échelle. `ShoppingListPage` affiche « selon le goût » à la place de la quantité. Formulaire : `IngredientRow` gagne une case « Selon le goût » qui masque quantité/unité ; `RecipeFormPage` et `RecipeImportForm` n'exigent plus quantité/unité pour ces lignes et les envoient sans. Un import dont la source laisse une ligne résolue sans quantité (« salt to taste ») arrive directement en « selon le goût ». Le chemin clause-de-technique (session de cuisine) tolérait déjà `quantity` null — inchangé. Tests : Mocha (création/édition d'une ligne sans quantité, rejet quantité-sans-unité, liste de courses non sommée) ; Cypress (case à cocher + parcours), régression recette/liste de courses OK. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
c3560eccec
commit
ba030ec18c
19 changed files with 437 additions and 90 deletions
|
|
@ -0,0 +1,8 @@
|
|||
-- Ingrédients d'assaisonnement « selon le goût » (issue #128, puce 1 de #3) :
|
||||
-- une ligne de recette peut désormais n'avoir ni quantité ni unité. Purement
|
||||
-- additif — toutes les lignes existantes gardent leurs valeurs, aucune n'est
|
||||
-- réécrite.
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "recipe_ingredient" ALTER COLUMN "quantity" DROP NOT NULL;
|
||||
ALTER TABLE "recipe_ingredient" ALTER COLUMN "unit_id" DROP NOT NULL;
|
||||
|
|
@ -700,12 +700,17 @@ model Unit {
|
|||
model RecipeIngredient {
|
||||
recipeId Int @map("recipe_id")
|
||||
ingredientId Int @map("ingredient_id")
|
||||
quantity Decimal @db.Decimal(10, 2)
|
||||
unitId Int @map("unit_id")
|
||||
/// `null` (with `unitId` also `null`) = a "selon le goût" seasoning line —
|
||||
/// salt, pepper… added to taste, with nothing to scale by portions or to
|
||||
/// aggregate on a shopping list. The "both null or neither" invariant is
|
||||
/// enforced service-side (and by `recipeIngredientInputSchema`), not by
|
||||
/// the schema — same posture as this file's other cross-field invariants.
|
||||
quantity Decimal? @db.Decimal(10, 2)
|
||||
unitId Int? @map("unit_id")
|
||||
|
||||
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
|
||||
unit Unit @relation(fields: [unitId], references: [id])
|
||||
unit Unit? @relation(fields: [unitId], references: [id])
|
||||
|
||||
@@id([recipeId, ingredientId])
|
||||
@@map("recipe_ingredient")
|
||||
|
|
|
|||
|
|
@ -84,8 +84,8 @@ type RecipeWithDetails = Prisma.RecipeGetPayload<{
|
|||
}>;
|
||||
/** Exported — `shopping-list.service.ts` fetches its own, narrower ingredient include (no need for a whole `RecipeWithDetails`) but shapes the same `allergies`/`diets` nesting, so it reuses {@link toIngredientView} directly instead of re-deriving this type. */
|
||||
export type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"];
|
||||
/** Exported — see {@link IngredientWithDetails}, same reuse by `shopping-list.service.ts`. */
|
||||
export type UnitWithDetails = RecipeWithDetails["ingredients"][number]["unit"];
|
||||
/** Exported — see {@link IngredientWithDetails}, same reuse by `shopping-list.service.ts`. `NonNullable` since a `RecipeIngredient.unit` is now `null` on a "selon le goût" line — callers guard that before calling {@link toUnitView}. */
|
||||
export type UnitWithDetails = NonNullable<RecipeWithDetails["ingredients"][number]["unit"]>;
|
||||
|
||||
/** Shapes a Prisma `Unit` row into the public {@link UnitView} — same "Decimal → number" conversion `reference.service.ts`'s `getUnits` does. Exported — reused as-is by `shopping-list.service.ts` (a shopping list resolves the same reference data, no need for a second copy of this mapping). */
|
||||
export function toUnitView(unit: UnitWithDetails): UnitView {
|
||||
|
|
@ -244,8 +244,9 @@ export function toStepTechStepViews(
|
|||
function toRecipeView(recipe: RecipeWithDetails): RecipeView {
|
||||
const ingredients = recipe.ingredients.map((recipeIngredient) => ({
|
||||
ingredient: toIngredientView(recipeIngredient.ingredient),
|
||||
quantity: Number(recipeIngredient.quantity),
|
||||
unit: toUnitView(recipeIngredient.unit),
|
||||
// Both `null` on a "selon le goût" seasoning line — see `RecipeIngredientView`.
|
||||
quantity: recipeIngredient.quantity === null ? null : Number(recipeIngredient.quantity),
|
||||
unit: recipeIngredient.unit === null ? null : toUnitView(recipeIngredient.unit),
|
||||
}));
|
||||
return {
|
||||
...toRecipeSummaryView(recipe),
|
||||
|
|
@ -578,11 +579,11 @@ async function matchStepsTechSteps<T extends { description: string }>(
|
|||
);
|
||||
}
|
||||
|
||||
/** One resolved ingredient line — every `placeholderName` has been turned into a real (placeholder) `ingredientId`, ready for a `RecipeIngredient` create. */
|
||||
/** One resolved ingredient line — every `placeholderName` has been turned into a real (placeholder) `ingredientId`, ready for a `RecipeIngredient` create. `quantity`/`unitId` are both `null` for a "selon le goût" seasoning line. */
|
||||
interface ResolvedIngredientLine {
|
||||
ingredientId: number;
|
||||
quantity: number;
|
||||
unitId: number;
|
||||
quantity: number | null;
|
||||
unitId: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -615,8 +616,8 @@ async function resolveIngredientLines(
|
|||
if (line.ingredientId !== undefined) {
|
||||
resolved.push({
|
||||
ingredientId: line.ingredientId,
|
||||
quantity: line.quantity,
|
||||
unitId: line.unitId,
|
||||
quantity: line.quantity ?? null,
|
||||
unitId: line.unitId ?? null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
|
@ -632,7 +633,11 @@ async function resolveIngredientLines(
|
|||
},
|
||||
select: { id: true },
|
||||
});
|
||||
resolved.push({ ingredientId: placeholder.id, quantity: line.quantity, unitId: line.unitId });
|
||||
resolved.push({
|
||||
ingredientId: placeholder.id,
|
||||
quantity: line.quantity ?? null,
|
||||
unitId: line.unitId ?? null,
|
||||
});
|
||||
createdPlaceholders.push({ id: placeholder.id, name });
|
||||
}
|
||||
return { resolved, createdPlaceholders };
|
||||
|
|
@ -651,7 +656,9 @@ async function createRecipeInternal(
|
|||
await assertIngredientsExist(
|
||||
input.ingredients.map((i) => i.ingredientId).filter((id): id is number => id !== undefined),
|
||||
);
|
||||
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
|
||||
await assertUnitsExist(
|
||||
input.ingredients.flatMap((i) => (i.unitId === undefined ? [] : [i.unitId])),
|
||||
);
|
||||
await assertDietsExist(input.dietIds);
|
||||
// Matched up front (one call per step, in parallel) rather than inline
|
||||
// inside the `steps.create` map below — `techStepClassifier` is async
|
||||
|
|
@ -752,7 +759,9 @@ export async function updateRecipe(
|
|||
.map((i) => i.ingredientId)
|
||||
.filter((ingredientId): ingredientId is number => ingredientId !== undefined),
|
||||
);
|
||||
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
|
||||
await assertUnitsExist(
|
||||
input.ingredients.flatMap((i) => (i.unitId === undefined ? [] : [i.unitId])),
|
||||
);
|
||||
await assertDietsExist(input.dietIds);
|
||||
const stepsWithTechSteps = await matchStepsTechSteps(input.steps, DEFAULT_TECH_STEP_LOCALE);
|
||||
|
||||
|
|
|
|||
|
|
@ -44,11 +44,11 @@ type PlanningWithIngredients = Prisma.PlanningGetPayload<{
|
|||
include: ReturnType<typeof shoppingListPlanningInclude>;
|
||||
}>;
|
||||
|
||||
/** Accumulates a running sum per `(ingredientId, unitId)` pair while walking every planning item's ingredient lines — see {@link aggregateShoppingList}. */
|
||||
/** Accumulates a running sum per `(ingredientId, unitId)` pair while walking every planning item's ingredient lines — see {@link aggregateShoppingList}. `unit`/`quantity` are both `null` for a "selon le goût" seasoning line: nothing to sum, surfaced once. */
|
||||
interface RunningTotal {
|
||||
ingredient: IngredientView;
|
||||
unit: UnitView;
|
||||
quantity: number;
|
||||
unit: UnitView | null;
|
||||
quantity: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -70,16 +70,31 @@ function aggregateShoppingList(items: PlanningWithIngredients["items"]): Shoppin
|
|||
for (const item of items) {
|
||||
const scale = item.portions / item.recipe.portions;
|
||||
for (const recipeIngredient of item.recipe.ingredients) {
|
||||
// A "selon le goût" seasoning line (no quantity/unit) — surfaced once
|
||||
// per ingredient, never scaled or summed. Its key can't collide with a
|
||||
// real `(ingredient, unit)` line for the same ingredient.
|
||||
if (recipeIngredient.quantity === null) {
|
||||
const seasoningKey = `${recipeIngredient.ingredientId}:toTaste`;
|
||||
if (!totals.has(seasoningKey)) {
|
||||
totals.set(seasoningKey, {
|
||||
ingredient: toIngredientView(recipeIngredient.ingredient),
|
||||
unit: null,
|
||||
quantity: null,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = `${recipeIngredient.ingredientId}:${recipeIngredient.unitId}`;
|
||||
const addedQuantity = Number(recipeIngredient.quantity) * scale;
|
||||
|
||||
const existing = totals.get(key);
|
||||
if (existing) {
|
||||
if (existing && existing.quantity !== null) {
|
||||
existing.quantity += addedQuantity;
|
||||
} else {
|
||||
totals.set(key, {
|
||||
ingredient: toIngredientView(recipeIngredient.ingredient),
|
||||
unit: toUnitView(recipeIngredient.unit),
|
||||
unit: recipeIngredient.unit === null ? null : toUnitView(recipeIngredient.unit),
|
||||
quantity: addedQuantity,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -652,6 +652,57 @@ describe("Recipes", () => {
|
|||
expect(zero.status).to.equal(400);
|
||||
expect(zero.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||
});
|
||||
|
||||
it("creates a « selon le goût » seasoning line with no quantity or unit (issue #128)", async () => {
|
||||
const { agent } = await signup();
|
||||
const tomate = await ingredientId("tomato");
|
||||
const sel = await ingredientId("salt");
|
||||
const gram = await unitId("gram");
|
||||
|
||||
const res = await agent.post("/recipes").send({
|
||||
name: "Tomates au sel",
|
||||
portions: 2,
|
||||
dietIds: [],
|
||||
ingredients: [
|
||||
{ ingredientId: tomate, quantity: 200, unitId: gram },
|
||||
{ ingredientId: sel }, // selon le goût — no quantity/unit
|
||||
],
|
||||
steps: [{ description: "Saler les tomates" }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(201);
|
||||
const seasoning = res.body.ingredients.find(
|
||||
(line: { ingredient: { key: string } }) => line.ingredient.key === "salt",
|
||||
);
|
||||
expect(seasoning.quantity).to.equal(null);
|
||||
expect(seasoning.unit).to.equal(null);
|
||||
});
|
||||
|
||||
it("rejects an ingredient line with a quantity but no unit (or vice versa) with 400 VALIDATION_ERROR", async () => {
|
||||
const { agent } = await signup();
|
||||
const tomate = await ingredientId("tomato");
|
||||
const gram = await unitId("gram");
|
||||
const base = {
|
||||
name: "Test",
|
||||
portions: 2,
|
||||
dietIds: [],
|
||||
steps: [{ description: "Étape" }],
|
||||
};
|
||||
|
||||
const quantityOnly = await agent.post("/recipes").send({
|
||||
...base,
|
||||
ingredients: [{ ingredientId: tomate, quantity: 100 }],
|
||||
});
|
||||
const unitOnly = await agent.post("/recipes").send({
|
||||
...base,
|
||||
ingredients: [{ ingredientId: tomate, unitId: gram }],
|
||||
});
|
||||
|
||||
expect(quantityOnly.status).to.equal(400);
|
||||
expect(quantityOnly.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||
expect(unitOnly.status).to.equal(400);
|
||||
expect(unitOnly.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /recipes/:id", () => {
|
||||
|
|
@ -781,6 +832,38 @@ describe("Recipes", () => {
|
|||
expect(res.body.steps).to.have.length(2);
|
||||
});
|
||||
|
||||
it("carries a « selon le goût » seasoning line through an edit (issue #128)", async () => {
|
||||
const { agent } = await signup();
|
||||
const tomate = await ingredientId("tomato");
|
||||
const poivre = await ingredientId("blackPepper");
|
||||
const gram = await unitId("gram");
|
||||
const created = await agent.post("/recipes").send({
|
||||
name: "Salade",
|
||||
portions: 4,
|
||||
dietIds: [],
|
||||
ingredients: [{ ingredientId: tomate, quantity: 100, unitId: gram }],
|
||||
steps: [{ description: "Couper" }],
|
||||
});
|
||||
|
||||
const res = await agent.patch(`/recipes/${created.body.id}`).send({
|
||||
name: "Salade poivrée",
|
||||
portions: 4,
|
||||
dietIds: [],
|
||||
ingredients: [
|
||||
{ ingredientId: tomate, quantity: 100, unitId: gram },
|
||||
{ ingredientId: poivre }, // selon le goût
|
||||
],
|
||||
steps: [{ description: "Couper" }, { description: "Poivrer" }],
|
||||
});
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
const seasoning = res.body.ingredients.find(
|
||||
(line: { ingredient: { key: string } }) => line.ingredient.key === "blackPepper",
|
||||
);
|
||||
expect(seasoning.quantity).to.equal(null);
|
||||
expect(seasoning.unit).to.equal(null);
|
||||
});
|
||||
|
||||
it("recomputes each replaced step's technique sequence", async () => {
|
||||
const { agent } = await signup();
|
||||
const tomate = await ingredientId("tomato");
|
||||
|
|
|
|||
|
|
@ -214,6 +214,76 @@ describe("Shopping list", () => {
|
|||
expect(res.body.items[0].quantity).to.equal(300);
|
||||
});
|
||||
|
||||
it("lists a « selon le goût » seasoning ingredient once, with no quantity, never summed (issue #128)", async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
||||
const houseId: number = houseRes.body.id;
|
||||
const authorId: number = houseRes.body.adminId;
|
||||
|
||||
const tomatoId = await ingredientId("tomato");
|
||||
const saltId = await ingredientId("salt");
|
||||
const gramId = await unitId("gram");
|
||||
|
||||
// 100g tomato + salt "selon le goût" (quantity/unit null), planned
|
||||
// twice at 4 portions each (recipe yields 2): tomato scales to 400g,
|
||||
// salt stays a single unquantified line.
|
||||
const recipe = await prisma.recipe.create({
|
||||
data: {
|
||||
name: "Tomates au sel",
|
||||
authorId,
|
||||
portions: 2,
|
||||
ingredients: {
|
||||
create: [
|
||||
{ ingredientId: tomatoId, quantity: 100, unitId: gramId },
|
||||
{ ingredientId: saltId, quantity: null, unitId: null },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const planning = await prisma.planning.create({
|
||||
data: {
|
||||
houseId,
|
||||
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
|
||||
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
|
||||
},
|
||||
});
|
||||
await prisma.planningItem.createMany({
|
||||
data: [
|
||||
{
|
||||
planningId: planning.id,
|
||||
weekDay: "lundi",
|
||||
meal: "dejeuner",
|
||||
recipeId: recipe.id,
|
||||
portions: 4,
|
||||
portionsRequested: 4,
|
||||
},
|
||||
{
|
||||
planningId: planning.id,
|
||||
weekDay: "mercredi",
|
||||
meal: "diner",
|
||||
recipeId: recipe.id,
|
||||
portions: 4,
|
||||
portionsRequested: 4,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const res = await agent.get("/shopping-list").query({ date: today() });
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
const salt = res.body.items.filter(
|
||||
(item: { ingredient: { key: string } }) => item.ingredient.key === "salt",
|
||||
);
|
||||
expect(salt).to.have.length(1);
|
||||
expect(salt[0].quantity).to.equal(null);
|
||||
expect(salt[0].unit).to.equal(null);
|
||||
const tomato = res.body.items.find(
|
||||
(item: { ingredient: { key: string } }) => item.ingredient.key === "tomato",
|
||||
);
|
||||
expect(tomato.quantity).to.equal(400);
|
||||
});
|
||||
|
||||
it("sums the same ingredient across two different recipes sharing a unit", async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
|
|
|
|||
|
|
@ -68,6 +68,20 @@ Feature: Recipe form — associating ingredients
|
|||
Then the ingredient "Carotte" should be visible in the picker
|
||||
And there should be 0 ingredient rows
|
||||
|
||||
Scenario: Marks an ingredient as « selon le goût » and saves it with no quantity
|
||||
Given creating the recipe will succeed and return id 44
|
||||
When I visit "/recettes/nouvelle"
|
||||
And I fill in the "recipe-name" field with "Tomates au sel"
|
||||
And I select the ingredient "Tomate" from the picker
|
||||
And I mark the ingredient "Tomate" as seasoning
|
||||
Then the ingredient "Tomate" should have no quantity field
|
||||
When I add a step
|
||||
And I fill in the step description with "Saler."
|
||||
Then the "Enregistrer" button should not be disabled
|
||||
When I click the button "Enregistrer"
|
||||
Then the recipe creation request should have included the seasoning-only ingredient 1
|
||||
And the URL should include "/recettes/44"
|
||||
|
||||
Scenario: The save/cancel bar stays pinned to the foot of a long form
|
||||
When I visit "/recettes/nouvelle"
|
||||
And I add a step
|
||||
|
|
|
|||
|
|
@ -95,6 +95,25 @@ Then(
|
|||
},
|
||||
);
|
||||
|
||||
When("I mark the ingredient {string} as seasoning", (name: string) => {
|
||||
cy.contains(".ingredient-row", name)
|
||||
.find(".ingredient-row__to-taste input[type=checkbox]")
|
||||
.check({ force: true });
|
||||
});
|
||||
|
||||
Then("the ingredient {string} should have no quantity field", (name: string) => {
|
||||
cy.contains(".ingredient-row", name).find(".ingredient-row__quantity").should("not.exist");
|
||||
});
|
||||
|
||||
Then(
|
||||
"the recipe creation request should have included the seasoning-only ingredient {int}",
|
||||
(ingredientId: number) => {
|
||||
cy.wait("@createRecipe")
|
||||
.its("request.body.ingredients")
|
||||
.should("deep.equal", [{ ingredientId }]);
|
||||
},
|
||||
);
|
||||
|
||||
When("I scroll the recipe form to the top", () => {
|
||||
// `.app-content` is the one scroll container (AppLayout.scss) — the sticky
|
||||
// action bar pins against it, so it's what has to be scrolled to prove it.
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ Feature: Shopping list
|
|||
And the shopping list should show "Tomate" at quantity "400 g"
|
||||
And the shopping list should show "Oeuf" at quantity "6 unité"
|
||||
|
||||
Scenario: A « selon le goût » seasoning line shows no quantity (issue #128)
|
||||
Given the shopping list for "2026-08-17" has a « selon le goût » line for "salt"
|
||||
When I visit "/liste-de-courses"
|
||||
Then the shopping list should show "Sel" at quantity "selon le goût"
|
||||
|
||||
Scenario: Navigating to another week fetches and shows that week's own list
|
||||
Given the shopping list for "2026-08-17" is empty
|
||||
And the shopping list for "2026-08-24" contains:
|
||||
|
|
|
|||
|
|
@ -43,6 +43,38 @@ Given("the shopping list for {string} contains:", (date: string, dataTable: Data
|
|||
});
|
||||
});
|
||||
|
||||
// A "selon le goût" line — `quantity`/`unit` both null (issue #128). Its own
|
||||
// step rather than a `buildShoppingListItem` data-table column so that
|
||||
// helper's `quantity`/`unitKey` contract stays required for every other row.
|
||||
Given(
|
||||
"the shopping list for {string} has a « selon le goût » line for {string}",
|
||||
(date: string, ingredientKey: string) => {
|
||||
cy.intercept("GET", `**/shopping-list?date=${date}`, {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
startDate: date,
|
||||
finishDate: date,
|
||||
items: [
|
||||
{
|
||||
ingredient: {
|
||||
id: 1,
|
||||
key: ingredientKey,
|
||||
icon: "JAR",
|
||||
category: "condimentsAndSpices",
|
||||
subcategory: "spices",
|
||||
reproducible: false,
|
||||
allergens: [],
|
||||
diets: [],
|
||||
},
|
||||
quantity: null,
|
||||
unit: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Same class as PlanningPage's own week navigator (`WeekNavigator`, now
|
||||
// shared between the two pages) — `planning-page.cy.ts` already exercises
|
||||
// the prev/next arrows directly by class, same approach here.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { IngredientView, UnitView } from "@batch-cooking/shared";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckboxOption } from "../../../components/ui/Checkbox";
|
||||
import { AllergenBadges } from "../badges/AllergenBadges";
|
||||
import { DietBadges } from "../badges/DietBadges";
|
||||
import { ReproducibleBadge } from "../badges/ReproducibleBadge";
|
||||
|
|
@ -7,25 +8,30 @@ import { IngredientTypeIcon } from "./ingredient-icons";
|
|||
import { ingredientLabel } from "./ingredient-label";
|
||||
import "../recipes.scss";
|
||||
|
||||
/** One selected ingredient line in the recipe form — the ingredient itself (picked via `IngredientPicker`) plus its quantity/unit for this recipe. Quantity is kept as a raw string while editing (not parsed to a number until submit) so an in-progress/invalid value doesn't fight the input; `unit` picks from `unitsCatalog` (a closed reference list, see `Unit` in schema.prisma) rather than free text. */
|
||||
/** One selected ingredient line in the recipe form — the ingredient itself (picked via `IngredientPicker`) plus its quantity/unit for this recipe. Quantity is kept as a raw string while editing (not parsed to a number until submit) so an in-progress/invalid value doesn't fight the input; `unit` picks from `unitsCatalog` (a closed reference list, see `Unit` in schema.prisma) rather than free text. A line marked `toTaste` ("selon le goût") carries no quantity/unit at all — see {@link toTaste}. */
|
||||
export function IngredientRow({
|
||||
ingredient,
|
||||
quantity,
|
||||
unitId,
|
||||
unitsCatalog,
|
||||
toTaste,
|
||||
duplicate = false,
|
||||
onQuantityChange,
|
||||
onUnitChange,
|
||||
onToTasteChange,
|
||||
onRemove,
|
||||
}: {
|
||||
ingredient: IngredientView;
|
||||
quantity: string;
|
||||
unitId: number | null;
|
||||
unitsCatalog: UnitView[];
|
||||
/** `true` = a "selon le goût" seasoning line (salt, pepper…): the quantity/unit controls are hidden and the line submits with neither, stored as `null`/`null` on `RecipeIngredient` (schema.prisma). Never `--incomplete` for a missing unit while this is set. */
|
||||
toTaste: boolean;
|
||||
/** This ingredient is also picked by another line in the same form — `RecipeIngredient`'s primary key is `(recipeId, ingredientId)`, one row per ingredient (schema.prisma), so submitting two lines for it would fail. Never true for a manually-added line (`IngredientPicker`'s `excludeIds` already prevents picking the same ingredient twice) — only reachable via a source import, whose raw lines aren't deduplicated (e.g. "Egg Yolks" and "Eggs" both resolving to "Egg" — see issue #53's `RecipeImportForm`). */
|
||||
duplicate?: boolean;
|
||||
onQuantityChange: (quantity: string) => void;
|
||||
onUnitChange: (unitId: number) => void;
|
||||
onToTasteChange: (toTaste: boolean) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -34,8 +40,9 @@ export function IngredientRow({
|
|||
// (see issue #53: an import pre-filled from a source can land here with
|
||||
// an ingredient resolved but no unit — e.g. a bare count like "4 Egg
|
||||
// Yolks" — and nothing used to tell the user which line was blocking
|
||||
// them, or why).
|
||||
const unitMissing = unitId === null;
|
||||
// them, or why). A "selon le goût" line has no unit *by design*, so it's
|
||||
// never treated as incomplete.
|
||||
const unitMissing = !toTaste && unitId === null;
|
||||
const needsAttention = unitMissing || duplicate;
|
||||
// A free-text placeholder line (the catalog had nothing matching) — shown
|
||||
// with an "à compléter" badge and none of the allergen/diet/reproducible
|
||||
|
|
@ -56,6 +63,8 @@ export function IngredientRow({
|
|||
</span>
|
||||
)}
|
||||
</span>
|
||||
{!toTaste && (
|
||||
<>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
|
|
@ -81,6 +90,18 @@ export function IngredientRow({
|
|||
</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
{/* "Selon le goût" — a seasoning line with nothing to measure. Toggling
|
||||
it on hides the quantity/unit controls above and submits the line
|
||||
with neither. */}
|
||||
<CheckboxOption
|
||||
className="ingredient-row__to-taste"
|
||||
checked={toTaste}
|
||||
onChange={onToTasteChange}
|
||||
>
|
||||
{t("recipes.form.toTasteToggle")}
|
||||
</CheckboxOption>
|
||||
{!isPlaceholder && (
|
||||
<>
|
||||
<AllergenBadges allergens={ingredient.allergens} />
|
||||
|
|
|
|||
|
|
@ -1573,6 +1573,16 @@
|
|||
width: 11rem;
|
||||
}
|
||||
|
||||
// "Selon le goût" toggle — the app's selectable-card checkbox
|
||||
// (global.scss's `label:has(> input[type="checkbox"])`), shrunk to sit
|
||||
// inline in the row without dominating it.
|
||||
&__to-taste {
|
||||
margin-top: 0;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__remove {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
|
|
|
|||
|
|
@ -29,12 +29,13 @@ import "../recipes.scss";
|
|||
/** 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` (`ingredient` may be a synthetic placeholder view, see there). */
|
||||
/** A resolved ingredient line — identical shape to `RecipeFormPage`'s own `IngredientLine` (`ingredient` may be a synthetic placeholder view, see there). `toTaste` = a "selon le goût" seasoning line, submitted with no quantity/unit. */
|
||||
interface IngredientLine {
|
||||
key: string;
|
||||
ingredient: IngredientView;
|
||||
quantity: string;
|
||||
unitId: number | null;
|
||||
toTaste: boolean;
|
||||
}
|
||||
|
||||
/** 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, keep it as a free-text placeholder, or discard it, before this recipe can be saved. */
|
||||
|
|
@ -146,6 +147,12 @@ export function RecipeImportForm({
|
|||
ingredient: line.ingredient,
|
||||
quantity: line.quantity !== null ? String(line.quantity) : "",
|
||||
unitId: line.unit?.id ?? null,
|
||||
// A source line that resolved to a real ingredient but carried
|
||||
// no quantity (e.g. "salt to taste") — see
|
||||
// `translateRecipeIngredients`, which leaves `quantity`/`unitId`
|
||||
// null in exactly that case. Land it as a "selon le goût" line
|
||||
// rather than blocking submit on a missing quantity.
|
||||
toTaste: line.quantity === null,
|
||||
});
|
||||
} else {
|
||||
unresolved.push({
|
||||
|
|
@ -179,13 +186,13 @@ export function RecipeImportForm({
|
|||
function addIngredient(ingredient: IngredientView) {
|
||||
setIngredientLines((lines) => [
|
||||
...lines,
|
||||
{ key: makeClientKey(), ingredient, quantity: "", unitId: null },
|
||||
{ key: makeClientKey(), ingredient, quantity: "", unitId: null, toTaste: false },
|
||||
]);
|
||||
}
|
||||
|
||||
function updateIngredientLine(
|
||||
key: string,
|
||||
patch: Partial<Pick<IngredientLine, "quantity" | "unitId">>,
|
||||
patch: Partial<Pick<IngredientLine, "quantity" | "unitId" | "toTaste">>,
|
||||
) {
|
||||
setIngredientLines((lines) =>
|
||||
lines.map((line) => (line.key === key ? { ...line, ...patch } : line)),
|
||||
|
|
@ -203,7 +210,13 @@ export function RecipeImportForm({
|
|||
if (line) {
|
||||
setIngredientLines((resolved) => [
|
||||
...resolved,
|
||||
{ key: makeClientKey(), ingredient, quantity: line.quantity, unitId: null },
|
||||
{
|
||||
key: makeClientKey(),
|
||||
ingredient,
|
||||
quantity: line.quantity,
|
||||
unitId: null,
|
||||
toTaste: false,
|
||||
},
|
||||
]);
|
||||
}
|
||||
return lines.filter((l) => l.key !== unresolvedKey);
|
||||
|
|
@ -223,6 +236,7 @@ export function RecipeImportForm({
|
|||
ingredient: makePlaceholderIngredientView(line.rawText.trim()),
|
||||
quantity: line.quantity,
|
||||
unitId: null,
|
||||
toTaste: false,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
|
@ -241,7 +255,9 @@ export function RecipeImportForm({
|
|||
// this, a pre-filled import whose auto-matched ingredients are otherwise
|
||||
// complete could leave `canSubmit` false with nothing on the page saying
|
||||
// why (see issue #53).
|
||||
const hasIngredientMissingUnit = ingredientLines.some((line) => line.unitId === null);
|
||||
const hasIngredientMissingUnit = ingredientLines.some(
|
||||
(line) => !line.toTaste && line.unitId === null,
|
||||
);
|
||||
|
||||
// Two raw source lines can independently resolve to the same catalog
|
||||
// ingredient (e.g. "Egg Yolks" and "Eggs" both matching "Egg") —
|
||||
|
|
@ -270,7 +286,9 @@ export function RecipeImportForm({
|
|||
Number.isInteger(Number(portions)) &&
|
||||
Number(portions) > 0 &&
|
||||
ingredientLines.length > 0 &&
|
||||
ingredientLines.every((line) => Number(line.quantity) > 0 && line.unitId !== null) &&
|
||||
ingredientLines.every(
|
||||
(line) => line.toTaste || (Number(line.quantity) > 0 && line.unitId !== null),
|
||||
) &&
|
||||
!hasDuplicateIngredient &&
|
||||
unresolvedIngredients.length === 0 &&
|
||||
steps.length > 0 &&
|
||||
|
|
@ -294,11 +312,9 @@ export function RecipeImportForm({
|
|||
...(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 —
|
||||
// same "?? 0, the schema rejects it if ever reached" reasoning as
|
||||
// RecipeFormPage's identical submit.
|
||||
unitId: line.unitId ?? 0,
|
||||
// A "selon le goût" line submits with neither quantity nor unit —
|
||||
// same branch as RecipeFormPage's identical submit.
|
||||
...(line.toTaste ? {} : { quantity: Number(line.quantity), unitId: line.unitId ?? 0 }),
|
||||
})),
|
||||
steps: steps.map((step) => ({
|
||||
description: step.description.trim(),
|
||||
|
|
@ -432,9 +448,11 @@ export function RecipeImportForm({
|
|||
quantity={line.quantity}
|
||||
unitId={line.unitId}
|
||||
unitsCatalog={unitsCatalog}
|
||||
toTaste={line.toTaste}
|
||||
duplicate={duplicateIngredientIds.has(line.ingredient.id)}
|
||||
onQuantityChange={(quantity) => updateIngredientLine(line.key, { quantity })}
|
||||
onUnitChange={(unitId) => updateIngredientLine(line.key, { unitId })}
|
||||
onToTasteChange={(toTaste) => updateIngredientLine(line.key, { toTaste })}
|
||||
onRemove={() => removeIngredientLine(line.key)}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -357,13 +357,15 @@
|
|||
"submit": "Enregistrer",
|
||||
"submitting": "Enregistrement…",
|
||||
"cancel": "Annuler",
|
||||
"toTasteToggle": "Selon le goût",
|
||||
"genericError": "Le formulaire contient des erreurs"
|
||||
}
|
||||
},
|
||||
"shoppingList": {
|
||||
"title": "Liste de courses",
|
||||
"loading": "Chargement de la liste de courses…",
|
||||
"empty": "Aucun ingrédient à acheter pour cette semaine — ajoutez des recettes à votre planning."
|
||||
"empty": "Aucun ingrédient à acheter pour cette semaine — ajoutez des recettes à votre planning.",
|
||||
"toTaste": "selon le goût"
|
||||
},
|
||||
"cookingSession": {
|
||||
"title": "Cuisiner cette semaine",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ interface IngredientLine {
|
|||
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. */
|
||||
|
|
@ -111,8 +113,9 @@ export function RecipeFormPage() {
|
|||
recipe.ingredients.map((line) => ({
|
||||
key: makeClientKey(),
|
||||
ingredient: line.ingredient,
|
||||
quantity: String(line.quantity),
|
||||
unitId: line.unit.id,
|
||||
quantity: line.quantity === null ? "" : String(line.quantity),
|
||||
unitId: line.unit?.id ?? null,
|
||||
toTaste: line.quantity === null,
|
||||
})),
|
||||
);
|
||||
setSteps(
|
||||
|
|
@ -137,7 +140,7 @@ export function RecipeFormPage() {
|
|||
function addIngredient(ingredient: IngredientView) {
|
||||
setIngredientLines((lines) => [
|
||||
...lines,
|
||||
{ key: makeClientKey(), ingredient, quantity: "", unitId: null },
|
||||
{ key: makeClientKey(), ingredient, quantity: "", unitId: null, toTaste: false },
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -150,13 +153,14 @@ export function RecipeFormPage() {
|
|||
ingredient: makePlaceholderIngredientView(name),
|
||||
quantity: "",
|
||||
unitId: null,
|
||||
toTaste: false,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function updateIngredientLine(
|
||||
key: string,
|
||||
patch: Partial<Pick<IngredientLine, "quantity" | "unitId">>,
|
||||
patch: Partial<Pick<IngredientLine, "quantity" | "unitId" | "toTaste">>,
|
||||
) {
|
||||
setIngredientLines((lines) =>
|
||||
lines.map((line) => (line.key === key ? { ...line, ...patch } : line)),
|
||||
|
|
@ -172,17 +176,22 @@ export function RecipeFormPage() {
|
|||
// `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);
|
||||
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.
|
||||
// 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) => Number(line.quantity) > 0 && line.unitId !== null) &&
|
||||
ingredientLines.every(
|
||||
(line) => line.toTaste || (Number(line.quantity) > 0 && line.unitId !== null),
|
||||
) &&
|
||||
steps.length > 0 &&
|
||||
steps.every((step) => step.description.trim().length > 0);
|
||||
|
||||
|
|
@ -216,13 +225,18 @@ export function RecipeFormPage() {
|
|||
...(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 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.
|
||||
// `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(),
|
||||
|
|
@ -333,8 +347,10 @@ export function RecipeFormPage() {
|
|||
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)}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@ type ShoppingListState =
|
|||
| { status: "loaded"; list: ShoppingListView }
|
||||
| { status: "error" };
|
||||
|
||||
/** React list key for a `(ingredient, unit)` line — also the key the "in the cart" checklist is stored under (see {@link shoppingListProgress}). */
|
||||
/** React list key for a `(ingredient, unit)` line — also the key the "in the cart" checklist is stored under (see {@link shoppingListProgress}). A "selon le goût" line has no unit; `"tt"` keeps its key distinct from any real unit line for the same ingredient. */
|
||||
function itemKey(item: ShoppingListItemView): string {
|
||||
return `${item.ingredient.id}-${item.unit.id}`;
|
||||
return `${item.ingredient.id}-${item.unit?.id ?? "tt"}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -149,8 +149,11 @@ function ShoppingListItems({ weekKey, items }: { weekKey: string; items: Shoppin
|
|||
{ingredientLabel(item.ingredient, t)}
|
||||
</span>
|
||||
<span className="shopping-list__item-quantity">
|
||||
{formatShoppingListQuantity(item.quantity)}{" "}
|
||||
{t(`catalog.units.${item.unit.key}`)}
|
||||
{item.quantity === null || item.unit === null
|
||||
? t("shoppingList.toTaste")
|
||||
: `${formatShoppingListQuantity(item.quantity)} ${t(
|
||||
`catalog.units.${item.unit.key}`,
|
||||
)}`}
|
||||
</span>
|
||||
</CheckboxOption>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -16,21 +16,28 @@ import { z } from "zod";
|
|||
* row for this line (see `Ingredient.isPlaceholder` in schema.prisma and
|
||||
* `recipe.service.ts`'s `createRecipeInternal`); it is never browsable.
|
||||
*
|
||||
* Requiring exactly one keeps `RecipeIngredient` unchanged (still a real
|
||||
* `ingredientId` after the service resolves the line).
|
||||
* `quantity`/`unitId` are **both present or both absent** — absent means a
|
||||
* "selon le goût" seasoning line (salt, pepper…), stored as `null`/`null` on
|
||||
* `RecipeIngredient`, excluded from portion scaling and shopping-list
|
||||
* aggregation. A quantity without a unit (or the reverse) is rejected here.
|
||||
*/
|
||||
const recipeIngredientInputSchema = z
|
||||
.object({
|
||||
ingredientId: z.number().int().positive().optional(),
|
||||
/** Free-text ingredient name for a line the catalog couldn't cover — see this schema's doc comment. Mutually exclusive with `ingredientId`. */
|
||||
placeholderName: z.string().trim().min(1).max(120).optional(),
|
||||
quantity: z.number().positive("La quantité doit être positive"),
|
||||
/** References a reference `Unit` row (see `GET /reference/units`) — free-text units were replaced by this closed catalog, see `Unit` in schema.prisma. An unknown id is rejected service-side with `UNIT_NOT_FOUND`, same posture as `ingredientId`. */
|
||||
unitId: z.number().int().positive(),
|
||||
/** Omitted for a "selon le goût" line — see this schema's doc comment. Present ⇒ `unitId` present too. */
|
||||
quantity: z.number().positive("La quantité doit être positive").optional(),
|
||||
/** References a reference `Unit` row (see `GET /reference/units`) — free-text units were replaced by this closed catalog, see `Unit` in schema.prisma. An unknown id is rejected service-side with `UNIT_NOT_FOUND`, same posture as `ingredientId`. Omitted for a "selon le goût" line. */
|
||||
unitId: z.number().int().positive().optional(),
|
||||
})
|
||||
.refine((line) => (line.ingredientId === undefined) !== (line.placeholderName === undefined), {
|
||||
message: "Chaque ingrédient doit avoir soit un identifiant catalogue, soit un nom libre",
|
||||
path: ["ingredientId"],
|
||||
})
|
||||
.refine((line) => (line.quantity === undefined) === (line.unitId === undefined), {
|
||||
message: "Indiquez une quantité et une unité, ou aucune des deux (« selon le goût »)",
|
||||
path: ["quantity"],
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -22,13 +22,17 @@ export type RecipeVisibility = "PERSONAL" | "HOUSE" | "PUBLIC";
|
|||
* allergens), plus the quantity specific to this recipe (carried by
|
||||
* `RecipeIngredient` in schema.prisma, not by `Ingredient` itself). `unit`
|
||||
* is likewise resolved to its full reference data (`Unit`) rather than a
|
||||
* raw key — same "resolve at read time" treatment as `ingredient`, now that
|
||||
* it's a catalog reference instead of free text (see `UnitView`).
|
||||
* raw key — same "resolve at read time" treatment as `ingredient`.
|
||||
*
|
||||
* `quantity` and `unit` are **both `null`** for a "selon le goût" seasoning
|
||||
* line (salt, pepper… added to taste) — see `RecipeIngredient.quantity` in
|
||||
* schema.prisma. A consumer showing a quantity must handle that case (e.g.
|
||||
* render "selon le goût" instead of a number).
|
||||
*/
|
||||
export interface RecipeIngredientView {
|
||||
ingredient: IngredientView;
|
||||
quantity: number;
|
||||
unit: UnitView;
|
||||
quantity: number | null;
|
||||
unit: UnitView | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -15,11 +15,17 @@ import type { IngredientView, UnitView } from "./reference.js";
|
|||
* built (see that field's own doc comment), so the same ingredient
|
||||
* requested in two different units surfaces as two separate lines rather
|
||||
* than silently guessing a conversion.
|
||||
*
|
||||
* `quantity` and `unit` are **both `null`** on a "selon le goût" line — a
|
||||
* seasoning ingredient (salt, pepper…) used by at least one planned recipe
|
||||
* with no quantity to buy by (see `RecipeIngredient.quantity`,
|
||||
* schema.prisma). Such a line appears once, deduplicated by ingredient, and
|
||||
* is never summed.
|
||||
*/
|
||||
export interface ShoppingListItemView {
|
||||
ingredient: IngredientView;
|
||||
quantity: number;
|
||||
unit: UnitView;
|
||||
quantity: number | null;
|
||||
unit: UnitView | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue