Merge pull request #31 from kyuno053/feat/recipe-portions
feat(recipes): nombre de portions par recette
This commit is contained in:
commit
102f6846d5
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")
|
sourceId Int? @map("source_id")
|
||||||
description String?
|
description String?
|
||||||
picture 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
|
/// Creator — not in the original spec doc, required once recipes carry a
|
||||||
/// visibility level (`PERSONAL`/`HOUSE` need someone to scope against).
|
/// visibility level (`PERSONAL`/`HOUSE` need someone to scope against).
|
||||||
authorId Int @map("author_id")
|
authorId Int @map("author_id")
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ function toRecipeSummaryView(recipe: RecipeWithDetails): RecipeSummaryView {
|
||||||
name: recipe.name,
|
name: recipe.name,
|
||||||
description: recipe.description,
|
description: recipe.description,
|
||||||
picture: recipe.picture,
|
picture: recipe.picture,
|
||||||
|
portions: recipe.portions,
|
||||||
authorId: recipe.authorId,
|
authorId: recipe.authorId,
|
||||||
visibility: recipe.visibility,
|
visibility: recipe.visibility,
|
||||||
allergens,
|
allergens,
|
||||||
|
|
@ -307,6 +308,7 @@ export async function createRecipe(
|
||||||
name: input.name,
|
name: input.name,
|
||||||
description: input.description ?? null,
|
description: input.description ?? null,
|
||||||
picture: input.picture ?? null,
|
picture: input.picture ?? null,
|
||||||
|
portions: input.portions,
|
||||||
authorId,
|
authorId,
|
||||||
authorHouseId,
|
authorHouseId,
|
||||||
visibility: input.visibility,
|
visibility: input.visibility,
|
||||||
|
|
@ -363,6 +365,7 @@ export async function updateRecipe(
|
||||||
name: input.name,
|
name: input.name,
|
||||||
description: input.description ?? null,
|
description: input.description ?? null,
|
||||||
picture: input.picture ?? null,
|
picture: input.picture ?? null,
|
||||||
|
portions: input.portions,
|
||||||
visibility: input.visibility,
|
visibility: input.visibility,
|
||||||
ingredients: {
|
ingredients: {
|
||||||
create: input.ingredients.map((ingredient) => ({
|
create: input.ingredients.map((ingredient) => ({
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ describe("Planning", () => {
|
||||||
const houseId: number = houseRes.body.id;
|
const houseId: number = houseRes.body.id;
|
||||||
|
|
||||||
const recipe = await prisma.recipe.create({
|
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({
|
const planning = await prisma.planning.create({
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -154,7 +154,7 @@ describe("Planning", () => {
|
||||||
const houseId: number = houseRes.body.id;
|
const houseId: number = houseRes.body.id;
|
||||||
|
|
||||||
const recipe = await prisma.recipe.create({
|
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 nextWeek = TEST_REFERENCE_DATE.plus({ weeks: 1 });
|
||||||
const planning = await prisma.planning.create({
|
const planning = await prisma.planning.create({
|
||||||
|
|
|
||||||
|
|
@ -72,10 +72,10 @@ describe("Recipes", () => {
|
||||||
it("filters the catalog by name when ?search= is given", async () => {
|
it("filters the catalog by name when ?search= is given", async () => {
|
||||||
const { agent, profileId } = await signup();
|
const { agent, profileId } = await signup();
|
||||||
await prisma.recipe.create({
|
await prisma.recipe.create({
|
||||||
data: { name: "Ratatouille", authorId: profileId, visibility: "PUBLIC" },
|
data: { name: "Ratatouille", authorId: profileId, visibility: "PUBLIC", portions: 4 },
|
||||||
});
|
});
|
||||||
await prisma.recipe.create({
|
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" });
|
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 () => {
|
it("perso tab only returns the viewer's own PERSONAL recipes", async () => {
|
||||||
const { agent, profileId } = await signup();
|
const { agent, profileId } = await signup();
|
||||||
const { profileId: otherId } = await signup();
|
const { profileId: otherId } = await signup();
|
||||||
await prisma.recipe.create({ data: { name: "La mienne", authorId: profileId } });
|
await prisma.recipe.create({ data: { name: "La mienne", authorId: profileId, portions: 4 } });
|
||||||
await prisma.recipe.create({ data: { name: "Pas la mienne", authorId: otherId } });
|
await prisma.recipe.create({
|
||||||
|
data: { name: "Pas la mienne", authorId: otherId, portions: 4 },
|
||||||
|
});
|
||||||
|
|
||||||
const res = await agent.get("/recipes").query({ tab: "perso" });
|
const res = await agent.get("/recipes").query({ tab: "perso" });
|
||||||
|
|
||||||
|
|
@ -107,6 +109,7 @@ describe("Recipes", () => {
|
||||||
authorId: profileId,
|
authorId: profileId,
|
||||||
visibility: "HOUSE",
|
visibility: "HOUSE",
|
||||||
authorHouseId: houseRes.body.id,
|
authorHouseId: houseRes.body.id,
|
||||||
|
portions: 4,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await prisma.recipe.create({
|
await prisma.recipe.create({
|
||||||
|
|
@ -115,6 +118,7 @@ describe("Recipes", () => {
|
||||||
authorId: otherId,
|
authorId: otherId,
|
||||||
visibility: "HOUSE",
|
visibility: "HOUSE",
|
||||||
authorHouseId: otherHouseRes.body.id,
|
authorHouseId: otherHouseRes.body.id,
|
||||||
|
portions: 4,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -135,10 +139,10 @@ describe("Recipes", () => {
|
||||||
it("favoris tab only returns recipes the viewer has favorited", async () => {
|
it("favoris tab only returns recipes the viewer has favorited", async () => {
|
||||||
const { agent, profileId } = await signup();
|
const { agent, profileId } = await signup();
|
||||||
const favorited = await prisma.recipe.create({
|
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({
|
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`);
|
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 () => {
|
it("a PERSONAL recipe from another author is invisible in the publique tab", async () => {
|
||||||
const { agent } = await signup();
|
const { agent } = await signup();
|
||||||
const { profileId: otherId } = 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" });
|
const res = await agent.get("/recipes").query({ tab: "publique" });
|
||||||
|
|
||||||
|
|
@ -170,6 +174,7 @@ describe("Recipes", () => {
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Omelette provençale",
|
name: "Omelette provençale",
|
||||||
description: "Rapide et savoureuse",
|
description: "Rapide et savoureuse",
|
||||||
|
portions: 2,
|
||||||
dietIds: [vegetarien.id],
|
dietIds: [vegetarien.id],
|
||||||
ingredients: [
|
ingredients: [
|
||||||
{ ingredientId: tomate, quantity: 2, unit: "unité" },
|
{ ingredientId: tomate, quantity: 2, unit: "unité" },
|
||||||
|
|
@ -180,6 +185,7 @@ describe("Recipes", () => {
|
||||||
|
|
||||||
expect(res.status).to.equal(201);
|
expect(res.status).to.equal(201);
|
||||||
expect(res.body.name).to.equal("Omelette provençale");
|
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.ingredients).to.have.length(2);
|
||||||
expect(
|
expect(
|
||||||
res.body.steps.map((s: { description: string; order: number }) => s.order),
|
res.body.steps.map((s: { description: string; order: number }) => s.order),
|
||||||
|
|
@ -196,6 +202,7 @@ describe("Recipes", () => {
|
||||||
|
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Test",
|
name: "Test",
|
||||||
|
portions: 4,
|
||||||
dietIds: [],
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
|
||||||
steps: [{ description: "Étape" }],
|
steps: [{ description: "Étape" }],
|
||||||
|
|
@ -207,6 +214,7 @@ describe("Recipes", () => {
|
||||||
const houseRecipe = await agent.post("/recipes").send({
|
const houseRecipe = await agent.post("/recipes").send({
|
||||||
name: "Foyer",
|
name: "Foyer",
|
||||||
visibility: "HOUSE",
|
visibility: "HOUSE",
|
||||||
|
portions: 4,
|
||||||
dietIds: [],
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
|
||||||
steps: [{ description: "Étape" }],
|
steps: [{ description: "Étape" }],
|
||||||
|
|
@ -221,6 +229,7 @@ describe("Recipes", () => {
|
||||||
|
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Test",
|
name: "Test",
|
||||||
|
portions: 4,
|
||||||
dietIds: [],
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: 999_999, quantity: 1, unit: "g" }],
|
ingredients: [{ ingredientId: 999_999, quantity: 1, unit: "g" }],
|
||||||
steps: [{ description: "Étape" }],
|
steps: [{ description: "Étape" }],
|
||||||
|
|
@ -236,6 +245,7 @@ describe("Recipes", () => {
|
||||||
|
|
||||||
const res = await agent.post("/recipes").send({
|
const res = await agent.post("/recipes").send({
|
||||||
name: "Test",
|
name: "Test",
|
||||||
|
portions: 4,
|
||||||
dietIds: [999_999],
|
dietIds: [999_999],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "g" }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "g" }],
|
||||||
steps: [{ description: "Étape" }],
|
steps: [{ description: "Étape" }],
|
||||||
|
|
@ -248,13 +258,36 @@ describe("Recipes", () => {
|
||||||
it("rejects an empty ingredients or steps list with 400 VALIDATION_ERROR", async () => {
|
it("rejects an empty ingredients or steps list with 400 VALIDATION_ERROR", async () => {
|
||||||
const { agent } = await signup();
|
const { agent } = await signup();
|
||||||
|
|
||||||
const res = await agent
|
const res = await agent.post("/recipes").send({
|
||||||
.post("/recipes")
|
name: "Test",
|
||||||
.send({ name: "Test", dietIds: [], ingredients: [], steps: [{ description: "Étape" }] });
|
portions: 4,
|
||||||
|
dietIds: [],
|
||||||
|
ingredients: [],
|
||||||
|
steps: [{ description: "Étape" }],
|
||||||
|
});
|
||||||
|
|
||||||
expect(res.status).to.equal(400);
|
expect(res.status).to.equal(400);
|
||||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
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", () => {
|
describe("GET /recipes/:id", () => {
|
||||||
|
|
@ -272,6 +305,7 @@ describe("Recipes", () => {
|
||||||
const tomate = await ingredientId("tomato");
|
const tomate = await ingredientId("tomato");
|
||||||
const created = await agent.post("/recipes").send({
|
const created = await agent.post("/recipes").send({
|
||||||
name: "Salade",
|
name: "Salade",
|
||||||
|
portions: 4,
|
||||||
dietIds: [],
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
|
||||||
steps: [{ description: "Couper" }],
|
steps: [{ description: "Couper" }],
|
||||||
|
|
@ -281,6 +315,7 @@ describe("Recipes", () => {
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
expect(res.status).to.equal(200);
|
||||||
expect(res.body.name).to.equal("Salade");
|
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.ingredients[0].ingredient.key).to.equal("tomato");
|
||||||
expect(res.body.isFavorite).to.equal(false);
|
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 () => {
|
it("returns 404 for a PERSONAL recipe belonging to someone else", async () => {
|
||||||
const { agent } = await signup();
|
const { agent } = await signup();
|
||||||
const { profileId: otherId } = 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}`);
|
const res = await agent.get(`/recipes/${recipe.id}`);
|
||||||
|
|
||||||
|
|
@ -300,7 +337,7 @@ describe("Recipes", () => {
|
||||||
const { agent } = await signup();
|
const { agent } = await signup();
|
||||||
const { profileId: otherId } = await signup();
|
const { profileId: otherId } = await signup();
|
||||||
const recipe = await prisma.recipe.create({
|
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}`);
|
const res = await agent.get(`/recipes/${recipe.id}`);
|
||||||
|
|
@ -318,6 +355,7 @@ describe("Recipes", () => {
|
||||||
authorId: otherId,
|
authorId: otherId,
|
||||||
visibility: "HOUSE",
|
visibility: "HOUSE",
|
||||||
authorHouseId: houseRes.body.id,
|
authorHouseId: houseRes.body.id,
|
||||||
|
portions: 4,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const otherHouseId = (
|
const otherHouseId = (
|
||||||
|
|
@ -331,6 +369,7 @@ describe("Recipes", () => {
|
||||||
authorId: otherId,
|
authorId: otherId,
|
||||||
visibility: "HOUSE",
|
visibility: "HOUSE",
|
||||||
authorHouseId: otherHouseId,
|
authorHouseId: otherHouseId,
|
||||||
|
portions: 4,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -349,6 +388,7 @@ describe("Recipes", () => {
|
||||||
const oignon = await ingredientId("onion");
|
const oignon = await ingredientId("onion");
|
||||||
const created = await agent.post("/recipes").send({
|
const created = await agent.post("/recipes").send({
|
||||||
name: "Salade",
|
name: "Salade",
|
||||||
|
portions: 4,
|
||||||
dietIds: [],
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
|
||||||
steps: [{ description: "Couper" }],
|
steps: [{ description: "Couper" }],
|
||||||
|
|
@ -356,6 +396,7 @@ describe("Recipes", () => {
|
||||||
|
|
||||||
const res = await agent.patch(`/recipes/${created.body.id}`).send({
|
const res = await agent.patch(`/recipes/${created.body.id}`).send({
|
||||||
name: "Salade composée",
|
name: "Salade composée",
|
||||||
|
portions: 6,
|
||||||
visibility: "PUBLIC",
|
visibility: "PUBLIC",
|
||||||
dietIds: [],
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: oignon, quantity: 2, unit: "unité" }],
|
ingredients: [{ ingredientId: oignon, quantity: 2, unit: "unité" }],
|
||||||
|
|
@ -364,6 +405,7 @@ describe("Recipes", () => {
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
expect(res.status).to.equal(200);
|
||||||
expect(res.body.name).to.equal("Salade composée");
|
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.visibility).to.equal("PUBLIC");
|
||||||
expect(res.body.ingredients).to.have.length(1);
|
expect(res.body.ingredients).to.have.length(1);
|
||||||
expect(res.body.ingredients[0].ingredient.key).to.equal("onion");
|
expect(res.body.ingredients[0].ingredient.key).to.equal("onion");
|
||||||
|
|
@ -376,6 +418,7 @@ describe("Recipes", () => {
|
||||||
|
|
||||||
const res = await agent.patch("/recipes/999999").send({
|
const res = await agent.patch("/recipes/999999").send({
|
||||||
name: "Test",
|
name: "Test",
|
||||||
|
portions: 4,
|
||||||
dietIds: [],
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
|
||||||
steps: [{ description: "Étape" }],
|
steps: [{ description: "Étape" }],
|
||||||
|
|
@ -390,6 +433,7 @@ describe("Recipes", () => {
|
||||||
const tomate = await ingredientId("tomato");
|
const tomate = await ingredientId("tomato");
|
||||||
const created = await agent.post("/recipes").send({
|
const created = await agent.post("/recipes").send({
|
||||||
name: "Salade",
|
name: "Salade",
|
||||||
|
portions: 4,
|
||||||
dietIds: [],
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
|
||||||
steps: [{ description: "Couper" }],
|
steps: [{ description: "Couper" }],
|
||||||
|
|
@ -397,6 +441,7 @@ describe("Recipes", () => {
|
||||||
|
|
||||||
const res = await agent.patch(`/recipes/${created.body.id}`).send({
|
const res = await agent.patch(`/recipes/${created.body.id}`).send({
|
||||||
name: "Salade",
|
name: "Salade",
|
||||||
|
portions: 4,
|
||||||
dietIds: [999_999],
|
dietIds: [999_999],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
|
||||||
steps: [{ description: "Couper" }],
|
steps: [{ description: "Couper" }],
|
||||||
|
|
@ -411,11 +456,12 @@ describe("Recipes", () => {
|
||||||
const { agent: otherAgent } = await signup();
|
const { agent: otherAgent } = await signup();
|
||||||
const tomate = await ingredientId("tomato");
|
const tomate = await ingredientId("tomato");
|
||||||
const recipe = await prisma.recipe.create({
|
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({
|
const res = await otherAgent.patch(`/recipes/${recipe.id}`).send({
|
||||||
name: "Hack",
|
name: "Hack",
|
||||||
|
portions: 4,
|
||||||
dietIds: [],
|
dietIds: [],
|
||||||
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
|
ingredients: [{ ingredientId: tomate, quantity: 1, unit: "unité" }],
|
||||||
steps: [{ description: "Étape" }],
|
steps: [{ description: "Étape" }],
|
||||||
|
|
@ -430,7 +476,7 @@ describe("Recipes", () => {
|
||||||
it("deletes a recipe not referenced by any planning item", async () => {
|
it("deletes a recipe not referenced by any planning item", async () => {
|
||||||
const { agent, profileId } = await signup();
|
const { agent, profileId } = await signup();
|
||||||
const recipe = await prisma.recipe.create({
|
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}`);
|
const res = await agent.delete(`/recipes/${recipe.id}`);
|
||||||
|
|
@ -444,7 +490,7 @@ describe("Recipes", () => {
|
||||||
const { agent, profileId } = await signup();
|
const { agent, profileId } = await signup();
|
||||||
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
||||||
const recipe = await prisma.recipe.create({
|
const recipe = await prisma.recipe.create({
|
||||||
data: { name: "Ratatouille", authorId: profileId },
|
data: { name: "Ratatouille", authorId: profileId, portions: 4 },
|
||||||
});
|
});
|
||||||
const planning = await prisma.planning.create({
|
const planning = await prisma.planning.create({
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -482,7 +528,7 @@ describe("Recipes", () => {
|
||||||
const { profileId } = await signup();
|
const { profileId } = await signup();
|
||||||
const { agent: otherAgent } = await signup();
|
const { agent: otherAgent } = await signup();
|
||||||
const recipe = await prisma.recipe.create({
|
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}`);
|
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 () => {
|
it("adds and removes a recipe from the viewer's favorites", async () => {
|
||||||
const { agent, profileId } = await signup();
|
const { agent, profileId } = await signup();
|
||||||
const recipe = await prisma.recipe.create({
|
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`);
|
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 () => {
|
it("is idempotent — favoriting an already-favorited recipe doesn't error", async () => {
|
||||||
const { agent, profileId } = await signup();
|
const { agent, profileId } = await signup();
|
||||||
const recipe = await prisma.recipe.create({
|
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`);
|
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 () => {
|
it("rejects favoriting a recipe the viewer can't see with 404 RECIPE_NOT_FOUND", async () => {
|
||||||
const { agent } = await signup();
|
const { agent } = await signup();
|
||||||
const { profileId: otherId } = 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`);
|
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."
|
And I fill in the step description with "Couper les tomates."
|
||||||
Then the "Enregistrer" button should not be disabled
|
Then the "Enregistrer" button should not be disabled
|
||||||
When I click the button "Enregistrer"
|
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"
|
And the URL should include "/recettes/42"
|
||||||
|
|
||||||
# Regression test for the exact bug reported: `crypto.randomUUID()` (used
|
# 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(
|
Then(
|
||||||
"the recipe creation request should have included name {string} and ingredient {int} with quantity {int} and unit {string}",
|
"the recipe creation request should have included name {string}, portions {int}, and ingredient {int} with quantity {int} and unit {string}",
|
||||||
(name: string, ingredientId: number, quantity: number, unit: string) => {
|
(name: string, portions: number, ingredientId: number, quantity: number, unit: string) => {
|
||||||
cy.wait("@createRecipe")
|
cy.wait("@createRecipe")
|
||||||
.its("request.body")
|
.its("request.body")
|
||||||
.should("deep.include", {
|
.should("deep.include", {
|
||||||
name,
|
name,
|
||||||
|
portions,
|
||||||
ingredients: [{ ingredientId, quantity, unit }],
|
ingredients: [{ ingredientId, quantity, unit }],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
@ -131,6 +132,7 @@ Given("recipe 7 exists with an egg omelette", () => {
|
||||||
name: "Omelette",
|
name: "Omelette",
|
||||||
description: null,
|
description: null,
|
||||||
picture: null,
|
picture: null,
|
||||||
|
portions: 2,
|
||||||
authorId: 1,
|
authorId: 1,
|
||||||
visibility: "PERSONAL",
|
visibility: "PERSONAL",
|
||||||
allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }],
|
allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }],
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ const ratatouille = {
|
||||||
name: "Ratatouille",
|
name: "Ratatouille",
|
||||||
description: null,
|
description: null,
|
||||||
picture: null,
|
picture: null,
|
||||||
|
portions: 4,
|
||||||
authorId: 1,
|
authorId: 1,
|
||||||
visibility: "PERSONAL",
|
visibility: "PERSONAL",
|
||||||
allergens: [],
|
allergens: [],
|
||||||
|
|
@ -36,6 +37,7 @@ const omelette = {
|
||||||
name: "Omelette",
|
name: "Omelette",
|
||||||
description: null,
|
description: null,
|
||||||
picture: null,
|
picture: null,
|
||||||
|
portions: 2,
|
||||||
authorId: 1,
|
authorId: 1,
|
||||||
visibility: "PERSONAL",
|
visibility: "PERSONAL",
|
||||||
allergens: [oeufs],
|
allergens: [oeufs],
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ const omelette = {
|
||||||
name: "Omelette",
|
name: "Omelette",
|
||||||
description: null,
|
description: null,
|
||||||
picture: null,
|
picture: null,
|
||||||
|
portions: 2,
|
||||||
authorId: 1,
|
authorId: 1,
|
||||||
visibility: "PERSONAL",
|
visibility: "PERSONAL",
|
||||||
allergens: [oeufs],
|
allergens: [oeufs],
|
||||||
|
|
|
||||||
|
|
@ -273,9 +273,14 @@ export function RecipePickerDialog({
|
||||||
<RecipeTable
|
<RecipeTable
|
||||||
recipes={listState.recipes}
|
recipes={listState.recipes}
|
||||||
selectedId={null}
|
selectedId={null}
|
||||||
onSelect={(id) =>
|
onSelect={(id) => {
|
||||||
setSelectedRecipe(listState.recipes.find((recipe) => recipe.id === id) ?? null)
|
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>
|
</Dialog>
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,12 @@ export function RecipeDetailPanel({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="recipe-detail-panel__title-row">
|
<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">
|
<div className="recipe-detail-panel__title-badges">
|
||||||
<AllergenBadges allergens={recipe.allergens} />
|
<AllergenBadges allergens={recipe.allergens} />
|
||||||
{dislikedIngredients.length > 0 && (
|
{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 {
|
&__title-badges {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,8 @@
|
||||||
"detail": {
|
"detail": {
|
||||||
"empty": "Sélectionnez une recette dans le tableau pour voir son détail ici.",
|
"empty": "Sélectionnez une recette dans le tableau pour voir son détail ici.",
|
||||||
"favorite": "Ajouter aux favoris",
|
"favorite": "Ajouter aux favoris",
|
||||||
"unfavorite": "Retirer des favoris"
|
"unfavorite": "Retirer des favoris",
|
||||||
|
"portions": "{{count}} portion(s)"
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"newTitle": "Nouvelle recette",
|
"newTitle": "Nouvelle recette",
|
||||||
|
|
@ -174,6 +175,7 @@
|
||||||
"nameLabel": "Nom de la recette",
|
"nameLabel": "Nom de la recette",
|
||||||
"descriptionLabel": "Description",
|
"descriptionLabel": "Description",
|
||||||
"pictureLabel": "Photo (URL)",
|
"pictureLabel": "Photo (URL)",
|
||||||
|
"portionsLabel": "Nombre de portions",
|
||||||
"visibilityLabel": "Visible par",
|
"visibilityLabel": "Visible par",
|
||||||
"visibility": {
|
"visibility": {
|
||||||
"PERSONAL": "Moi uniquement",
|
"PERSONAL": "Moi uniquement",
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,11 @@ export function RecipeFormPage() {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [picture, setPicture] = 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 [visibility, setVisibility] = useState<RecipeVisibility>("PERSONAL");
|
||||||
const [dietIds, setDietIds] = useState<number[]>([]);
|
const [dietIds, setDietIds] = useState<number[]>([]);
|
||||||
const [ingredientLines, setIngredientLines] = useState<IngredientLine[]>([]);
|
const [ingredientLines, setIngredientLines] = useState<IngredientLine[]>([]);
|
||||||
|
|
@ -79,6 +84,7 @@ export function RecipeFormPage() {
|
||||||
setName(recipe.name);
|
setName(recipe.name);
|
||||||
setDescription(recipe.description ?? "");
|
setDescription(recipe.description ?? "");
|
||||||
setPicture(recipe.picture ?? "");
|
setPicture(recipe.picture ?? "");
|
||||||
|
setPortions(String(recipe.portions));
|
||||||
setVisibility(recipe.visibility);
|
setVisibility(recipe.visibility);
|
||||||
setDietIds(recipe.diets.map((diet) => diet.id));
|
setDietIds(recipe.diets.map((diet) => diet.id));
|
||||||
setIngredientLines(
|
setIngredientLines(
|
||||||
|
|
@ -133,6 +139,8 @@ export function RecipeFormPage() {
|
||||||
// that doesn't need a round trip through zod on every keystroke.
|
// that doesn't need a round trip through zod on every keystroke.
|
||||||
const canSubmit =
|
const canSubmit =
|
||||||
name.trim().length > 0 &&
|
name.trim().length > 0 &&
|
||||||
|
Number.isInteger(Number(portions)) &&
|
||||||
|
Number(portions) > 0 &&
|
||||||
ingredientLines.length > 0 &&
|
ingredientLines.length > 0 &&
|
||||||
ingredientLines.every((line) => Number(line.quantity) > 0 && line.unit.trim().length > 0) &&
|
ingredientLines.every((line) => Number(line.quantity) > 0 && line.unit.trim().length > 0) &&
|
||||||
steps.length > 0 &&
|
steps.length > 0 &&
|
||||||
|
|
@ -146,6 +154,7 @@ export function RecipeFormPage() {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
description: description.trim() || null,
|
description: description.trim() || null,
|
||||||
picture: picture.trim() || null,
|
picture: picture.trim() || null,
|
||||||
|
portions: Number(portions),
|
||||||
visibility,
|
visibility,
|
||||||
dietIds,
|
dietIds,
|
||||||
ingredients: ingredientLines.map((line) => ({
|
ingredients: ingredientLines.map((line) => ({
|
||||||
|
|
@ -222,6 +231,16 @@ export function RecipeFormPage() {
|
||||||
placeholder="https://…"
|
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>
|
<label htmlFor="recipe-visibility">{t("recipes.form.visibilityLabel")}</label>
|
||||||
<select
|
<select
|
||||||
id="recipe-visibility"
|
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),
|
name: z.string().trim().min(1, "Le nom de la recette est requis").max(150),
|
||||||
description: z.string().trim().max(2000).nullable().optional(),
|
description: z.string().trim().max(2000).nullable().optional(),
|
||||||
picture: z.string().trim().url("URL invalide").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"),
|
visibility: recipeVisibilitySchema.default("PERSONAL"),
|
||||||
/** `dietId`s tagged as "this recipe suits this regime" — a manual reminder, not computed from ingredients. Empty = no regime associated. */
|
/** `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()),
|
dietIds: z.array(z.number().int().positive()),
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ export interface PlanningItemView {
|
||||||
weekDay: string;
|
weekDay: string;
|
||||||
/** Which meal of the day this item is for — see {@link Meal} (free-form for now, same reason). */
|
/** Which meal of the day this item is for — see {@link Meal} (free-form for now, same reason). */
|
||||||
meal: string;
|
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;
|
portions: number;
|
||||||
recipe: {
|
recipe: {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,8 @@ export interface RecipeSummaryView {
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
picture: 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;
|
authorId: number;
|
||||||
visibility: RecipeVisibility;
|
visibility: RecipeVisibility;
|
||||||
allergens: AllergyView[];
|
allergens: AllergyView[];
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue