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
v-on="on"
></v-text-field>
</template>
<v-date-picker v-model="newMealdate" no-title @input="pickerMenu = false"></v-date-picker>
</v-menu>
@ -77,7 +76,7 @@
:key="list.id"
hover
class="my-2 left-border"
@click="addRecipeToList(list.id)"
@click="openShoppingListIngredientDialog(list)"
>
<v-card-title class="py-2">
{{ list.name }}
@ -85,6 +84,59 @@
</v-card>
</v-card-text>
</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
offset-y
left
@ -121,7 +173,8 @@ import RecipeDialogShare from "./RecipeDialogShare.vue";
import { useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
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 { PlanEntryType } from "~/lib/api/types/meal-plan";
import { useAxiosDownloader } from "~/composables/api/use-axios-download";
@ -232,6 +285,7 @@ export default defineComponent({
recipeDeleteDialog: false,
mealplannerDialog: false,
shoppingListDialog: false,
shoppingListIngredientDialog: false,
recipeDuplicateDialog: false,
recipeName: props.name,
loading: false,
@ -328,6 +382,9 @@ export default defineComponent({
// Context Menu Event Handler
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() {
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,
};
},
});
</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",
"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"

View File

@ -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,8 +26,8 @@ export class ShoppingListsApi extends BaseCRUDAPI<ShoppingListCreate, ShoppingLi
baseRoute = routes.shoppingLists;
itemRoute = routes.shoppingListsId;
async addRecipe(itemId: string, recipeId: string, recipeIncrementQuantity = 1) {
return await this.requests.post(routes.shoppingListIdAddRecipe(itemId, recipeId), {recipeIncrementQuantity});
async addRecipe(itemId: string, recipeId: string, recipeIncrementQuantity = 1, recipeIngredients: RecipeIngredient[] | null = null) {
return await this.requests.post(routes.shoppingListIdAddRecipe(itemId, recipeId), { recipeIncrementQuantity, recipeIngredients });
}
async removeRecipe(itemId: string, recipeId: string, recipeDecrementQuantity = 1) {

View File

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

View File

@ -228,7 +228,7 @@ class ShoppingListController(BaseCrudController):
self, item_id: UUID4, recipe_id: UUID4, data: ShoppingListAddRecipeParams | None = None
):
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)

View File

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

View File

@ -14,7 +14,11 @@ from mealie.schema.group.group_shopping_list import (
ShoppingListItemUpdate,
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
@ -265,16 +269,23 @@ class ShoppingListService:
return ShoppingListItemsCollectionOut(created_items=[], updated_items=[], deleted_items=deleted_items)
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]:
"""Generates a list of new list items based on a recipe"""
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))

View File

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