feat(recipes): ajouter le nombre de portions couvertes par une recette
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).
This commit is contained in:
parent
4647a82942
commit
0cb1dccd92
17 changed files with 155 additions and 30 deletions
|
|
@ -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;
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" }],
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ const omelette = {
|
|||
name: "Omelette",
|
||||
description: null,
|
||||
picture: null,
|
||||
portions: 2,
|
||||
authorId: 1,
|
||||
visibility: "PERSONAL",
|
||||
allergens: [oeufs],
|
||||
|
|
|
|||
|
|
@ -273,9 +273,14 @@ export function RecipePickerDialog({
|
|||
<RecipeTable
|
||||
recipes={listState.recipes}
|
||||
selectedId={null}
|
||||
onSelect={(id) =>
|
||||
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));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -90,7 +90,12 @@ export function RecipeDetailPanel({
|
|||
</div>
|
||||
|
||||
<div className="recipe-detail-panel__title-row">
|
||||
<h2>{recipe.name}</h2>
|
||||
<div className="recipe-detail-panel__title-main">
|
||||
<h2>{recipe.name}</h2>
|
||||
<p className="recipe-detail-panel__portions">
|
||||
{t("recipes.detail.portions", { count: recipe.portions })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="recipe-detail-panel__title-badges">
|
||||
<AllergenBadges allergens={recipe.allergens} />
|
||||
{dislikedIngredients.length > 0 && (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<RecipeVisibility>("PERSONAL");
|
||||
const [dietIds, setDietIds] = useState<number[]>([]);
|
||||
const [ingredientLines, setIngredientLines] = useState<IngredientLine[]>([]);
|
||||
|
|
@ -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://…"
|
||||
/>
|
||||
|
||||
<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"
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ export const createRecipeSchema = z.object({
|
|||
name: z.string().trim().min(1, "Le nom de la recette est requis").max(150),
|
||||
description: z.string().trim().max(2000).nullable().optional(),
|
||||
picture: z.string().trim().url("URL invalide").nullable().optional(),
|
||||
/** How many portions this recipe yields as written — see `Recipe.portions` in schema.prisma. */
|
||||
portions: z.number().int().positive("Le nombre de portions doit être positif"),
|
||||
visibility: recipeVisibilitySchema.default("PERSONAL"),
|
||||
/** `dietId`s tagged as "this recipe suits this regime" — a manual reminder, not computed from ingredients. Empty = no regime associated. */
|
||||
dietIds: z.array(z.number().int().positive()),
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export interface PlanningItemView {
|
|||
weekDay: string;
|
||||
/** Which meal of the day this item is for — see {@link Meal} (free-form for now, same reason). */
|
||||
meal: string;
|
||||
/** How many portions to prepare for this slot — entered by whoever assigns the recipe (`addPlanningItemSchema`'s `portions`), not derived from any recipe default (no such default exists, see `Recipe` in schema.prisma). */
|
||||
/** How many portions to prepare for this slot — entered by whoever assigns the recipe (`addPlanningItemSchema`'s `portions`). The picker UI pre-fills this from `Recipe.portions` (see {@link RecipeSummaryView.portions}) but it's still stored independently here, since a slot may scale the recipe up/down from its written yield. */
|
||||
portions: number;
|
||||
recipe: {
|
||||
id: number;
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ export interface RecipeSummaryView {
|
|||
name: string;
|
||||
description: string | null;
|
||||
picture: string | null;
|
||||
/** How many portions this recipe yields as written. Surfaced on the summary (not just the full detail) so the planning recipe picker can pre-fill its own "how many portions?" step from it — see `RecipePickerDialog`. */
|
||||
portions: number;
|
||||
authorId: number;
|
||||
visibility: RecipeVisibility;
|
||||
allergens: AllergyView[];
|
||||
|
|
|
|||
Loading…
Reference in a new issue