From 0cb1dccd924d865b21fad4a1a8f0dabbeca34d5e Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 19 Aug 2026 21:44:01 +0200 Subject: [PATCH 1/2] feat(recipes): ajouter le nombre de portions couvertes par une recette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute Recipe.portions (combien de portions la recette produit telle qu'écrite) — formulaire de création/édition, fiche détail, migration Prisma (backfill à 4, même pattern que planning_item.portions). Le sélecteur de recette du planning pré-remplit désormais son propre champ "portions" depuis cette valeur au lieu de toujours démarrer à 1 (RecipeSummaryView.portions), tout en gardant PlanningItem.portions indépendant (une recette peut être mise à l'échelle pour un créneau). Couverture : tests API (création/édition/validation), scénarios cypress (formulaire + préchargement en édition). --- .../migration.sql | 10 +++ apps/api/prisma/schema.prisma | 7 ++ apps/api/src/modules/recipe/recipe.service.ts | 3 + apps/api/test/planning.test.ts | 4 +- apps/api/test/recipe.test.ts | 86 +++++++++++++++---- apps/web/cypress/e2e/recipe-form.feature | 2 +- apps/web/cypress/e2e/recipe-form.ts | 6 +- apps/web/cypress/e2e/recipes.cy.ts | 2 + apps/web/cypress/e2e/recipes.ts | 1 + .../features/planning/RecipePickerDialog.tsx | 11 ++- .../features/recipes/RecipeDetailPanel.tsx | 7 +- apps/web/src/features/recipes/recipes.scss | 17 ++++ apps/web/src/locales/fr/translation.json | 4 +- apps/web/src/pages/RecipeFormPage.tsx | 19 ++++ packages/shared/src/schemas/recipe.ts | 2 + packages/shared/src/types/planning.ts | 2 +- packages/shared/src/types/recipe.ts | 2 + 17 files changed, 155 insertions(+), 30 deletions(-) create mode 100644 apps/api/prisma/migrations/20260819192851_recipe_portions/migration.sql diff --git a/apps/api/prisma/migrations/20260819192851_recipe_portions/migration.sql b/apps/api/prisma/migrations/20260819192851_recipe_portions/migration.sql new file mode 100644 index 0000000..bd9d28b --- /dev/null +++ b/apps/api/prisma/migrations/20260819192851_recipe_portions/migration.sql @@ -0,0 +1,10 @@ +-- Adds how many portions a recipe yields as written (`Recipe.portions`) — +-- the planning recipe picker now pre-fills its own "how many portions?" +-- step from this (see `RecipePickerDialog`), closing the gap noted on +-- `PlanningItem.portions`'s own migration ("no such default exists ... on +-- `Recipe` either"). Backfills any pre-existing row with 4 portions (a +-- reasonable default recipe yield) via a transient DEFAULT, then drops it +-- so it isn't implicitly reused for new inserts going forward — same +-- pattern as `20260819064721_planning_item_portions`. +ALTER TABLE "recipe" ADD COLUMN "portions" INTEGER NOT NULL DEFAULT 4; +ALTER TABLE "recipe" ALTER COLUMN "portions" DROP DEFAULT; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 85da367..57985d8 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -241,6 +241,13 @@ model Recipe { sourceId Int? @map("source_id") description String? picture String? + /// How many portions this recipe yields as written (its ingredient + /// quantities/steps assume this count) — distinct from + /// `PlanningItem.portions`, which is how many to actually prepare for one + /// planning slot and now defaults to this value client-side but is still + /// entered/stored independently (a planning slot may scale the recipe + /// up/down). + portions Int /// Creator — not in the original spec doc, required once recipes carry a /// visibility level (`PERSONAL`/`HOUSE` need someone to scope against). authorId Int @map("author_id") diff --git a/apps/api/src/modules/recipe/recipe.service.ts b/apps/api/src/modules/recipe/recipe.service.ts index aa8baa4..324da54 100644 --- a/apps/api/src/modules/recipe/recipe.service.ts +++ b/apps/api/src/modules/recipe/recipe.service.ts @@ -78,6 +78,7 @@ function toRecipeSummaryView(recipe: RecipeWithDetails): RecipeSummaryView { name: recipe.name, description: recipe.description, picture: recipe.picture, + portions: recipe.portions, authorId: recipe.authorId, visibility: recipe.visibility, allergens, @@ -307,6 +308,7 @@ export async function createRecipe( name: input.name, description: input.description ?? null, picture: input.picture ?? null, + portions: input.portions, authorId, authorHouseId, visibility: input.visibility, @@ -363,6 +365,7 @@ export async function updateRecipe( name: input.name, description: input.description ?? null, picture: input.picture ?? null, + portions: input.portions, visibility: input.visibility, ingredients: { create: input.ingredients.map((ingredient) => ({ diff --git a/apps/api/test/planning.test.ts b/apps/api/test/planning.test.ts index a2de7a7..8b29062 100644 --- a/apps/api/test/planning.test.ts +++ b/apps/api/test/planning.test.ts @@ -98,7 +98,7 @@ describe("Planning", () => { const houseId: number = houseRes.body.id; const recipe = await prisma.recipe.create({ - data: { name: "Ratatouille", authorId: houseRes.body.adminId }, + data: { name: "Ratatouille", authorId: houseRes.body.adminId, portions: 4 }, }); const planning = await prisma.planning.create({ data: { @@ -154,7 +154,7 @@ describe("Planning", () => { const houseId: number = houseRes.body.id; const recipe = await prisma.recipe.create({ - data: { name: "Curry de lentilles", authorId: houseRes.body.adminId }, + data: { name: "Curry de lentilles", authorId: houseRes.body.adminId, portions: 4 }, }); const nextWeek = TEST_REFERENCE_DATE.plus({ weeks: 1 }); const planning = await prisma.planning.create({ diff --git a/apps/api/test/recipe.test.ts b/apps/api/test/recipe.test.ts index 8bb51e3..3b51b25 100644 --- a/apps/api/test/recipe.test.ts +++ b/apps/api/test/recipe.test.ts @@ -72,10 +72,10 @@ describe("Recipes", () => { it("filters the catalog by name when ?search= is given", async () => { const { agent, profileId } = await signup(); await prisma.recipe.create({ - data: { name: "Ratatouille", authorId: profileId, visibility: "PUBLIC" }, + data: { name: "Ratatouille", authorId: profileId, visibility: "PUBLIC", portions: 4 }, }); await prisma.recipe.create({ - data: { name: "Tarte aux pommes", authorId: profileId, visibility: "PUBLIC" }, + data: { name: "Tarte aux pommes", authorId: profileId, visibility: "PUBLIC", portions: 6 }, }); const res = await agent.get("/recipes").query({ tab: "publique", search: "rata" }); @@ -87,8 +87,10 @@ describe("Recipes", () => { it("perso tab only returns the viewer's own PERSONAL recipes", async () => { const { agent, profileId } = await signup(); const { profileId: otherId } = await signup(); - await prisma.recipe.create({ data: { name: "La mienne", authorId: profileId } }); - await prisma.recipe.create({ data: { name: "Pas la mienne", authorId: otherId } }); + await prisma.recipe.create({ data: { name: "La mienne", authorId: profileId, portions: 4 } }); + await prisma.recipe.create({ + data: { name: "Pas la mienne", authorId: otherId, portions: 4 }, + }); const res = await agent.get("/recipes").query({ tab: "perso" }); @@ -107,6 +109,7 @@ describe("Recipes", () => { authorId: profileId, visibility: "HOUSE", authorHouseId: houseRes.body.id, + portions: 4, }, }); await prisma.recipe.create({ @@ -115,6 +118,7 @@ describe("Recipes", () => { authorId: otherId, visibility: "HOUSE", authorHouseId: otherHouseRes.body.id, + portions: 4, }, }); @@ -135,10 +139,10 @@ describe("Recipes", () => { it("favoris tab only returns recipes the viewer has favorited", async () => { const { agent, profileId } = await signup(); const favorited = await prisma.recipe.create({ - data: { name: "Favorite", authorId: profileId, visibility: "PUBLIC" }, + data: { name: "Favorite", authorId: profileId, visibility: "PUBLIC", portions: 4 }, }); await prisma.recipe.create({ - data: { name: "Pas favorite", authorId: profileId, visibility: "PUBLIC" }, + data: { name: "Pas favorite", authorId: profileId, visibility: "PUBLIC", portions: 4 }, }); await agent.post(`/recipes/${favorited.id}/favorite`); @@ -150,7 +154,7 @@ describe("Recipes", () => { it("a PERSONAL recipe from another author is invisible in the publique tab", async () => { const { agent } = await signup(); const { profileId: otherId } = await signup(); - await prisma.recipe.create({ data: { name: "Secrète", authorId: otherId } }); + await prisma.recipe.create({ data: { name: "Secrète", authorId: otherId, portions: 4 } }); const res = await agent.get("/recipes").query({ tab: "publique" }); @@ -170,6 +174,7 @@ describe("Recipes", () => { const res = await agent.post("/recipes").send({ name: "Omelette provençale", description: "Rapide et savoureuse", + portions: 2, dietIds: [vegetarien.id], ingredients: [ { ingredientId: tomate, quantity: 2, unit: "unité" }, @@ -180,6 +185,7 @@ describe("Recipes", () => { expect(res.status).to.equal(201); expect(res.body.name).to.equal("Omelette provençale"); + expect(res.body.portions).to.equal(2); expect(res.body.ingredients).to.have.length(2); expect( res.body.steps.map((s: { description: string; order: number }) => s.order), @@ -196,6 +202,7 @@ describe("Recipes", () => { const res = await agent.post("/recipes").send({ name: "Test", + portions: 4, dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], steps: [{ description: "Étape" }], @@ -207,6 +214,7 @@ describe("Recipes", () => { const houseRecipe = await agent.post("/recipes").send({ name: "Foyer", visibility: "HOUSE", + portions: 4, dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], steps: [{ description: "Étape" }], @@ -221,6 +229,7 @@ describe("Recipes", () => { const res = await agent.post("/recipes").send({ name: "Test", + portions: 4, dietIds: [], ingredients: [{ ingredientId: 999_999, quantity: 1, unit: "g" }], steps: [{ description: "Étape" }], @@ -236,6 +245,7 @@ describe("Recipes", () => { const res = await agent.post("/recipes").send({ name: "Test", + portions: 4, dietIds: [999_999], ingredients: [{ ingredientId: tomate, quantity: 1, unit: "g" }], steps: [{ description: "Étape" }], @@ -248,13 +258,36 @@ describe("Recipes", () => { it("rejects an empty ingredients or steps list with 400 VALIDATION_ERROR", async () => { const { agent } = await signup(); - const res = await agent - .post("/recipes") - .send({ name: "Test", dietIds: [], ingredients: [], steps: [{ description: "Étape" }] }); + const res = await agent.post("/recipes").send({ + name: "Test", + portions: 4, + dietIds: [], + ingredients: [], + steps: [{ description: "Étape" }], + }); expect(res.status).to.equal(400); expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); }); + + it("rejects a missing or non-positive portions with 400 VALIDATION_ERROR", async () => { + const { agent } = await signup(); + const tomate = await ingredientId("tomato"); + const basePayload = { + name: "Test", + dietIds: [], + ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], + steps: [{ description: "Étape" }], + }; + + const missing = await agent.post("/recipes").send(basePayload); + const zero = await agent.post("/recipes").send({ ...basePayload, portions: 0 }); + + expect(missing.status).to.equal(400); + expect(missing.body.code).to.equal(ErrorCode.VALIDATION_ERROR); + expect(zero.status).to.equal(400); + expect(zero.body.code).to.equal(ErrorCode.VALIDATION_ERROR); + }); }); describe("GET /recipes/:id", () => { @@ -272,6 +305,7 @@ describe("Recipes", () => { const tomate = await ingredientId("tomato"); const created = await agent.post("/recipes").send({ name: "Salade", + portions: 4, dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], steps: [{ description: "Couper" }], @@ -281,6 +315,7 @@ describe("Recipes", () => { expect(res.status).to.equal(200); expect(res.body.name).to.equal("Salade"); + expect(res.body.portions).to.equal(4); expect(res.body.ingredients[0].ingredient.key).to.equal("tomato"); expect(res.body.isFavorite).to.equal(false); }); @@ -288,7 +323,9 @@ describe("Recipes", () => { it("returns 404 for a PERSONAL recipe belonging to someone else", async () => { const { agent } = await signup(); const { profileId: otherId } = await signup(); - const recipe = await prisma.recipe.create({ data: { name: "Secrète", authorId: otherId } }); + const recipe = await prisma.recipe.create({ + data: { name: "Secrète", authorId: otherId, portions: 4 }, + }); const res = await agent.get(`/recipes/${recipe.id}`); @@ -300,7 +337,7 @@ describe("Recipes", () => { const { agent } = await signup(); const { profileId: otherId } = await signup(); const recipe = await prisma.recipe.create({ - data: { name: "Ouverte", authorId: otherId, visibility: "PUBLIC" }, + data: { name: "Ouverte", authorId: otherId, visibility: "PUBLIC", portions: 4 }, }); const res = await agent.get(`/recipes/${recipe.id}`); @@ -318,6 +355,7 @@ describe("Recipes", () => { authorId: otherId, visibility: "HOUSE", authorHouseId: houseRes.body.id, + portions: 4, }, }); const otherHouseId = ( @@ -331,6 +369,7 @@ describe("Recipes", () => { authorId: otherId, visibility: "HOUSE", authorHouseId: otherHouseId, + portions: 4, }, }); @@ -349,6 +388,7 @@ describe("Recipes", () => { const oignon = await ingredientId("onion"); const created = await agent.post("/recipes").send({ name: "Salade", + portions: 4, dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], steps: [{ description: "Couper" }], @@ -356,6 +396,7 @@ describe("Recipes", () => { const res = await agent.patch(`/recipes/${created.body.id}`).send({ name: "Salade composée", + portions: 6, visibility: "PUBLIC", dietIds: [], ingredients: [{ ingredientId: oignon, quantity: 2, unit: "unité" }], @@ -364,6 +405,7 @@ describe("Recipes", () => { expect(res.status).to.equal(200); expect(res.body.name).to.equal("Salade composée"); + expect(res.body.portions).to.equal(6); expect(res.body.visibility).to.equal("PUBLIC"); expect(res.body.ingredients).to.have.length(1); expect(res.body.ingredients[0].ingredient.key).to.equal("onion"); @@ -376,6 +418,7 @@ describe("Recipes", () => { const res = await agent.patch("/recipes/999999").send({ name: "Test", + portions: 4, dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], steps: [{ description: "Étape" }], @@ -390,6 +433,7 @@ describe("Recipes", () => { const tomate = await ingredientId("tomato"); const created = await agent.post("/recipes").send({ name: "Salade", + portions: 4, dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], steps: [{ description: "Couper" }], @@ -397,6 +441,7 @@ describe("Recipes", () => { const res = await agent.patch(`/recipes/${created.body.id}`).send({ name: "Salade", + portions: 4, dietIds: [999_999], ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], steps: [{ description: "Couper" }], @@ -411,11 +456,12 @@ describe("Recipes", () => { const { agent: otherAgent } = await signup(); const tomate = await ingredientId("tomato"); const recipe = await prisma.recipe.create({ - data: { name: "Publique", authorId: profileId, visibility: "PUBLIC" }, + data: { name: "Publique", authorId: profileId, visibility: "PUBLIC", portions: 4 }, }); const res = await otherAgent.patch(`/recipes/${recipe.id}`).send({ name: "Hack", + portions: 4, dietIds: [], ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }], steps: [{ description: "Étape" }], @@ -430,7 +476,7 @@ describe("Recipes", () => { it("deletes a recipe not referenced by any planning item", async () => { const { agent, profileId } = await signup(); const recipe = await prisma.recipe.create({ - data: { name: "À supprimer", authorId: profileId }, + data: { name: "À supprimer", authorId: profileId, portions: 4 }, }); const res = await agent.delete(`/recipes/${recipe.id}`); @@ -444,7 +490,7 @@ describe("Recipes", () => { const { agent, profileId } = await signup(); const houseRes = await agent.post("/house").send({ name: "Chez moi" }); const recipe = await prisma.recipe.create({ - data: { name: "Ratatouille", authorId: profileId }, + data: { name: "Ratatouille", authorId: profileId, portions: 4 }, }); const planning = await prisma.planning.create({ data: { @@ -482,7 +528,7 @@ describe("Recipes", () => { const { profileId } = await signup(); const { agent: otherAgent } = await signup(); const recipe = await prisma.recipe.create({ - data: { name: "Publique", authorId: profileId, visibility: "PUBLIC" }, + data: { name: "Publique", authorId: profileId, visibility: "PUBLIC", portions: 4 }, }); const res = await otherAgent.delete(`/recipes/${recipe.id}`); @@ -496,7 +542,7 @@ describe("Recipes", () => { it("adds and removes a recipe from the viewer's favorites", async () => { const { agent, profileId } = await signup(); const recipe = await prisma.recipe.create({ - data: { name: "Recette", authorId: profileId, visibility: "PUBLIC" }, + data: { name: "Recette", authorId: profileId, visibility: "PUBLIC", portions: 4 }, }); const addRes = await agent.post(`/recipes/${recipe.id}/favorite`); @@ -511,7 +557,7 @@ describe("Recipes", () => { it("is idempotent — favoriting an already-favorited recipe doesn't error", async () => { const { agent, profileId } = await signup(); const recipe = await prisma.recipe.create({ - data: { name: "Recette", authorId: profileId, visibility: "PUBLIC" }, + data: { name: "Recette", authorId: profileId, visibility: "PUBLIC", portions: 4 }, }); await agent.post(`/recipes/${recipe.id}/favorite`); @@ -523,7 +569,9 @@ describe("Recipes", () => { it("rejects favoriting a recipe the viewer can't see with 404 RECIPE_NOT_FOUND", async () => { const { agent } = await signup(); const { profileId: otherId } = await signup(); - const recipe = await prisma.recipe.create({ data: { name: "Secrète", authorId: otherId } }); + const recipe = await prisma.recipe.create({ + data: { name: "Secrète", authorId: otherId, portions: 4 }, + }); const res = await agent.post(`/recipes/${recipe.id}/favorite`); diff --git a/apps/web/cypress/e2e/recipe-form.feature b/apps/web/cypress/e2e/recipe-form.feature index 604cebe..3d1db3c 100644 --- a/apps/web/cypress/e2e/recipe-form.feature +++ b/apps/web/cypress/e2e/recipe-form.feature @@ -21,7 +21,7 @@ Feature: Recipe form — associating ingredients And I fill in the step description with "Couper les tomates." Then the "Enregistrer" button should not be disabled When I click the button "Enregistrer" - Then the recipe creation request should have included name "Salade de tomates" and ingredient 1 with quantity 3 and unit "unité" + Then the recipe creation request should have included name "Salade de tomates", portions 4, and ingredient 1 with quantity 3 and unit "unité" And the URL should include "/recettes/42" # Regression test for the exact bug reported: `crypto.randomUUID()` (used diff --git a/apps/web/cypress/e2e/recipe-form.ts b/apps/web/cypress/e2e/recipe-form.ts index 7840121..469a726 100644 --- a/apps/web/cypress/e2e/recipe-form.ts +++ b/apps/web/cypress/e2e/recipe-form.ts @@ -114,12 +114,13 @@ Then("there should be {int} step editor items", (count: number) => { }); Then( - "the recipe creation request should have included name {string} and ingredient {int} with quantity {int} and unit {string}", - (name: string, ingredientId: number, quantity: number, unit: string) => { + "the recipe creation request should have included name {string}, portions {int}, and ingredient {int} with quantity {int} and unit {string}", + (name: string, portions: number, ingredientId: number, quantity: number, unit: string) => { cy.wait("@createRecipe") .its("request.body") .should("deep.include", { name, + portions, ingredients: [{ ingredientId, quantity, unit }], }); }, @@ -131,6 +132,7 @@ Given("recipe 7 exists with an egg omelette", () => { name: "Omelette", description: null, picture: null, + portions: 2, authorId: 1, visibility: "PERSONAL", allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }], diff --git a/apps/web/cypress/e2e/recipes.cy.ts b/apps/web/cypress/e2e/recipes.cy.ts index cae0124..39f233f 100644 --- a/apps/web/cypress/e2e/recipes.cy.ts +++ b/apps/web/cypress/e2e/recipes.cy.ts @@ -24,6 +24,7 @@ const ratatouille = { name: "Ratatouille", description: null, picture: null, + portions: 4, authorId: 1, visibility: "PERSONAL", allergens: [], @@ -36,6 +37,7 @@ const omelette = { name: "Omelette", description: null, picture: null, + portions: 2, authorId: 1, visibility: "PERSONAL", allergens: [oeufs], diff --git a/apps/web/cypress/e2e/recipes.ts b/apps/web/cypress/e2e/recipes.ts index 867b801..b162bae 100644 --- a/apps/web/cypress/e2e/recipes.ts +++ b/apps/web/cypress/e2e/recipes.ts @@ -7,6 +7,7 @@ const omelette = { name: "Omelette", description: null, picture: null, + portions: 2, authorId: 1, visibility: "PERSONAL", allergens: [oeufs], diff --git a/apps/web/src/features/planning/RecipePickerDialog.tsx b/apps/web/src/features/planning/RecipePickerDialog.tsx index 65ce812..cd0f4c4 100644 --- a/apps/web/src/features/planning/RecipePickerDialog.tsx +++ b/apps/web/src/features/planning/RecipePickerDialog.tsx @@ -273,9 +273,14 @@ export function RecipePickerDialog({ - setSelectedRecipe(listState.recipes.find((recipe) => recipe.id === id) ?? null) - } + onSelect={(id) => { + const recipe = listState.recipes.find((r) => r.id === id) ?? null; + setSelectedRecipe(recipe); + // Pre-fill from the recipe's own written yield rather than + // always starting at 1 — still freely editable below, this is + // just a better starting point (see `Recipe.portions`). + if (recipe) setPortions(String(recipe.portions)); + }} /> )} diff --git a/apps/web/src/features/recipes/RecipeDetailPanel.tsx b/apps/web/src/features/recipes/RecipeDetailPanel.tsx index 2b4a001..2db360a 100644 --- a/apps/web/src/features/recipes/RecipeDetailPanel.tsx +++ b/apps/web/src/features/recipes/RecipeDetailPanel.tsx @@ -90,7 +90,12 @@ export function RecipeDetailPanel({
-

{recipe.name}

+
+

{recipe.name}

+

+ {t("recipes.detail.portions", { count: recipe.portions })} +

+
{dislikedIngredients.length > 0 && ( diff --git a/apps/web/src/features/recipes/recipes.scss b/apps/web/src/features/recipes/recipes.scss index 3abf302..b305bc1 100644 --- a/apps/web/src/features/recipes/recipes.scss +++ b/apps/web/src/features/recipes/recipes.scss @@ -396,6 +396,23 @@ } } + &__title-main { + display: flex; + flex-direction: column; + gap: 0.2rem; + min-width: 0; + + h2 { + margin: 0; + } + } + + &__portions { + margin: 0; + color: var(--color-text-muted); + font-size: var(--font-size-sm); + } + &__title-badges { display: flex; flex-direction: column; diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 7a3df8c..d0e5b6c 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -166,7 +166,8 @@ "detail": { "empty": "Sélectionnez une recette dans le tableau pour voir son détail ici.", "favorite": "Ajouter aux favoris", - "unfavorite": "Retirer des favoris" + "unfavorite": "Retirer des favoris", + "portions": "{{count}} portion(s)" }, "form": { "newTitle": "Nouvelle recette", @@ -174,6 +175,7 @@ "nameLabel": "Nom de la recette", "descriptionLabel": "Description", "pictureLabel": "Photo (URL)", + "portionsLabel": "Nombre de portions", "visibilityLabel": "Visible par", "visibility": { "PERSONAL": "Moi uniquement", diff --git a/apps/web/src/pages/RecipeFormPage.tsx b/apps/web/src/pages/RecipeFormPage.tsx index f4012c7..e279d1d 100644 --- a/apps/web/src/pages/RecipeFormPage.tsx +++ b/apps/web/src/pages/RecipeFormPage.tsx @@ -54,6 +54,11 @@ export function RecipeFormPage() { 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("PERSONAL"); const [dietIds, setDietIds] = useState([]); const [ingredientLines, setIngredientLines] = useState([]); @@ -79,6 +84,7 @@ export function RecipeFormPage() { setName(recipe.name); setDescription(recipe.description ?? ""); setPicture(recipe.picture ?? ""); + setPortions(String(recipe.portions)); setVisibility(recipe.visibility); setDietIds(recipe.diets.map((diet) => diet.id)); setIngredientLines( @@ -133,6 +139,8 @@ export function RecipeFormPage() { // 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.unit.trim().length > 0) && steps.length > 0 && @@ -146,6 +154,7 @@ export function RecipeFormPage() { name: name.trim(), description: description.trim() || null, picture: picture.trim() || null, + portions: Number(portions), visibility, dietIds, ingredients: ingredientLines.map((line) => ({ @@ -222,6 +231,16 @@ export function RecipeFormPage() { placeholder="https://…" /> + + setPortions(e.target.value)} + /> +