add to shopping from card context menu

This commit is contained in:
smilerz 2021-10-31 15:18:00 -05:00
parent 867e2d4fbf
commit 60d7e63da8
16 changed files with 279 additions and 250 deletions

View File

@ -673,7 +673,7 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer):
recipe_mealplan = ShoppingListRecipeSerializer(source='list_recipe', read_only=True)
amount = CustomDecimalField()
created_by = UserNameSerializer(read_only=True)
completed_at = serializers.DateTimeField(allow_null=True)
completed_at = serializers.DateTimeField(allow_null=True, required=False)
def get_fields(self, *args, **kwargs):
fields = super().get_fields(*args, **kwargs)

View File

@ -1,4 +1,5 @@
{% load i18n %}
{% comment %} TODO: Deprecate {% endcomment %}
<div class="modal" tabindex="-1" role="dialog" id="id_modal_cook_log">
<div class="modal-dialog" role="document">

View File

@ -1,5 +1,12 @@
{% extends "base.html" %} {% comment %} TODO: Deprecate {% endcomment %} {% load django_tables2 %} {% load crispy_forms_tags %} {% load static %} {% load i18n %} {% block title
%}{% trans "Shopping List" %}{% endblock %} {% block extra_head %} {% include 'include/vue_base.html' %}
{% extends "base.html" %}
{% comment %} TODO: Deprecate {% endcomment %}
{% load django_tables2 %}
{% load crispy_forms_tags %}
{% load static %}
{% load i18n %}
{% block title %}{% trans "Shopping List" %}{% endblock %}
{% block extra_head %}
{% include 'include/vue_base.html' %}
<link rel="stylesheet" href="{% static 'css/vue-multiselect-bs4.min.css' %}" />
<script src="{% static 'js/vue-multiselect.min.js' %}"></script>
@ -907,7 +914,7 @@
this.makeToast(gettext('Error'), gettext("There was an error loading a resource!") + err.bodyText, 'danger')
})
},
searchSupermarket: function (query) { //TODO move to central component
searchSupermarket: function (query) {
this.supermarkets_loading = true
this.$http.get("{% url 'api:supermarket-list' %}" + '?query=' + query + '&limit=10').then((response) => {
this.supermarkets = response.data

View File

@ -714,7 +714,6 @@
},
methods: {
makeToast: function (title, message, variant = null) {
//TODO remove duplicate function in favor of central one
this.$bvToast.toast(message, {
title: title,
variant: variant,

View File

@ -657,7 +657,6 @@ class RecipeViewSet(viewsets.ModelViewSet):
servings = request.data.get('servings', obj.servings)
list_recipe = request.data.get('list_recipe', None)
content = {'msg': _(f'{obj.name} was added to the shopping list.')}
# TODO: Consider if this should be a Recipe method
list_from_recipe(list_recipe=list_recipe, recipe=obj, ingredients=ingredients, servings=servings, space=request.space, created_by=request.user)
return Response(content, status=status.HTTP_204_NO_CONTENT)

View File

@ -90,7 +90,7 @@ export default {
left_counts: { max: 9999, current: 0 },
this_model: undefined,
model_menu: undefined,
this_action: undefined,
this_action: {},
this_recipe_param: undefined,
this_item: {},
this_target: {},
@ -193,6 +193,13 @@ export default {
this.getRecipes(param, source)
}
break
case "add-shopping":
//TODO: add modal to edit units and amount
this.addShopping(e.source)
break
case "add-onhand":
this.addOnhand(e.source)
break
}
},
finishAction: function (e) {
@ -232,26 +239,6 @@ export default {
let results = result.data?.results ?? result.data
if (results?.length) {
// let secondaryRequest = undefined;
// if (this['items_' + column]?.length < getConfig(this.this_model, this.Actions.LIST).config.pageSize.default * (params.page - 1)) {
// // the item list is smaller than it should be based on the site the user is own
// // this happens when an item is deleted (or merged)
// // to prevent issues insert the last item of the previous search page before loading the new results
// params.page = params.page - 1
// secondaryRequest = this.genericAPI(this.this_model, this.Actions.LIST, params).then((result) => {
// let prev_page_results = result.data?.results ?? result.data
// if (prev_page_results?.length) {
// results = [prev_page_results[prev_page_results.length]].concat(results)
//
// this['items_' + column] = this['items_' + column].concat(results) //TODO duplicate code, find some elegant workaround
// this[column + '_counts']['current'] = getConfig(this.this_model, this.Actions.LIST).config.pageSize.default * (params.page - 1) + results.length
// this[column + '_counts']['max'] = result.data?.count ?? 0
// }
// })
// } else {
//
// }
this["items_" + column] = this["items_" + column].concat(results)
this[column + "_counts"]["current"] = getConfig(this.this_model, this.Actions.LIST).config.pageSize.default * (params.page - 1) + results.length
this[column + "_counts"]["max"] = result.data?.count ?? 0
@ -276,6 +263,21 @@ export default {
// this creates a deep copy to make sure that columns stay independent
this.items_right = [{ ...item }].concat(this.destroyCard(item?.id, this.items_right))
},
// this currently assumes shopping is only applicable on FOOD model
addShopping: function (food) {
let api = new ApiApiFactory()
food.shopping = true
api.createShoppingListEntry({ food: food, amount: 1 }).then(() => {
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
this.refreshCard(food, this.items_left)
this.refreshCard({ ...food }, this.items_right)
})
},
addOnhand: function (item) {
item.on_hand = true
this.saveThis(item)
},
updateThis: function (item) {
this.refreshThis(item.id)
},
@ -300,7 +302,7 @@ export default {
})
.catch((err) => {
console.log(err)
makeToast(this.$t("Error"), err.bodyText, "danger")
StandardToasts.makeStandardToast(StandardToasts.FAIL_MOVE, err?.bodyText)
})
},
moveUpdateItem: function (source_id, target_id) {
@ -336,12 +338,12 @@ export default {
.then((result) => {
this.mergeUpdateItem(source_id, target_id)
// TODO make standard toast
makeToast(this.$t("Success"), "Succesfully merged resource", "success")
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_MERGE)
})
.catch((err) => {
//TODO error checking not working with OpenAPI methods
console.log("Error", err)
makeToast(this.$t("Error"), err.bodyText, "danger")
StandardToasts.makeStandardToast(StandardToasts.FAIL_MOVE, err?.bodyText)
})
if (automate) {
@ -425,7 +427,7 @@ export default {
},
clearState: function () {
this.show_modal = false
this.this_action = undefined
this.this_action = {}
this.this_item = undefined
this.this_target = undefined
},

View File

@ -1,40 +1,45 @@
<template>
<span>
<b-button class="btn text-decoration-none fas px-1 py-0 border-0" variant="link" v-b-popover.hover.html
:title="[onhand ? $t('FoodOnHand', {'food': item.name}) : $t('FoodNotOnHand', {'food': item.name})]"
:class="[onhand ? 'text-success fa-clipboard-check' : 'text-muted fa-clipboard' ]"
@click="toggleOnHand"
<span>
<b-button
class="btn text-decoration-none fas px-1 py-0 border-0"
variant="link"
v-b-popover.hover.html
:title="[onhand ? $t('FoodOnHand', { food: item.name }) : $t('FoodNotOnHand', { food: item.name })]"
:class="[onhand ? 'text-success fa-clipboard-check' : 'text-muted fa-clipboard']"
@click="toggleOnHand"
/>
</span>
</span>
</template>
<script>
import {ApiMixin} from "@/utils/utils";
import { ApiMixin } from "@/utils/utils"
export default {
name: 'OnHandBadge',
props: {
item: {type: Object}
},
mixins: [ ApiMixin ],
data() {
return {
onhand: false
}
},
mounted() {
this.onhand = this.item.on_hand
},
watch: {
},
methods: {
toggleOnHand() {
let params = {'id': this.item.id, 'on_hand': !this.onhand}
this.genericAPI(this.Models.FOOD, this.Actions.UPDATE, params).then(() => {
this.onhand = !this.onhand
})
}
}
name: "OnHandBadge",
props: {
item: { type: Object },
},
mixins: [ApiMixin],
data() {
return {
onhand: false,
}
},
mounted() {
this.onhand = this.item.on_hand
},
watch: {
"item.on_hand": function(newVal, oldVal) {
this.onhand = newVal
},
},
methods: {
toggleOnHand() {
let params = { id: this.item.id, on_hand: !this.onhand }
this.genericAPI(this.Models.FOOD, this.Actions.UPDATE, params).then(() => {
this.onhand = !this.onhand
})
},
},
}
</script>

View File

@ -1,94 +1,96 @@
<template>
<span>
<b-button class="btn text-decoration-none px-1 border-0" variant="link"
v-if="ShowBadge"
:id="`shopping${item.id}`"
@click="addShopping()">
<i class="fas"
v-b-popover.hover.html
:title="[shopping ? $t('RemoveFoodFromShopping', {'food': item.name}) : $t('AddFoodToShopping', {'food': item.name})]"
:class="[shopping ? 'text-success fa-shopping-cart' : 'text-muted fa-cart-plus']"
/>
</b-button>
<b-popover :target="`${ShowConfirmation}`" :ref="'shopping'+item.id" triggers="focus" placement="top" >
<template #title>{{DeleteConfirmation}}</template>
<b-row align-h="end">
<b-col cols="auto"><b-button class="btn btn-sm btn-info shadow-none px-1 border-0" @click="cancelDelete()">{{$t("Cancel")}}</b-button>
<b-button class="btn btn-sm btn-danger shadow-none px-1" @click="confirmDelete()">{{$t("Confirm")}}</b-button></b-col>
</b-row >
</b-popover>
</span>
<span>
<b-button class="btn text-decoration-none px-1 border-0" variant="link" v-if="ShowBadge" :id="`shopping${item.id}`" @click="addShopping()">
<i
class="fas"
v-b-popover.hover.html
:title="[shopping ? $t('RemoveFoodFromShopping', { food: item.name }) : $t('AddFoodToShopping', { food: item.name })]"
:class="[shopping ? 'text-success fa-shopping-cart' : 'text-muted fa-cart-plus']"
/>
</b-button>
<b-popover :target="`${ShowConfirmation}`" :ref="'shopping' + item.id" triggers="focus" placement="top">
<template #title>{{ DeleteConfirmation }}</template>
<b-row align-h="end">
<b-col cols="auto"
><b-button class="btn btn-sm btn-info shadow-none px-1 border-0" @click="cancelDelete()">{{ $t("Cancel") }}</b-button>
<b-button class="btn btn-sm btn-danger shadow-none px-1" @click="confirmDelete()">{{ $t("Confirm") }}</b-button></b-col
>
</b-row>
</b-popover>
</span>
</template>
<script>
import {ApiMixin, StandardToasts} from "@/utils/utils";
import { ApiMixin, StandardToasts } from "@/utils/utils"
export default {
name: 'ShoppingBadge',
props: {
item: {type: Object},
override_ignore: {type: Boolean, default: false}
},
mixins: [ ApiMixin ],
data() {
return {
shopping: false,
}
},
mounted() {
// let random = [true, false,]
this.shopping = this.item?.shopping //?? random[Math.floor(Math.random() * random.length)]
},
computed: {
ShowBadge() {
if (this.override_ignore) {
return true
} else {
return !this.item.ignore_shopping
}
name: "ShoppingBadge",
props: {
item: { type: Object },
override_ignore: { type: Boolean, default: false },
},
DeleteConfirmation() {
return this.$t('DeleteShoppingConfirm',{'food':this.item.name})
mixins: [ApiMixin],
data() {
return {
shopping: false,
}
},
mounted() {
// let random = [true, false,]
this.shopping = this.item?.shopping //?? random[Math.floor(Math.random() * random.length)]
},
computed: {
ShowBadge() {
if (this.override_ignore) {
return true
} else {
return !this.item.ignore_shopping
}
},
DeleteConfirmation() {
return this.$t("DeleteShoppingConfirm", { food: this.item.name })
},
ShowConfirmation() {
if (this.shopping) {
return "shopping" + this.item.id
} else {
return "NoDialog"
}
},
},
watch: {
"item.shopping": function(newVal, oldVal) {
this.shopping = newVal
},
},
methods: {
addShopping() {
if (this.shopping) {
return
} // if item already in shopping list, excution handled after confirmation
let params = {
id: this.item.id,
amount: 1,
}
this.genericAPI(this.Models.FOOD, this.Actions.SHOPPING, params).then((result) => {
this.shopping = true
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
})
},
cancelDelete() {
this.$refs["shopping" + this.item.id].$emit("close")
},
confirmDelete() {
let params = {
id: this.item.id,
_delete: "true",
}
this.genericAPI(this.Models.FOOD, this.Actions.SHOPPING, params).then(() => {
this.shopping = false
this.$refs["shopping" + this.item.id].$emit("close")
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
})
},
},
ShowConfirmation() {
if (this.shopping) {
return 'shopping' + this.item.id
} else {
return 'NoDialog'
}
}
},
watch: {
},
methods: {
addShopping() {
if (this.shopping) {return} // if item already in shopping list, excution handled after confirmation
let params = {
'id': this.item.id,
'amount': 1
}
this.genericAPI(this.Models.FOOD, this.Actions.SHOPPING, params).then((result) => {
this.shopping = true
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
})
},
cancelDelete() {
this.$refs['shopping' + this.item.id].$emit('close')
},
confirmDelete() {
let params = {
'id': this.item.id,
'_delete': 'true'
}
this.genericAPI(this.Models.FOOD, this.Actions.SHOPPING, params).then(() => {
this.shopping = false
this.$refs['shopping' + this.item.id].$emit('close')
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
})
}
}
}
</script>

View File

@ -1,42 +1,38 @@
<template>
<span>
<b-dropdown variant="link" toggle-class="text-decoration-none" right no-caret style="boundary:window">
<template #button-content>
<i class="fas fa-ellipsis-v" ></i>
</template>
<b-dropdown-item v-on:click="$emit('item-action', 'edit')" v-if="show_edit">
<i class="fas fa-pencil-alt fa-fw"></i> {{ $t('Edit') }}
</b-dropdown-item>
<span>
<b-dropdown variant="link" toggle-class="text-decoration-none" right no-caret style="boundary:window">
<template #button-content>
<i class="fas fa-ellipsis-v"></i>
</template>
<b-dropdown-item v-on:click="$emit('item-action', 'edit')" v-if="show_edit"> <i class="fas fa-pencil-alt fa-fw"></i> {{ $t("Edit") }} </b-dropdown-item>
<b-dropdown-item v-on:click="$emit('item-action', 'delete')" v-if="show_delete">
<i class="fas fa-trash-alt fa-fw"></i> {{ $t('Delete') }}
</b-dropdown-item>
<b-dropdown-item v-on:click="$emit('item-action', 'delete')" v-if="show_delete"> <i class="fas fa-trash-alt fa-fw"></i> {{ $t("Delete") }} </b-dropdown-item>
<b-dropdown-item v-on:click="$emit('item-action', 'add-shopping')" v-if="show_shopping">
<i class="fas fa-cart-plus fa-fw"></i> {{ $t("Add_to_Shopping") }}
</b-dropdown-item>
<b-dropdown-item v-on:click="$emit('item-action', 'add-onhand')" v-if="show_onhand"> <i class="fas fa-clipboard-check fa-fw"></i> {{ $t("OnHand") }} </b-dropdown-item>
<b-dropdown-item v-on:click="$emit('item-action', 'move')" v-if="show_move">
<i class="fas fa-expand-arrows-alt fa-fw"></i> {{ $t('Move') }}
</b-dropdown-item>
<b-dropdown-item v-on:click="$emit('item-action', 'move')" v-if="show_move"> <i class="fas fa-expand-arrows-alt fa-fw"></i> {{ $t("Move") }} </b-dropdown-item>
<b-dropdown-item v-if="show_merge" v-on:click="$emit('item-action', 'merge')">
<i class="fas fa-compress-arrows-alt fa-fw"></i> {{ $t('Merge') }}
</b-dropdown-item>
<b-dropdown-item v-if="show_merge" v-on:click="$emit('item-action', 'merge')"> <i class="fas fa-compress-arrows-alt fa-fw"></i> {{ $t("Merge") }} </b-dropdown-item>
<b-dropdown-item v-if="show_merge" v-on:click="$emit('item-action', 'merge-automate')">
<i class="fas fa-robot fa-fw"></i> {{$t('Merge')}} & {{$t('Automate')}} <b-badge v-b-tooltip.hover :title="$t('warning_feature_beta')">BETA</b-badge>
</b-dropdown-item>
</b-dropdown>
</span>
<b-dropdown-item v-if="show_merge" v-on:click="$emit('item-action', 'merge-automate')">
<i class="fas fa-robot fa-fw"></i> {{ $t("Merge") }} & {{ $t("Automate") }} <b-badge v-b-tooltip.hover :title="$t('warning_feature_beta')">BETA</b-badge>
</b-dropdown-item>
</b-dropdown>
</span>
</template>
<script>
export default {
name: 'GenericContextMenu',
props: {
show_edit: {type: Boolean, default: true},
show_delete: {type: Boolean, default: true},
show_move: {type: Boolean, default: false},
show_merge: {type: Boolean, default: false},
}
name: "GenericContextMenu",
props: {
show_edit: { type: Boolean, default: true },
show_delete: { type: Boolean, default: true },
show_move: { type: Boolean, default: false },
show_merge: { type: Boolean, default: false },
show_shopping: { type: Boolean, default: false },
show_onhand: { type: Boolean, default: false },
},
}
</script>

View File

@ -24,7 +24,7 @@
<b-card-text class=" h-100 my-0 d-flex flex-column" style="text-overflow: ellipsis">
<h5 class="m-0 mt-1 text-truncate">{{ item[title] }}</h5>
<div class="m-0 text-truncate">{{ item[subtitle] }}</div>
<!-- <span>{{this_item[itemTags.field]}}</span> -->
<generic-pill v-for="x in itemTags" :key="x.field" :item_list="item[x.field]" :label="x.label" :color="x.color" />
<generic-ordered-pill
v-for="x in itemOrderedTags"
@ -66,6 +66,8 @@
class="p-0"
:show_merge="useMerge"
:show_move="useMove"
:show_shopping="useShopping"
:show_onhand="useOnhand"
@item-action="$emit('item-action', { action: $event, source: item })"
>
</generic-context-menu>
@ -126,8 +128,6 @@
<b-list-group-item action v-on:click="closeMenu()">
<i class="fas fa-times fa-fw"></i> <b>{{ $t("Cancel") }}</b>
</b-list-group-item>
<!-- TODO add to shopping list -->
<!-- TODO toggle onhand -->
</b-list-group>
</div>
</template>
@ -185,6 +185,12 @@ export default {
useMerge: function() {
return this.model?.["merge"] ?? false ? true : false
},
useShopping: function() {
return this.model?.["shop"] ?? false ? true : false
},
useOnhand: function() {
return this.model?.["onhand"] ?? false ? true : false
},
useDrag: function() {
return this.useMove || this.useMerge
},

View File

@ -1,72 +1,75 @@
<template>
<draggable v-if="itemList" v-model="this_list" tag="span" group="ordered_items" z-index="500"
@change="orderChanged">
<span :key="k.id" v-for="k in itemList" class="pl-1">
<b-badge squared :variant="color"><i class="fas fa-grip-lines-vertical text-muted"></i><span class="ml-1">{{thisLabel(k)}}</span></b-badge>
</span>
<draggable v-if="itemList" v-model="this_list" tag="span" group="ordered_items" z-index="500" @change="orderChanged">
<span :key="k.id" v-for="k in itemList" class="pl-1">
<b-badge squared :variant="color"
><i class="fas fa-grip-lines-vertical text-muted"></i><span class="ml-1">{{ thisLabel(k) }}</span></b-badge
>
</span>
</draggable>
</template>
<script>
// you can't use this component with a horizontal card that is also draggable
import draggable from 'vuedraggable'
import draggable from "vuedraggable"
export default {
name: 'GenericOrderedPill',
components: {draggable},
props: {
item_list: {required: true, type: Array},
label: {type: String, default: 'name'},
color: {type: String, default: 'light'},
field: {type: String, required: true},
item: {type: Object},
},
data() {
return {
this_list: [],
}
},
computed: {
itemList: function() {
if(Array.isArray(this.this_list)) {
return this.this_list
} else if (!this.this_list?.name) {
return false
} else {
return [this.this_list]
}
name: "GenericOrderedPill",
components: { draggable },
props: {
item_list: {
type: Array,
default() {
return []
},
},
label: { type: String, default: "name" },
color: { type: String, default: "light" },
field: { type: String, required: true },
item: { type: Object },
},
},
mounted() {
this.this_list = this.item_list
},
watch: {
'item_list': function (newVal) {
this.this_list = newVal
}
},
methods: {
thisLabel: function (item) {
let fields = this.label.split('::')
let value = item
fields.forEach(x => {
value = value[x]
});
return value
data() {
return {
this_list: [],
}
},
orderChanged: function(e){
let order = 0
this.this_list.forEach(x => {
x['order'] = order
order++
})
let new_order = {...this.item}
new_order[this.field] = this.this_list
this.$emit('finish-action', {'action':'save','form_data': new_order })
computed: {
itemList: function() {
if (Array.isArray(this.this_list)) {
return this.this_list
} else if (!this.this_list?.name) {
return false
} else {
return [this.this_list]
}
},
},
mounted() {
this.this_list = this.item_list
},
watch: {
item_list: function(newVal) {
this.this_list = newVal
},
},
methods: {
thisLabel: function(item) {
let fields = this.label.split("::")
let value = item
fields.forEach((x) => {
value = value[x]
})
return value
},
orderChanged: function(e) {
let order = 0
this.this_list.forEach((x) => {
x["order"] = order
order++
})
let new_order = { ...this.item }
new_order[this.field] = this.this_list
this.$emit("finish-action", { action: "save", form_data: new_order })
},
},
}
}
</script>

View File

@ -83,7 +83,6 @@ export default {
show: function () {
if (this.show) {
this.form = getForm(this.model, this.action, this.item1, this.item2)
// TODO: I don't know how to generalize this, but Food needs default values to drive inheritance
if (this.form?.form_function) {
this.form = formFunctions[this.form.form_function](this.form)
}

View File

@ -82,6 +82,9 @@ export default {
footer_text: String,
footer_icon: String,
},
mounted() {
console.log(this.recipe)
},
computed: {
detailed: function() {
return this.recipe?.steps !== undefined

View File

@ -65,6 +65,8 @@ export class Models {
paginated: true,
move: true,
merge: true,
shop: true,
onhand: true,
badges: {
linked_recipe: true,
on_hand: true,

View File

@ -2160,7 +2160,7 @@ export interface ShoppingListEntries {
* @type {string}
* @memberof ShoppingListEntries
*/
completed_at: string | null;
completed_at?: string | null;
/**
*
* @type {string}
@ -2251,7 +2251,7 @@ export interface ShoppingListEntry {
* @type {string}
* @memberof ShoppingListEntry
*/
completed_at: string | null;
completed_at?: string | null;
/**
*
* @type {string}
@ -3014,10 +3014,10 @@ export interface UserPreference {
food_ignore_default?: string;
/**
*
* @type {number}
* @type {string}
* @memberof UserPreference
*/
default_delay?: number;
default_delay?: string;
/**
*
* @type {boolean}
@ -3036,6 +3036,12 @@ export interface UserPreference {
* @memberof UserPreference
*/
shopping_share?: Array<number>;
/**
*
* @type {number}
* @memberof UserPreference
*/
shopping_recent_days?: number;
}
/**

View File

@ -89,7 +89,6 @@ module.exports = {
},
},
},
// TODO make this conditional on .env DEBUG = FALSE
config.optimization.minimize(false)
)