From 5562effd6659209516304229d1d7c71d6960ae2f Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Sun, 19 Feb 2023 19:20:32 -0600 Subject: [PATCH] feat: select ingredients to add to shopping List (#2136) * added recipe ingredient override to backend * pytest * new dialog to filter recipe items added to list --- .../Domain/Recipe/RecipeContextMenu.vue | 136 +++++++++++++++++- frontend/lang/messages/en-US.json | 1 + frontend/lib/api/user/group-shopping-lists.ts | 7 +- frontend/lib/icons/icons.ts | 2 + .../groups/controller_shopping_lists.py | 2 +- mealie/schema/group/group_shopping_list.py | 3 + .../services/group_services/shopping_lists.py | 35 +++-- .../test_group_shopping_lists.py | 47 ++++++ 8 files changed, 215 insertions(+), 18 deletions(-) diff --git a/frontend/components/Domain/Recipe/RecipeContextMenu.vue b/frontend/components/Domain/Recipe/RecipeContextMenu.vue index 54776be78f26..c8f0e6ec1de9 100644 --- a/frontend/components/Domain/Recipe/RecipeContextMenu.vue +++ b/frontend/components/Domain/Recipe/RecipeContextMenu.vue @@ -58,7 +58,6 @@ readonly v-on="on" > - @@ -77,7 +76,7 @@ :key="list.id" hover class="my-2 left-border" - @click="addRecipeToList(list.id)" + @click="openShoppingListIngredientDialog(list)" > {{ list.name }} @@ -85,6 +84,59 @@ + + + + + + + + + +
+ +
+
(); + const selectedShoppingList = ref(); + const recipe = ref(props.recipe); + const recipeIngredients = ref<{ checked: boolean; ingredient: RecipeIngredient; display: string }[]>([]); async function getShoppingLists() { const { data } = await api.shopping.lists.getAll(); @@ -336,11 +393,65 @@ export default defineComponent({ } } - async function addRecipeToList(listId: string) { - const { data } = await api.shopping.lists.addRecipe(listId, props.recipeId, props.recipeScale); + async function refreshRecipe() { + const { data } = await api.recipes.getOne(props.slug); + if (data) { + recipe.value = data; + } + } + + async function openShoppingListIngredientDialog(list: ShoppingListSummary) { + selectedShoppingList.value = list; + if (!recipe.value) { + await refreshRecipe(); + } + + if (recipe.value?.recipeIngredient) { + recipeIngredients.value = recipe.value.recipeIngredient.map((ingredient) => { + return { + checked: true, + ingredient, + display: parseIngredientText(ingredient, recipe.value?.settings?.disableAmount || false, props.recipeScale), + }; + }); + } + + state.shoppingListDialog = false; + state.shoppingListIngredientDialog = true; + } + + function bulkCheckIngredients(value = true) { + recipeIngredients.value.forEach((data) => { + data.checked = value; + }); + } + + async function addRecipeToList() { + if (!selectedShoppingList.value) { + return; + } + + const ingredients: RecipeIngredient[] = []; + recipeIngredients.value.forEach((data) => { + if (data.checked) { + ingredients.push(data.ingredient); + } + }); + + if (!ingredients.length) { + return; + } + + const { data } = await api.shopping.lists.addRecipe( + selectedShoppingList.value.id, + props.recipeId, + props.recipeScale, + ingredients + ); if (data) { alert.success(i18n.t("recipe.recipe-added-to-list") as string); state.shoppingListDialog = false; + state.shoppingListIngredientDialog = false; } } @@ -404,7 +515,9 @@ export default defineComponent({ }, shoppingList: () => { getShoppingLists(); + state.shoppingListDialog = true; + state.shoppingListIngredientDialog = false; }, share: () => { state.shareDialog = true; @@ -435,14 +548,27 @@ export default defineComponent({ return { ...toRefs(state), shoppingLists, + selectedShoppingList, + openShoppingListIngredientDialog, addRecipeToList, + bulkCheckIngredients, duplicateRecipe, contextMenuEventHandler, deleteRecipe, addRecipeToPlan, icon, planTypeOptions, + recipeIngredients, }; }, }); + + diff --git a/frontend/lang/messages/en-US.json b/frontend/lang/messages/en-US.json index 510f7f1baa02..b31364146783 100644 --- a/frontend/lang/messages/en-US.json +++ b/frontend/lang/messages/en-US.json @@ -604,6 +604,7 @@ "delete-checked": "Delete Checked", "toggle-label-sort": "Toggle Label Sort", "uncheck-all-items": "Uncheck All Items", + "check-all-items": "Check All Items", "linked-recipes-count": "No Linked Recipes|One Linked Recipe|{count} Linked Recipes", "items-checked-count": "No items checked|One item checked|{count} items checked", "no-label": "No Label" diff --git a/frontend/lib/api/user/group-shopping-lists.ts b/frontend/lib/api/user/group-shopping-lists.ts index 0c849eb09e75..0dcb111709b0 100644 --- a/frontend/lib/api/user/group-shopping-lists.ts +++ b/frontend/lib/api/user/group-shopping-lists.ts @@ -1,4 +1,5 @@ import { BaseCRUDAPI } from "../base/base-clients"; +import { RecipeIngredient } from "../types/recipe"; import { ApiRequestInstance } from "~/lib/api/types/non-generated"; import { ShoppingListCreate, @@ -25,12 +26,12 @@ export class ShoppingListsApi extends BaseCRUDAPI list[ShoppingListItemCreate]: """Generates a list of new list items based on a recipe""" - recipe = self.repos.recipes.get_one(recipe_id, "id") - if not recipe: - raise UnexpectedNone("Recipe not found") + if recipe_ingredients is None: + recipe = self.repos.recipes.get_one(recipe_id, "id") + if not recipe: + raise UnexpectedNone("Recipe not found") + + recipe_ingredients = recipe.recipe_ingredient list_items: list[ShoppingListItemCreate] = [] - for ingredient in recipe.recipe_ingredient: + for ingredient in recipe_ingredients: if isinstance(ingredient.food, IngredientFood): is_food = True food_id = ingredient.food.id @@ -301,7 +312,7 @@ class ShoppingListService: unit_id=unit_id, recipe_references=[ ShoppingListItemRecipeRefCreate( - recipe_id=recipe.id, recipe_quantity=ingredient.quantity, recipe_scale=scale + recipe_id=recipe_id, recipe_quantity=ingredient.quantity, recipe_scale=scale ) ], ) @@ -331,7 +342,11 @@ class ShoppingListService: return list_items def add_recipe_ingredients_to_list( - self, list_id: UUID4, recipe_id: UUID4, recipe_increment: float = 1 + self, + list_id: UUID4, + recipe_id: UUID4, + recipe_increment: float = 1, + recipe_ingredients: list[RecipeIngredient] | None = None, ) -> tuple[ShoppingListOut, ShoppingListItemsCollectionOut]: """ Adds a recipe's ingredients to a list @@ -341,7 +356,9 @@ class ShoppingListService: - Impacted Shopping List Items """ - items_to_create = self.get_shopping_list_items_from_recipe(list_id, recipe_id, recipe_increment) + items_to_create = self.get_shopping_list_items_from_recipe( + list_id, recipe_id, recipe_increment, recipe_ingredients + ) item_changes = self.bulk_create_items(items_to_create) updated_list = cast(ShoppingListOut, self.shopping_lists.get_one(list_id)) diff --git a/tests/integration_tests/user_group_tests/test_group_shopping_lists.py b/tests/integration_tests/user_group_tests/test_group_shopping_lists.py index 968df968cb0e..4f96700f6b8a 100644 --- a/tests/integration_tests/user_group_tests/test_group_shopping_lists.py +++ b/tests/integration_tests/user_group_tests/test_group_shopping_lists.py @@ -4,6 +4,7 @@ from fastapi.testclient import TestClient from mealie.schema.group.group_shopping_list import ShoppingListOut from mealie.schema.recipe.recipe import Recipe +from mealie.schema.recipe.recipe_ingredient import RecipeIngredient from tests import utils from tests.utils import api_routes from tests.utils.factories import random_int, random_string @@ -186,6 +187,52 @@ def test_shopping_lists_add_one_with_zero_quantity( assert found +def test_shopping_lists_add_custom_recipe_items( + api_client: TestClient, + unique_user: TestUser, + shopping_lists: list[ShoppingListOut], + recipe_ingredient_only: Recipe, +): + sample_list = random.choice(shopping_lists) + recipe = recipe_ingredient_only + + response = api_client.post( + api_routes.groups_shopping_lists_item_id_recipe_recipe_id(sample_list.id, recipe.id), headers=unique_user.token + ) + assert response.status_code == 200 + + custom_items = random.sample(recipe_ingredient_only.recipe_ingredient, k=3) + response = api_client.post( + api_routes.groups_shopping_lists_item_id_recipe_recipe_id(sample_list.id, recipe.id), + headers=unique_user.token, + json={"recipeIngredients": utils.jsonify(custom_items)}, + ) + assert response.status_code == 200 + + # get list and verify items against ingredients + response = api_client.get(api_routes.groups_shopping_lists_item_id(sample_list.id), headers=unique_user.token) + as_json = utils.assert_derserialize(response, 200) + assert len(as_json["listItems"]) == len(recipe.recipe_ingredient) + + known_ingredients = {ingredient.note: ingredient for ingredient in recipe.recipe_ingredient} + custom_ingredients = [ingredient.note for ingredient in custom_items] + for item in as_json["listItems"]: + assert item["note"] in known_ingredients + + ingredient = known_ingredients[item["note"]] + if item["note"] in custom_ingredients: + assert item["quantity"] == (ingredient.quantity * 2 if ingredient.quantity else 0) + + else: + assert item["quantity"] == (ingredient.quantity or 0) + + # check recipe reference was added with quantity 2 + refs = as_json["recipeReferences"] + assert len(refs) == 1 + assert refs[0]["recipeId"] == str(recipe.id) + assert refs[0]["recipeQuantity"] == 2 + + def test_shopping_list_ref_removes_itself( api_client: TestClient, unique_user: TestUser, shopping_list: ShoppingListOut, recipe_ingredient_only: Recipe ):