Merge pull request #1447 from smilerz/mealplan_shopping_useability

Mealplan shopping useability
This commit is contained in:
vabene1111 2022-02-01 08:28:59 +01:00 committed by GitHub
commit b2a415b333
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 990 additions and 744 deletions

View File

@ -205,9 +205,9 @@ class CustomIsShared(permissions.BasePermission):
return request.user.is_authenticated return request.user.is_authenticated
def has_object_permission(self, request, view, obj): def has_object_permission(self, request, view, obj):
# temporary hack to make old shopping list work with new shopping list # # temporary hack to make old shopping list work with new shopping list
if obj.__class__.__name__ in ['ShoppingList', 'ShoppingListEntry']: # if obj.__class__.__name__ in ['ShoppingList', 'ShoppingListEntry']:
return is_object_shared(request.user, obj) or obj.created_by in list(request.user.get_shopping_share()) # return is_object_shared(request.user, obj) or obj.created_by in list(request.user.get_shopping_share())
return is_object_shared(request.user, obj) return is_object_shared(request.user, obj)

View File

@ -8,7 +8,7 @@ from django.utils import timezone
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from cookbook.helper.HelperFunctions import Round, str2bool from cookbook.helper.HelperFunctions import Round, str2bool
from cookbook.models import (Ingredient, ShoppingListEntry, ShoppingListRecipe, from cookbook.models import (Ingredient, MealPlan, Recipe, ShoppingListEntry, ShoppingListRecipe,
SupermarketCategoryRelation) SupermarketCategoryRelation)
from recipes import settings from recipes import settings
@ -38,118 +38,272 @@ def shopping_helper(qs, request):
return qs.order_by(*supermarket_order).select_related('unit', 'food', 'ingredient', 'created_by', 'list_recipe', 'list_recipe__mealplan', 'list_recipe__recipe') return qs.order_by(*supermarket_order).select_related('unit', 'food', 'ingredient', 'created_by', 'list_recipe', 'list_recipe__mealplan', 'list_recipe__recipe')
# TODO refactor as class class RecipeShoppingEditor():
def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None, ingredients=None, created_by=None, space=None, append=False): def __init__(self, user, space, **kwargs):
""" self.created_by = user
Creates ShoppingListRecipe and associated ShoppingListEntrys from a recipe or a meal plan with a recipe self.space = space
:param list_recipe: Modify an existing ShoppingListRecipe self._kwargs = {**kwargs}
:param recipe: Recipe to use as list of ingredients. One of [recipe, mealplan] are required
:param mealplan: alternatively use a mealplan recipe as source of ingredients
:param servings: Optional: Number of servings to use to scale shoppinglist. If servings = 0 an existing recipe list will be deleted
:param ingredients: Ingredients, list of ingredient IDs to include on the shopping list. When not provided all ingredients will be used
:param append: If False will remove any entries not included with ingredients, when True will append ingredients to the shopping list
"""
r = recipe or getattr(mealplan, 'recipe', None) or getattr(list_recipe, 'recipe', None)
if not r:
raise ValueError(_("You must supply a recipe or mealplan"))
created_by = created_by or getattr(ShoppingListEntry.objects.filter(list_recipe=list_recipe).first(), 'created_by', None) self.mealplan = self._kwargs.get('mealplan', None)
if not created_by: if type(self.mealplan) in [int, float]:
raise ValueError(_("You must supply a created_by")) self.mealplan = MealPlan.objects.filter(id=self.mealplan, space=self.space)
self.id = self._kwargs.get('id', None)
try: self._shopping_list_recipe = self.get_shopping_list_recipe(self.id, self.created_by, self.space)
servings = float(servings)
except (ValueError, TypeError):
servings = getattr(mealplan, 'servings', 1.0)
servings_factor = servings / r.servings if self._shopping_list_recipe:
# created_by needs to be sticky to original creator as it is 'their' shopping list
# changing shopping list created_by can shift some items to new owner which may not share in the other direction
self.created_by = getattr(self._shopping_list_recipe.entries.first(), 'created_by', self.created_by)
shared_users = list(created_by.get_shopping_share()) self.recipe = getattr(self._shopping_list_recipe, 'recipe', None) or self._kwargs.get('recipe', None) or getattr(self.mealplan, 'recipe', None)
shared_users.append(created_by) if type(self.recipe) in [int, float]:
if list_recipe: self.recipe = Recipe.objects.filter(id=self.recipe, space=self.space)
created = False
else:
list_recipe = ShoppingListRecipe.objects.create(recipe=r, mealplan=mealplan, servings=servings)
created = True
related_step_ing = [] try:
if servings == 0 and not created: self.servings = float(self._kwargs.get('servings', None))
list_recipe.delete() except (ValueError, TypeError):
return [] self.servings = getattr(self._shopping_list_recipe, 'servings', None) or getattr(self.mealplan, 'servings', None) or getattr(self.recipe, 'servings', None)
elif ingredients:
ingredients = Ingredient.objects.filter(pk__in=ingredients, space=space)
else:
ingredients = Ingredient.objects.filter(step__recipe=r, food__ignore_shopping=False, space=space)
if exclude_onhand := created_by.userpreference.mealplan_autoexclude_onhand: @property
ingredients = ingredients.exclude(food__onhand_users__id__in=[x.id for x in shared_users]) def _servings_factor(self):
return self.servings / self.recipe.servings
if related := created_by.userpreference.mealplan_autoinclude_related: @property
# TODO: add levels of related recipes (related recipes of related recipes) to use when auto-adding mealplans def _shared_users(self):
related_recipes = r.get_related_recipes() return [*list(self.created_by.get_shopping_share()), self.created_by]
for x in related_recipes: @staticmethod
# related recipe is a Step serving size is driven by recipe serving size def get_shopping_list_recipe(id, user, space):
# TODO once/if Steps can have a serving size this needs to be refactored return ShoppingListRecipe.objects.filter(id=id).filter(Q(shoppinglist__space=space) | Q(entries__space=space)).filter(
if exclude_onhand: Q(shoppinglist__created_by=user)
# if steps are used more than once in a recipe or subrecipe - I don' think this results in the desired behavior | Q(shoppinglist__shared=user)
related_step_ing += Ingredient.objects.filter(step__recipe=x, space=space).exclude(food__onhand_users__id__in=[x.id for x in shared_users]).values_list('id', flat=True) | Q(entries__created_by=user)
else: | Q(entries__created_by__in=list(user.get_shopping_share()))
related_step_ing += Ingredient.objects.filter(step__recipe=x, space=space).values_list('id', flat=True) ).prefetch_related('entries').first()
x_ing = [] def get_recipe_ingredients(self, id, exclude_onhand=False):
if ingredients.filter(food__recipe=x).exists(): if exclude_onhand:
for ing in ingredients.filter(food__recipe=x): return Ingredient.objects.filter(step__recipe__id=id, food__ignore_shopping=False, space=self.space).exclude(food__onhand_users__id__in=[x.id for x in self._shared_users])
if exclude_onhand: else:
x_ing = Ingredient.objects.filter(step__recipe=x, food__ignore_shopping=False, space=space).exclude(food__onhand_users__id__in=[x.id for x in shared_users]) return Ingredient.objects.filter(step__recipe__id=id, food__ignore_shopping=False, space=self.space)
else:
x_ing = Ingredient.objects.filter(step__recipe=x, food__ignore_shopping=False, space=space).exclude(food__ignore_shopping=True)
for i in [x for x in x_ing]:
ShoppingListEntry.objects.create(
list_recipe=list_recipe,
food=i.food,
unit=i.unit,
ingredient=i,
amount=i.amount * Decimal(servings_factor),
created_by=created_by,
space=space,
)
# dont' add food to the shopping list that are actually recipes that will be added as ingredients
ingredients = ingredients.exclude(food__recipe=x)
add_ingredients = list(ingredients.values_list('id', flat=True)) + related_step_ing @property
if not append: def _include_related(self):
existing_list = ShoppingListEntry.objects.filter(list_recipe=list_recipe) return self.created_by.userpreference.mealplan_autoinclude_related
# delete shopping list entries not included in ingredients
existing_list.exclude(ingredient__in=ingredients).delete()
# add shopping list entries that did not previously exist
add_ingredients = set(add_ingredients) - set(existing_list.values_list('ingredient__id', flat=True))
add_ingredients = Ingredient.objects.filter(id__in=add_ingredients, space=space)
# if servings have changed, update the ShoppingListRecipe and existing Entries @property
if servings <= 0: def _exclude_onhand(self):
servings = 1 return self.created_by.userpreference.mealplan_autoexclude_onhand
if not created and list_recipe.servings != servings: def create(self, **kwargs):
update_ingredients = set(ingredients.values_list('id', flat=True)) - set(add_ingredients.values_list('id', flat=True)) ingredients = kwargs.get('ingredients', None)
list_recipe.servings = servings exclude_onhand = not ingredients and self._exclude_onhand
list_recipe.save() if servings := kwargs.get('servings', None):
for sle in ShoppingListEntry.objects.filter(list_recipe=list_recipe, ingredient__id__in=update_ingredients): self.servings = float(servings)
sle.amount = sle.ingredient.amount * Decimal(servings_factor)
if mealplan := kwargs.get('mealplan', None):
self.mealplan = mealplan
self.recipe = mealplan.recipe
elif recipe := kwargs.get('recipe', None):
self.recipe = recipe
if not self.servings:
self.servings = getattr(self.mealplan, 'servings', None) or getattr(self.recipe, 'servings', 1.0)
self._shopping_list_recipe = ShoppingListRecipe.objects.create(recipe=self.recipe, mealplan=self.mealplan, servings=self.servings)
if ingredients:
self._add_ingredients(ingredients=ingredients)
else:
if self._include_related:
related = self.recipe.get_related_recipes()
self._add_ingredients(self.get_recipe_ingredients(self.recipe.id, exclude_onhand=exclude_onhand).exclude(food__recipe__in=related))
for r in related:
self._add_ingredients(self.get_recipe_ingredients(r.id, exclude_onhand=exclude_onhand).exclude(food__recipe__in=related))
else:
self._add_ingredients(self.get_recipe_ingredients(self.recipe.id, exclude_onhand=exclude_onhand))
return True
def add(self, **kwargs):
return
def edit(self, servings=None, ingredients=None, **kwargs):
if servings:
self.servings = servings
self._delete_ingredients(ingredients=ingredients)
if self.servings != self._shopping_list_recipe.servings:
self.edit_servings()
self._add_ingredients(ingredients=ingredients)
return True
def edit_servings(self, servings=None, **kwargs):
if servings:
self.servings = servings
if id := kwargs.get('id', None):
self._shopping_list_recipe = self.get_shopping_list_recipe(id, self.created_by, self.space)
if not self.servings:
raise ValueError(_("You must supply a servings size"))
if self._shopping_list_recipe.servings == self.servings:
return True
for sle in ShoppingListEntry.objects.filter(list_recipe=self._shopping_list_recipe):
sle.amount = sle.ingredient.amount * Decimal(self._servings_factor)
sle.save() sle.save()
self._shopping_list_recipe.servings = self.servings
self._shopping_list_recipe.save()
return True
# add any missing Entries def delete(self, **kwargs):
for i in [x for x in add_ingredients if x.food]: try:
self._shopping_list_recipe.delete()
return True
except:
return False
ShoppingListEntry.objects.create( def _add_ingredients(self, ingredients=None):
list_recipe=list_recipe, if not ingredients:
food=i.food, return
unit=i.unit, elif type(ingredients) == list:
ingredient=i, ingredients = Ingredient.objects.filter(id__in=ingredients)
amount=i.amount * Decimal(servings_factor), existing = self._shopping_list_recipe.entries.filter(ingredient__in=ingredients).values_list('ingredient__pk', flat=True)
created_by=created_by, add_ingredients = ingredients.exclude(id__in=existing)
space=space,
)
# return all shopping list items for i in [x for x in add_ingredients if x.food]:
return list_recipe ShoppingListEntry.objects.create(
list_recipe=self._shopping_list_recipe,
food=i.food,
unit=i.unit,
ingredient=i,
amount=i.amount * Decimal(self._servings_factor),
created_by=self.created_by,
space=self.space,
)
# deletes shopping list entries not in ingredients list
def _delete_ingredients(self, ingredients=None):
if not ingredients:
return
to_delete = self._shopping_list_recipe.entries.exclude(ingredient__in=ingredients)
ShoppingListEntry.objects.filter(id__in=to_delete).delete()
self._shopping_list_recipe = self.get_shopping_list_recipe(self.id, self.created_by, self.space)
# # TODO refactor as class
# def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None, ingredients=None, created_by=None, space=None, append=False):
# """
# Creates ShoppingListRecipe and associated ShoppingListEntrys from a recipe or a meal plan with a recipe
# :param list_recipe: Modify an existing ShoppingListRecipe
# :param recipe: Recipe to use as list of ingredients. One of [recipe, mealplan] are required
# :param mealplan: alternatively use a mealplan recipe as source of ingredients
# :param servings: Optional: Number of servings to use to scale shoppinglist. If servings = 0 an existing recipe list will be deleted
# :param ingredients: Ingredients, list of ingredient IDs to include on the shopping list. When not provided all ingredients will be used
# :param append: If False will remove any entries not included with ingredients, when True will append ingredients to the shopping list
# """
# r = recipe or getattr(mealplan, 'recipe', None) or getattr(list_recipe, 'recipe', None)
# if not r:
# raise ValueError(_("You must supply a recipe or mealplan"))
# created_by = created_by or getattr(ShoppingListEntry.objects.filter(list_recipe=list_recipe).first(), 'created_by', None)
# if not created_by:
# raise ValueError(_("You must supply a created_by"))
# try:
# servings = float(servings)
# except (ValueError, TypeError):
# servings = getattr(mealplan, 'servings', 1.0)
# servings_factor = servings / r.servings
# shared_users = list(created_by.get_shopping_share())
# shared_users.append(created_by)
# if list_recipe:
# created = False
# else:
# list_recipe = ShoppingListRecipe.objects.create(recipe=r, mealplan=mealplan, servings=servings)
# created = True
# related_step_ing = []
# if servings == 0 and not created:
# list_recipe.delete()
# return []
# elif ingredients:
# ingredients = Ingredient.objects.filter(pk__in=ingredients, space=space)
# else:
# ingredients = Ingredient.objects.filter(step__recipe=r, food__ignore_shopping=False, space=space)
# if exclude_onhand := created_by.userpreference.mealplan_autoexclude_onhand:
# ingredients = ingredients.exclude(food__onhand_users__id__in=[x.id for x in shared_users])
# if related := created_by.userpreference.mealplan_autoinclude_related:
# # TODO: add levels of related recipes (related recipes of related recipes) to use when auto-adding mealplans
# related_recipes = r.get_related_recipes()
# for x in related_recipes:
# # related recipe is a Step serving size is driven by recipe serving size
# # TODO once/if Steps can have a serving size this needs to be refactored
# if exclude_onhand:
# # if steps are used more than once in a recipe or subrecipe - I don' think this results in the desired behavior
# related_step_ing += Ingredient.objects.filter(step__recipe=x, space=space).exclude(food__onhand_users__id__in=[x.id for x in shared_users]).values_list('id', flat=True)
# else:
# related_step_ing += Ingredient.objects.filter(step__recipe=x, space=space).values_list('id', flat=True)
# x_ing = []
# if ingredients.filter(food__recipe=x).exists():
# for ing in ingredients.filter(food__recipe=x):
# if exclude_onhand:
# x_ing = Ingredient.objects.filter(step__recipe=x, food__ignore_shopping=False, space=space).exclude(food__onhand_users__id__in=[x.id for x in shared_users])
# else:
# x_ing = Ingredient.objects.filter(step__recipe=x, food__ignore_shopping=False, space=space).exclude(food__ignore_shopping=True)
# for i in [x for x in x_ing]:
# ShoppingListEntry.objects.create(
# list_recipe=list_recipe,
# food=i.food,
# unit=i.unit,
# ingredient=i,
# amount=i.amount * Decimal(servings_factor),
# created_by=created_by,
# space=space,
# )
# # dont' add food to the shopping list that are actually recipes that will be added as ingredients
# ingredients = ingredients.exclude(food__recipe=x)
# add_ingredients = list(ingredients.values_list('id', flat=True)) + related_step_ing
# if not append:
# existing_list = ShoppingListEntry.objects.filter(list_recipe=list_recipe)
# # delete shopping list entries not included in ingredients
# existing_list.exclude(ingredient__in=ingredients).delete()
# # add shopping list entries that did not previously exist
# add_ingredients = set(add_ingredients) - set(existing_list.values_list('ingredient__id', flat=True))
# add_ingredients = Ingredient.objects.filter(id__in=add_ingredients, space=space)
# # if servings have changed, update the ShoppingListRecipe and existing Entries
# if servings <= 0:
# servings = 1
# if not created and list_recipe.servings != servings:
# update_ingredients = set(ingredients.values_list('id', flat=True)) - set(add_ingredients.values_list('id', flat=True))
# list_recipe.servings = servings
# list_recipe.save()
# for sle in ShoppingListEntry.objects.filter(list_recipe=list_recipe, ingredient__id__in=update_ingredients):
# sle.amount = sle.ingredient.amount * Decimal(servings_factor)
# sle.save()
# # add any missing Entries
# for i in [x for x in add_ingredients if x.food]:
# ShoppingListEntry.objects.create(
# list_recipe=list_recipe,
# food=i.food,
# unit=i.unit,
# ingredient=i,
# amount=i.amount * Decimal(servings_factor),
# created_by=created_by,
# space=space,
# )
# # return all shopping list items
# return list_recipe

View File

@ -0,0 +1,20 @@
from django.db import migrations
from cookbook.models import SearchFields
def create_searchfields(apps, schema_editor):
SearchFields.objects.create(name='Units', field='steps__ingredients__unit__name')
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0167_userpreference_left_handed'),
]
operations = [
migrations.RunPython(
create_searchfields
),
]

View File

@ -609,7 +609,7 @@ class NutritionInformation(models.Model, PermissionModelMixin):
) )
proteins = models.DecimalField(default=0, decimal_places=16, max_digits=32) proteins = models.DecimalField(default=0, decimal_places=16, max_digits=32)
calories = models.DecimalField(default=0, decimal_places=16, max_digits=32) calories = models.DecimalField(default=0, decimal_places=16, max_digits=32)
source = models.CharField( max_length=512, default="", null=True, blank=True) source = models.CharField(max_length=512, default="", null=True, blank=True)
space = models.ForeignKey(Space, on_delete=models.CASCADE) space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space') objects = ScopedManager(space='space')
@ -852,11 +852,12 @@ class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), model
def __str__(self): def __str__(self):
return f'Shopping list entry {self.id}' return f'Shopping list entry {self.id}'
# TODO deprecate
def get_shared(self): def get_shared(self):
return self.shoppinglist_set.first().shared.all() try:
return self.shoppinglist_set.first().shared.all()
except AttributeError:
return self.created_by.userpreference.shopping_share.all()
# TODO deprecate
def get_owner(self): def get_owner(self):
try: try:
return self.created_by or self.shoppinglist_set.first().created_by return self.created_by or self.shoppinglist_set.first().created_by
@ -881,6 +882,12 @@ class ShoppingList(ExportModelOperationsMixin('shopping_list'), models.Model, Pe
def __str__(self): def __str__(self):
return f'Shopping list {self.id}' return f'Shopping list {self.id}'
def get_shared(self):
try:
return self.shared.all() or self.created_by.userpreference.shopping_share.all()
except AttributeError:
return []
class ShareLink(ExportModelOperationsMixin('share_link'), models.Model, PermissionModelMixin): class ShareLink(ExportModelOperationsMixin('share_link'), models.Model, PermissionModelMixin):
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE) recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)

View File

@ -13,7 +13,7 @@ from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.fields import empty from rest_framework.fields import empty
from cookbook.helper.HelperFunctions import str2bool from cookbook.helper.HelperFunctions import str2bool
from cookbook.helper.shopping_helper import list_from_recipe from cookbook.helper.shopping_helper import RecipeShoppingEditor
from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Food, from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Food,
FoodInheritField, ImportLog, Ingredient, Keyword, MealPlan, MealType, FoodInheritField, ImportLog, Ingredient, Keyword, MealPlan, MealType,
NutritionInformation, Recipe, RecipeBook, RecipeBookEntry, NutritionInformation, Recipe, RecipeBook, RecipeBookEntry,
@ -660,7 +660,8 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
validated_data['created_by'] = self.context['request'].user validated_data['created_by'] = self.context['request'].user
mealplan = super().create(validated_data) mealplan = super().create(validated_data)
if self.context['request'].data.get('addshopping', False): if self.context['request'].data.get('addshopping', False):
list_from_recipe(mealplan=mealplan, servings=validated_data['servings'], created_by=validated_data['created_by'], space=validated_data['space']) SLR = RecipeShoppingEditor(user=validated_data['created_by'], space=validated_data['space'])
SLR.create(mealplan=mealplan, servings=validated_data['servings'])
return mealplan return mealplan
class Meta: class Meta:
@ -694,12 +695,8 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer):
def update(self, instance, validated_data): def update(self, instance, validated_data):
# TODO remove once old shopping list # TODO remove once old shopping list
if 'servings' in validated_data and self.context.get('view', None).__class__.__name__ != 'ShoppingListViewSet': if 'servings' in validated_data and self.context.get('view', None).__class__.__name__ != 'ShoppingListViewSet':
list_from_recipe( SLR = RecipeShoppingEditor(user=self.context['request'].user, space=self.context['request'].space)
list_recipe=instance, SLR.edit_servings(servings=validated_data['servings'], id=instance.id)
servings=validated_data['servings'],
created_by=self.context['request'].user,
space=self.context['request'].space
)
return super().update(instance, validated_data) return super().update(instance, validated_data)
class Meta: class Meta:

View File

@ -6,8 +6,9 @@ from django.contrib.postgres.search import SearchVector
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.utils import translation from django.utils import translation
from django_scopes import scope
from cookbook.helper.shopping_helper import list_from_recipe from cookbook.helper.shopping_helper import RecipeShoppingEditor
from cookbook.managers import DICTIONARY from cookbook.managers import DICTIONARY
from cookbook.models import (Food, FoodInheritField, Ingredient, MealPlan, Recipe, from cookbook.models import (Food, FoodInheritField, Ingredient, MealPlan, Recipe,
ShoppingListEntry, Step) ShoppingListEntry, Step)
@ -104,20 +105,31 @@ def update_food_inheritance(sender, instance=None, created=False, **kwargs):
@receiver(post_save, sender=MealPlan) @receiver(post_save, sender=MealPlan)
def auto_add_shopping(sender, instance=None, created=False, weak=False, **kwargs): def auto_add_shopping(sender, instance=None, created=False, weak=False, **kwargs):
if not instance:
return
user = instance.get_owner() user = instance.get_owner()
if not user.userpreference.mealplan_autoadd_shopping: with scope(space=instance.space):
slr_exists = instance.shoppinglistrecipe_set.exists()
if not created and slr_exists:
for x in instance.shoppinglistrecipe_set.all():
# assuming that permissions checks for the MealPlan have happened upstream
if instance.servings != x.servings:
SLR = RecipeShoppingEditor(id=x.id, user=user, space=instance.space)
SLR.edit_servings(servings=instance.servings)
# list_recipe = list_from_recipe(list_recipe=x, servings=instance.servings, space=instance.space)
elif not user.userpreference.mealplan_autoadd_shopping or not instance.recipe:
return return
if not created and instance.shoppinglistrecipe_set.exists(): if created:
for x in instance.shoppinglistrecipe_set.all():
if instance.servings != x.servings:
list_recipe = list_from_recipe(list_recipe=x, servings=instance.servings, space=instance.space)
elif created:
# if creating a mealplan - perform shopping list activities # if creating a mealplan - perform shopping list activities
kwargs = { # kwargs = {
'mealplan': instance, # 'mealplan': instance,
'space': instance.space, # 'space': instance.space,
'created_by': user, # 'created_by': user,
'servings': instance.servings # 'servings': instance.servings
} # }
list_recipe = list_from_recipe(**kwargs) SLR = RecipeShoppingEditor(user=user, space=instance.space)
SLR.create(mealplan=instance, servings=instance.servings)
# list_recipe = list_from_recipe(**kwargs)

View File

@ -156,6 +156,32 @@ def test_sharing(request, shared, count, sle_2, sle, u1_s1):
# confirm shared user sees their list and the list that's shared with them # confirm shared user sees their list and the list that's shared with them
assert len(json.loads(r.content)) == count assert len(json.loads(r.content)) == count
# test shared user can mark complete
x = shared_client.patch(
reverse(DETAIL_URL, args={sle[0].id}),
{'checked': True},
content_type='application/json'
)
r = json.loads(shared_client.get(reverse(LIST_URL)).content)
assert len(r) == count
# count unchecked entries
if not x.status_code == 404:
count = count-1
assert [x['checked'] for x in r].count(False) == count
# test shared user can delete
x = shared_client.delete(
reverse(
DETAIL_URL,
args={sle[1].id}
)
)
r = json.loads(shared_client.get(reverse(LIST_URL)).content)
assert len(r) == count
# count unchecked entries
if not x.status_code == 404:
count = count-1
assert [x['checked'] for x in r].count(False) == count
def test_completed(sle, u1_s1): def test_completed(sle, u1_s1):
# check 1 entry # check 1 entry

View File

@ -164,7 +164,7 @@ def test_shopping_recipe_edit(request, recipe, sle_count, use_mealplan, u1_s1, u
assert len(r) == sle_count assert len(r) == sle_count
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count
# test removing 2 items from shopping list # test removing 3 items from shopping list
u2_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}), u2_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}),
{'list_recipe': list_recipe, 'ingredients': keep_ing}, {'list_recipe': list_recipe, 'ingredients': keep_ing},
content_type='application/json' content_type='application/json'

View File

@ -41,7 +41,7 @@ from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsGuest, Cus
from cookbook.helper.recipe_html_import import get_recipe_from_source from cookbook.helper.recipe_html_import import get_recipe_from_source
from cookbook.helper.recipe_search import RecipeFacet, RecipeSearch, old_search from cookbook.helper.recipe_search import RecipeFacet, RecipeSearch, old_search
from cookbook.helper.recipe_url_import import get_from_scraper from cookbook.helper.recipe_url_import import get_from_scraper
from cookbook.helper.shopping_helper import list_from_recipe, shopping_helper from cookbook.helper.shopping_helper import RecipeShoppingEditor, shopping_helper
from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, FoodInheritField, from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, FoodInheritField,
ImportLog, Ingredient, Keyword, MealPlan, MealType, Recipe, RecipeBook, ImportLog, Ingredient, Keyword, MealPlan, MealType, Recipe, RecipeBook,
RecipeBookEntry, ShareLink, ShoppingList, ShoppingListEntry, RecipeBookEntry, ShareLink, ShoppingList, ShoppingListEntry,
@ -153,11 +153,15 @@ class FuzzyFilterMixin(ViewSetMixin, ExtendedRecipeMixin):
) )
else: else:
# TODO have this check unaccent search settings or other search preferences? # TODO have this check unaccent search settings or other search preferences?
filter = Q(name__icontains=query)
if any([self.model.__name__.lower() in x for x in self.request.user.searchpreference.unaccent.values_list('field', flat=True)]):
filter |= Q(name__unaccent__icontains=query)
self.queryset = ( self.queryset = (
self.queryset self.queryset
.annotate(starts=Case(When(name__istartswith=query, then=(Value(100))), .annotate(starts=Case(When(name__istartswith=query, then=(Value(100))),
default=Value(0))) # put exact matches at the top of the result set default=Value(0))) # put exact matches at the top of the result set
.filter(name__icontains=query).order_by('-starts', 'name') .filter(filter).order_by('-starts', 'name')
) )
updated_at = self.request.query_params.get('updated_at', None) updated_at = self.request.query_params.get('updated_at', None)
@ -644,7 +648,6 @@ class RecipeViewSet(viewsets.ModelViewSet):
schema = QueryParamAutoSchema() schema = QueryParamAutoSchema()
def get_queryset(self): def get_queryset(self):
if self.detail: if self.detail:
self.queryset = self.queryset.filter(space=self.request.space) self.queryset = self.queryset.filter(space=self.request.space)
return super().get_queryset() return super().get_queryset()
@ -717,16 +720,27 @@ class RecipeViewSet(viewsets.ModelViewSet):
obj = self.get_object() obj = self.get_object()
ingredients = request.data.get('ingredients', None) ingredients = request.data.get('ingredients', None)
servings = request.data.get('servings', None) servings = request.data.get('servings', None)
list_recipe = ShoppingListRecipe.objects.filter(id=request.data.get('list_recipe', None)).first() list_recipe = request.data.get('list_recipe', None)
if servings is None: mealplan = request.data.get('mealplan', None)
servings = getattr(list_recipe, 'servings', obj.servings) SLR = RecipeShoppingEditor(request.user, request.space, id=list_recipe, recipe=obj, mealplan=mealplan)
# created_by needs to be sticky to original creator as it is 'their' shopping list
# changing shopping list created_by can shift some items to new owner which may not share in the other direction
created_by = getattr(ShoppingListEntry.objects.filter(list_recipe=list_recipe).first(), 'created_by', request.user)
content = {'msg': _(f'{obj.name} was added to the shopping list.')}
list_from_recipe(list_recipe=list_recipe, recipe=obj, ingredients=ingredients, servings=servings, space=request.space, created_by=created_by)
return Response(content, status=status.HTTP_204_NO_CONTENT) content = {'msg': _(f'{obj.name} was added to the shopping list.')}
http_status = status.HTTP_204_NO_CONTENT
if servings and servings <= 0:
result = SLR.delete()
elif list_recipe:
result = SLR.edit(servings=servings, ingredients=ingredients)
else:
result = SLR.create(servings=servings, ingredients=ingredients)
if not result:
content = {'msg': ('An error occurred')}
http_status = status.HTTP_500_INTERNAL_SERVER_ERROR
else:
content = {'msg': _(f'{obj.name} was added to the shopping list.')}
http_status = status.HTTP_204_NO_CONTENT
return Response(content, status=http_status)
@decorators.action( @decorators.action(
detail=True, detail=True,

View File

@ -54,20 +54,14 @@
<div class="col-12 col-md-3 calender-options"> <div class="col-12 col-md-3 calender-options">
<h5>{{ $t("Planner_Settings") }}</h5> <h5>{{ $t("Planner_Settings") }}</h5>
<b-form> <b-form>
<b-form-group id="UomInput" :label="$t('Period')" :description="$t('Plan_Period_To_Show')" <b-form-group id="UomInput" :label="$t('Period')" :description="$t('Plan_Period_To_Show')" label-for="UomInput">
label-for="UomInput"> <b-form-select id="UomInput" v-model="settings.displayPeriodUom" :options="options.displayPeriodUom"></b-form-select>
<b-form-select id="UomInput" v-model="settings.displayPeriodUom"
:options="options.displayPeriodUom"></b-form-select>
</b-form-group> </b-form-group>
<b-form-group id="PeriodInput" :label="$t('Periods')" <b-form-group id="PeriodInput" :label="$t('Periods')" :description="$t('Plan_Show_How_Many_Periods')" label-for="PeriodInput">
:description="$t('Plan_Show_How_Many_Periods')" label-for="PeriodInput"> <b-form-select id="PeriodInput" v-model="settings.displayPeriodCount" :options="options.displayPeriodCount"></b-form-select>
<b-form-select id="PeriodInput" v-model="settings.displayPeriodCount"
:options="options.displayPeriodCount"></b-form-select>
</b-form-group> </b-form-group>
<b-form-group id="DaysInput" :label="$t('Starting_Day')" :description="$t('Starting_Day')" <b-form-group id="DaysInput" :label="$t('Starting_Day')" :description="$t('Starting_Day')" label-for="DaysInput">
label-for="DaysInput"> <b-form-select id="DaysInput" v-model="settings.startingDayOfWeek" :options="dayNames"></b-form-select>
<b-form-select id="DaysInput" v-model="settings.startingDayOfWeek"
:options="dayNames"></b-form-select>
</b-form-group> </b-form-group>
<b-form-group id="WeekNumInput" :label="$t('Week_Numbers')"> <b-form-group id="WeekNumInput" :label="$t('Week_Numbers')">
<b-form-checkbox v-model="settings.displayWeekNumbers" name="week_num"> <b-form-checkbox v-model="settings.displayWeekNumbers" name="week_num">
@ -80,23 +74,18 @@
<h5>{{ $t("Meal_Types") }}</h5> <h5>{{ $t("Meal_Types") }}</h5>
<div> <div>
<draggable :list="meal_types" group="meal_types" :empty-insert-threshold="10" @sort="sortMealTypes()" ghost-class="ghost"> <draggable :list="meal_types" group="meal_types" :empty-insert-threshold="10" @sort="sortMealTypes()" ghost-class="ghost">
<b-card no-body class="mt-1 list-group-item p-2" style="cursor:move" v-for="(meal_type, index) in meal_types" v-hover <b-card no-body class="mt-1 list-group-item p-2" style="cursor: move" v-for="(meal_type, index) in meal_types" v-hover :key="meal_type.id">
:key="meal_type.id">
<b-card-header class="p-2 border-0"> <b-card-header class="p-2 border-0">
<div class="row"> <div class="row">
<div class="col-2"> <div class="col-2">
<button type="button" class="btn btn-lg shadow-none"><i <button type="button" class="btn btn-lg shadow-none"><i class="fas fa-arrows-alt-v"></i></button>
class="fas fa-arrows-alt-v"></i></button>
</div> </div>
<div class="col-10"> <div class="col-10">
<h5 class="mt-1 mb-1"> <h5 class="mt-1 mb-1">
{{ meal_type.icon }} {{ {{ meal_type.icon }} {{ meal_type.name
meal_type.name }}<span class="float-right text-primary" style="cursor: pointer"
}}<span class="float-right text-primary" style="cursor:pointer" ><i class="fa" v-bind:class="{ 'fa-pen': !meal_type.editing, 'fa-save': meal_type.editing }" @click="editOrSaveMealType(index)" aria-hidden="true"></i
><i class="fa" ></span>
v-bind:class="{ 'fa-pen': !meal_type.editing, 'fa-save': meal_type.editing }"
@click="editOrSaveMealType(index)" aria-hidden="true"></i
></span>
</h5> </h5>
</div> </div>
</div> </div>
@ -104,26 +93,19 @@
<b-card-body class="p-4" v-if="meal_type.editing"> <b-card-body class="p-4" v-if="meal_type.editing">
<div class="form-group"> <div class="form-group">
<label>{{ $t("Name") }}</label> <label>{{ $t("Name") }}</label>
<input class="form-control" placeholder="Name" v-model="meal_type.name"/> <input class="form-control" placeholder="Name" v-model="meal_type.name" />
</div> </div>
<div class="form-group"> <div class="form-group">
<emoji-input :field="'icon'" :label="$t('Icon')" <emoji-input :field="'icon'" :label="$t('Icon')" :value="meal_type.icon"></emoji-input>
:value="meal_type.icon"></emoji-input>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>{{ $t("Color") }}</label> <label>{{ $t("Color") }}</label>
<input class="form-control" type="color" name="Name" <input class="form-control" type="color" name="Name" :value="meal_type.color" @change="meal_type.color = $event.target.value" />
:value="meal_type.color"
@change="meal_type.color = $event.target.value"/>
</div> </div>
<b-form-checkbox id="checkbox-1" v-model="meal_type.default" <b-form-checkbox id="checkbox-1" v-model="meal_type.default" name="default_checkbox" class="mb-2">
name="default_checkbox" class="mb-2">
{{ $t("Default") }} {{ $t("Default") }}
</b-form-checkbox> </b-form-checkbox>
<button class="btn btn-danger" @click="deleteMealType(index)">{{ <button class="btn btn-danger" @click="deleteMealType(index)">{{ $t("Delete") }}</button>
$t("Delete")
}}
</button>
<button class="btn btn-primary float-right" @click="editOrSaveMealType(index)"> <button class="btn btn-primary float-right" @click="editOrSaveMealType(index)">
{{ $t("Save") }} {{ $t("Save") }}
</button> </button>
@ -147,15 +129,16 @@
openEntryEdit(contextData.originalItem.entry) openEntryEdit(contextData.originalItem.entry)
" "
> >
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-pen"></i> {{ <a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-pen"></i> {{ $t("Edit") }}</a>
$t("Edit")
}}</a>
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem <ContextMenuItem
v-if="contextData.originalItem.entry.recipe != null" v-if="contextData && contextData.originalItem && contextData.originalItem.entry.recipe != null"
@click="$refs.menu.close();openRecipe(contextData.originalItem.entry.recipe)"> @click="
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-pizza-slice"></i> $refs.menu.close()
{{ $t("Recipe") }}</a> openRecipe(contextData.originalItem.entry.recipe)
"
>
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-pizza-slice"></i> {{ $t("Recipe") }}</a>
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem <ContextMenuItem
@click=" @click="
@ -163,8 +146,7 @@
moveEntryLeft(contextData) moveEntryLeft(contextData)
" "
> >
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-arrow-left"></i> <a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-arrow-left"></i> {{ $t("Move") }}</a>
{{ $t("Move") }}</a>
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem <ContextMenuItem
@click=" @click="
@ -172,8 +154,7 @@
moveEntryRight(contextData) moveEntryRight(contextData)
" "
> >
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-arrow-right"></i> <a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-arrow-right"></i> {{ $t("Move") }}</a>
{{ $t("Move") }}</a>
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem <ContextMenuItem
@click=" @click="
@ -189,8 +170,7 @@
addToShopping(contextData) addToShopping(contextData)
" "
> >
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-shopping-cart"></i> <a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-shopping-cart"></i> {{ $t("Add_to_Shopping") }}</a>
{{ $t("Add_to_Shopping") }}</a>
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem <ContextMenuItem
@click=" @click="
@ -198,15 +178,12 @@
deleteEntry(contextData) deleteEntry(contextData)
" "
> >
<a class="dropdown-item p-2 text-danger" href="javascript:void(0)"><i class="fas fa-trash"></i> <a class="dropdown-item p-2 text-danger" href="javascript:void(0)"><i class="fas fa-trash"></i> {{ $t("Delete") }}</a>
{{ $t("Delete") }}</a>
</ContextMenuItem> </ContextMenuItem>
</template> </template>
</ContextMenu> </ContextMenu>
<meal-plan-edit-modal <meal-plan-edit-modal
:entry="entryEditing" :entry="entryEditing"
:entryEditing_initial_recipe="entryEditing_initial_recipe"
:entry-editing_initial_meal_type="entryEditing_initial_meal_type"
:modal_title="modal_title" :modal_title="modal_title"
:edit_modal_show="edit_modal_show" :edit_modal_show="edit_modal_show"
@save-entry="editEntry" @save-entry="editEntry"
@ -230,10 +207,11 @@
<div class="col-12 mt-1" v-if="shopping_list.length > 0"> <div class="col-12 mt-1" v-if="shopping_list.length > 0">
<b-button-group> <b-button-group>
<b-button variant="success" @click="saveShoppingList" <b-button variant="success" @click="saveShoppingList"
><i class="fas fa-external-link-alt"></i> ><i class="fas fa-external-link-alt"></i>
{{ $t("Open") }} {{ $t("Open") }}
</b-button> </b-button>
<b-button variant="danger" @click="shopping_list = []"><i class="fa fa-trash"></i> <b-button variant="danger" @click="shopping_list = []"
><i class="fa fa-trash"></i>
{{ $t("Clear") }} {{ $t("Clear") }}
</b-button> </b-button>
</b-button-group> </b-button-group>
@ -243,46 +221,37 @@
</div> </div>
</template> </template>
<transition name="slide-fade"> <transition name="slide-fade">
<div class="row fixed-bottom p-2 b-1 border-top text-center" style="background: rgba(255, 255, 255, 0.6)" <div class="row fixed-bottom p-2 b-1 border-top text-center" style="background: rgba(255, 255, 255, 0.6)" v-if="current_tab === 0">
v-if="current_tab === 0">
<div class="col-md-3 col-6"> <div class="col-md-3 col-6">
<button class="btn btn-block btn-success shadow-none" @click="createEntryClick(new Date())"><i <button class="btn btn-block btn-success shadow-none" @click="createEntryClick(new Date())"><i class="fas fa-calendar-plus"></i> {{ $t("Create") }}</button>
class="fas fa-calendar-plus"></i> {{ $t("Create") }}
</button>
</div> </div>
<div class="col-md-3 col-6"> <div class="col-md-3 col-6">
<button class="btn btn-block btn-primary shadow-none" v-b-toggle.sidebar-shopping><i <button class="btn btn-block btn-primary shadow-none" v-b-toggle.sidebar-shopping><i class="fas fa-shopping-cart"></i> {{ $t("Shopping_list") }}</button>
class="fas fa-shopping-cart"></i> {{ $t("Shopping_list") }}
</button>
</div> </div>
<div class="col-md-3 col-6"> <div class="col-md-3 col-6">
<a class="btn btn-block btn-primary shadow-none" :href="iCalUrl" <a class="btn btn-block btn-primary shadow-none" :href="iCalUrl"
><i class="fas fa-download"></i> ><i class="fas fa-download"></i>
{{ $t("Export_To_ICal") }} {{ $t("Export_To_ICal") }}
</a> </a>
</div> </div>
<div class="col-md-3 col-6"> <div class="col-md-3 col-6">
<button class="btn btn-block btn-primary shadow-none disabled" v-b-tooltip.focus.top <button class="btn btn-block btn-primary shadow-none disabled" v-b-tooltip.focus.top :title="$t('Coming_Soon')">
:title="$t('Coming_Soon')">
{{ $t("Auto_Planner") }} {{ $t("Auto_Planner") }}
</button> </button>
</div> </div>
<div class="col-12 d-flex justify-content-center mt-2 d-block d-md-none"> <div class="col-12 d-flex justify-content-center mt-2 d-block d-md-none">
<b-button-toolbar key-nav aria-label="Toolbar with button groups"> <b-button-toolbar key-nav aria-label="Toolbar with button groups">
<b-button-group class="mx-1"> <b-button-group class="mx-1">
<b-button v-html="'<<'" <b-button v-html="'<<'" @click="setShowDate($refs.header.headerProps.previousPeriod)"></b-button>
@click="setShowDate($refs.header.headerProps.previousPeriod)"></b-button>
<b-button v-html="'<'" @click="setStartingDay(-1)"></b-button> <b-button v-html="'<'" @click="setStartingDay(-1)"></b-button>
</b-button-group> </b-button-group>
<b-button-group class="mx-1"> <b-button-group class="mx-1">
<b-button @click="setShowDate($refs.header.headerProps.currentPeriod)"><i <b-button @click="setShowDate($refs.header.headerProps.currentPeriod)"><i class="fas fa-home"></i></b-button>
class="fas fa-home"></i></b-button>
<b-form-datepicker button-only button-variant="secondary"></b-form-datepicker> <b-form-datepicker button-only button-variant="secondary"></b-form-datepicker>
</b-button-group> </b-button-group>
<b-button-group class="mx-1"> <b-button-group class="mx-1">
<b-button v-html="'>'" @click="setStartingDay(1)"></b-button> <b-button v-html="'>'" @click="setStartingDay(1)"></b-button>
<b-button v-html="'>>'" <b-button v-html="'>>'" @click="setShowDate($refs.header.headerProps.nextPeriod)"></b-button>
@click="setShowDate($refs.header.headerProps.nextPeriod)"></b-button>
</b-button-group> </b-button-group>
</b-button-toolbar> </b-button-toolbar>
</div> </div>
@ -293,7 +262,7 @@
<script> <script>
import Vue from "vue" import Vue from "vue"
import {BootstrapVue} from "bootstrap-vue" import { BootstrapVue } from "bootstrap-vue"
import "bootstrap-vue/dist/bootstrap-vue.css" import "bootstrap-vue/dist/bootstrap-vue.css"
import ContextMenu from "@/components/ContextMenu/ContextMenu" import ContextMenu from "@/components/ContextMenu/ContextMenu"
@ -307,11 +276,11 @@ import moment from "moment"
import draggable from "vuedraggable" import draggable from "vuedraggable"
import VueCookies from "vue-cookies" import VueCookies from "vue-cookies"
import {ApiMixin, StandardToasts, ResolveUrlMixin} from "@/utils/utils" import { ApiMixin, StandardToasts, ResolveUrlMixin } from "@/utils/utils"
import {CalendarView, CalendarMathMixin} from "vue-simple-calendar/src/components/bundle" import { CalendarView, CalendarMathMixin } from "vue-simple-calendar/src/components/bundle"
import {ApiApiFactory} from "@/utils/openapi/api" import { ApiApiFactory } from "@/utils/openapi/api"
const {makeToast} = require("@/utils/utils") const { makeToast } = require("@/utils/utils")
Vue.prototype.moment = moment Vue.prototype.moment = moment
Vue.use(BootstrapVue) Vue.use(BootstrapVue)
@ -349,12 +318,12 @@ export default {
current_context_menu_item: null, current_context_menu_item: null,
options: { options: {
displayPeriodUom: [ displayPeriodUom: [
{text: this.$t("Week"), value: "week"}, { text: this.$t("Week"), value: "week" },
{ {
text: this.$t("Month"), text: this.$t("Month"),
value: "month", value: "month",
}, },
{text: this.$t("Year"), value: "year"}, { text: this.$t("Year"), value: "year" },
], ],
displayPeriodCount: [1, 2, 3], displayPeriodCount: [1, 2, 3],
entryEditing: { entryEditing: {
@ -385,20 +354,6 @@ export default {
return this.$t("Edit_Meal_Plan_Entry") return this.$t("Edit_Meal_Plan_Entry")
} }
}, },
entryEditing_initial_recipe: function () {
if (this.entryEditing.recipe != null) {
return [this.entryEditing.recipe]
} else {
return []
}
},
entryEditing_initial_meal_type: function () {
if (this.entryEditing.meal_type != null) {
return [this.entryEditing.meal_type]
} else {
return []
}
},
plan_items: function () { plan_items: function () {
let items = [] let items = []
this.plan_entries.forEach((entry) => { this.plan_entries.forEach((entry) => {
@ -412,7 +367,7 @@ export default {
dayNames: function () { dayNames: function () {
let options = [] let options = []
this.getFormattedWeekdayNames(this.userLocale, "long", 0).forEach((day, index) => { this.getFormattedWeekdayNames(this.userLocale, "long", 0).forEach((day, index) => {
options.push({text: day, value: index}) options.push({ text: day, value: index })
}) })
return options return options
}, },
@ -455,7 +410,7 @@ export default {
}, },
methods: { methods: {
openRecipe: function (recipe) { openRecipe: function (recipe) {
window.open(this.resolveDjangoUrl('view_recipe', recipe.id)) window.open(this.resolveDjangoUrl("view_recipe", recipe.id))
}, },
addToShopping(entry) { addToShopping(entry) {
if (entry.originalItem.entry.recipe !== null) { if (entry.originalItem.entry.recipe !== null) {
@ -491,7 +446,7 @@ export default {
let apiClient = new ApiApiFactory() let apiClient = new ApiApiFactory()
apiClient apiClient
.createMealType({name: this.$t("Meal_Type")}) .createMealType({ name: this.$t("Meal_Type") })
.then((e) => { .then((e) => {
this.periodChangedCallback(this.current_period) this.periodChangedCallback(this.current_period)
}) })
@ -879,7 +834,7 @@ having to override as much.
} }
.ghost { .ghost {
opacity: 0.5; opacity: 0.5;
background: #c8ebfb; background: #c8ebfb;
} }
</style> </style>

View File

@ -161,7 +161,7 @@
:flat="true" :flat="true"
:auto-load-root-options="false" :auto-load-root-options="false"
searchNested searchNested
:placeholder="$t('Ingredients')" :placeholder="$t('Foods')"
:normalizer="normalizer" :normalizer="normalizer"
@input="refreshData(false)" @input="refreshData(false)"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0" style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
@ -457,7 +457,7 @@ export default {
this.pagination_count = result.data.count this.pagination_count = result.data.count
this.facets = result.data.facets this.facets = result.data.facets
this.recipes = this.removeDuplicates(result.data.results, (recipe) => recipe.id) this.recipes = [...this.removeDuplicates(result.data.results, (recipe) => recipe.id)]
if (!this.searchFiltered()) { if (!this.searchFiltered()) {
// if meal plans are being shown - filter out any meal plan recipes from the recipe list // if meal plans are being shown - filter out any meal plan recipes from the recipe list
let mealPlans = [] let mealPlans = []

View File

@ -93,6 +93,7 @@
:servings="servings" :servings="servings"
:header="true" :header="true"
@checked-state-changed="updateIngredientCheckedState" @checked-state-changed="updateIngredientCheckedState"
@change-servings="servings = $event"
/> />
</div> </div>
@ -271,8 +272,7 @@ export default {
</script> </script>
<style> <style>
#app > div > div{ #app > div > div {
break-inside: avoid; break-inside: avoid;
} }
</style> </style>

View File

@ -123,7 +123,7 @@
<div class="collapse" :id="'section-' + sectionID(x, i)" visible role="tabpanel" :class="{ show: x == 'false' }"> <div class="collapse" :id="'section-' + sectionID(x, i)" visible role="tabpanel" :class="{ show: x == 'false' }">
<!-- passing an array of values to the table grouped by Food --> <!-- passing an array of values to the table grouped by Food -->
<transition-group name="slide-fade"> <transition-group name="slide-fade">
<div v-for="(entries, x) in Object.entries(s)" :key="x"> <div class="mx-4" v-for="(entries, x) in Object.entries(s)" :key="x">
<transition name="slide-fade" mode="out-in"> <transition name="slide-fade" mode="out-in">
<ShoppingLineItem <ShoppingLineItem
:entries="entries[1]" :entries="entries[1]"
@ -190,6 +190,9 @@
<td class="block-inline"> <td class="block-inline">
<b-form-input min="1" type="number" :debounce="300" :value="r.recipe_mealplan.servings" @input="updateServings($event, r.list_recipe)"></b-form-input> <b-form-input min="1" type="number" :debounce="300" :value="r.recipe_mealplan.servings" @input="updateServings($event, r.list_recipe)"></b-form-input>
</td> </td>
<td>
<i class="btn text-primary far fa-eye fa-lg px-2 border-0" variant="link" :title="$t('view_recipe')" @click="editRecipeList($event, r)" />
</td>
<td> <td>
<i class="btn text-danger fas fa-trash fa-lg px-2 border-0" variant="link" :title="$t('Delete')" @click="deleteRecipe($event, r.list_recipe)" /> <i class="btn text-danger fas fa-trash fa-lg px-2 border-0" variant="link" :title="$t('Delete')" @click="deleteRecipe($event, r.list_recipe)" />
</td> </td>
@ -432,7 +435,10 @@
<div class="col col-md-6 text-right"> <div class="col col-md-6 text-right">
<generic-multiselect <generic-multiselect
size="sm" size="sm"
@change="settings.shopping_share = $event.val;saveSettings()" @change="
settings.shopping_share = $event.val
saveSettings()
"
:model="Models.USER" :model="Models.USER"
:initial_selection="settings.shopping_share" :initial_selection="settings.shopping_share"
label="username" label="username"
@ -557,6 +563,26 @@
</div> </div>
</b-tab> </b-tab>
</b-tabs> </b-tabs>
<transition name="slided-fade">
<div class="row fixed-bottom p-2 b-1 border-top text-center d-flex d-md-none" style="background: rgba(255, 255, 255, 0.6)" v-if="current_tab === 0">
<div class="col-6">
<a class="btn btn-block btn-success shadow-none" @click="entrymode = !entrymode"
><i class="fas fa-cart-plus"></i>
{{ $t("New Entry") }}
</a>
</div>
<div class="col-6">
<b-dropdown id="dropdown-dropup" block dropup variant="primary" class="shadow-none">
<template #button-content> <i class="fas fa-download"></i> {{ $t("Export") }} </template>
<DownloadPDF dom="#shoppinglist" name="shopping.pdf" :label="$t('download_pdf')" icon="far fa-file-pdf" />
<DownloadCSV :items="csvData" :delim="settings.csv_delim" name="shopping.csv" :label="$t('download_csv')" icon="fas fa-file-csv" />
<CopyToClipboard :items="csvData" :settings="settings" :label="$t('copy_to_clipboard')" icon="fas fa-clipboard-list" />
<CopyToClipboard :items="csvData" :settings="settings" format="table" :label="$t('copy_markdown_table')" icon="fab fa-markdown" />
</b-dropdown>
</div>
</div>
</transition>
<b-popover target="id_filters_button" triggers="click" placement="bottomleft" :title="$t('Filters')"> <b-popover target="id_filters_button" triggers="click" placement="bottomleft" :title="$t('Filters')">
<div> <div>
<b-form-group v-bind:label="$t('GroupBy')" label-for="popover-input-1" label-cols="6" class="mb-1"> <b-form-group v-bind:label="$t('GroupBy')" label-for="popover-input-1" label-cols="6" class="mb-1">
@ -637,26 +663,7 @@
</ContextMenuItem> </ContextMenuItem>
</template> </template>
</ContextMenu> </ContextMenu>
<transition name="slided-fade"> <shopping-modal v-if="new_recipe.id" :recipe="new_recipe" :servings="parseInt(add_recipe_servings)" :modal_id="new_recipe.id" @finish="finishShopping" :list_recipe="new_recipe.list_recipe" />
<div class="row fixed-bottom p-2 b-1 border-top text-center d-flex d-md-none" style="background: rgba(255, 255, 255, 0.6)" v-if="current_tab === 0">
<div class="col-6">
<a class="btn btn-block btn-success shadow-none" @click="entrymode = !entrymode"
><i class="fas fa-cart-plus"></i>
{{ $t("New Entry") }}
</a>
</div>
<div class="col-6">
<b-dropdown id="dropdown-dropup" block dropup variant="primary" class="shadow-none">
<template #button-content> <i class="fas fa-download"></i> {{ $t("Export") }} </template>
<DownloadPDF dom="#shoppinglist" name="shopping.pdf" :label="$t('download_pdf')" icon="far fa-file-pdf" />
<DownloadCSV :items="csvData" :delim="settings.csv_delim" name="shopping.csv" :label="$t('download_csv')" icon="fas fa-file-csv" />
<CopyToClipboard :items="csvData" :settings="settings" :label="$t('copy_to_clipboard')" icon="fas fa-clipboard-list" />
<CopyToClipboard :items="csvData" :settings="settings" format="table" :label="$t('copy_markdown_table')" icon="fab fa-markdown" />
</b-dropdown>
</div>
</div>
</transition>
<shopping-modal v-if="new_recipe.id" :recipe="new_recipe" :servings="parseInt(add_recipe_servings)" :modal_id="new_recipe.id" @finish="finishShopping" />
</div> </div>
</template> </template>
@ -908,6 +915,7 @@ export default {
}, },
}, },
mounted() { mounted() {
console.log(screen.height)
this.getShoppingList() this.getShoppingList()
this.getSupermarkets() this.getSupermarkets()
this.getShoppingCategories() this.getShoppingCategories()
@ -1096,7 +1104,6 @@ export default {
if (!autosync) { if (!autosync) {
if (results.data?.length) { if (results.data?.length) {
this.items = results.data this.items = results.data
console.log(this.items)
} else { } else {
console.log("no data returned") console.log("no data returned")
} }
@ -1399,11 +1406,23 @@ export default {
window.removeEventListener("offline", this.updateOnlineStatus) window.removeEventListener("offline", this.updateOnlineStatus)
}, },
addRecipeToShopping() { addRecipeToShopping() {
console.log(this.new_recipe)
this.$bvModal.show(`shopping_${this.new_recipe.id}`) this.$bvModal.show(`shopping_${this.new_recipe.id}`)
}, },
finishShopping() { finishShopping() {
this.add_recipe_servings = 1
this.new_recipe = { id: undefined }
this.edit_recipe_list = undefined
this.getShoppingList() this.getShoppingList()
}, },
editRecipeList(e, r) {
this.new_recipe = { id: r.recipe_mealplan.recipe, name: r.recipe_mealplan.recipe_name, servings: r.recipe_mealplan.servings, list_recipe: r.list_recipe }
this.$nextTick(function () {
this.$bvModal.show(`shopping_${this.new_recipe.id}`)
})
// this.$bvModal.show(`shopping_${this.new_recipe.id}`)
},
}, },
directives: { directives: {
hover: { hover: {
@ -1465,14 +1484,25 @@ export default {
font-size: 20px; font-size: 20px;
} }
@media (max-width: 768px) { @media screen and (max-width: 768px) {
#shoppinglist { #shoppinglist {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-grow: 1; flex-grow: 1;
overflow-y: scroll; overflow-y: scroll;
overflow-x: hidden; overflow-x: hidden;
height: 65vh; height: 6vh;
padding-right: 8px !important;
}
}
@media screen and (min-height: 700px) and (max-width: 768px) {
#shoppinglist {
display: flex;
flex-direction: column;
flex-grow: 1;
overflow-y: scroll;
overflow-x: hidden;
height: 72vh;
padding-right: 8px !important; padding-right: 8px !important;
} }
} }

View File

@ -3,7 +3,7 @@
v-model="selected_objects" v-model="selected_objects"
:options="objects" :options="objects"
:close-on-select="true" :close-on-select="true"
:clear-on-select="true" :clear-on-select="multiple"
:hide-selected="multiple" :hide-selected="multiple"
:preserve-search="true" :preserve-search="true"
:internal-search="false" :internal-search="false"
@ -35,7 +35,7 @@ export default {
// this.Models and this.Actions inherited from ApiMixin // this.Models and this.Actions inherited from ApiMixin
loading: false, loading: false,
objects: [], objects: [],
selected_objects: [], selected_objects: undefined,
} }
}, },
props: { props: {
@ -48,7 +48,7 @@ export default {
}, },
label: { type: String, default: "name" }, label: { type: String, default: "name" },
parent_variable: { type: String, default: undefined }, parent_variable: { type: String, default: undefined },
limit: { type: Number, default: 10 }, limit: { type: Number, default: 25 },
sticky_options: { sticky_options: {
type: Array, type: Array,
default() { default() {
@ -61,6 +61,10 @@ export default {
return [] return []
}, },
}, },
initial_single_selection: {
type: Object,
default: undefined,
},
multiple: { type: Boolean, default: true }, multiple: { type: Boolean, default: true },
allow_create: { type: Boolean, default: false }, allow_create: { type: Boolean, default: false },
create_placeholder: { type: String, default: "You Forgot to Add a Tag Placeholder" }, create_placeholder: { type: String, default: "You Forgot to Add a Tag Placeholder" },
@ -71,18 +75,37 @@ export default {
// watch it // watch it
this.selected_objects = newVal this.selected_objects = newVal
}, },
initial_single_selection: function (newVal, oldVal) {
// watch it
this.selected_objects = newVal
},
clear: function (newVal, oldVal) { clear: function (newVal, oldVal) {
this.selected_objects = [] if (this.multiple || !this.initial_single_selection) {
this.selected_objects = []
} else {
this.selected_objects = undefined
}
}, },
}, },
mounted() { mounted() {
this.search("") this.search("")
this.selected_objects = this.initial_selection if (this.multiple || !this.initial_single_selection) {
this.selected_objects = this.initial_selection
} else {
this.selected_objects = this.initial_single_selection
}
}, },
computed: { computed: {
lookupPlaceholder() { lookupPlaceholder() {
return this.placeholder || this.model.name || this.$t("Search") return this.placeholder || this.model.name || this.$t("Search")
}, },
nothingSelected() {
if (this.multiple || !this.initial_single_selection) {
return this.selected_objects.length === 0 && this.initial_selection.length === 0
} else {
return !this.selected_objects && !this.initial_single_selection
}
},
}, },
methods: { methods: {
// this.genericAPI inherited from ApiMixin // this.genericAPI inherited from ApiMixin
@ -95,8 +118,9 @@ export default {
} }
this.genericAPI(this.model, this.Actions.LIST, options).then((result) => { this.genericAPI(this.model, this.Actions.LIST, options).then((result) => {
this.objects = this.sticky_options.concat(result.data?.results ?? result.data) this.objects = this.sticky_options.concat(result.data?.results ?? result.data)
if (this.selected_objects.length === 0 && this.initial_selection.length === 0 && this.objects.length > 0) { if (this.nothingSelected && this.objects.length > 0) {
this.objects.forEach((item) => { this.objects.forEach((item) => {
// select default items when present in object
if ("default" in item) { if ("default" in item) {
if (item.default) { if (item.default) {
if (this.multiple) { if (this.multiple) {
@ -109,6 +133,7 @@ export default {
} }
}) })
} }
// this.removeMissingItems() # This removes items that are on another page of results
}) })
}, },
selectionChanged: function () { selectionChanged: function () {
@ -121,6 +146,13 @@ export default {
this.search("") this.search("")
}, 750) }, 750)
}, },
// removeMissingItems: function () {
// if (this.multiple) {
// this.selected_objects = this.selected_objects.filter((x) => !this.objects.map((y) => y.id).includes(x))
// } else {
// this.selected_objects = this.objects.filter((x) => x.id === this.selected_objects.id)[0]
// }
// },
}, },
} }
</script> </script>

View File

@ -7,7 +7,7 @@
</template> </template>
<template v-else> <template v-else>
<td class="d-print-non" v-if="detailed && !add_shopping_mode" @click="done"> <td class="d-print-non" v-if="detailed && !show_shopping" @click="done">
<i class="far fa-check-circle text-success" v-if="ingredient.checked"></i> <i class="far fa-check-circle text-success" v-if="ingredient.checked"></i>
<i class="far fa-check-circle text-primary" v-if="!ingredient.checked"></i> <i class="far fa-check-circle text-primary" v-if="!ingredient.checked"></i>
</td> </td>
@ -39,9 +39,9 @@
variant="link" variant="link"
v-b-popover.hover.click.blur.html.top="{ title: ShoppingPopover, variant: 'outline-dark' }" v-b-popover.hover.click.blur.html.top="{ title: ShoppingPopover, variant: 'outline-dark' }"
:class="{ :class="{
'text-success': shopping_status === true, 'text-success': ingredient.shopping_status === true,
'text-muted': shopping_status === false, 'text-muted': ingredient.shopping_status === false,
'text-warning': shopping_status === null, 'text-warning': ingredient.shopping_status === null,
}" }"
/> />
<span v-if="!ingredient.food.ignore_shopping" class="px-2"> <span v-if="!ingredient.food.ignore_shopping" class="px-2">
@ -64,111 +64,49 @@ export default {
ingredient: Object, ingredient: Object,
ingredient_factor: { type: Number, default: 1 }, ingredient_factor: { type: Number, default: 1 },
detailed: { type: Boolean, default: true }, detailed: { type: Boolean, default: true },
recipe_list: { type: Number }, // ShoppingListRecipe ID, to filter ShoppingStatus
show_shopping: { type: Boolean, default: false }, show_shopping: { type: Boolean, default: false },
add_shopping_mode: { type: Boolean, default: false },
shopping_list: {
type: Array,
default() {
return []
},
}, // list of unchecked ingredients in shopping list
}, },
mixins: [ResolveUrlMixin, ApiMixin], mixins: [ResolveUrlMixin, ApiMixin],
data() { data() {
return { return {
checked: false, checked: false,
shopping_status: null, // in any shopping list: boolean + null=in shopping list, but not for this recipe
shopping_items: [],
shop: false, // in shopping list for this recipe: boolean shop: false, // in shopping list for this recipe: boolean
dirty: undefined, dirty: undefined,
} }
}, },
watch: { watch: {
ShoppingListAndFilter: { ingredient: {
immediate: true, handler() {},
handler(newVal, oldVal) { deep: true,
// this whole sections is overly complicated },
// trying to infer status of shopping for THIS recipe and THIS ingredient "ingredient.shop": function (newVal) {
// without know which recipe it is. this.shop = newVal
// If refactored:
// ## Needs to handle same recipe (multiple mealplans) being in shopping list multiple times
// ## Needs to handle same recipe being added as ShoppingListRecipe AND ingredients added from recipe as one-off
let filtered_list = this.shopping_list
// if a recipe list is provided, filter the shopping list
if (this.recipe_list) {
filtered_list = filtered_list.filter((x) => x.list_recipe == this.recipe_list)
}
// how many ShoppingListRecipes are there for this recipe?
let count_shopping_recipes = [...new Set(filtered_list.filter((x) => x.list_recipe))].length
let count_shopping_ingredient = filtered_list.filter((x) => x.ingredient == this.ingredient.id).length
if (count_shopping_recipes >= 1 && this.recipe_list) {
// This recipe is in the shopping list
this.shop = false // don't check any boxes until user selects a shopping list to edit
if (count_shopping_ingredient >= 1) {
this.shopping_status = true // ingredient is in the shopping list - probably (but not definitely, this ingredient)
} else if (this.ingredient?.food?.shopping) {
this.shopping_status = null // food is in the shopping list, just not for this ingredient/recipe
} else {
// food is not in any shopping list
this.shopping_status = false
}
} else {
// there are not recipes in the shopping list
// set default value
this.shop = !this.ingredient?.food?.food_onhand && !this.ingredient?.food?.recipe && !this.ingredient?.food?.ignore_shopping
this.$emit("add-to-shopping", { item: this.ingredient, add: this.shop })
// mark checked if the food is in the shopping list for this ingredient/recipe
if (count_shopping_ingredient >= 1) {
// ingredient is in this shopping list (not entirely sure how this could happen?)
this.shopping_status = true
} else if (count_shopping_ingredient == 0 && this.ingredient?.food?.shopping) {
// food is in the shopping list, just not for this ingredient/recipe
this.shopping_status = null
} else {
// the food is not in any shopping list
this.shopping_status = false
}
}
if (this.add_shopping_mode) {
// if we are in add shopping mode (e.g. recipe_shopping_modal) start with all checks marked
// except if on_hand (could be if recipe too?)
this.shop = !this.ingredient?.food?.food_onhand && !this.ingredient?.food?.recipe && !this.ingredient?.food?.ignore_shopping
}
},
}, },
}, },
mounted() {}, mounted() {
this.shop = this.ingredient?.shop
},
computed: { computed: {
ShoppingListAndFilter() {
// hack to watch the shopping list and the recipe list at the same time
return this.shopping_list.map((x) => x.id).join(this.recipe_list)
},
ShoppingPopover() { ShoppingPopover() {
if (this.shopping_status == false) { if (this.ingredient?.shopping_status == false) {
return this.$t("NotInShopping", { food: this.ingredient.food.name }) return this.$t("NotInShopping", { food: this.ingredient.food.name })
} else { } else {
let list = this.shopping_list.filter((x) => x.food.id == this.ingredient.food.id) let category = this.$t("Category") + ": " + this.ingredient?.category ?? this.$t("Undefined")
let category = this.$t("Category") + ": " + this.ingredient?.food?.supermarket_category?.name ?? this.$t("Undefined")
let popover = [] let popover = []
;(this.ingredient?.shopping_list ?? []).forEach((x) => {
list.forEach((x) => {
popover.push( popover.push(
[ [
"<tr style='border-bottom: 1px solid #ccc'>", "<tr style='border-bottom: 1px solid #ccc'>",
"<td style='padding: 3px;'><em>", "<td style='padding: 3px;'><em>",
x?.recipe_mealplan?.name ?? "", x?.mealplan ?? "",
"</em></td>", "</em></td>",
"<td style='padding: 3px;'>", "<td style='padding: 3px;'>",
x?.amount ?? "", x?.amount ?? "",
"</td>", "</td>",
"<td style='padding: 3px;'>", "<td style='padding: 3px;'>",
x?.unit?.name ?? "" + "</td>", x?.unit ?? "" + "</td>",
"<td style='padding: 3px;'>", "<td style='padding: 3px;'>",
x?.food?.name ?? "", x?.food ?? "",
"</td></tr>", "</td></tr>",
].join("") ].join("")
) )

View File

@ -13,7 +13,7 @@
</h4> </h4>
</div> </div>
</div> </div>
<div class="row text-right" v-if="ShoppingRecipes.length > 1"> <div class="row text-right" v-if="ShoppingRecipes.length > 1 && !add_shopping_mode">
<div class="col col-md-6 offset-md-6 text-right"> <div class="col col-md-6 offset-md-6 text-right">
<b-form-select v-model="selected_shoppingrecipe" :options="ShoppingRecipes" size="sm"></b-form-select> <b-form-select v-model="selected_shoppingrecipe" :options="ShoppingRecipes" size="sm"></b-form-select>
</div> </div>
@ -31,14 +31,11 @@
</tr> </tr>
<template v-for="i in s.ingredients"> <template v-for="i in s.ingredients">
<ingredient-component <ingredient-component
:ingredient="i" :ingredient="prepareIngredient(i)"
:ingredient_factor="ingredient_factor" :ingredient_factor="ingredient_factor"
:key="i.id" :key="i.id"
:show_shopping="show_shopping" :show_shopping="show_shopping"
:shopping_list="shopping_list"
:add_shopping_mode="add_shopping_mode"
:detailed="detailed" :detailed="detailed"
:recipe_list="selected_shoppingrecipe"
@checked-state-changed="$emit('checked-state-changed', $event)" @checked-state-changed="$emit('checked-state-changed', $event)"
@add-to-shopping="addShopping($event)" @add-to-shopping="addShopping($event)"
/> />
@ -59,6 +56,7 @@ import "bootstrap-vue/dist/bootstrap-vue.css"
import IngredientComponent from "@/components/IngredientComponent" import IngredientComponent from "@/components/IngredientComponent"
import { ApiMixin, StandardToasts } from "@/utils/utils" import { ApiMixin, StandardToasts } from "@/utils/utils"
import ShoppingListViewVue from "../apps/ShoppingListView/ShoppingListView.vue"
Vue.use(BootstrapVue) Vue.use(BootstrapVue)
@ -79,6 +77,7 @@ export default {
detailed: { type: Boolean, default: true }, detailed: { type: Boolean, default: true },
header: { type: Boolean, default: false }, header: { type: Boolean, default: false },
add_shopping_mode: { type: Boolean, default: false }, add_shopping_mode: { type: Boolean, default: false },
recipe_list: { type: Number, default: undefined },
}, },
data() { data() {
return { return {
@ -107,13 +106,14 @@ export default {
watch: { watch: {
ShoppingRecipes: function (newVal, oldVal) { ShoppingRecipes: function (newVal, oldVal) {
if (newVal.length === 0 || this.add_shopping_mode) { if (newVal.length === 0 || this.add_shopping_mode) {
this.selected_shoppingrecipe = undefined this.selected_shoppingrecipe = this.recipe_list
} else if (newVal.length === 1) { } else if (newVal.length === 1) {
this.selected_shoppingrecipe = newVal[0].value this.selected_shoppingrecipe = newVal[0].value
} }
}, },
selected_shoppingrecipe: function (newVal, oldVal) { selected_shoppingrecipe: function (newVal, oldVal) {
this.update_shopping = this.shopping_list.filter((x) => x.list_recipe === newVal).map((x) => x.ingredient) this.update_shopping = this.shopping_list.filter((x) => x.list_recipe === newVal).map((x) => x.ingredient)
this.$emit("change-servings", this.ShoppingRecipes.filter((x) => x.value === this.selected_shoppingrecipe)[0].servings)
}, },
}, },
mounted() { mounted() {
@ -140,13 +140,31 @@ export default {
} }
this.genericAPI(this.Models.SHOPPING_LIST, this.Actions.LIST, params).then((result) => { this.genericAPI(this.Models.SHOPPING_LIST, this.Actions.LIST, params).then((result) => {
this.shopping_list = result.data this.shopping_list = result.data
if (this.add_shopping_mode) {
if (this.recipe_list) {
this.$emit(
"starting-cart",
this.shopping_list.filter((x) => x.list_recipe === this.recipe_list).map((x) => x.ingredient)
)
} else {
this.$emit(
"starting-cart",
this.steps
.map((x) => x.ingredients)
.flat()
.filter((x) => x?.food?.food_onhand == false && x?.food?.ignore_shopping == false)
.map((x) => x.id)
)
}
}
}) })
} }
}, },
saveShopping: function (del_shopping = false) { saveShopping: function (del_shopping = false) {
let servings = this.servings let servings = this.servings
if (del_shopping) { if (del_shopping) {
servings = 0 servings = -1
} }
let params = { let params = {
id: this.recipe, id: this.recipe,
@ -155,7 +173,7 @@ export default {
servings: servings, servings: servings,
} }
this.genericAPI(this.Models.RECIPE, this.Actions.SHOPPING, params) this.genericAPI(this.Models.RECIPE, this.Actions.SHOPPING, params)
.then(() => { .then((result) => {
if (del_shopping) { if (del_shopping) {
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE) StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
} else if (this.selected_shoppingrecipe) { } else if (this.selected_shoppingrecipe) {
@ -164,13 +182,6 @@ export default {
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE) StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
} }
}) })
.then(() => {
if (!this.add_shopping_mode) {
return this.getShopping(false)
} else {
this.$emit("shopping-added")
}
})
.catch((err) => { .catch((err) => {
if (del_shopping) { if (del_shopping) {
StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE) StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE)
@ -186,13 +197,51 @@ export default {
// ALERT: this will all break if ingredients are re-used between recipes // ALERT: this will all break if ingredients are re-used between recipes
if (e.add) { if (e.add) {
this.update_shopping.push(e.item.id) this.update_shopping.push(e.item.id)
this.shopping_list.push({
id: Math.random(),
amount: e.item.amount,
ingredient: e.item.id,
food: e.item.food,
list_recipe: this.selected_shoppingrecipe,
})
} else { } else {
this.update_shopping = this.update_shopping.filter((x) => x !== e.item.id) this.update_shopping = [...this.update_shopping.filter((x) => x !== e.item.id)]
this.shopping_list = [...this.shopping_list.filter((x) => !(x.ingredient === e.item.id && x.list_recipe === this.selected_shoppingrecipe))]
} }
if (this.add_shopping_mode) { if (this.add_shopping_mode) {
this.$emit("add-to-shopping", e) this.$emit("add-to-shopping", e)
} }
}, },
prepareIngredient: function (i) {
let shopping = this.shopping_list.filter((x) => x.ingredient === i.id)
let selected_list = this.shopping_list.filter((x) => x.list_recipe === this.selected_shoppingrecipe && x.ingredient === i.id)
// checked = in the selected shopping list OR if in shoppping mode without a selected recipe, the default value true unless it is ignored or onhand
let checked = selected_list.length > 0 || (this.add_shopping_mode && !this.selected_shoppingrecipe && !i?.food?.ignore_recipe && !i?.food?.food_onhand)
let shopping_status = false // not in shopping list
if (shopping.length > 0) {
if (selected_list.length > 0) {
shopping_status = true // in shopping list for *this* recipe
} else {
shopping_status = null // in shopping list but not *this* recipe
}
}
return {
...i,
shop: checked,
shopping_status: shopping_status, // possible values: true, false, null
category: i.food.supermarket_category?.name,
shopping_list: shopping.map((x) => {
return {
mealplan: x?.recipe_mealplan?.name,
amount: x.amount,
food: x.food?.name,
unit: x.unit?.name,
}
}),
}
},
}, },
} }
</script> </script>

View File

@ -71,9 +71,7 @@ export default {
image_placeholder: window.IMAGE_PLACEHOLDER, image_placeholder: window.IMAGE_PLACEHOLDER,
} }
}, },
mounted() { mounted() {},
console.log(this.value)
},
computed: { computed: {
entry: function () { entry: function () {
return this.value.originalItem return this.value.originalItem

View File

@ -25,7 +25,7 @@
<b-form-group> <b-form-group>
<generic-multiselect <generic-multiselect
@change="selectRecipe" @change="selectRecipe"
:initial_selection="entryEditing_initial_recipe" :initial_single_selection="entryEditing.recipe"
:label="'name'" :label="'name'"
:model="Models.RECIPE" :model="Models.RECIPE"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0" style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
@ -45,7 +45,7 @@
v-bind:placeholder="$t('Meal_Type')" v-bind:placeholder="$t('Meal_Type')"
:limit="10" :limit="10"
:multiple="false" :multiple="false"
:initial_selection="entryEditing_initial_meal_type" :initial_single_selection="entryEditing.meal_type"
:allow_create="true" :allow_create="true"
:create_placeholder="$t('Create_New_Meal_Type')" :create_placeholder="$t('Create_New_Meal_Type')"
@new="createMealType" @new="createMealType"
@ -76,13 +76,16 @@
<small tabindex="-1" class="form-text text-muted">{{ $t("Share") }}</small> <small tabindex="-1" class="form-text text-muted">{{ $t("Share") }}</small>
</b-form-group> </b-form-group>
<b-input-group v-if="!autoMealPlan"> <b-input-group v-if="!autoMealPlan">
<b-form-checkbox id="AddToShopping" v-model="entryEditing.addshopping" /> <b-form-checkbox id="AddToShopping" v-model="mealplan_settings.addshopping" />
<small tabindex="-1" class="form-text text-muted">{{ $t("AddToShopping") }}</small> <small tabindex="-1" class="form-text text-muted">{{ $t("AddToShopping") }}</small>
</b-input-group> </b-input-group>
<b-input-group v-if="mealplan_settings.addshopping">
<b-form-checkbox id="reviewShopping" v-model="mealplan_settings.reviewshopping" />
<small tabindex="-1" class="form-text text-muted">{{ $t("review_shopping") }}</small>
</b-input-group>
</div> </div>
<div class="col-lg-6 d-none d-lg-block d-xl-block"> <div class="col-lg-6 d-none d-lg-block d-xl-block">
<recipe-card v-if="entryEditing.recipe && !entryEditing.addshopping" :recipe="entryEditing.recipe" :detailed="false"></recipe-card> <recipe-card v-if="entryEditing.recipe" :recipe="entryEditing.recipe" :detailed="false"></recipe-card>
<ingredients-card v-if="entryEditing.recipe && entryEditing.addshopping" :recipe="entryEditing.recipe" :detailed="false"></ingredients-card>
</div> </div>
</div> </div>
<div class="row mt-3 mb-3"> <div class="row mt-3 mb-3">
@ -100,6 +103,7 @@
<script> <script>
import Vue from "vue" import Vue from "vue"
import VueCookies from "vue-cookies"
import { BootstrapVue } from "bootstrap-vue" import { BootstrapVue } from "bootstrap-vue"
import GenericMultiselect from "@/components/GenericMultiselect" import GenericMultiselect from "@/components/GenericMultiselect"
import { ApiMixin, getUserPreference } from "@/utils/utils" import { ApiMixin, getUserPreference } from "@/utils/utils"
@ -108,13 +112,13 @@ const { ApiApiFactory } = require("@/utils/openapi/api")
const { StandardToasts } = require("@/utils/utils") const { StandardToasts } = require("@/utils/utils")
Vue.use(BootstrapVue) Vue.use(BootstrapVue)
Vue.use(VueCookies)
let MEALPLAN_COOKIE_NAME = "mealplan_settings"
export default { export default {
name: "MealPlanEditModal", name: "MealPlanEditModal",
props: { props: {
entry: Object, entry: Object,
entryEditing_initial_recipe: Array,
entryEditing_initial_meal_type: Array,
entryEditing_inital_servings: Number, entryEditing_inital_servings: Number,
modal_title: String, modal_title: String,
modal_id: { modal_id: {
@ -130,7 +134,6 @@ export default {
components: { components: {
GenericMultiselect, GenericMultiselect,
RecipeCard: () => import("@/components/RecipeCard.vue"), RecipeCard: () => import("@/components/RecipeCard.vue"),
IngredientsCard: () => import("@/components/IngredientsCard.vue"),
}, },
data() { data() {
return { return {
@ -138,18 +141,36 @@ export default {
missing_recipe: false, missing_recipe: false,
missing_meal_type: false, missing_meal_type: false,
default_plan_share: [], default_plan_share: [],
mealplan_settings: {
addshopping: false,
reviewshopping: false,
},
} }
}, },
watch: { watch: {
entry: { entry: {
handler() { handler() {
this.entryEditing = Object.assign({}, this.entry) this.entryEditing = Object.assign({}, this.entry)
if (this.entryEditing_inital_servings) { if (this.entryEditing_inital_servings) {
this.entryEditing.servings = this.entryEditing_inital_servings this.entryEditing.servings = this.entryEditing_inital_servings
} }
}, },
deep: true, deep: true,
}, },
entryEditing: {
handler(newVal) {},
deep: true,
},
mealplan_settings: {
handler(newVal) {
this.$cookies.set(MEALPLAN_COOKIE_NAME, this.mealplan_settings)
},
deep: true,
},
entryEditing_inital_servings: function (newVal) {
this.entryEditing.servings = newVal
},
}, },
mounted: function () {}, mounted: function () {},
computed: { computed: {
@ -159,6 +180,9 @@ export default {
}, },
methods: { methods: {
showModal() { showModal() {
if (this.$cookies.isKey(MEALPLAN_COOKIE_NAME)) {
this.mealplan_settings = Object.assign({}, this.mealplan_settings, this.$cookies.get(MEALPLAN_COOKIE_NAME))
}
let apiClient = new ApiApiFactory() let apiClient = new ApiApiFactory()
apiClient.listUserPreferences().then((result) => { apiClient.listUserPreferences().then((result) => {
@ -181,8 +205,10 @@ export default {
cancel = true cancel = true
} }
if (!cancel) { if (!cancel) {
console.log("saving", { ...this.mealplan_settings, ...this.entryEditing })
this.$bvModal.hide(`edit-modal`) this.$bvModal.hide(`edit-modal`)
this.$emit("save-entry", this.entryEditing) this.$emit("save-entry", { ...this.mealplan_settings, ...this.entryEditing })
console.log("after emit", { ...this.mealplan_settings, ...this.entryEditing }.addshopping)
} }
}, },
deleteEntry() { deleteEntry() {

View File

@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<b-modal :id="`shopping_${this.modal_id}`" hide-footer @show="loadRecipe"> <b-modal :id="`shopping_${this.modal_id}`" @show="loadRecipe">
<template v-slot:modal-title <template v-slot:modal-title
><h4>{{ $t("Add_Servings_to_Shopping", { servings: recipe_servings }) }}</h4></template ><h4>{{ $t("Add_Servings_to_Shopping", { servings: recipe_servings }) }}</h4></template
> >
@ -16,10 +16,11 @@
:recipe="recipe.id" :recipe="recipe.id"
:ingredient_factor="ingredient_factor" :ingredient_factor="ingredient_factor"
:servings="recipe_servings" :servings="recipe_servings"
:show_shopping="true"
:add_shopping_mode="true" :add_shopping_mode="true"
:recipe_list="list_recipe"
:header="false" :header="false"
@add-to-shopping="addShopping($event)" @add-to-shopping="addShopping($event)"
@starting-cart="add_shopping = $event"
/> />
</b-collapse> </b-collapse>
<!-- eslint-disable vue/no-v-for-template-key-on-child --> <!-- eslint-disable vue/no-v-for-template-key-on-child -->
@ -34,10 +35,11 @@
:recipe="r.recipe.id" :recipe="r.recipe.id"
:ingredient_factor="ingredient_factor" :ingredient_factor="ingredient_factor"
:servings="recipe_servings" :servings="recipe_servings"
:show_shopping="true"
:add_shopping_mode="true" :add_shopping_mode="true"
:recipe_list="list_recipe"
:header="false" :header="false"
@add-to-shopping="addShopping($event)" @add-to-shopping="addShopping($event)"
@starting-cart="add_shopping = [...add_shopping, ...$event]"
/> />
</b-collapse> </b-collapse>
</b-card> </b-card>
@ -46,18 +48,20 @@
</b-card> </b-card>
</div> </div>
<b-input-group class="my-3"> <template #modal-footer="">
<b-input-group-prepend is-text> <b-input-group class="mr-3">
{{ $t("Servings") }} <b-input-group-prepend is-text>
</b-input-group-prepend> {{ $t("Servings") }}
</b-input-group-prepend>
<b-form-spinbutton min="1" v-model="recipe_servings" inline style="height: 3em"></b-form-spinbutton> <b-form-spinbutton min="1" v-model="recipe_servings" inline style="height: 3em"></b-form-spinbutton>
<b-input-group-append> <b-input-group-append>
<b-button variant="secondary" @click="$bvModal.hide(`shopping_${modal_id}`)">{{ $t("Cancel") }} </b-button> <b-button variant="secondary" @click="$bvModal.hide(`shopping_${modal_id}`)">{{ $t("Cancel") }} </b-button>
<b-button variant="success" @click="saveShopping">{{ $t("Save") }} </b-button> <b-button variant="success" @click="saveShopping">{{ $t("Save") }} </b-button>
</b-input-group-append> </b-input-group-append>
</b-input-group> </b-input-group>
</template>
</b-modal> </b-modal>
</div> </div>
</template> </template>
@ -80,6 +84,8 @@ export default {
recipe: { required: true, type: Object }, recipe: { required: true, type: Object },
servings: { type: Number, default: undefined }, servings: { type: Number, default: undefined },
modal_id: { required: true, type: Number }, modal_id: { required: true, type: Number },
mealplan: { type: Number, default: undefined },
list_recipe: { type: Number, default: undefined },
}, },
data() { data() {
return { return {
@ -106,7 +112,6 @@ export default {
deep: true, deep: true,
}, },
servings: function (newVal) { servings: function (newVal) {
console.log(newVal)
this.recipe_servings = parseInt(newVal) this.recipe_servings = parseInt(newVal)
}, },
}, },
@ -121,14 +126,6 @@ export default {
this.steps = result.data.steps this.steps = result.data.steps
// ALERT: this will all break if ingredients are re-used between recipes // ALERT: this will all break if ingredients are re-used between recipes
// ALERT: this also doesn't quite work right if the same recipe appears multiple time in the related recipes // ALERT: this also doesn't quite work right if the same recipe appears multiple time in the related recipes
this.add_shopping = [
...this.add_shopping,
...this.steps
.map((x) => x.ingredients)
.flat()
.filter((x) => !x?.food?.food_onhand)
.map((x) => x.id),
]
if (!this.recipe_servings) { if (!this.recipe_servings) {
this.recipe_servings = result.data?.servings this.recipe_servings = result.data?.servings
} }
@ -155,18 +152,20 @@ export default {
}) })
return Promise.all(promises) return Promise.all(promises)
}) })
.then(() => { // .then(() => {
this.add_shopping = [ // if (!this.list_recipe) {
...this.add_shopping, // this.add_shopping = [
...this.related_recipes // ...this.add_shopping,
.map((x) => x.steps) // ...this.related_recipes
.flat() // .map((x) => x.steps)
.map((x) => x.ingredients) // .flat()
.flat() // .map((x) => x.ingredients)
.filter((x) => !x.food.override_ignore) // .flat()
.map((x) => x.id), // .filter((x) => !x.food.override_ignore)
] // .map((x) => x.id),
}) // ]
// }
// })
}) })
}, },
addShopping: function (e) { addShopping: function (e) {
@ -182,6 +181,8 @@ export default {
id: this.recipe.id, id: this.recipe.id,
ingredients: this.add_shopping, ingredients: this.add_shopping,
servings: this.recipe_servings, servings: this.recipe_servings,
mealplan: this.mealplan,
list_recipe: this.list_recipe,
} }
let apiClient = new ApiApiFactory() let apiClient = new ApiApiFactory()
apiClient apiClient

View File

@ -49,7 +49,7 @@
<cook-log :recipe="recipe" :modal_id="modal_id"></cook-log> <cook-log :recipe="recipe" :modal_id="modal_id"></cook-log>
<add-recipe-to-book :recipe="recipe" :modal_id="modal_id" :entryEditing_inital_servings="servings_value"></add-recipe-to-book> <add-recipe-to-book :recipe="recipe" :modal_id="modal_id" :entryEditing_inital_servings="servings_value"></add-recipe-to-book>
<shopping-modal :recipe="recipe" :servings="servings_value" :modal_id="modal_id" /> <shopping-modal :recipe="recipe" :servings="servings_value" :modal_id="modal_id" :mealplan="undefined" />
<b-modal :id="`modal-share-link_${modal_id}`" v-bind:title="$t('Share')" hide-footer> <b-modal :id="`modal-share-link_${modal_id}`" v-bind:title="$t('Share')" hide-footer>
<div class="row"> <div class="row">
@ -65,9 +65,7 @@
<meal-plan-edit-modal <meal-plan-edit-modal
:entry="entryEditing" :entry="entryEditing"
:entryEditing_initial_recipe="[recipe]"
:entryEditing_inital_servings="servings_value" :entryEditing_inital_servings="servings_value"
:entry-editing_initial_meal_type="[]"
@save-entry="saveMealPlan" @save-entry="saveMealPlan"
:modal_id="`modal-meal-plan_${modal_id}`" :modal_id="`modal-meal-plan_${modal_id}`"
:allow_delete="false" :allow_delete="false"
@ -118,6 +116,7 @@ export default {
}, },
}, },
entryEditing: {}, entryEditing: {},
mealplan: undefined,
} }
}, },
props: { props: {
@ -147,12 +146,19 @@ export default {
}, },
saveMealPlan: function (entry) { saveMealPlan: function (entry) {
entry.date = moment(entry.date).format("YYYY-MM-DD") entry.date = moment(entry.date).format("YYYY-MM-DD")
let reviewshopping = entry.addshopping && entry.reviewshopping
entry.addshopping = entry.addshopping && !entry.reviewshopping
let apiClient = new ApiApiFactory() let apiClient = new ApiApiFactory()
apiClient apiClient
.createMealPlan(entry) .createMealPlan(entry)
.then((result) => { .then((result) => {
this.$bvModal.hide(`modal-meal-plan_${this.modal_id}`) this.$bvModal.hide(`modal-meal-plan_${this.modal_id}`)
if (reviewshopping) {
this.mealplan = result.data.id
this.servings_value = result.data.servings
this.addToShopping()
}
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE) StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
}) })
.catch((error) => { .catch((error) => {
@ -163,7 +169,9 @@ export default {
this.entryEditing = this.options.entryEditing this.entryEditing = this.options.entryEditing
this.entryEditing.recipe = this.recipe this.entryEditing.recipe = this.recipe
this.entryEditing.date = moment(new Date()).format("YYYY-MM-DD") this.entryEditing.date = moment(new Date()).format("YYYY-MM-DD")
this.$bvModal.show(`modal-meal-plan_${this.modal_id}`) this.$nextTick(function () {
this.$bvModal.show(`modal-meal-plan_${this.modal_id}`)
})
}, },
createShareLink: function () { createShareLink: function () {
axios axios

View File

@ -1,334 +1,311 @@
<template> <template>
<div id="shopping_line_item"> <div id="shopping_line_item">
<b-row align-h="start">
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none pr-0"
v-if="settings.left_handed">
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile" :checked="formatChecked"
@change="updateChecked"
:key="entries[0].id"/>
<b-button size="sm" @click="showDetails = !showDetails" class="d-inline-block d-md-none p-0" variant="link">
<div class="text-nowrap"><i class="fa fa-chevron-right rotate" :class="showDetails ? 'rotated' : ''"></i>
</div>
</b-button>
</b-col>
<b-col cols="1" class="align-items-center d-flex">
<div class="dropdown b-dropdown position-static inline-block" data-html2canvas-ignore="true"
@click.stop="$emit('open-context-menu', $event, entries)">
<button
aria-haspopup="true"
aria-expanded="false"
type="button"
:class="settings.left_handed ? 'dropdown-spacing' : ''"
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret">
<i class="fas fa-ellipsis-v fa-lg"></i>
</button>
</div>
</b-col>
<b-col cols="1" class="justify-content-center align-items-center d-none d-md-flex">
<input type="checkbox" class="form-control form-control-sm checkbox-control" :checked="formatChecked"
@change="updateChecked"
:key="entries[0].id"/>
</b-col>
<b-col cols="8" md="9">
<b-row class="d-flex h-100">
<b-col cols="5" md="3" class="d-flex align-items-center" v-if="Object.entries(formatAmount).length == 1">
<strong class="mr-1">{{ Object.entries(formatAmount)[0][1] }}</strong> {{
Object.entries(formatAmount)[0][0]
}}
</b-col>
<b-col cols="5" md="3" class="d-flex flex-column" v-if="Object.entries(formatAmount).length != 1">
<div class="small" v-for="(x, i) in Object.entries(formatAmount)" :key="i">{{ x[1] }} &ensp;
{{ x[0] }}
</div>
</b-col>
<b-col cols="7" md="6" class="align-items-center d-flex pl-0 pr-0 pl-md-2 pr-md-2">
{{ formatFood }}
</b-col>
<b-col cols="3" data-html2canvas-ignore="true"
class="align-items-center d-none d-md-flex justify-content-end">
<b-button size="sm" @click="showDetails = !showDetails" class="p-0 mr-0 mr-md-2 p-md-2 text-decoration-none"
variant="link">
<div class="text-nowrap"><i class="fa fa-chevron-right rotate"
:class="showDetails ? 'rotated' : ''"></i> <span
class="d-none d-md-inline-block">{{ $t('Details') }}</span>
</div>
</b-button>
</b-col>
</b-row>
</b-col>
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none"
v-if="!settings.left_handed">
<b-button size="sm" @click="showDetails = !showDetails" class="d-inline-block d-md-none p-0" variant="link">
<div class="text-nowrap"><i class="fa fa-chevron-right rotate" :class="showDetails ? 'rotated' : ''"></i>
</div>
</b-button>
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile" :checked="formatChecked"
@change="updateChecked"
:key="entries[0].id"/>
</b-col>
</b-row>
<b-row align-h="center" class="d-none d-md-flex">
<b-col cols="12">
<div class="small text-muted text-truncate">{{ formatHint }}</div>
</b-col>
</b-row>
<!-- detail rows -->
<div class="card no-body mb-1 pt-2 align-content-center shadow-sm" v-if="showDetails">
<div v-for="(e, x) in entries" :key="e.id">
<b-row class="small justify-content-around">
<b-col cols="auto" md="4" class="overflow-hidden text-nowrap">
<button
aria-haspopup="true"
aria-expanded="false"
type="button"
class="btn btn-link btn-sm m-0 p-0 pl-2"
style="text-overflow: ellipsis"
@click.stop="openRecipeCard($event, e)"
@mouseover="openRecipeCard($event, e)">
{{ formatOneRecipe(e) }}
</button>
</b-col>
<b-col cols="auto" md="4" class="text-muted">{{ formatOneMealPlan(e) }}</b-col>
<b-col cols="auto" md="4" class="text-muted text-right overflow-hidden text-nowrap pr-4">
{{ formatOneCreatedBy(e) }}
<div v-if="formatOneCompletedAt(e)">{{ formatOneCompletedAt(e) }}</div>
</b-col>
</b-row>
<b-row align-h="start"> <b-row align-h="start">
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none pr-0" <b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none pr-0" v-if="settings.left_handed">
v-if="settings.left_handed"> <input type="checkbox" class="form-control form-control-sm checkbox-control-mobile" :checked="formatChecked" @change="updateChecked" :key="entries[0].id" />
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile" <b-button size="sm" @click="showDetails = !showDetails" class="d-inline-block d-md-none p-0" variant="link">
:checked="formatChecked" <div class="text-nowrap"><i class="fa fa-chevron-right rotate" :class="showDetails ? 'rotated' : ''"></i></div>
@change="updateChecked" </b-button>
:key="entries[0].id"/> </b-col>
</b-col> <b-col cols="1" class="align-items-center d-flex">
<b-col cols="1" class="align-items-center d-flex"> <div class="dropdown b-dropdown position-static inline-block" data-html2canvas-ignore="true" @click.stop="$emit('open-context-menu', $event, entries)">
<div class="dropdown b-dropdown position-static inline-block" data-html2canvas-ignore="true" <button
@click.stop="$emit('open-context-menu', $event, e)"> aria-haspopup="true"
<button aria-expanded="false"
aria-haspopup="true" type="button"
aria-expanded="false" :class="settings.left_handed ? 'dropdown-spacing' : ''"
type="button" class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret"
:class="settings.left_handed ? 'dropdown-spacing' : ''" >
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret"> <i class="fas fa-ellipsis-v fa-lg"></i>
<i class="fas fa-ellipsis-v fa-lg"></i> </button>
</button> </div>
</div> </b-col>
</b-col> <b-col cols="1" class="px-1 justify-content-center align-items-center d-none d-md-flex">
<b-col cols="1" class="justify-content-center align-items-center d-none d-md-flex"> <input type="checkbox" class="form-control form-control-sm checkbox-control" :checked="formatChecked" @change="updateChecked" :key="entries[0].id" />
<input type="checkbox" class="form-control form-control-sm checkbox-control" :checked="formatChecked" </b-col>
@change="updateChecked" <b-col cols="8" md="9">
:key="entries[0].id"/> <b-row class="d-flex h-100">
</b-col> <b-col cols="5" md="3" class="d-flex align-items-center" v-if="Object.entries(formatAmount).length == 1">
<b-col cols="8" md="9"> <strong class="mr-1">{{ Object.entries(formatAmount)[0][1] }}</strong> {{ Object.entries(formatAmount)[0][0] }}
<b-row class="d-flex align-items-center h-100"> </b-col>
<b-col cols="5" md="3" class="d-flex align-items-center"> <b-col cols="5" md="3" class="d-flex flex-column" v-if="Object.entries(formatAmount).length != 1">
<strong class="mr-1">{{ formatOneAmount(e) }}</strong> {{ formatOneUnit(e) }} <div class="small" v-for="(x, i) in Object.entries(formatAmount)" :key="i">
</b-col> {{ x[1] }} &ensp;
<b-col cols="7" md="6" class="align-items-center d-flex pl-0 pr-0 pl-md-2 pr-md-2"> {{ x[0] }}
{{ formatOneFood(e) }} </div>
</b-col> </b-col>
<b-col cols="12" class="d-flex d-md-none">
<div class="small text-muted text-truncate" v-for="(n, i) in formatOneNote(e)" :key="i">{{ n }}</div> <b-col cols="7" md="6" class="align-items-center d-flex pl-0 pr-0 pl-md-2 pr-md-2">
</b-col> {{ formatFood }}
</b-row> </b-col>
</b-col> <b-col cols="3" data-html2canvas-ignore="true" class="align-items-center d-none d-md-flex justify-content-end">
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none" <b-button size="sm" @click="showDetails = !showDetails" class="p-0 mr-0 mr-md-2 p-md-2 text-decoration-none" variant="link">
v-if="!settings.left_handed"> <div class="text-nowrap">
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile" <i class="fa fa-chevron-right rotate" :class="showDetails ? 'rotated' : ''"></i> <span class="d-none d-md-inline-block">{{ $t("Details") }}</span>
:checked="formatChecked" </div>
@change="updateChecked" </b-button>
:key="entries[0].id"/> </b-col>
</b-col> </b-row>
</b-col>
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none" v-if="!settings.left_handed">
<b-button size="sm" @click="showDetails = !showDetails" class="d-inline-block d-md-none p-0" variant="link">
<div class="text-nowrap"><i class="fa fa-chevron-right rotate" :class="showDetails ? 'rotated' : ''"></i></div>
</b-button>
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile" :checked="formatChecked" @change="updateChecked" :key="entries[0].id" />
</b-col>
</b-row> </b-row>
<hr class="w-75" v-if="x !== entries.length -1"/> <b-row align-h="center" class="d-none d-md-flex">
<div class="pb-4" v-if="x === entries.length -1"></div> <b-col cols="12">
</div> <div class="small text-muted text-truncate">{{ formatHint }}</div>
</div> </b-col>
<hr class="m-1" v-if="!showDetails"/> </b-row>
<ContextMenu ref="recipe_card" triggers="click, hover" :title="$t('Filters')" style="max-width: 300"> <!-- detail rows -->
<template #menu="{ contextData }" v-if="recipe"> <div class="card no-body mb-1 pt-2 align-content-center shadow-sm" v-if="showDetails">
<ContextMenuItem> <div v-for="(e, x) in entries" :key="e.id">
<RecipeCard :recipe="contextData" :detail="false"></RecipeCard> <b-row class="small justify-content-around">
</ContextMenuItem> <b-col cols="auto" md="4" class="overflow-hidden text-nowrap">
<ContextMenuItem @click="$refs.menu.close()"> <button
<b-form-group label-cols="9" content-cols="3" class="text-nowrap m-0 mr-2"> aria-haspopup="true"
<template #label> aria-expanded="false"
<a class="dropdown-item p-2" href="#"><i class="fas fa-pizza-slice"></i> {{ $t("Servings") }}</a> type="button"
</template> class="btn btn-link btn-sm m-0 p-0 pl-2"
<div @click.prevent.stop> style="text-overflow: ellipsis"
<b-form-input class="mt-2" min="0" type="number" v-model="servings"></b-form-input> @click.stop="openRecipeCard($event, e)"
@mouseover="openRecipeCard($event, e)"
>
{{ formatOneRecipe(e) }}
</button>
</b-col>
<b-col cols="auto" md="4" class="text-muted">{{ formatOneMealPlan(e) }}</b-col>
<b-col cols="auto" md="4" class="text-muted text-right overflow-hidden text-nowrap pr-4">
{{ formatOneCreatedBy(e) }}
<div v-if="formatOneCompletedAt(e)">{{ formatOneCompletedAt(e) }}</div>
</b-col>
</b-row>
<b-row align-h="start">
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none pr-0" v-if="settings.left_handed">
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile" :checked="formatChecked" @change="updateChecked" :key="entries[0].id" />
</b-col>
<b-col cols="1" class="align-items-center d-flex">
<div class="dropdown b-dropdown position-static inline-block" data-html2canvas-ignore="true" @click.stop="$emit('open-context-menu', $event, e)">
<button
aria-haspopup="true"
aria-expanded="false"
type="button"
:class="settings.left_handed ? 'dropdown-spacing' : ''"
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret"
>
<i class="fas fa-ellipsis-v fa-lg"></i>
</button>
</div>
</b-col>
<b-col cols="1" class="justify-content-center align-items-center d-none d-md-flex">
<input type="checkbox" class="form-control form-control-sm checkbox-control" :checked="formatChecked" @change="updateChecked" :key="entries[0].id" />
</b-col>
<b-col cols="8" md="9">
<b-row class="d-flex align-items-center h-100">
<b-col cols="5" md="3" class="d-flex align-items-center">
<strong class="mr-1">{{ formatOneAmount(e) }}</strong> {{ formatOneUnit(e) }}
</b-col>
<b-col cols="7" md="6" class="align-items-center d-flex pl-0 pr-0 pl-md-2 pr-md-2">
{{ formatOneFood(e) }}
</b-col>
<b-col cols="12" class="d-flex d-md-none">
<div class="small text-muted text-truncate" v-for="(n, i) in formatOneNote(e)" :key="i">{{ n }}</div>
</b-col>
</b-row>
</b-col>
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none" v-if="!settings.left_handed">
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile" :checked="formatChecked" @change="updateChecked" :key="entries[0].id" />
</b-col>
</b-row>
<hr class="w-75" v-if="x !== entries.length - 1" />
<div class="pb-4" v-if="x === entries.length - 1"></div>
</div> </div>
</b-form-group> </div>
</ContextMenuItem> <hr class="m-1" v-if="!showDetails" />
</template> <ContextMenu ref="recipe_card" triggers="click, hover" :title="$t('Filters')" style="max-width: 300">
</ContextMenu> <template #menu="{ contextData }" v-if="recipe">
</div> <ContextMenuItem>
<RecipeCard :recipe="contextData" :detail="false"></RecipeCard>
</ContextMenuItem>
<ContextMenuItem @click="$refs.menu.close()">
<b-form-group label-cols="9" content-cols="3" class="text-nowrap m-0 mr-2">
<template #label>
<a class="dropdown-item p-2" href="#"><i class="fas fa-pizza-slice"></i> {{ $t("Servings") }}</a>
</template>
<div @click.prevent.stop>
<b-form-input class="mt-2" min="0" type="number" v-model="servings"></b-form-input>
</div>
</b-form-group>
</ContextMenuItem>
</template>
</ContextMenu>
</div>
</template> </template>
<script> <script>
import Vue from "vue" import Vue from "vue"
import {BootstrapVue} from "bootstrap-vue" import { BootstrapVue } from "bootstrap-vue"
import "bootstrap-vue/dist/bootstrap-vue.css" import "bootstrap-vue/dist/bootstrap-vue.css"
import ContextMenu from "@/components/ContextMenu/ContextMenu" import ContextMenu from "@/components/ContextMenu/ContextMenu"
import ContextMenuItem from "@/components/ContextMenu/ContextMenuItem" import ContextMenuItem from "@/components/ContextMenu/ContextMenuItem"
import {ApiMixin} from "@/utils/utils" import { ApiMixin } from "@/utils/utils"
import RecipeCard from "./RecipeCard.vue" import RecipeCard from "./RecipeCard.vue"
Vue.use(BootstrapVue) Vue.use(BootstrapVue)
export default { export default {
// TODO ApiGenerator doesn't capture and share error information - would be nice to share error details when available // TODO ApiGenerator doesn't capture and share error information - would be nice to share error details when available
// or i'm capturing it incorrectly // or i'm capturing it incorrectly
name: "ShoppingLineItem", name: "ShoppingLineItem",
mixins: [ApiMixin], mixins: [ApiMixin],
components: {RecipeCard, ContextMenu, ContextMenuItem}, components: { RecipeCard, ContextMenu, ContextMenuItem },
props: { props: {
entries: { entries: {
type: Array, type: Array,
},
settings: Object,
groupby: { type: String },
}, },
settings: Object, data() {
groupby: {type: String}, return {
}, showDetails: false,
data() { recipe: undefined,
return { servings: 1,
showDetails: false,
recipe: undefined,
servings: 1,
}
},
computed: {
formatAmount: function () {
let amount = {}
this.entries.forEach((entry) => {
let unit = entry?.unit?.name ?? "----"
if (entry.amount) {
if (amount[unit]) {
amount[unit] += entry.amount
} else {
amount[unit] = entry.amount
}
} }
})
for (const [k, v] of Object.entries(amount)) {
amount[k] = Math.round(v * 100 + Number.EPSILON) / 100 // javascript hack to force rounding at 2 places
}
return amount
}, },
formatCategory: function () { computed: {
return this.formatOneCategory(this.entries[0]) || this.$t("Undefined") formatAmount: function () {
}, let amount = {}
formatChecked: function () { this.entries.forEach((entry) => {
return this.entries.map((x) => x.checked).every((x) => x === true) let unit = entry?.unit?.name ?? "----"
}, if (entry.amount) {
formatHint: function () { if (amount[unit]) {
if (this.groupby == "recipe") { amount[unit] += entry.amount
return this.formatCategory } else {
} else { amount[unit] = entry.amount
return this.formatRecipe }
} }
},
formatFood: function () {
return this.formatOneFood(this.entries[0])
},
formatUnit: function () {
return this.formatOneUnit(this.entries[0])
},
formatRecipe: function () {
if (this.entries?.length == 1) {
return this.formatOneMealPlan(this.entries[0]) || ""
} else {
let mealplan_name = this.entries.filter((x) => x?.recipe_mealplan?.name)
// return [this.formatOneMealPlan(mealplan_name?.[0]), this.$t("CountMore", { count: this.entries?.length - 1 })].join(" ")
return mealplan_name
.map((x) => {
return this.formatOneMealPlan(x)
}) })
.join(" - ") for (const [k, v] of Object.entries(amount)) {
} amount[k] = Math.round(v * 100 + Number.EPSILON) / 100 // javascript hack to force rounding at 2 places
}, }
formatNotes: function () { return amount
if (this.entries?.length == 1) { },
return this.formatOneNote(this.entries[0]) || "" formatCategory: function () {
} return this.formatOneCategory(this.entries[0]) || this.$t("Undefined")
return "" },
}, formatChecked: function () {
}, return this.entries.map((x) => x.checked).every((x) => x === true)
watch: {}, },
mounted() { formatHint: function () {
this.servings = this.entries?.[0]?.recipe_mealplan?.servings ?? 0 if (this.groupby == "recipe") {
}, return this.formatCategory
methods: { } else {
// this.genericAPI inherited from ApiMixin return this.formatRecipe
}
},
formatFood: function () {
return this.formatOneFood(this.entries[0])
},
formatUnit: function () {
return this.formatOneUnit(this.entries[0])
},
formatRecipe: function () {
if (this.entries?.length == 1) {
return this.formatOneMealPlan(this.entries[0]) || ""
} else {
let mealplan_name = this.entries.filter((x) => x?.recipe_mealplan?.name)
// return [this.formatOneMealPlan(mealplan_name?.[0]), this.$t("CountMore", { count: this.entries?.length - 1 })].join(" ")
formatDate: function (datetime) { return mealplan_name
if (!datetime) { .map((x) => {
return return this.formatOneMealPlan(x)
} })
return Intl.DateTimeFormat(window.navigator.language, { .join(" - ")
dateStyle: "short", }
timeStyle: "short" },
}).format(Date.parse(datetime)) formatNotes: function () {
if (this.entries?.length == 1) {
return this.formatOneNote(this.entries[0]) || ""
}
return ""
},
}, },
formatOneAmount: function (item) { watch: {},
return item?.amount ?? 1 mounted() {
this.servings = this.entries?.[0]?.recipe_mealplan?.servings ?? 0
}, },
formatOneUnit: function (item) { methods: {
return item?.unit?.name ?? "" // this.genericAPI inherited from ApiMixin
formatDate: function (datetime) {
if (!datetime) {
return
}
return Intl.DateTimeFormat(window.navigator.language, {
dateStyle: "short",
timeStyle: "short",
}).format(Date.parse(datetime))
},
formatOneAmount: function (item) {
return item?.amount ?? 1
},
formatOneUnit: function (item) {
return item?.unit?.name ?? ""
},
formatOneCategory: function (item) {
return item?.food?.supermarket_category?.name
},
formatOneCompletedAt: function (item) {
if (!item.completed_at) {
return false
}
return [this.$t("Completed"), "@", this.formatDate(item.completed_at)].join(" ")
},
formatOneFood: function (item) {
return item.food.name
},
formatOneDelayUntil: function (item) {
if (!item.delay_until || (item.delay_until && item.checked)) {
return false
}
return [this.$t("DelayUntil"), "-", this.formatDate(item.delay_until)].join(" ")
},
formatOneMealPlan: function (item) {
return item?.recipe_mealplan?.name ?? ""
},
formatOneRecipe: function (item) {
return item?.recipe_mealplan?.recipe_name ?? ""
},
formatOneNote: function (item) {
if (!item) {
item = this.entries[0]
}
return [item?.recipe_mealplan?.mealplan_note, item?.ingredient_note].filter(String)
},
formatOneCreatedBy: function (item) {
return [this.$t("Added_by"), item?.created_by.username, "@", this.formatDate(item.created_at)].join(" ")
},
openRecipeCard: function (e, item) {
this.genericAPI(this.Models.RECIPE, this.Actions.FETCH, { id: item.recipe_mealplan.recipe }).then((result) => {
let recipe = result.data
recipe.steps = undefined
this.recipe = true
this.$refs.recipe_card.open(e, recipe)
})
},
updateChecked: function (e, item) {
let update = undefined
if (!item) {
update = { entries: this.entries.map((x) => x.id), checked: !this.formatChecked }
} else {
update = { entries: [item], checked: !item.checked }
}
this.$emit("update-checkbox", update)
},
}, },
formatOneCategory: function (item) {
return item?.food?.supermarket_category?.name
},
formatOneCompletedAt: function (item) {
if (!item.completed_at) {
return false
}
return [this.$t("Completed"), "@", this.formatDate(item.completed_at)].join(" ")
},
formatOneFood: function (item) {
return item.food.name
},
formatOneDelayUntil: function (item) {
if (!item.delay_until || (item.delay_until && item.checked)) {
return false
}
return [this.$t("DelayUntil"), "-", this.formatDate(item.delay_until)].join(" ")
},
formatOneMealPlan: function (item) {
return item?.recipe_mealplan?.name ?? ""
},
formatOneRecipe: function (item) {
return item?.recipe_mealplan?.recipe_name ?? ""
},
formatOneNote: function (item) {
if (!item) {
item = this.entries[0]
}
return [item?.recipe_mealplan?.mealplan_note, item?.ingredient_note].filter(String)
},
formatOneCreatedBy: function (item) {
return [this.$t("Added_by"), item?.created_by.username, "@", this.formatDate(item.created_at)].join(" ")
},
openRecipeCard: function (e, item) {
this.genericAPI(this.Models.RECIPE, this.Actions.FETCH, {id: item.recipe_mealplan.recipe}).then((result) => {
let recipe = result.data
recipe.steps = undefined
this.recipe = true
this.$refs.recipe_card.open(e, recipe)
})
},
updateChecked: function (e, item) {
let update = undefined
if (!item) {
update = {entries: this.entries.map((x) => x.id), checked: !this.formatChecked}
} else {
update = {entries: [item], checked: !item.checked}
}
this.$emit("update-checkbox", update)
},
},
} }
</script> </script>
@ -344,34 +321,34 @@ export default {
/* border-bottom: 1px solid #000; /* …and with a border on the top */ /* border-bottom: 1px solid #000; /* …and with a border on the top */
/* } */ /* } */
.checkbox-control { .checkbox-control {
font-size: 0.6rem font-size: 0.6rem;
} }
.checkbox-control-mobile { .checkbox-control-mobile {
font-size: 1rem font-size: 1rem;
} }
.rotate { .rotate {
-moz-transition: all 0.25s linear; -moz-transition: all 0.25s linear;
-webkit-transition: all 0.25s linear; -webkit-transition: all 0.25s linear;
transition: all 0.25s linear; transition: all 0.25s linear;
} }
.rotated { .rotated {
-moz-transform: rotate(90deg); -moz-transform: rotate(90deg);
-webkit-transform: rotate(90deg); -webkit-transform: rotate(90deg);
transform: rotate(90deg); transform: rotate(90deg);
} }
.unit-badge-lg { .unit-badge-lg {
font-size: 1rem !important; font-size: 1rem !important;
font-weight: 500 !important; font-weight: 500 !important;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.dropdown-spacing { .dropdown-spacing {
padding-left: 0 !important; padding-left: 0 !important;
padding-right: 0 !important; padding-right: 0 !important;
} }
} }
</style> </style>

View File

@ -294,5 +294,7 @@
"ignore_shopping_help": "Never add food to the shopping list (e.g. water)", "ignore_shopping_help": "Never add food to the shopping list (e.g. water)",
"shopping_category_help": "Supermarkets can be ordered and filtered by Shopping Category according to the layout of the aisles.", "shopping_category_help": "Supermarkets can be ordered and filtered by Shopping Category according to the layout of the aisles.",
"food_recipe_help": "Linking a recipe here will include the linked recipe in any other recipe that use this food", "food_recipe_help": "Linking a recipe here will include the linked recipe in any other recipe that use this food",
"Foods":"Foods" "Foods": "Foods",
"review_shopping": "Review shopping entries before saving",
"view_recipe": "View Recipe"
} }