mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-07-09 03:04:54 -04:00
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:
parent
89b003589d
commit
5562effd66
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -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<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) {
|
||||
return await this.requests.post(routes.shoppingListIdRemoveRecipe(itemId, recipeId), {recipeDecrementQuantity});
|
||||
return await this.requests.post(routes.shoppingListIdRemoveRecipe(itemId, recipeId), { recipeDecrementQuantity });
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
|
@ -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):
|
||||
|
@ -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))
|
||||
|
@ -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
|
||||
):
|
||||
|
Loading…
x
Reference in New Issue
Block a user