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
This commit is contained in:
Michael Genson 2023-02-19 19:20:32 -06:00 committed by GitHub
parent 89b003589d
commit 5562effd66
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 215 additions and 18 deletions

View File

@ -58,7 +58,6 @@
readonly readonly
v-on="on" v-on="on"
></v-text-field> ></v-text-field>
</template> </template>
<v-date-picker v-model="newMealdate" no-title @input="pickerMenu = false"></v-date-picker> <v-date-picker v-model="newMealdate" no-title @input="pickerMenu = false"></v-date-picker>
</v-menu> </v-menu>
@ -77,7 +76,7 @@
:key="list.id" :key="list.id"
hover hover
class="my-2 left-border" class="my-2 left-border"
@click="addRecipeToList(list.id)" @click="openShoppingListIngredientDialog(list)"
> >
<v-card-title class="py-2"> <v-card-title class="py-2">
{{ list.name }} {{ list.name }}
@ -85,6 +84,59 @@
</v-card> </v-card>
</v-card-text> </v-card-text>
</BaseDialog> </BaseDialog>
<BaseDialog
v-model="shoppingListIngredientDialog"
:title="selectedShoppingList ? selectedShoppingList.name : $t('recipe.add-to-list')"
:icon="$globals.icons.cartCheck"
width="70%"
:submit-text="$tc('recipe.add-to-list')"
@submit="addRecipeToList()"
>
<v-card
elevation="0"
height="fit-content"
max-height="60vh"
width="100%"
class="ingredient-grid"
:style="{ gridTemplateRows: `repeat(${Math.ceil(recipeIngredients.length / 2)}, min-content)` }"
style="overflow-y: auto"
>
<v-list-item
v-for="(ingredientData, i) in recipeIngredients"
:key="'ingredient' + i"
dense
@click="recipeIngredients[i].checked = !recipeIngredients[i].checked"
>
<v-checkbox
hide-details
:input-value="ingredientData.checked"
class="pt-0 my-auto py-auto"
color="secondary"
/>
<v-list-item-content :key="ingredientData.ingredient.quantity">
<SafeMarkdown class="ma-0 pa-0 text-subtitle-1 dense-markdown" :source="ingredientData.display" />
</v-list-item-content>
</v-list-item>
</v-card>
<div class="d-flex justify-end mb-4 mt-2">
<BaseButtonGroup
:buttons="[
{
icon: $globals.icons.checkboxBlankOutline,
text: $tc('shopping-list.uncheck-all-items'),
event: 'uncheck',
},
{
icon: $globals.icons.checkboxOutline,
text: $tc('shopping-list.check-all-items'),
event: 'check',
},
]"
@uncheck="bulkCheckIngredients(false)"
@check="bulkCheckIngredients(true)"
/>
</div>
</BaseDialog>
<v-menu <v-menu
offset-y offset-y
left left
@ -121,7 +173,8 @@ import RecipeDialogShare from "./RecipeDialogShare.vue";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast"; import { alert } from "~/composables/use-toast";
import { planTypeOptions } from "~/composables/use-group-mealplan"; import { planTypeOptions } from "~/composables/use-group-mealplan";
import { Recipe } from "~/lib/api/types/recipe"; import { Recipe, RecipeIngredient } from "~/lib/api/types/recipe";
import { parseIngredientText } from "~/composables/recipes";
import { ShoppingListSummary } from "~/lib/api/types/group"; import { ShoppingListSummary } from "~/lib/api/types/group";
import { PlanEntryType } from "~/lib/api/types/meal-plan"; import { PlanEntryType } from "~/lib/api/types/meal-plan";
import { useAxiosDownloader } from "~/composables/api/use-axios-download"; import { useAxiosDownloader } from "~/composables/api/use-axios-download";
@ -232,6 +285,7 @@ export default defineComponent({
recipeDeleteDialog: false, recipeDeleteDialog: false,
mealplannerDialog: false, mealplannerDialog: false,
shoppingListDialog: false, shoppingListDialog: false,
shoppingListIngredientDialog: false,
recipeDuplicateDialog: false, recipeDuplicateDialog: false,
recipeName: props.name, recipeName: props.name,
loading: false, loading: false,
@ -328,6 +382,9 @@ export default defineComponent({
// Context Menu Event Handler // Context Menu Event Handler
const shoppingLists = ref<ShoppingListSummary[]>(); const shoppingLists = ref<ShoppingListSummary[]>();
const selectedShoppingList = ref<ShoppingListSummary>();
const recipe = ref<Recipe>(props.recipe);
const recipeIngredients = ref<{ checked: boolean; ingredient: RecipeIngredient; display: string }[]>([]);
async function getShoppingLists() { async function getShoppingLists() {
const { data } = await api.shopping.lists.getAll(); const { data } = await api.shopping.lists.getAll();
@ -336,11 +393,65 @@ export default defineComponent({
} }
} }
async function addRecipeToList(listId: string) { async function refreshRecipe() {
const { data } = await api.shopping.lists.addRecipe(listId, props.recipeId, props.recipeScale); 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) { if (data) {
alert.success(i18n.t("recipe.recipe-added-to-list") as string); alert.success(i18n.t("recipe.recipe-added-to-list") as string);
state.shoppingListDialog = false; state.shoppingListDialog = false;
state.shoppingListIngredientDialog = false;
} }
} }
@ -404,7 +515,9 @@ export default defineComponent({
}, },
shoppingList: () => { shoppingList: () => {
getShoppingLists(); getShoppingLists();
state.shoppingListDialog = true; state.shoppingListDialog = true;
state.shoppingListIngredientDialog = false;
}, },
share: () => { share: () => {
state.shareDialog = true; state.shareDialog = true;
@ -435,14 +548,27 @@ export default defineComponent({
return { return {
...toRefs(state), ...toRefs(state),
shoppingLists, shoppingLists,
selectedShoppingList,
openShoppingListIngredientDialog,
addRecipeToList, addRecipeToList,
bulkCheckIngredients,
duplicateRecipe, duplicateRecipe,
contextMenuEventHandler, contextMenuEventHandler,
deleteRecipe, deleteRecipe,
addRecipeToPlan, addRecipeToPlan,
icon, icon,
planTypeOptions, planTypeOptions,
recipeIngredients,
}; };
}, },
}); });
</script> </script>
<style scoped lang="css">
.ingredient-grid {
display: grid;
grid-auto-flow: column;
grid-template-columns: 1fr 1fr;
grid-gap: 0.5rem;
}
</style>

View File

@ -604,6 +604,7 @@
"delete-checked": "Delete Checked", "delete-checked": "Delete Checked",
"toggle-label-sort": "Toggle Label Sort", "toggle-label-sort": "Toggle Label Sort",
"uncheck-all-items": "Uncheck All Items", "uncheck-all-items": "Uncheck All Items",
"check-all-items": "Check All Items",
"linked-recipes-count": "No Linked Recipes|One Linked Recipe|{count} Linked Recipes", "linked-recipes-count": "No Linked Recipes|One Linked Recipe|{count} Linked Recipes",
"items-checked-count": "No items checked|One item checked|{count} items checked", "items-checked-count": "No items checked|One item checked|{count} items checked",
"no-label": "No Label" "no-label": "No Label"

View File

@ -1,4 +1,5 @@
import { BaseCRUDAPI } from "../base/base-clients"; import { BaseCRUDAPI } from "../base/base-clients";
import { RecipeIngredient } from "../types/recipe";
import { ApiRequestInstance } from "~/lib/api/types/non-generated"; import { ApiRequestInstance } from "~/lib/api/types/non-generated";
import { import {
ShoppingListCreate, ShoppingListCreate,
@ -25,12 +26,12 @@ export class ShoppingListsApi extends BaseCRUDAPI<ShoppingListCreate, ShoppingLi
baseRoute = routes.shoppingLists; baseRoute = routes.shoppingLists;
itemRoute = routes.shoppingListsId; itemRoute = routes.shoppingListsId;
async addRecipe(itemId: string, recipeId: string, recipeIncrementQuantity = 1) { async addRecipe(itemId: string, recipeId: string, recipeIncrementQuantity = 1, recipeIngredients: RecipeIngredient[] | null = null) {
return await this.requests.post(routes.shoppingListIdAddRecipe(itemId, recipeId), {recipeIncrementQuantity}); return await this.requests.post(routes.shoppingListIdAddRecipe(itemId, recipeId), { recipeIncrementQuantity, recipeIngredients });
} }
async removeRecipe(itemId: string, recipeId: string, recipeDecrementQuantity = 1) { async removeRecipe(itemId: string, recipeId: string, recipeDecrementQuantity = 1) {
return await this.requests.post(routes.shoppingListIdRemoveRecipe(itemId, recipeId), {recipeDecrementQuantity}); return await this.requests.post(routes.shoppingListIdRemoveRecipe(itemId, recipeId), { recipeDecrementQuantity });
} }
} }

View File

@ -133,6 +133,7 @@ import {
mdiDockRight, mdiDockRight,
mdiDockTop, mdiDockTop,
mdiDockBottom, mdiDockBottom,
mdiCheckboxOutline,
} from "@mdi/js"; } from "@mdi/js";
export const icons = { export const icons = {
@ -167,6 +168,7 @@ export const icons = {
cartCheck: mdiCartCheck, cartCheck: mdiCartCheck,
check: mdiCheck, check: mdiCheck,
checkboxBlankOutline: mdiCheckboxBlankOutline, checkboxBlankOutline: mdiCheckboxBlankOutline,
checkboxOutline: mdiCheckboxOutline,
checkboxMarkedCircle: mdiCheckboxMarkedCircle, checkboxMarkedCircle: mdiCheckboxMarkedCircle,
chefHat: mdiChefHat, chefHat: mdiChefHat,
clipboardCheck: mdiClipboardCheck, clipboardCheck: mdiClipboardCheck,

View File

@ -228,7 +228,7 @@ class ShoppingListController(BaseCrudController):
self, item_id: UUID4, recipe_id: UUID4, data: ShoppingListAddRecipeParams | None = None self, item_id: UUID4, recipe_id: UUID4, data: ShoppingListAddRecipeParams | None = None
): ):
shopping_list, items = self.service.add_recipe_ingredients_to_list( shopping_list, items = self.service.add_recipe_ingredients_to_list(
item_id, recipe_id, data.recipe_increment_quantity if data else 1 item_id, recipe_id, data.recipe_increment_quantity if data else 1, data.recipe_ingredients if data else None
) )
publish_list_item_events(self.publish_event, items) publish_list_item_events(self.publish_event, items)

View File

@ -14,6 +14,7 @@ from mealie.schema.recipe.recipe_ingredient import (
MAX_INGREDIENT_DENOMINATOR, MAX_INGREDIENT_DENOMINATOR,
IngredientFood, IngredientFood,
IngredientUnit, IngredientUnit,
RecipeIngredient,
) )
from mealie.schema.response.pagination import PaginationBase from mealie.schema.response.pagination import PaginationBase
@ -237,6 +238,8 @@ class ShoppingListOut(ShoppingListUpdate):
class ShoppingListAddRecipeParams(MealieModel): class ShoppingListAddRecipeParams(MealieModel):
recipe_increment_quantity: float = 1 recipe_increment_quantity: float = 1
recipe_ingredients: list[RecipeIngredient] | None = None
"""optionally override which ingredients are added from the recipe"""
class ShoppingListRemoveRecipeParams(MealieModel): class ShoppingListRemoveRecipeParams(MealieModel):

View File

@ -14,7 +14,11 @@ from mealie.schema.group.group_shopping_list import (
ShoppingListItemUpdate, ShoppingListItemUpdate,
ShoppingListItemUpdateBulk, ShoppingListItemUpdateBulk,
) )
from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit from mealie.schema.recipe.recipe_ingredient import (
IngredientFood,
IngredientUnit,
RecipeIngredient,
)
from mealie.schema.response.pagination import PaginationQuery from mealie.schema.response.pagination import PaginationQuery
@ -265,16 +269,23 @@ class ShoppingListService:
return ShoppingListItemsCollectionOut(created_items=[], updated_items=[], deleted_items=deleted_items) return ShoppingListItemsCollectionOut(created_items=[], updated_items=[], deleted_items=deleted_items)
def get_shopping_list_items_from_recipe( def get_shopping_list_items_from_recipe(
self, list_id: UUID4, recipe_id: UUID4, scale: float = 1 self,
list_id: UUID4,
recipe_id: UUID4,
scale: float = 1,
recipe_ingredients: list[RecipeIngredient] | None = None,
) -> list[ShoppingListItemCreate]: ) -> list[ShoppingListItemCreate]:
"""Generates a list of new list items based on a recipe""" """Generates a list of new list items based on a recipe"""
recipe = self.repos.recipes.get_one(recipe_id, "id") if recipe_ingredients is None:
if not recipe: recipe = self.repos.recipes.get_one(recipe_id, "id")
raise UnexpectedNone("Recipe not found") if not recipe:
raise UnexpectedNone("Recipe not found")
recipe_ingredients = recipe.recipe_ingredient
list_items: list[ShoppingListItemCreate] = [] list_items: list[ShoppingListItemCreate] = []
for ingredient in recipe.recipe_ingredient: for ingredient in recipe_ingredients:
if isinstance(ingredient.food, IngredientFood): if isinstance(ingredient.food, IngredientFood):
is_food = True is_food = True
food_id = ingredient.food.id food_id = ingredient.food.id
@ -301,7 +312,7 @@ class ShoppingListService:
unit_id=unit_id, unit_id=unit_id,
recipe_references=[ recipe_references=[
ShoppingListItemRecipeRefCreate( 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 return list_items
def add_recipe_ingredients_to_list( 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]: ) -> tuple[ShoppingListOut, ShoppingListItemsCollectionOut]:
""" """
Adds a recipe's ingredients to a list Adds a recipe's ingredients to a list
@ -341,7 +356,9 @@ class ShoppingListService:
- Impacted Shopping List Items - 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) item_changes = self.bulk_create_items(items_to_create)
updated_list = cast(ShoppingListOut, self.shopping_lists.get_one(list_id)) updated_list = cast(ShoppingListOut, self.shopping_lists.get_one(list_id))

View File

@ -4,6 +4,7 @@ from fastapi.testclient import TestClient
from mealie.schema.group.group_shopping_list import ShoppingListOut from mealie.schema.group.group_shopping_list import ShoppingListOut
from mealie.schema.recipe.recipe import Recipe from mealie.schema.recipe.recipe import Recipe
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient
from tests import utils from tests import utils
from tests.utils import api_routes from tests.utils import api_routes
from tests.utils.factories import random_int, random_string from tests.utils.factories import random_int, random_string
@ -186,6 +187,52 @@ def test_shopping_lists_add_one_with_zero_quantity(
assert found 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( def test_shopping_list_ref_removes_itself(
api_client: TestClient, unique_user: TestUser, shopping_list: ShoppingListOut, recipe_ingredient_only: Recipe api_client: TestClient, unique_user: TestUser, shopping_list: ShoppingListOut, recipe_ingredient_only: Recipe
): ):