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
):