Fix after rebase

This commit is contained in:
smilerz
2021-10-28 07:35:30 -05:00
parent f16e457d14
commit 10a33add75
73 changed files with 5579 additions and 2524 deletions

23
vue/.gitignore vendored
View File

@@ -1,23 +0,0 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,8 @@
.gitignore
.npmignore
api.ts
base.ts
common.ts
configuration.ts
git_push.sh
index.ts

View File

@@ -0,0 +1 @@
5.2.1

View File

@@ -1,195 +0,0 @@
<template>
<div id="app" style="margin-bottom: 4vh" v-if="this_model">
<generic-modal-form v-if="this_model"
:model="this_model"
:action="this_action"
:item1="this_item"
:item2="this_target"
:show="show_modal"
@finish-action="finishAction"/>
<div class="row">
<div class="col-md-2 d-none d-md-block">
</div>
<div class="col-xl-8 col-12">
<div class="container-fluid d-flex flex-column flex-grow-1">
<div class="row">
<div class="col-md-6" style="margin-top: 1vh">
<h3>
<!-- <model-menu/> Replace with a List Menu or a Checklist Menu? -->
<span>{{ this.this_model.name }}</span>
<span><b-button variant="link" @click="startAction({'action':'new'})"><i
class="fas fa-plus-circle fa-2x"></i></b-button></span>
</h3>
</div>
</div>
<div class="row">
<div class="col col-md-12">
this is where shopping list items go
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import Vue from 'vue'
import {BootstrapVue} from 'bootstrap-vue'
import 'bootstrap-vue/dist/bootstrap-vue.css'
import {ApiMixin} from "@/utils/utils";
import {StandardToasts, ToastMixin} from "@/utils/utils";
import GenericModalForm from "@/components/Modals/GenericModalForm";
Vue.use(BootstrapVue)
export default {
// TODO ApiGenerator doesn't capture and share error information - would be nice to share error details when available
// or i'm capturing it incorrectly
name: 'ModelListView',
mixins: [ApiMixin, ToastMixin],
components: {GenericModalForm},
data() {
return {
// this.Models and this.Actions inherited from ApiMixin
items: [],
this_model: undefined,
model_menu: undefined,
this_action: undefined,
this_item: {},
show_modal: false,
}
},
mounted() {
// value is passed from lists.py
let model_config = JSON.parse(document.getElementById('model_config').textContent)
this.this_model = this.Models[model_config?.model]
},
methods: {
// this.genericAPI inherited from ApiMixin
startAction: function (e, param) {
let source = e?.source ?? {}
this.this_item = source
// remove recipe from shopping list
// mark on-hand
// mark puchased
// edit shopping category on food
// delete food from shopping list
// add food to shopping list
// add other to shopping list
// edit unit conversion
// edit purchaseable unit
switch (e.action) {
case 'delete':
this.this_action = this.Actions.DELETE
this.show_modal = true
break;
case 'new':
this.this_action = this.Actions.CREATE
this.show_modal = true
break;
case 'edit':
this.this_item = e.source
this.this_action = this.Actions.UPDATE
this.show_modal = true
break;
}
},
finishAction: function (e) {
let update = undefined
switch (e?.action) {
case 'save':
this.saveThis(e.form_data)
break;
}
if (e !== 'cancel') {
switch (this.this_action) {
case this.Actions.DELETE:
this.deleteThis(this.this_item.id)
break;
case this.Actions.CREATE:
this.saveThis(e.form_data)
break;
case this.Actions.UPDATE:
update = e.form_data
update.id = this.this_item.id
this.saveThis(update)
break;
case this.Actions.MERGE:
this.mergeThis(this.this_item, e.form_data.target, false)
break;
case this.Actions.MOVE:
this.moveThis(this.this_item.id, e.form_data.target.id)
break;
}
}
this.clearState()
},
getItems: function (params) {
this.genericAPI(this.this_model, this.Actions.LIST, params).then((results) => {
if (results?.length) {
this.items = results.data
} else {
console.log('no data returned')
}
}).catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
})
},
getThis: function (id) {
return this.genericAPI(this.this_model, this.Actions.FETCH, {'id': id})
},
saveThis: function (thisItem) {
if (!thisItem?.id) { // if there is no item id assume it's a new item
this.genericAPI(this.this_model, this.Actions.CREATE, thisItem).then((result) => {
// this.items = result.data - refresh the list here
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
}).catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
})
} else {
this.genericAPI(this.this_model, this.Actions.UPDATE, thisItem).then((result) => {
// this.refreshThis(thisItem.id) refresh the list here
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
}).catch((err) => {
console.log(err, err.response)
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
})
}
},
getRecipe: function (item) {
// change to get pop up card? maybe same for unit and food?
},
deleteThis: function (id) {
this.genericAPI(this.this_model, this.Actions.DELETE, {'id': id}).then((result) => {
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
}).catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE)
})
},
clearState: function () {
this.show_modal = false
this.this_action = undefined
this.this_item = undefined
}
}
}
</script>
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>
<style>
</style>

View File

@@ -1,18 +0,0 @@
import Vue from 'vue'
import App from './ChecklistView'
import i18n from '@/i18n'
Vue.config.productionTip = false
// TODO move this and other default stuff to centralized JS file (verify nothing breaks)
let publicPath = localStorage.STATIC_URL + 'vue/'
if (process.env.NODE_ENV === 'development') {
publicPath = 'http://localhost:8080/'
}
export default __webpack_public_path__ = publicPath // eslint-disable-line
new Vue({
i18n,
render: h => h(App),
}).$mount('#app')

View File

@@ -61,10 +61,10 @@ import Vue from 'vue'
import {BootstrapVue} from 'bootstrap-vue'
import 'bootstrap-vue/dist/bootstrap-vue.css'
import {ApiApiFactory} from "@/utils/openapi/api";
import CookbookSlider from "@/components/CookbookSlider";
import LoadingSpinner from "@/components/LoadingSpinner";
import {StandardToasts} from "@/utils/utils";
import {ApiApiFactory} from "@/utils/openapi/api.ts";
import CookbookSlider from "../../components/CookbookSlider";
import LoadingSpinner from "../../components/LoadingSpinner";
import {StandardToasts} from "../../utils/utils";
Vue.use(BootstrapVue)

File diff suppressed because it is too large Load Diff

View File

@@ -25,14 +25,7 @@
</h3>
</div>
<div class="col-md-3" style="position: relative; margin-top: 1vh">
<b-form-checkbox
v-model="show_split"
name="check-button"
v-if="paginated"
class="shadow-none"
style="position: relative; top: 50%; transform: translateY(-50%)"
switch
>
<b-form-checkbox v-model="show_split" name="check-button" v-if="paginated" class="shadow-none" style="position: relative; top: 50%; transform: translateY(-50%)" switch>
{{ $t("show_split_screen") }}
</b-form-checkbox>
</div>
@@ -42,46 +35,19 @@
<div class="col" :class="{ 'col-md-6': show_split }">
<!-- model isn't paginated and loads in one API call -->
<div v-if="!paginated">
<generic-horizontal-card
v-for="i in items_left"
v-bind:key="i.id"
:item="i"
:model="this_model"
@item-action="startAction($event, 'left')"
@finish-action="finishAction"
/>
<generic-horizontal-card v-for="i in items_left" v-bind:key="i.id" :item="i" :model="this_model" @item-action="startAction($event, 'left')" @finish-action="finishAction" />
</div>
<!-- model is paginated and needs managed -->
<generic-infinite-cards v-if="paginated" :card_counts="left_counts" :scroll="show_split" @search="getItems($event, 'left')" @reset="resetList('left')">
<template v-slot:cards>
<generic-horizontal-card
v-for="i in items_left"
v-bind:key="i.id"
:item="i"
:model="this_model"
@item-action="startAction($event, 'left')"
@finish-action="finishAction"
/>
<generic-horizontal-card v-for="i in items_left" v-bind:key="i.id" :item="i" :model="this_model" @item-action="startAction($event, 'left')" @finish-action="finishAction" />
</template>
</generic-infinite-cards>
</div>
<div class="col col-md-6" v-if="show_split">
<generic-infinite-cards
v-if="this_model"
:card_counts="right_counts"
:scroll="show_split"
@search="getItems($event, 'right')"
@reset="resetList('right')"
>
<generic-infinite-cards v-if="this_model" :card_counts="right_counts" :scroll="show_split" @search="getItems($event, 'right')" @reset="resetList('right')">
<template v-slot:cards>
<generic-horizontal-card
v-for="i in items_right"
v-bind:key="i.id"
:item="i"
:model="this_model"
@item-action="startAction($event, 'right')"
@finish-action="finishAction"
/>
<generic-horizontal-card v-for="i in items_right" v-bind:key="i.id" :item="i" :model="this_model" @item-action="startAction($event, 'right')" @finish-action="finishAction" />
</template>
</generic-infinite-cards>
</div>
@@ -98,13 +64,12 @@ import { BootstrapVue } from "bootstrap-vue"
import "bootstrap-vue/dist/bootstrap-vue.css"
import { CardMixin, ApiMixin, getConfig } from "@/utils/utils"
import { StandardToasts, ToastMixin } from "@/utils/utils"
import { CardMixin, ApiMixin, getConfig, StandardToasts, getUserPreference, makeToast } from "@/utils/utils"
import GenericInfiniteCards from "@/components/GenericInfiniteCards"
import GenericHorizontalCard from "@/components/GenericHorizontalCard"
import GenericModalForm from "@/components/Modals/GenericModalForm"
import ModelMenu from "@/components/ModelMenu"
import ModelMenu from "@/components/ContextMenu/ModelMenu"
import { ApiApiFactory } from "@/utils/openapi/api"
//import StorageQuota from "@/components/StorageQuota";
@@ -114,13 +79,8 @@ export default {
// TODO ApiGenerator doesn't capture and share error information - would be nice to share error details when available
// or i'm capturing it incorrectly
name: "ModelListView",
mixins: [CardMixin, ApiMixin, ToastMixin],
components: {
GenericHorizontalCard,
GenericModalForm,
GenericInfiniteCards,
ModelMenu,
},
mixins: [CardMixin, ApiMixin],
components: { GenericHorizontalCard, GenericModalForm, GenericInfiniteCards, ModelMenu },
data() {
return {
// this.Models and this.Actions inherited from ApiMixin
@@ -236,6 +196,7 @@ export default {
}
},
finishAction: function (e) {
let update = undefined
switch (e?.action) {
case "save":
this.saveThis(e.form_data)
@@ -263,7 +224,7 @@ export default {
}
this.clearState()
},
getItems: function (params, col) {
getItems: function (params = {}, col) {
let column = col || "left"
params.options = { query: { extended: 1 } } // returns extended values in API response
this.genericAPI(this.this_model, this.Actions.LIST, params)

View File

@@ -629,7 +629,6 @@ export default {
apiFactory.updateRecipe(this.recipe_id, this.recipe,
{}).then((response) => {
console.log(response)
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
this.recipe_changed = false
if (view_after) {

View File

@@ -1,90 +1,61 @@
<template>
<div id="app">
<template v-if="loading">
<loading-spinner></loading-spinner>
</template>
<div id="app">
<template v-if="loading">
<loading-spinner></loading-spinner>
</template>
<div v-if="!loading">
<div class="row">
<div class="col-12" style="text-align: center">
<h3>{{ recipe.name }}</h3>
</div>
</div>
<div class="row text-center">
<div class="col col-md-12">
<recipe-rating :recipe="recipe"></recipe-rating>
<last-cooked :recipe="recipe" class="mt-2"></last-cooked>
</div>
</div>
<div class="my-auto">
<div class="col-12" style="text-align: center">
<i>{{ recipe.description }}</i>
</div>
</div>
<div style="text-align: center">
<keywords-component :recipe="recipe"></keywords-component>
</div>
<hr/>
<div class="row">
<div class="col col-md-3">
<div class="row d-flex" style="padding-left: 16px">
<div class="my-auto" style="padding-right: 4px">
<i class="fas fa-user-clock fa-2x text-primary"></i>
<div v-if="!loading">
<div class="row">
<div class="col-12" style="text-align: center">
<h3>{{ recipe.name }}</h3>
</div>
</div>
<div class="my-auto" style="padding-right: 4px">
<span class="text-primary"><b>{{ $t('Preparation') }}</b></span><br/>
{{ recipe.working_time }} {{ $t('min') }}
</div>
</div>
</div>
<div class="col col-md-3">
<div class="row d-flex">
<div class="my-auto" style="padding-right: 4px">
<i class="far fa-clock fa-2x text-primary"></i>
<div class="row text-center">
<div class="col col-md-12">
<recipe-rating :recipe="recipe"></recipe-rating>
<last-cooked :recipe="recipe" class="mt-2"></last-cooked>
</div>
</div>
<div class="my-auto" style="padding-right: 4px">
<span class="text-primary"><b>{{ $t('Waiting') }}</b></span><br/>
{{ recipe.waiting_time }} {{ $t('min') }}
</div>
</div>
</div>
<div class="col col-md-4 col-10 mt-2 mt-md-0 mt-lg-0 mt-xl-0">
<div class="row d-flex" style="padding-left: 16px">
<div class="my-auto" style="padding-right: 4px">
<i class="fas fa-pizza-slice fa-2x text-primary"></i>
<div class="my-auto">
<div class="col-12" style="text-align: center">
<i>{{ recipe.description }}</i>
</div>
</div>
<div class="my-auto" style="padding-right: 4px">
<input
style="text-align: right; border-width:0px;border:none; padding:0px; padding-left: 0.5vw; padding-right: 8px; max-width: 80px"
value="1" maxlength="3" min="0"
type="number" class="form-control form-control-lg" v-model.number="servings"/>
</div>
<div class="my-auto ">
<span class="text-primary"><b><template v-if="recipe.servings_text === ''">{{ $t('Servings') }}</template><template
v-else>{{ recipe.servings_text }}</template></b></span>
</div>
</div>
</div>
<div class="col col-md-2 col-2 my-auto" style="text-align: right; padding-right: 1vw">
<recipe-context-menu v-bind:recipe="recipe" :servings="servings"></recipe-context-menu>
</div>
</div>
<hr/>
<div style="text-align: center">
<keywords :recipe="recipe"></keywords>
</div>
<div class="row">
<div class="col-md-6 order-md-1 col-sm-12 order-sm-2 col-12 order-2" v-if="recipe && ingredient_count > 0">
<div class="card border-primary">
<div class="card-body">
<div class="row">
<div class="col col-md-8">
<h4 class="card-title"><i class="fas fa-pepper-hot"></i> {{ $t('Ingredients') }}</h4>
<hr />
<div class="row">
<div class="col col-md-3">
<div class="row d-flex" style="padding-left: 16px">
<div class="my-auto" style="padding-right: 4px">
<i class="fas fa-user-clock fa-2x text-primary"></i>
</div>
<div class="my-auto" style="padding-right: 4px">
<span class="text-primary"
><b>{{ $t("Preparation") }}</b></span
><br />
{{ recipe.working_time }} {{ $t("min") }}
</div>
</div>
</div>
<div class="col col-md-3">
<div class="row d-flex">
<div class="my-auto" style="padding-right: 4px">
<i class="far fa-clock fa-2x text-primary"></i>
</div>
<div class="my-auto" style="padding-right: 4px">
<span class="text-primary"
><b>{{ $t("Waiting") }}</b></span
><br />
{{ recipe.waiting_time }} {{ $t("min") }}
</div>
</div>
</div>
</div>
<br/>
@@ -94,184 +65,186 @@
<template v-if="s.show_as_header && s.name !== '' && s.ingredients.length > 0">
<b v-bind:key="s.id">{{s.name}}</b>
</template>
<table class="table table-sm">
<table class="table table-sm">
<template v-for="i in s.ingredients" :key="i.id">
<ingredient-component :ingredient="i" :ingredient_factor="ingredient_factor"
@checked-state-changed="updateIngredientCheckedState"></ingredient-component>
</template>
</table>
</div>
</div>
</div>
<div class="col col-md-2 col-2 my-auto" style="text-align: right; padding-right: 1vw">
<recipe-context-menu v-bind:recipe="recipe" :servings="servings"></recipe-context-menu>
</div>
</template>
</div>
</div>
</div>
<hr />
<div class="col-12 order-1 col-sm-12 order-sm-1 col-md-6 order-md-2">
<div class="row">
<div class="col-12">
<div class="row">
<div class="col-md-8 order-md-1 col-sm-12 order-sm-2 col-12 order-2" v-if="recipe && ingredient_count > 0">
<ingredients-card
:steps="recipe.steps"
:recipe="recipe.id"
:ingredient_factor="ingredient_factor"
:servings="servings"
:header="true"
@checked-state-changed="updateIngredientCheckedState"
/>
</div>
<img class="img img-fluid rounded" :src="recipe.image" style="max-height: 30vh;"
:alt="$t( 'Recipe_Image')" v-if="recipe.image !== null">
<div class="col-12 order-1 col-sm-12 order-sm-1 col-md-4 order-md-2">
<div class="row">
<div class="col-12">
<img class="img img-fluid rounded" :src="recipe.image" style="max-height: 30vh" :alt="$t('Recipe_Image')" v-if="recipe.image !== null" />
</div>
</div>
<div class="row" style="margin-top: 2vh; margin-bottom: 2vh">
<div class="col-12">
<Nutrition :recipe="recipe" :ingredient_factor="ingredient_factor"></Nutrition>
</div>
</div>
</div>
</div>
</div>
<div class="row" style="margin-top: 2vh; margin-bottom: 2vh">
<div class="col-12">
<Nutrition-component :recipe="recipe" :ingredient_factor="ingredient_factor"></Nutrition-component>
<template v-if="!recipe.internal">
<div v-if="recipe.file_path.includes('.pdf')">
<PdfViewer :recipe="recipe"></PdfViewer>
</div>
<div v-if="recipe.file_path.includes('.png') || recipe.file_path.includes('.jpg') || recipe.file_path.includes('.jpeg') || recipe.file_path.includes('.gif')">
<ImageViewer :recipe="recipe"></ImageViewer>
</div>
</template>
<div v-for="(s, index) in recipe.steps" v-bind:key="s.id" style="margin-top: 1vh">
<Step
:recipe="recipe"
:step="s"
:ingredient_factor="ingredient_factor"
:index="index"
:start_time="start_time"
@update-start-time="updateStartTime"
@checked-state-changed="updateIngredientCheckedState"
></Step>
</div>
</div>
</div>
<add-recipe-to-book :recipe="recipe"></add-recipe-to-book>
</div>
<template v-if="!recipe.internal">
<div v-if="recipe.file_path.includes('.pdf')">
<PdfViewer :recipe="recipe"></PdfViewer>
<div class="row text-center d-print-none" style="margin-top: 3vh; margin-bottom: 3vh" v-if="share_uid !== 'None'">
<div class="col col-md-12">
<a :href="resolveDjangoUrl('view_report_share_abuse', share_uid)">{{ $t("Report Abuse") }}</a>
</div>
</div>
<div
v-if="recipe.file_path.includes('.png') || recipe.file_path.includes('.jpg') || recipe.file_path.includes('.jpeg') || recipe.file_path.includes('.gif')">
<ImageViewer :recipe="recipe"></ImageViewer>
</div>
</template>
<div v-for="(s, index) in recipe.steps" v-bind:key="s.id" style="margin-top: 1vh">
<step-component :recipe="recipe" :step="s" :ingredient_factor="ingredient_factor" :index="index" :start_time="start_time"
@update-start-time="updateStartTime" @checked-state-changed="updateIngredientCheckedState"></step-component>
</div>
</div>
<add-recipe-to-book :recipe="recipe"></add-recipe-to-book>
<div class="row text-center d-print-none" style="margin-top: 3vh; margin-bottom: 3vh" v-if="share_uid !== 'None'">
<div class="col col-md-12">
<a :href="resolveDjangoUrl('view_report_share_abuse', share_uid)">{{ $t('Report Abuse') }}</a>
</div>
</div>
</div>
</template>
<script>
import Vue from 'vue'
import {BootstrapVue} from 'bootstrap-vue'
import 'bootstrap-vue/dist/bootstrap-vue.css'
import Vue from "vue"
import { BootstrapVue } from "bootstrap-vue"
import "bootstrap-vue/dist/bootstrap-vue.css"
import {apiLoadRecipe} from "@/utils/api";
import { apiLoadRecipe } from "@/utils/api"
import Step from "@/components/StepComponent";
import RecipeContextMenu from "@/components/RecipeContextMenu";
import {ResolveUrlMixin, ToastMixin} from "@/utils/utils";
import Ingredient from "@/components/IngredientComponent";
import Step from "@/components/Step"
import RecipeContextMenu from "@/components/ContextMenu/RecipeContextMenu"
import { ResolveUrlMixin, ToastMixin } from "@/utils/utils"
import IngredientsCard from "@/components/IngredientsCard"
import PdfViewer from "@/components/PdfViewer";
import ImageViewer from "@/components/ImageViewer";
import Nutrition from "@/components/NutritionComponent";
import PdfViewer from "@/components/PdfViewer"
import ImageViewer from "@/components/ImageViewer"
import Nutrition from "@/components/Nutrition"
import moment from 'moment'
import Keywords from "@/components/KeywordsComponent";
import LoadingSpinner from "@/components/LoadingSpinner";
import AddRecipeToBook from "@/components/AddRecipeToBook";
import RecipeRating from "@/components/RecipeRating";
import LastCooked from "@/components/LastCooked";
import IngredientComponent from "@/components/IngredientComponent";
import StepComponent from "@/components/StepComponent";
import KeywordsComponent from "@/components/KeywordsComponent";
import NutritionComponent from "@/components/NutritionComponent";
import moment from "moment"
import Keywords from "@/components/Keywords"
import LoadingSpinner from "@/components/LoadingSpinner"
import AddRecipeToBook from "@/components/Modals/AddRecipeToBook"
import RecipeRating from "@/components/RecipeRating"
import LastCooked from "@/components/LastCooked"
Vue.prototype.moment = moment
Vue.use(BootstrapVue)
export default {
name: 'RecipeView',
mixins: [
ResolveUrlMixin,
ToastMixin,
],
components: {
LastCooked,
RecipeRating,
PdfViewer,
ImageViewer,
IngredientComponent,
StepComponent,
RecipeContextMenu,
NutritionComponent,
KeywordsComponent,
LoadingSpinner,
AddRecipeToBook,
},
computed: {
ingredient_factor: function () {
return this.servings / this.recipe.servings
name: "RecipeView",
mixins: [ResolveUrlMixin, ToastMixin],
components: {
LastCooked,
RecipeRating,
PdfViewer,
ImageViewer,
IngredientsCard,
Step,
RecipeContextMenu,
Nutrition,
Keywords,
LoadingSpinner,
AddRecipeToBook,
},
},
data() {
return {
loading: true,
recipe: undefined,
ingredient_count: 0,
servings: 1,
start_time: "",
share_uid: window.SHARE_UID
}
},
mounted() {
this.loadRecipe(window.RECIPE_ID)
this.$i18n.locale = window.CUSTOM_LOCALE
},
methods: {
loadRecipe: function (recipe_id) {
apiLoadRecipe(recipe_id).then(recipe => {
if (window.USER_SERVINGS !== 0) {
recipe.servings = window.USER_SERVINGS
}
this.servings = recipe.servings
let total_time = 0
for (let step of recipe.steps) {
this.ingredient_count += step.ingredients.length
for (let ingredient of step.ingredients) {
this.$set(ingredient, 'checked', false)
}
step.time_offset = total_time
total_time += step.time
}
// set start time only if there are any steps with timers (otherwise no timers are rendered)
if (total_time > 0) {
this.start_time = moment().format('yyyy-MM-DDTHH:mm')
}
this.recipe = recipe
this.loading = false
})
computed: {
ingredient_factor: function () {
return this.servings / this.recipe.servings
},
},
updateStartTime: function (e) {
this.start_time = e
},
updateIngredientCheckedState: function (e) {
for (let step of this.recipe.steps) {
for (let ingredient of step.ingredients) {
if (ingredient.id === e.id) {
this.$set(ingredient, 'checked', !ingredient.checked)
}
data() {
return {
loading: true,
recipe: undefined,
ingredient_count: 0,
servings: 1,
start_time: "",
share_uid: window.SHARE_UID,
}
}
},
}
mounted() {
this.loadRecipe(window.RECIPE_ID)
this.$i18n.locale = window.CUSTOM_LOCALE
},
methods: {
loadRecipe: function (recipe_id) {
apiLoadRecipe(recipe_id).then((recipe) => {
if (window.USER_SERVINGS !== 0) {
recipe.servings = window.USER_SERVINGS
}
this.servings = recipe.servings
let total_time = 0
for (let step of recipe.steps) {
this.ingredient_count += step.ingredients.length
for (let ingredient of step.ingredients) {
this.$set(ingredient, "checked", false)
}
step.time_offset = total_time
total_time += step.time
}
// set start time only if there are any steps with timers (otherwise no timers are rendered)
if (total_time > 0) {
this.start_time = moment().format("yyyy-MM-DDTHH:mm")
}
this.recipe = recipe
this.loading = false
})
},
updateStartTime: function (e) {
this.start_time = e
},
updateIngredientCheckedState: function (e) {
for (let step of this.recipe.steps) {
for (let ingredient of step.ingredients) {
if (ingredient.id === e.id) {
this.$set(ingredient, "checked", !ingredient.checked)
}
}
}
},
},
}
</script>
<style>
</style>
<style></style>

View File

@@ -0,0 +1,805 @@
<template>
<div id="app" style="margin-bottom: 4vh">
<b-alert :show="!online" dismissible class="small float-up" variant="warning">{{ $t("OfflineAlert") }}</b-alert>
<div class="row float-top">
<div class="offset-md-10 col-md-2 no-gutter text-right">
<b-button variant="link" class="px-0">
<i class="btn fas fa-plus-circle fa-lg px-0" @click="entrymode = !entrymode" :class="entrymode ? 'text-success' : 'text-muted'" />
</b-button>
<b-button variant="link" id="id_filters_button" class="px-0">
<i class="btn fas fa-filter text-decoration-none fa-lg px-0" :class="filterApplied ? 'text-danger' : 'text-muted'" />
</b-button>
</div>
</div>
<b-tabs content-class="mt-3">
<!-- shopping list tab -->
<b-tab :title="$t('ShoppingList')" active>
<template #title> <b-spinner v-if="loading" type="border" small></b-spinner> {{ $t("ShoppingList") }} </template>
<div class="container">
<div class="row">
<div class="col col-md-12">
<div role="tablist" v-if="items && items.length > 0">
<div class="row justify-content-md-center w-75" v-if="entrymode">
<div class="col col-md-2 "><b-form-input min="1" type="number" :description="$t('Amount')" v-model="new_item.amount"></b-form-input></div>
<div class="col col-md-3">
<generic-multiselect
@change="new_item.unit = $event.val"
:model="Models.UNIT"
:multiple="false"
:allow_create="false"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
:placeholder="$t('Unit')"
/>
</div>
<div class="col col-md-4">
<generic-multiselect
@change="new_item.food = $event.val"
:model="Models.FOOD"
:multiple="false"
:allow_create="false"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
:placeholder="$t('Food')"
/>
</div>
<div class="col col-md-1 ">
<b-button variant="link" class="px-0">
<i class="btn fas fa-cart-plus fa-lg px-0 text-success" @click="addItem" />
</b-button>
</div>
</div>
<div v-for="(done, x) in Sections" :key="x">
<div v-if="x == 'true'">
<hr />
<hr />
<h4>{{ $t("Completed") }}</h4>
</div>
<div v-for="(s, i) in done" :key="i">
<h5 v-if="Object.entries(s).length > 0">
<b-button
class="btn btn-lg text-decoration-none text-dark px-1 py-0 border-0"
variant="link"
data-toggle="collapse"
:href="'#section-' + sectionID(x, i)"
:aria-expanded="'true' ? x == 'false' : 'true'"
>
<i class="fa fa-chevron-right rotate" />
{{ i }}
</b-button>
</h5>
<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 -->
<div v-for="(entries, x) in Object.entries(s)" :key="x">
<ShoppingLineItem :entries="entries[1]" :groupby="group_by" @open-context-menu="openContextMenu" @update-checkbox="updateChecked" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</b-tab>
<!-- recipe tab -->
<b-tab :title="$t('Recipes')">
<table class="table w-75">
<thead>
<tr>
<th scope="col">{{ $t("Meal_Plan") }}</th>
<th scope="col">{{ $t("Recipe") }}</th>
<th scope="col">{{ $t("Servings") }}</th>
<th scope="col"></th>
</tr>
</thead>
<tr v-for="r in Recipes" :key="r.list_recipe">
<td>{{ r.recipe_mealplan.name }}</td>
<td>{{ r.recipe_mealplan.recipe_name }}</td>
<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>
</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)" />
</td>
</tr>
</table>
</b-tab>
<!-- settings tab -->
<b-tab :title="$t('Settings')">
<div class="row">
<div class="col col-md-4 ">
<b-card class="no-body">
<div class="row">
<div class="col col-md-6">{{ $t("mealplan_autoadd_shopping") }}</div>
<div class="col col-md-6 text-right">
<input type="checkbox" size="sm" v-model="settings.mealplan_autoadd_shopping" @change="saveSettings" />
</div>
</div>
<div class="row sm mb-3">
<div class="col">
<em class="small text-muted">{{ $t("mealplan_autoadd_shopping_desc") }}</em>
</div>
</div>
<div v-if="settings.mealplan_autoadd_shopping">
<div class="row">
<div class="col col-md-6">{{ $t("mealplan_autoadd_shopping") }}</div>
<div class="col col-md-6 text-right">
<input type="checkbox" size="sm" v-model="settings.mealplan_autoexclude_onhand" @change="saveSettings" />
</div>
</div>
<div class="row sm mb-3">
<div class="col">
<em class="small text-muted">{{ $t("mealplan_autoadd_shopping_desc") }}</em>
</div>
</div>
</div>
<div v-if="settings.mealplan_autoadd_shopping">
<div class="row">
<div class="col col-md-6">{{ $t("mealplan_autoinclude_related") }}</div>
<div class="col col-md-6 text-right">
<input type="checkbox" size="sm" v-model="settings.mealplan_autoinclude_related" @change="saveSettings" />
</div>
</div>
<div class="row sm mb-3">
<div class="col">
<em class="small text-muted">
{{ $t("mealplan_autoinclude_related_desc") }}
</em>
</div>
</div>
</div>
<div class="row">
<div class="col col-md-6">{{ $t("shopping_share") }}</div>
<div class="col col-md-6 text-right">
<generic-multiselect
size="sm"
@change="
settings.shopping_share = $event.val
saveSettings()
"
:model="Models.USER"
:initial_selection="settings.shopping_share"
label="username"
:multiple="true"
:allow_create="false"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
:placeholder="$t('User')"
/>
</div>
</div>
<div class="row sm mb-3">
<div class="col">
<em class="small text-muted">{{ $t("shopping_share_desc") }}</em>
</div>
</div>
<div class="row">
<div class="col col-md-6">{{ $t("shopping_auto_sync") }}</div>
<div class="col col-md-6 text-right">
<input type="number" size="sm" v-model="settings.shopping_auto_sync" @change="saveSettings" />
</div>
</div>
<div class="row sm mb-3">
<div class="col">
<em class="small text-muted">
{{ $t("shopping_auto_sync_desc") }}
</em>
</div>
</div>
<div class="row">
<div class="col col-md-6">{{ $t("filter_to_supermarket") }}</div>
<div class="col col-md-6 text-right">
<input type="checkbox" size="sm" v-model="settings.filter_to_supermarket" @change="saveSettings" />
</div>
</div>
<div class="row sm mb-3">
<div class="col">
<em class="small text-muted">
{{ $t("filter_to_supermarket_desc") }}
</em>
</div>
</div>
<div class="row">
<div class="col col-md-6">{{ $t("default_delay") }}</div>
<div class="col col-md-6 text-right">
<input type="number" size="sm" min="1" v-model="settings.default_delay" @change="saveSettings" />
</div>
</div>
<div class="row sm mb-3">
<div class="col">
<em class="small text-muted">
{{ $t("default_delay_desc") }}
</em>
</div>
</div>
</b-card>
</div>
<div class="col col-md-8">
<b-card class=" no-body">
put the supermarket stuff here<br />
-add supermarkets<br />
-add supermarket categories<br />
-sort supermarket categories<br />
</b-card>
</div>
</div>
</b-tab>
</b-tabs>
<b-popover target="id_filters_button" triggers="click" placement="bottomleft" :title="$t('Filters')">
<div>
<b-form-group v-bind:label="$t('GroupBy')" label-for="popover-input-1" label-cols="6" class="mb-1">
<b-form-select v-model="group_by" :options="group_by_choices" size="sm"></b-form-select>
</b-form-group>
<b-form-group v-bind:label="$t('Supermarket')" label-for="popover-input-2" label-cols="6" class="mb-1">
<b-form-select v-model="selected_supermarket" :options="supermarkets" text-field="name" value-field="id" size="sm"></b-form-select>
</b-form-group>
<b-form-group v-bind:label="$t('ShowDelayed')" label-for="popover-input-3" content-cols="1" class="mb-1">
<b-form-checkbox v-model="show_delay"></b-form-checkbox>
</b-form-group>
<b-form-group v-bind:label="$t('ShowUncategorizedFood')" label-for="popover-input-4" content-cols="1" class="mb-1" v-if="!selected_supermarket">
<b-form-checkbox v-model="show_undefined_categories"></b-form-checkbox>
</b-form-group>
<b-form-group v-bind:label="$t('SupermarketCategoriesOnly')" label-for="popover-input-5" content-cols="1" class="mb-1" v-if="selected_supermarket">
<b-form-checkbox v-model="supermarket_categories_only"></b-form-checkbox>
</b-form-group>
</div>
<div class="row " style="margin-top: 1vh;min-width:300px">
<div class="col-12 " style="text-align: right;">
<b-button size="sm" variant="primary" class="mx-1" @click="resetFilters">{{ $t("Reset") }} </b-button>
<b-button size="sm" variant="secondary" class="mr-3" @click="$root.$emit('bv::hide::popover')">{{ $t("Close") }} </b-button>
</div>
</div>
</b-popover>
<ContextMenu ref="menu">
<template #menu="{ contextData }">
<ContextMenuItem
@click="
moveEntry($event, contextData)
$refs.menu.close()
"
>
<b-form-group label-cols="6" content-cols="6" class="text-nowrap m-0 mr-2">
<template #label>
<a class="dropdown-item p-2" href="#"><i class="fas fa-cubes"></i> {{ $t("MoveCategory") }}</a>
</template>
<span @click.prevent.stop @mouseup.prevent.stop>
<!-- would like to hide the dropdown value and only display value in button - not sure how to do that -->
<b-form-select class="mt-2 border-0" :options="shopping_categories" text-field="name" value-field="id" v-model="shopcat"></b-form-select>
</span>
</b-form-group>
</ContextMenuItem>
<ContextMenuItem
@click="
$refs.menu.close()
onHand(contextData)
"
>
<a class="dropdown-item p-2" href="#"><i class="fas fa-clipboard-check"></i> {{ $t("OnHand") }}</a>
</ContextMenuItem>
<ContextMenuItem
@click="
$refs.menu.close()
delayThis(contextData)
"
>
<b-form-group label-cols="10" content-cols="2" class="text-nowrap m-0 mr-2">
<template #label>
<a class="dropdown-item p-2" href="#"><i class="far fa-hourglass"></i> {{ $t("DelayFor", { hours: delay }) }}</a>
</template>
<div @click.prevent.stop>
<b-form-input class="mt-2" min="0" type="number" v-model="delay"></b-form-input>
</div>
</b-form-group>
</ContextMenuItem>
<ContextMenuItem
@click="
$refs.menu.close()
ignoreThis(contextData)
"
>
<a class="dropdown-item p-2" href="#"><i class="fas fa-ban"></i> {{ $t("IgnoreThis", { food: foodName(contextData) }) }}</a>
</ContextMenuItem>
<ContextMenuItem
@click="
$refs.menu.close()
deleteThis(contextData)
"
>
<a class="dropdown-item p-2 text-danger" href="#"><i class="fas fa-trash"></i> {{ $t("Delete") }}</a>
</ContextMenuItem>
</template>
</ContextMenu>
</div>
</template>
<script>
import Vue from "vue"
import { BootstrapVue } from "bootstrap-vue"
import "bootstrap-vue/dist/bootstrap-vue.css"
import ContextMenu from "@/components/ContextMenu/ContextMenu"
import ContextMenuItem from "@/components/ContextMenu/ContextMenuItem"
import ShoppingLineItem from "@/components/ShoppingLineItem"
import GenericMultiselect from "@/components/GenericMultiselect"
import { ApiMixin, getUserPreference } from "@/utils/utils"
import { ApiApiFactory } from "@/utils/openapi/api"
import { StandardToasts, makeToast } from "@/utils/utils"
Vue.use(BootstrapVue)
export default {
name: "ShoppingListView",
mixins: [ApiMixin],
components: { ContextMenu, ContextMenuItem, ShoppingLineItem, GenericMultiselect },
data() {
return {
// this.Models and this.Actions inherited from ApiMixin
items: [],
group_by: "category",
group_by_choices: ["created_by", "category", "recipe"],
supermarkets: [],
shopping_categories: [],
selected_supermarket: undefined,
show_undefined_categories: true,
supermarket_categories_only: false,
shopcat: null,
delay: 0,
settings: {
shopping_auto_sync: 0,
default_delay: 4,
mealplan_autoadd_shopping: false,
mealplan_autoinclude_related: false,
mealplan_autoexclude_onhand: true,
filter_to_supermarket: false,
},
autosync_id: undefined,
auto_sync_running: false,
show_delay: false,
show_modal: false,
fields: ["checked", "amount", "category", "unit", "food", "recipe", "details"],
loading: true,
entrymode: false,
new_item: { amount: 1, unit: undefined, food: undefined },
online: true,
}
},
computed: {
Sections() {
function getKey(item, group_by, x) {
switch (group_by) {
case "category":
return item?.food?.supermarket_category?.name ?? x
case "created_by":
return item?.created_by?.username ?? x
case "recipe":
return item?.recipe_mealplan?.recipe_name ?? x
}
}
let shopping_list = this.items
// filter out list items that are delayed
if (!this.show_delay && shopping_list) {
shopping_list = shopping_list.filter((x) => !x.delay_until || !Date.parse(x?.delay_until) > new Date(Date.now()))
}
// if a supermarket is selected and filtered to only supermarket categories filter out everything else
if (this.selected_supermarket && this.supermarket_categories_only) {
let shopping_categories = this.supermarkets // category IDs configured on supermarket
.map((x) => x.category_to_supermarket)
.flat()
.map((x) => x.category.id)
shopping_list = shopping_list.filter((x) => shopping_categories.includes(x?.food?.supermarket_category?.id))
// if showing undefined is off, filter undefined
} else if (!this.show_undefined_categories) {
shopping_list = shopping_list.filter((x) => x?.food?.supermarket_category)
}
let groups = { false: {}, true: {} } // force unchecked to always be first
if (this.selected_supermarket) {
let super_cats = this.supermarkets
.filter((x) => x.id === this.selected_supermarket)
.map((x) => x.category_to_supermarket)
.flat()
.map((x) => x.category.name)
new Set([...super_cats, ...this.shopping_categories.map((x) => x.name)]).forEach((cat) => {
groups["false"][cat.name] = {}
groups["true"][cat.name] = {}
})
} else {
this.shopping_categories.forEach((cat) => {
groups.false[cat.name] = {}
groups.true[cat.name] = {}
})
}
shopping_list.forEach((item) => {
let key = getKey(item, this.group_by, this.$t("Undefined"))
// first level of dict is done/not done
if (!groups[item.checked]) groups[item.checked] = {}
// second level of dict is this.group_by selection
if (!groups[item.checked][key]) groups[item.checked][key] = {}
// third level of dict is the food
if (groups[item.checked][key][item.food.name]) {
groups[item.checked][key][item.food.name].push(item)
} else {
groups[item.checked][key][item.food.name] = [item]
}
})
return groups
},
defaultDelay() {
return getUserPreference("default_delay") || 2
},
itemsDelayed() {
return this.items.filter((x) => !x.delay_until || !Date.parse(x?.delay_until) > new Date(Date.now())).length < this.items.length
},
filterApplied() {
return (this.itemsDelayed && !this.show_delay) || !this.show_undefined_categories || (this.supermarket_categories_only && this.selected_supermarket)
},
Recipes() {
return [...new Map(this.items.filter((x) => x.list_recipe).map((item) => [item["list_recipe"], item])).values()]
},
},
watch: {
selected_supermarket(newVal, oldVal) {
this.supermarket_categories_only = this.settings.filter_to_supermarket
},
"settings.filter_to_supermarket": function(newVal, oldVal) {
this.supermarket_categories_only = this.settings.filter_to_supermarket
},
"settings.shopping_auto_sync": function(newVal, oldVal) {
clearInterval(this.autosync_id)
this.autosync_id = undefined
if (!newVal) {
window.removeEventListener("online", this.updateOnlineStatus)
window.removeEventListener("offline", this.updateOnlineStatus)
return
} else if (oldVal === 0 && newVal > 0) {
window.addEventListener("online", this.updateOnlineStatus)
window.addEventListener("offline", this.updateOnlineStatus)
}
this.autosync_id = setInterval(() => {
if (this.online && !this.auto_sync_running) {
this.auto_sync_running = true
this.getShoppingList(true)
}
}, this.settings.shopping_auto_sync * 1000)
},
},
mounted() {
this.getShoppingList()
this.getSupermarkets()
this.getShoppingCategories()
this.settings = getUserPreference()
this.delay = this.settings.default_delay || 4
this.supermarket_categories_only = this.settings.filter_to_supermarket
if (this.settings.shopping_auto_sync) {
window.addEventListener("online", this.updateOnlineStatus)
window.addEventListener("offline", this.updateOnlineStatus)
}
},
methods: {
// this.genericAPI inherited from ApiMixin
addItem() {
let api = new ApiApiFactory()
api.createShoppingListEntry(this.new_item)
.then((results) => {
if (results?.data) {
this.items.push(results.data)
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
} else {
console.log("no data returned")
}
this.new_item = { amount: 1 }
})
.catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
})
},
categoryName: function(value) {
return this.shopping_categories.filter((x) => x.id == value)[0]?.name ?? ""
},
resetFilters: function() {
this.selected_supermarket = undefined
this.supermarket_categories_only = this.settings.filter_to_supermarket
this.show_undefined_categories = true
this.group_by = "category"
this.show_delay = false
},
delayThis: function(item) {
let entries = []
let promises = []
if (Array.isArray(item)) {
entries = item.map((x) => x.id)
} else {
entries = [item.id]
}
let delay_date = new Date(Date.now() + this.delay * (60 * 60 * 1000))
entries.forEach((entry) => {
promises.push(this.saveThis({ id: entry, delay_until: delay_date }, false))
})
Promise.all(promises).then(() => {
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
this.delay = this.defaultDelay
})
},
deleteRecipe: function(e, recipe) {
let api = new ApiApiFactory()
api.destroyShoppingListRecipe(recipe)
.then((x) => {
this.items = this.items.filter((x) => x.list_recipe !== recipe)
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
})
.catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE)
})
},
deleteThis: function(item) {
let api = new ApiApiFactory()
let entries = []
let promises = []
if (Array.isArray(item)) {
entries = item.map((x) => x.id)
} else {
entries = [item.id]
}
entries.forEach((x) => {
promises.push(
api.destroyShoppingListEntry(x).catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE)
})
)
})
Promise.all(promises).then((result) => {
this.items = this.items.filter((x) => !entries.includes(x.id))
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
})
},
foodName: function(value) {
return value?.food?.name ?? value?.[0]?.food?.name ?? ""
},
getShoppingCategories: function() {
let api = new ApiApiFactory()
api.listSupermarketCategorys().then((result) => {
this.shopping_categories = result.data
})
},
getShoppingList: function(autosync = false) {
let params = {}
params.supermarket = this.selected_supermarket
params.options = { query: { recent: 1 } }
if (autosync) {
params.options.query["autosync"] = 1
} else {
this.loading = true
}
this.genericAPI(this.Models.SHOPPING_LIST, this.Actions.LIST, params)
.then((results) => {
if (!autosync) {
if (results.data?.length) {
this.items = results.data
} else {
console.log("no data returned")
}
this.loading = false
} else {
this.mergeShoppingList(results.data)
}
})
.catch((err) => {
console.log(err)
if (!autosync) {
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
}
})
},
getSupermarkets: function() {
let api = new ApiApiFactory()
api.listSupermarkets().then((result) => {
this.supermarkets = result.data
})
},
getThis: function(id) {
return this.genericAPI(this.Models.SHOPPING_CATEGORY, this.Actions.FETCH, { id: id })
},
ignoreThis: function(item) {
let food = {
id: item?.[0]?.food.id ?? item.food.id,
ignore_shopping: true,
}
this.updateFood(food, "ignore_shopping")
},
mergeShoppingList: function(data) {
this.items.map((x) =>
data.map((y) => {
if (y.id === x.id) {
x.checked = y.checked
return x
}
})
)
this.auto_sync_running = false
},
moveEntry: function(e, item) {
if (!e) {
makeToast(this.$t("Warning"), this.$t("NoCategory"), "warning")
}
// TODO make decision - should inheritance always be turned off when category set manually or give user a choice at front-end or make it a setting?
let food = this.items.filter((x) => x.food.id == item?.[0]?.food.id ?? item.food.id)[0].food
food.supermarket_category = this.shopping_categories.filter((x) => x?.id === this.shopcat)?.[0]
this.updateFood(food, "supermarket_category")
this.shopcat = null
},
onHand: function(item) {
let api = new ApiApiFactory()
let food = {
id: item?.[0]?.food.id ?? item?.food?.id,
on_hand: true,
}
this.updateFood(food)
.then((result) => {
let entries = this.items.filter((x) => x.food.id == food.id).map((x) => x.id)
this.items = this.items.filter((x) => x.food.id !== food.id)
return entries
})
.then((entries) => {
entries.forEach((x) => {
api.destroyShoppingListEntry(x).then((result) => {})
})
})
},
openContextMenu(e, value) {
this.shopcat = value?.food?.supermarket_category?.id ?? value?.[0]?.food?.supermarket_category?.id ?? undefined
this.$refs.menu.open(e, value)
},
saveSettings: function() {
let api = ApiApiFactory()
api.partialUpdateUserPreference(this.settings.user, this.settings)
.then((result) => {
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
})
.catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
})
},
saveThis: function(thisItem, toast = true) {
let api = new ApiApiFactory()
if (!thisItem?.id) {
// if there is no item id assume it's a new item
return api
.createShoppingListEntry(thisItem)
.then((result) => {
if (toast) {
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
}
})
.catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
})
} else {
return api
.partialUpdateShoppingListEntry(thisItem.id, thisItem)
.then((result) => {
if (toast) {
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
}
})
.catch((err) => {
console.log(err, err.response)
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
})
}
},
sectionID: function(a, b) {
return (a + b).replace(/\W/g, "")
},
updateChecked: function(update) {
// when checking a sub item don't refresh the screen until all entries complete but change class to cross out
let promises = []
update.entries.forEach((x) => {
promises.push(this.saveThis({ id: x, checked: update.checked }, false))
let item = this.items.filter((entry) => entry.id == x)[0]
Vue.set(item, "checked", update.checked)
if (update.checked) {
Vue.set(item, "completed_at", new Date().toISOString())
} else {
Vue.set(item, "completed_at", undefined)
}
})
Promise.all(promises).catch((err) => {
console.log(err, err.response)
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
})
},
updateFood: function(food, field) {
let api = new ApiApiFactory()
let ignore_category
if (field) {
ignore_category = food.ignore_inherit
.map((x) => food.ignore_inherit.fields)
.flat()
.includes(field)
} else {
ignore_category = true
}
return api
.partialUpdateFood(food.id, food)
.then((result) => {
if (food.inherit && food.supermarket_category && !ignore_category && food.parent) {
makeToast(this.$t("Warning"), this.$t("InheritWarning", { food: food.name }), "warning")
} else {
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
}
if (food?.numchild > 0) {
this.getShoppingList() // if food has children, just get the whole list. probably could be more efficient
}
})
.catch((err) => {
console.log(err, Object.keys(err))
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
})
},
updateServings(e, plan) {
// maybe this needs debounced?
let api = new ApiApiFactory()
api.partialUpdateShoppingListRecipe(plan, { id: plan, servings: e }).then(() => {
this.getShoppingList()
})
},
updateOnlineStatus(e) {
const { type } = e
this.online = type === "online"
},
beforeDestroy() {
window.removeEventListener("online", this.updateOnlineStatus)
window.removeEventListener("offline", this.updateOnlineStatus)
},
},
}
</script>
<!--style src="vue-multiselect/dist/vue-multiselect.min.css"></style-->
<style>
.rotate {
-moz-transition: all 0.25s linear;
-webkit-transition: all 0.25s linear;
transition: all 0.25s linear;
}
.btn[aria-expanded="true"] > .rotate {
-moz-transform: rotate(90deg);
-webkit-transform: rotate(90deg);
transform: rotate(90deg);
}
.float-top {
padding-bottom: -3em;
margin-bottom: -3em;
}
.float-up {
padding-top: -3em;
margin-top: -3em;
}
</style>

View File

@@ -0,0 +1,10 @@
import i18n from "@/i18n"
import Vue from "vue"
import App from "./ShoppingListView"
Vue.config.productionTip = false
new Vue({
i18n,
render: (h) => h(App),
}).$mount("#app")

View File

@@ -4,16 +4,22 @@
:item="item"/>
<icon-badge v-if="Icon"
:item="item"/>
<on-hand-badge v-if="OnHand"
:item="item"/>
<shopping-badge v-if="Shopping"
:item="item"/>
</span>
</template>
<script>
import LinkedRecipe from "@/components/Badges/LinkedRecipe";
import IconBadge from "@/components/Badges/Icon";
import OnHandBadge from "@/components/Badges/OnHand";
import ShoppingBadge from "@/components/Badges/Shopping";
export default {
name: 'CardBadges',
components: {LinkedRecipe, IconBadge},
components: {LinkedRecipe, IconBadge, OnHandBadge, ShoppingBadge},
props: {
item: {type: Object},
model: {type: Object}
@@ -30,6 +36,12 @@ export default {
},
Icon: function () {
return this.model?.badges?.icon ?? false
},
OnHand: function () {
return this.model?.badges?.on_hand ?? false
},
Shopping: function () {
return this.model?.badges?.shopping ?? false
}
},
watch: {

View File

@@ -1,6 +1,6 @@
<template>
<span>
<b-button v-if="item.icon" class=" btn p-0 border-0" variant="link">
<b-button v-if="item.icon" class=" btn px-1 py-0 border-0 text-decoration-none" variant="link">
{{item.icon}}
</b-button>
</span>

View File

@@ -1,7 +1,7 @@
<template>
<span>
<b-button v-if="item.recipe" v-b-tooltip.hover :title="item.recipe.name"
class=" btn fas fa-book-open p-0 border-0" variant="link" :href="item.recipe.url"/>
class=" btn text-decoration-none fas fa-book-open px-1 py-0 border-0" variant="link" :href="item.recipe.url"/>
</span>
</template>

View File

@@ -0,0 +1,40 @@
<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>
</template>
<script>
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
})
}
}
}
</script>

View File

@@ -0,0 +1,94 @@
<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>
</template>
<script>
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
}
},
DeleteConfirmation() {
return this.$t('DeleteShoppingConfirm',{'food':this.item.name})
},
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,127 +1,118 @@
<template>
<div
class="context-menu"
ref="popper"
v-show="isVisible"
tabindex="-1"
v-click-outside="close"
@contextmenu.capture.prevent>
<ul class="dropdown-menu" role="menu">
<slot :contextData="contextData" name="menu"/>
</ul>
</div>
<div class="context-menu" ref="popper" v-show="isVisible" tabindex="-1" v-click-outside="close" @contextmenu.capture.prevent>
<ul class="dropdown-menu" role="menu">
<slot :contextData="contextData" name="menu" />
</ul>
</div>
</template>
<script>
import Popper from 'popper.js';
import Popper from "popper.js"
Popper.Defaults.modifiers.computeStyle.gpuAcceleration = false
import ClickOutside from 'vue-click-outside'
import ClickOutside from "vue-click-outside"
export default {
name: "ContextMenu.vue",
props: {
boundariesElement: {
type: String,
default: 'body',
},
},
components: {},
data() {
return {
opened: false,
contextData: {},
};
},
directives: {
ClickOutside,
},
computed: {
isVisible() {
return this.opened;
},
},
methods: {
open(evt, contextData) {
this.opened = true;
this.contextData = contextData;
if (this.popper) {
this.popper.destroy();
}
this.popper = new Popper(this.referenceObject(evt), this.$refs.popper, {
placement: 'right-start',
modifiers: {
preventOverflow: {
boundariesElement: document.querySelector(this.boundariesElement),
},
name: "ContextMenu.vue",
props: {
boundariesElement: {
type: String,
default: "body",
},
});
this.$nextTick(() => {
this.popper.scheduleUpdate();
});
},
close() {
this.opened = false;
this.contextData = null;
},
referenceObject(evt) {
const left = evt.clientX;
const top = evt.clientY;
const right = left + 1;
const bottom = top + 1;
const clientWidth = 1;
const clientHeight = 1;
function getBoundingClientRect() {
components: {},
data() {
return {
left,
top,
right,
bottom,
};
}
const obj = {
getBoundingClientRect,
clientWidth,
clientHeight,
};
return obj;
opened: false,
contextData: {},
}
},
},
beforeUnmount() {
if (this.popper !== undefined) {
this.popper.destroy();
}
},
};
directives: {
ClickOutside,
},
computed: {
isVisible() {
return this.opened
},
},
methods: {
open(evt, contextData) {
this.opened = true
this.contextData = contextData
if (this.popper) {
this.popper.destroy()
}
this.popper = new Popper(this.referenceObject(evt), this.$refs.popper, {
placement: "right-start",
modifiers: {
preventOverflow: {
boundariesElement: document.querySelector(this.boundariesElement),
},
},
})
this.$nextTick(() => {
this.popper.scheduleUpdate()
})
},
close() {
this.opened = false
this.contextData = null
},
referenceObject(evt) {
const left = evt.clientX
const top = evt.clientY
const right = left + 1
const bottom = top + 1
const clientWidth = 1
const clientHeight = 1
function getBoundingClientRect() {
return {
left,
top,
right,
bottom,
}
}
const obj = {
getBoundingClientRect,
clientWidth,
clientHeight,
}
return obj
},
},
beforeUnmount() {
if (this.popper !== undefined) {
this.popper.destroy()
}
},
}
</script>
<style scoped>
.context-menu {
position: fixed;
z-index: 999;
overflow: hidden;
background: #fff;
border-radius: 4px;
box-shadow: 0 1px 4px 0 #eee;
position: fixed;
z-index: 999;
overflow: hidden;
background: #fff;
border-radius: 4px;
box-shadow: 0 1px 4px 0 #eee;
}
.context-menu:focus {
outline: none;
outline: none;
}
.context-menu ul {
padding: 0px;
margin: 0px;
padding: 0px;
margin: 0px;
}
.dropdown-menu {
display: block;
position: relative;
display: block;
position: relative;
}
</style>

View File

@@ -1,16 +1,13 @@
<template>
<li @click="$emit('click', $event)" role="presentation">
<slot/>
</li>
<li @click="$emit('click', $event)" role="presentation">
<slot />
</li>
</template>
<script>
export default {
name: "ContextMenuItem.vue",
name: "ContextMenuItem.vue",
}
</script>
<style scoped>
</style>
<style scoped></style>

View File

@@ -0,0 +1,34 @@
<template>
<div>
<div style="position: static;" class=" btn-group">
<div class="dropdown b-dropdown position-static">
<li @click="$refs.submenu.open($event)" role="presentation" class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret">
<slot />
</li>
</div>
</div>
<ContextMenu ref="submenu">
<template #menu="{ contextData }">
<ContextMenuItem
@click="
$refs.menu.close()
moveEntry(contextData)
"
>
<a class="dropdown-item p-2" href="#"><i class="fas fa-cubes"></i> submenu item</a>
</ContextMenuItem>
</template>
</ContextMenu>
</div>
</template>
<script>
import ContextMenu from "@/components/ContextMenu/ContextMenu"
import ContextMenuItem from "@/components/ContextMenu/ContextMenuItem"
export default {
name: "ContextSubmenu.vue",
components: { ContextMenu, ContextMenuItem },
}
</script>
<style scoped></style>

View File

@@ -26,7 +26,11 @@
<i class="fas fa-shopping-cart fa-fw"></i> {{ $t('Add_to_Shopping') }}
</a>
<a class="dropdown-item" @click="createMealPlan" href="javascript:void(0);"><i
<a class="dropdown-item" v-if="recipe.internal" @click="addToShopping" href="#">
<i class="fas fa-shopping-cart fa-fw"></i> New {{ $t('Add_to_Shopping') }}
</a>
<a class="dropdown-item" @click="createMealPlan" href="#"><i
class="fas fa-calendar fa-fw"></i> {{ $t('Add_to_Plan') }}
</a>
@@ -76,6 +80,7 @@
<meal-plan-edit-modal :entry="entryEditing" :entryEditing_initial_recipe="[recipe]"
:entry-editing_initial_meal_type="[]" @save-entry="saveMealPlan"
:modal_id="`modal-meal-plan_${modal_id}`" :allow_delete="false" :modal_title="$t('Create_Meal_Plan_Entry')"></meal-plan-edit-modal>
<shopping-modal :recipe="recipe" :servings="servings_value" :modal_id="modal_id"/>
</div>
</template>
@@ -84,8 +89,9 @@
import {makeToast, resolveDjangoUrl, ResolveUrlMixin, StandardToasts} from "@/utils/utils";
import CookLog from "@/components/CookLog";
import axios from "axios";
import AddRecipeToBook from "./AddRecipeToBook";
import MealPlanEditModal from "@/components/MealPlanEditModal";
import AddRecipeToBook from "@/components/Modals/AddRecipeToBook";
import MealPlanEditModal from "@/components/Modals/MealPlanEditModal";
import ShoppingModal from "@/components/Modals/ShoppingModal";
import moment from "moment";
import Vue from "vue";
import {ApiApiFactory} from "@/utils/openapi/api";
@@ -100,7 +106,8 @@ export default {
components: {
AddRecipeToBook,
CookLog,
MealPlanEditModal
MealPlanEditModal,
ShoppingModal
},
data() {
return {
@@ -118,7 +125,7 @@ export default {
servings: 1,
shared: [],
title: '',
title_placeholder: this.$t('Title')
title_placeholder: this.$t('Title'),
}
},
entryEditing: {},
@@ -177,7 +184,10 @@ export default {
url: this.recipe_share_link
}
navigator.share(shareData)
}
},
addToShopping() {
this.$bvModal.show(`shopping_${this.modal_id}`)
},
}
}
</script>

View File

@@ -92,13 +92,13 @@
<i class="fas fa-times fa-fw"></i> <b>{{$t('Cancel')}}</b>
</b-list-group-item>
<!-- TODO add to shopping list -->
<!-- TODO add to and/or manage pantry -->
<!-- TODO toggle onhand -->
</b-list-group>
</div>
</template>
<script>
import GenericContextMenu from "@/components/GenericContextMenu";
import GenericContextMenu from "@/components/ContextMenu/GenericContextMenu";
import Badges from "@/components/Badges";
import GenericPill from "@/components/GenericPill";
import GenericOrderedPill from "@/components/GenericOrderedPill";

View File

@@ -1,88 +1,217 @@
<template>
<tr @click="$emit('checked-state-changed', ingredient)">
<template v-if="ingredient.is_header">
<td colspan="5">
<b>{{ ingredient.note }}</b>
</td>
</template>
<template v-else>
<td class="d-print-non" v-if="detailed">
<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>
</td>
<td>
<span v-if="ingredient.amount !== 0" v-html="calculateAmount(ingredient.amount)"></span>
</td>
<td>
<span v-if="ingredient.unit !== null && !ingredient.no_amount">{{ ingredient.unit.name }}</span>
</td>
<td>
<template v-if="ingredient.food !== null">
<a :href="resolveDjangoUrl('view_recipe', ingredient.food.recipe.id)" v-if="ingredient.food.recipe !== null"
target="_blank" rel="noopener noreferrer">{{ ingredient.food.name }}</a>
<span v-if="ingredient.food.recipe === null">{{ ingredient.food.name }}</span>
<tr>
<template v-if="ingredient.is_header">
<td colspan="5" @click="done">
<b>{{ ingredient.note }}</b>
</td>
</template>
</td>
<td v-if="detailed">
<div v-if="ingredient.note">
<span v-b-popover.hover="ingredient.note"
class="d-print-none touchable"> <i class="far fa-comment"></i>
</span>
<!-- v-if="ingredient.note.length > 15" -->
<!-- <span v-else>-->
<!-- {{ ingredient.note }}-->
<!-- </span>-->
<div class="d-none d-print-block">
<i class="far fa-comment-alt d-print-none"></i> {{ ingredient.note }}
</div>
</div>
</td>
</template>
</tr>
<template v-else>
<td class="d-print-non" v-if="detailed && !add_shopping_mode" @click="done">
<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>
</td>
<td class="text-nowrap" @click="done">
<span v-if="ingredient.amount !== 0" v-html="calculateAmount(ingredient.amount)"></span>
</td>
<td @click="done">
<span v-if="ingredient.unit !== null && !ingredient.no_amount">{{ ingredient.unit.name }}</span>
</td>
<td @click="done">
<template v-if="ingredient.food !== null">
<!-- <i
v-if="show_shopping && !add_shopping_mode"
class="far fa-edit fa-sm px-1"
@click="editFood()"
></i> -->
<a :href="resolveDjangoUrl('view_recipe', ingredient.food.recipe.id)" v-if="ingredient.food.recipe !== null" target="_blank" rel="noopener noreferrer">{{
ingredient.food.name
}}</a>
<span v-if="ingredient.food.recipe === null">{{ ingredient.food.name }}</span>
</template>
</td>
<td v-if="detailed && !show_shopping">
<div v-if="ingredient.note">
<span v-b-popover.hover="ingredient.note" class="d-print-none touchable">
<i class="far fa-comment"></i>
</span>
<!-- v-if="ingredient.note.length > 15" -->
<!-- <span v-else>-->
<!-- {{ ingredient.note }}-->
<!-- </span>-->
<div class="d-none d-print-block"><i class="far fa-comment-alt d-print-none"></i> {{ ingredient.note }}</div>
</div>
</td>
<td v-else-if="show_shopping" class="text-right text-nowrap">
<!-- in shopping mode and ingredient is not ignored -->
<div v-if="!ingredient.food.ignore_shopping">
<b-button
class="btn text-decoration-none fas fa-shopping-cart px-2 user-select-none"
variant="link"
v-b-popover.hover.click.blur.html.top="{ title: ShoppingPopover, variant: 'outline-dark' }"
:class="{
'text-success': shopping_status === true,
'text-muted': shopping_status === false,
'text-warning': shopping_status === null,
}"
/>
<span class="px-2">
<input type="checkbox" class="align-middle" v-model="shop" @change="changeShopping" />
</span>
<on-hand-badge :item="ingredient.food" />
</div>
<div v-else>
<!-- or in shopping mode and food is ignored: Shopping Badge bypasses linking ingredient to Recipe which would get ignored -->
<shopping-badge :item="ingredient.food" :override_ignore="true" class="px-1" />
<span class="px-2">
<input type="checkbox" class="align-middle" disabled v-b-popover.hover.click.blur :title="$t('IgnoredFood', { food: ingredient.food.name })" />
</span>
<on-hand-badge :item="ingredient.food" />
</div>
</td>
</template>
</tr>
</template>
<script>
import {calculateAmount, ResolveUrlMixin} from "@/utils/utils";
import { calculateAmount, ResolveUrlMixin, ApiMixin } from "@/utils/utils"
import OnHandBadge from "@/components/Badges/OnHand"
import ShoppingBadge from "@/components/Badges/Shopping"
export default {
name: 'IngredientComponent',
props: {
ingredient: Object,
ingredient_factor: {
type: Number,
default: 1,
name: "Ingredient",
components: { OnHandBadge, ShoppingBadge },
props: {
ingredient: Object,
ingredient_factor: { type: Number, default: 1 },
detailed: { type: Boolean, default: true },
recipe_list: { type: Number }, // ShoppingListRecipe ID, to filter ShoppingStatus
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],
data() {
return {
checked: false,
shopping_status: null,
shopping_items: [],
shop: false,
dirty: undefined,
}
},
watch: {
ShoppingListAndFilter: {
immediate: true,
handler(newVal, oldVal) {
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.map((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.shop = false // don't check any boxes until user selects a shopping list to edit
if (count_shopping_ingredient >= 1) {
this.shopping_status = true
} else if (this.ingredient.food.shopping) {
this.shopping_status = null // food is in the shopping list, just not for this ingredient/recipe
} else {
this.shopping_status = false // food is not in any shopping list
}
} else {
// 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
this.shop = true
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.shop = false
this.shopping_status = null
} else {
// the food is not in any shopping list
this.shop = false
this.shopping_status = false
}
}
// if we are in add shopping mode start with all checks marked
if (this.add_shopping_mode) {
this.shop = !this.ingredient.food.on_hand && !this.ingredient.food.ignore_shopping && !this.ingredient.food.recipe
}
},
},
},
mounted() {},
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() {
if (this.shopping_status == false) {
return this.$t("NotInShopping", { food: this.ingredient.food.name })
} else {
let list = this.shopping_list.filter((x) => x.food.id == this.ingredient.food.id)
let category = this.$t("Category") + ": " + this.ingredient?.food?.supermarket_category?.name ?? this.$t("Undefined")
let popover = []
list.forEach((x) => {
popover.push(
[
"<tr style='border-bottom: 1px solid #ccc'>",
"<td style='padding: 3px;'><em>",
x?.recipe_mealplan?.name ?? "",
"</em></td>",
"<td style='padding: 3px;'>",
x?.amount ?? "",
"</td>",
"<td style='padding: 3px;'>",
x?.unit?.name ?? "" + "</td>",
"<td style='padding: 3px;'>",
x?.food?.name ?? "",
"</td></tr>",
].join("")
)
})
return "<table class='table-small'><th colspan='4'>" + category + "</th>" + popover.join("") + "</table>"
}
},
},
methods: {
calculateAmount: function(x) {
return calculateAmount(x, this.ingredient_factor)
},
// sends parent recipe ingredient to notify complete has been toggled
done: function() {
this.$emit("checked-state-changed", this.ingredient)
},
// sends true/false to parent to save all ingredient shopping updates as a batch
changeShopping: function() {
this.$emit("add-to-shopping", { item: this.ingredient, add: this.shop })
},
editFood: function() {
console.log("edit the food")
},
},
detailed: {
type: Boolean,
default: true
}
},
mixins: [
ResolveUrlMixin
],
data() {
return {
checked: false
}
},
methods: {
calculateAmount: function (x) {
return calculateAmount(x, this.ingredient_factor)
}
}
}
</script>
<style scoped>
/* increase size of hover/touchable space without changing spacing */
.touchable {
padding-right: 2em;
padding-left: 2em;
margin-right: -2em;
margin-left: -2em;
padding-right: 2em;
padding-left: 2em;
margin-right: -2em;
margin-left: -2em;
}
</style>

View File

@@ -0,0 +1,187 @@
<template>
<div :class="{ 'card border-primary no-border': header }">
<div :class="{ 'card-body': header }">
<div class="row" v-if="header">
<div class="col col-md-8">
<h4 class="card-title"><i class="fas fa-pepper-hot"></i> {{ $t("Ingredients") }}</h4>
</div>
<div class="col col-md-4 text-right" v-if="header">
<h4>
<i v-if="show_shopping && ShoppingRecipes.length > 0" class="fas fa-trash text-danger px-2" @click="saveShopping(true)"></i>
<i v-if="show_shopping" class="fas fa-save text-success px-2" @click="saveShopping()"></i>
<i class="fas fa-shopping-cart px-2" @click="getShopping()"></i>
</h4>
</div>
</div>
<div class="row text-right" v-if="ShoppingRecipes.length > 1">
<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>
</div>
</div>
<br v-if="header" />
<div class="row no-gutter">
<div class="col-md-12">
<table class="table table-sm">
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
<template v-for="s in steps">
<template v-for="i in s.ingredients">
<ingredient
:ingredient="i"
:ingredient_factor="ingredient_factor"
:key="i.id"
:show_shopping="show_shopping"
:shopping_list="shopping_list"
:add_shopping_mode="add_shopping_mode"
:detailed="detailed"
:recipe_list="selected_shoppingrecipe"
@checked-state-changed="$emit('checked-state-changed', $event)"
@add-to-shopping="addShopping($event)"
/>
</template>
</template>
<!-- eslint-enable vue/no-v-for-template-key-on-child -->
</table>
</div>
</div>
</div>
</div>
</template>
<script>
import Vue from "vue"
import { BootstrapVue } from "bootstrap-vue"
import "bootstrap-vue/dist/bootstrap-vue.css"
import Ingredient from "@/components/Ingredient"
import { ApiMixin, StandardToasts } from "@/utils/utils"
Vue.use(BootstrapVue)
export default {
name: "IngredientCard",
mixins: [ApiMixin],
components: { Ingredient },
props: {
steps: {
type: Array,
default() {
return []
},
},
recipe: { type: Number },
ingredient_factor: { type: Number, default: 1 },
servings: { type: Number, default: 1 },
detailed: { type: Boolean, default: true },
header: { type: Boolean, default: false },
add_shopping_mode: { type: Boolean, default: false },
},
data() {
return {
show_shopping: false,
shopping_list: [],
update_shopping: [],
selected_shoppingrecipe: undefined,
}
},
computed: {
ShoppingRecipes() {
// returns open shopping lists associated with this recipe
let recipe_in_list = this.shopping_list
.map((x) => {
return { value: x?.list_recipe, text: x?.recipe_mealplan?.name, recipe: x?.recipe_mealplan?.recipe ?? 0, servings: x?.recipe_mealplan?.servings }
})
.filter((x) => x?.recipe == this.recipe)
return [...new Map(recipe_in_list.map((x) => [x["value"], x])).values()] // filter to unique lists
},
},
watch: {
ShoppingRecipes: function(newVal, oldVal) {
if (newVal.length === 0 || this.add_shopping_mode) {
this.selected_shoppingrecipe = undefined
} else if (newVal.length === 1) {
this.selected_shoppingrecipe = newVal[0].value
}
},
selected_shoppingrecipe: function(newVal, oldVal) {
this.update_shopping = this.shopping_list.filter((x) => x.list_recipe === newVal).map((x) => x.ingredient)
},
},
mounted() {
if (this.add_shopping_mode) {
this.show_shopping = true
this.getShopping(false)
}
},
methods: {
getShopping: function(toggle_shopping = true) {
if (toggle_shopping) {
this.show_shopping = !this.show_shopping
}
if (this.show_shopping) {
let ingredient_list = this.steps
.map((x) => x.ingredients)
.flat()
.map((x) => x.food.id)
let params = {
id: ingredient_list,
checked: "false",
}
this.genericAPI(this.Models.SHOPPING_LIST, this.Actions.LIST, params).then((result) => {
this.shopping_list = result.data
})
}
},
saveShopping: function(del_shopping = false) {
let servings = this.servings
if (del_shopping) {
servings = 0
}
let params = {
id: this.recipe,
list_recipe: this.selected_shoppingrecipe,
ingredients: this.update_shopping,
servings: servings,
}
this.genericAPI(this.Models.RECIPE, this.Actions.SHOPPING, params)
.then(() => {
if (del_shopping) {
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
} else if (this.selected_shoppingrecipe) {
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
} else {
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
}
})
.then(() => {
if (!this.add_shopping_mode) {
return this.getShopping(false)
} else {
this.$emit("shopping-added")
}
})
.catch((err) => {
if (del_shopping) {
StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE)
} else if (this.selected_shoppingrecipe) {
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
} else {
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
}
this.$emit("shopping-failed")
})
},
addShopping: function(e) {
// ALERT: this will all break if ingredients are re-used between recipes
if (e.add) {
this.update_shopping.push(e.item.id)
} else {
this.update_shopping = this.update_shopping.filter((x) => x !== e.item.id)
}
if (this.add_shopping_mode) {
this.$emit("add-to-shopping", e)
}
},
},
}
</script>

View File

@@ -1,216 +0,0 @@
<template>
<b-modal :id="modal_id" size="lg" :title="modal_title" hide-footer aria-label="" @show="showModal">
<div class="row">
<div class="col col-md-12">
<div class="row">
<div class="col-6 col-lg-9">
<b-input-group>
<b-form-input id="TitleInput" v-model="entryEditing.title"
:placeholder="entryEditing.title_placeholder"
@change="missing_recipe = false"></b-form-input>
<b-input-group-append class="d-none d-lg-block">
<b-button variant="primary" @click="entryEditing.title = ''"><i class="fa fa-eraser"></i></b-button>
</b-input-group-append>
</b-input-group>
<span class="text-danger" v-if="missing_recipe">{{ $t('Title_or_Recipe_Required') }}</span>
<small tabindex="-1" class="form-text text-muted" v-if="!missing_recipe">{{ $t("Title") }}</small>
</div>
<div class="col-6 col-lg-3">
<input type="date" id="DateInput" class="form-control" v-model="entryEditing.date">
<small tabindex="-1" class="form-text text-muted">{{ $t("Date") }}</small>
</div>
</div>
<div class="row mt-3">
<div class="col-12 col-lg-6 col-xl-6">
<b-form-group>
<generic-multiselect
@change="selectRecipe"
:initial_selection="entryEditing_initial_recipe"
:label="'name'"
:model="Models.RECIPE"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
v-bind:placeholder="$t('Recipe')" :limit="10"
:multiple="false"></generic-multiselect>
<small tabindex="-1" class="form-text text-muted">{{ $t("Recipe") }}</small>
</b-form-group>
<b-form-group class="mt-3">
<generic-multiselect required
@change="selectMealType"
:label="'name'"
:model="Models.MEAL_TYPE"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
v-bind:placeholder="$t('Meal_Type')" :limit="10"
:multiple="false"
:initial_selection="entryEditing_initial_meal_type"
:allow_create="true"
:create_placeholder="$t('Create_New_Meal_Type')"
@new="createMealType"
></generic-multiselect>
<span class="text-danger" v-if="missing_meal_type">{{ $t('Meal_Type_Required') }}</span>
<small tabindex="-1" class="form-text text-muted" v-if="!missing_meal_type">{{ $t("Meal_Type") }}</small>
</b-form-group>
<b-form-group
label-for="NoteInput"
:description="$t('Note')" class="mt-3">
<textarea class="form-control" id="NoteInput" v-model="entryEditing.note"
:placeholder="$t('Note')"></textarea>
</b-form-group>
<b-input-group>
<b-form-input id="ServingsInput" v-model="entryEditing.servings"
:placeholder="$t('Servings')"></b-form-input>
</b-input-group>
<small tabindex="-1" class="form-text text-muted">{{ $t("Servings") }}</small>
<b-form-group class="mt-3">
<generic-multiselect required
@change="entryEditing.shared = $event.val" parent_variable="entryEditing.shared"
:label="'username'"
:model="Models.USER_NAME"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
v-bind:placeholder="$t('Share')" :limit="10"
:multiple="true"
:initial_selection="entryEditing.shared"
></generic-multiselect>
<small tabindex="-1" class="form-text text-muted">{{ $t("Share") }}</small>
</b-form-group>
</div>
<div class="col-lg-6 d-none d-lg-block d-xl-block">
<recipe-card :recipe="entryEditing.recipe" v-if="entryEditing.recipe != null"></recipe-card>
</div>
</div>
<div class="row mt-3 mb-3">
<div class="col-12">
<b-button variant="danger" @click="deleteEntry" v-if="allow_delete">{{ $t('Delete') }}
</b-button>
<b-button class="float-right" variant="primary" @click="editEntry">{{ $t('Save') }}</b-button>
</div>
</div>
</div>
</div>
</b-modal>
</template>
<script>
import Vue from "vue";
import {BootstrapVue} from "bootstrap-vue";
import GenericMultiselect from "./GenericMultiselect";
import {ApiMixin} from "../utils/utils";
const {ApiApiFactory} = require("@/utils/openapi/api");
const {StandardToasts} = require("@/utils/utils");
Vue.use(BootstrapVue)
export default {
name: "MealPlanEditModal",
props: {
entry: Object,
entryEditing_initial_recipe: Array,
entryEditing_initial_meal_type: Array,
modal_title: String,
modal_id: {
type: String,
default: "edit-modal"
},
allow_delete: {
type: Boolean,
default: true
}
},
mixins: [ApiMixin],
components: {
GenericMultiselect,
RecipeCard: () => import('@/components/RecipeCard.vue')
},
data() {
return {
entryEditing: {},
missing_recipe: false,
missing_meal_type: false,
default_plan_share: []
}
},
watch: {
entry: {
handler() {
this.entryEditing = Object.assign({}, this.entry)
},
deep: true
}
},
methods: {
showModal() {
let apiClient = new ApiApiFactory()
apiClient.listUserPreferences().then(result => {
if (this.entry.id === -1) {
this.entryEditing.shared = result.data[0].plan_share
}
})
},
editEntry() {
this.missing_meal_type = false
this.missing_recipe = false
let cancel = false
if (this.entryEditing.meal_type == null) {
this.missing_meal_type = true
cancel = true
}
if (this.entryEditing.recipe == null && this.entryEditing.title === '') {
this.missing_recipe = true
cancel = true
}
if (!cancel) {
this.$bvModal.hide(`edit-modal`);
this.$emit('save-entry', this.entryEditing)
}
},
deleteEntry() {
this.$bvModal.hide(`edit-modal`);
this.$emit('delete-entry', this.entryEditing)
},
selectMealType(event) {
this.missing_meal_type = false
if (event.val != null) {
this.entryEditing.meal_type = event.val;
} else {
this.entryEditing.meal_type = null;
}
},
selectShared(event) {
if (event.val != null) {
this.entryEditing.shared = event.val;
} else {
this.entryEditing.meal_type = null;
}
},
createMealType(event) {
if (event != "") {
let apiClient = new ApiApiFactory()
apiClient.createMealType({name: event}).then(e => {
this.$emit('reload-meal-types')
}).catch(error => {
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
})
}
},
selectRecipe(event) {
this.missing_recipe = false
if (event.val != null) {
this.entryEditing.recipe = event.val;
this.entryEditing.title_placeholder = this.entryEditing.recipe.name
this.entryEditing.servings = this.entryEditing.recipe.servings
} else {
this.entryEditing.recipe = null;
this.entryEditing.title_placeholder = ""
this.entryEditing.servings = 1
}
},
}
}
</script>
<style scoped>
</style>

View File

@@ -28,7 +28,7 @@
<script>
import Vue from "vue"
import { BootstrapVue } from "bootstrap-vue"
import { getForm } from "@/utils/utils"
import { getForm, formFunctions } from "@/utils/utils"
Vue.use(BootstrapVue)
@@ -84,6 +84,10 @@ 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)
}
this.dirty = true
this.$bvModal.show("modal_" + this.id)
} else {

View File

@@ -0,0 +1,219 @@
<template>
<b-modal :id="modal_id" size="lg" :title="modal_title" hide-footer aria-label="">
<div class="row">
<div class="col col-md-12">
<div class="row">
<div class="col-6 col-lg-9">
<b-input-group>
<b-form-input
id="TitleInput"
v-model="entryEditing.title"
:placeholder="entryEditing.title_placeholder"
@change="missing_recipe = false"
></b-form-input>
<b-input-group-append class="d-none d-lg-block">
<b-button variant="primary" @click="entryEditing.title = ''"
><i class="fa fa-eraser"></i
></b-button>
</b-input-group-append>
</b-input-group>
<span class="text-danger" v-if="missing_recipe">{{ $t("Title_or_Recipe_Required") }}</span>
<small tabindex="-1" class="form-text text-muted" v-if="!missing_recipe">{{
$t("Title")
}}</small>
</div>
<div class="col-6 col-lg-3">
<input type="date" id="DateInput" class="form-control" v-model="entryEditing.date" />
<small tabindex="-1" class="form-text text-muted">{{ $t("Date") }}</small>
</div>
</div>
<div class="row mt-3">
<div class="col-12 col-lg-6 col-xl-6">
<b-form-group>
<generic-multiselect
@change="selectRecipe"
:initial_selection="entryEditing_initial_recipe"
:label="'name'"
:model="Models.RECIPE"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
v-bind:placeholder="$t('Recipe')"
:limit="10"
:multiple="false"
></generic-multiselect>
<small tabindex="-1" class="form-text text-muted">{{ $t("Recipe") }}</small>
</b-form-group>
<b-form-group class="mt-3">
<generic-multiselect
required
@change="selectMealType"
:label="'name'"
:model="Models.MEAL_TYPE"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
v-bind:placeholder="$t('Meal_Type')"
:limit="10"
:multiple="false"
:initial_selection="entryEditing_initial_meal_type"
:allow_create="true"
:create_placeholder="$t('Create_New_Meal_Type')"
@new="createMealType"
></generic-multiselect>
<span class="text-danger" v-if="missing_meal_type">{{ $t("Meal_Type_Required") }}</span>
<small tabindex="-1" class="form-text text-muted" v-if="!missing_meal_type">{{
$t("Meal_Type")
}}</small>
</b-form-group>
<b-form-group label-for="NoteInput" :description="$t('Note')" class="mt-3">
<textarea
class="form-control"
id="NoteInput"
v-model="entryEditing.note"
:placeholder="$t('Note')"
></textarea>
</b-form-group>
<b-input-group>
<b-form-input
id="ServingsInput"
v-model="entryEditing.servings"
:placeholder="$t('Servings')"
></b-form-input>
</b-input-group>
<small tabindex="-1" class="form-text text-muted">{{ $t("Servings") }}</small>
<!-- TODO: hide this checkbox if autoadding menuplans, but allow editing on-hand -->
<b-input-group v-if="!autoMealPlan">
<b-form-checkbox id="AddToShopping" v-model="entryEditing.addshopping" />
<small tabindex="-1" class="form-text text-muted">{{ $t("AddToShopping") }}</small>
</b-input-group>
</div>
<div class="col-lg-6 d-none d-lg-block d-xl-block">
<recipe-card :recipe="entryEditing.recipe" v-if="entryEditing.recipe != null"></recipe-card>
</div>
</div>
<div class="row mt-3 mb-3">
<div class="col-12">
<b-button variant="danger" @click="deleteEntry" v-if="allow_delete"
>{{ $t("Delete") }}
</b-button>
<b-button class="float-right" variant="primary" @click="editEntry">{{ $t("Save") }}</b-button>
</div>
</div>
</div>
</div>
</b-modal>
</template>
<script>
import Vue from "vue"
import { BootstrapVue } from "bootstrap-vue"
import GenericMultiselect from "@/components/GenericMultiselect"
import { ApiMixin, getUserPreference } from "@/utils/utils"
const { ApiApiFactory } = require("@/utils/openapi/api")
const { StandardToasts } = require("@/utils/utils")
Vue.use(BootstrapVue)
export default {
name: "MealPlanEditModal",
props: {
entry: Object,
entryEditing_initial_recipe: Array,
entryEditing_initial_meal_type: Array,
modal_title: String,
modal_id: {
type: String,
default: "edit-modal",
},
allow_delete: {
type: Boolean,
default: true,
},
},
mixins: [ApiMixin],
components: {
GenericMultiselect,
RecipeCard: () => import("@/components/RecipeCard.vue"),
},
data() {
return {
entryEditing: {},
missing_recipe: false,
missing_meal_type: false,
}
},
watch: {
entry: {
handler() {
this.entryEditing = Object.assign({}, this.entry)
},
deep: true,
},
},
mounted: function() {},
computed: {
autoMealPlan: function() {
return getUserPreference("mealplan_autoadd_shopping")
},
},
methods: {
editEntry() {
this.missing_meal_type = false
this.missing_recipe = false
let cancel = false
if (this.entryEditing.meal_type == null) {
this.missing_meal_type = true
cancel = true
}
if (this.entryEditing.recipe == null && this.entryEditing.title === "") {
this.missing_recipe = true
cancel = true
}
if (!cancel) {
this.$bvModal.hide(`edit-modal`)
this.$emit("save-entry", this.entryEditing)
}
},
deleteEntry() {
this.$bvModal.hide(`edit-modal`)
this.$emit("delete-entry", this.entryEditing)
},
selectMealType(event) {
this.missing_meal_type = false
if (event.val != null) {
this.entryEditing.meal_type = event.val
} else {
this.entryEditing.meal_type = null
}
},
createMealType(event) {
if (event != "") {
let apiClient = new ApiApiFactory()
apiClient
.createMealType({ name: event })
.then((e) => {
this.$emit("reload-meal-types")
})
.catch((error) => {
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
})
}
},
selectRecipe(event) {
console.log(event, this.entryEditing)
this.missing_recipe = false
if (event.val != null) {
this.entryEditing.recipe = event.val
this.entryEditing.title_placeholder = this.entryEditing.recipe.name
this.entryEditing.servings = this.entryEditing.recipe.servings
} else {
this.entryEditing.recipe = null
this.entryEditing.title_placeholder = ""
this.entryEditing.servings = 1
}
},
},
}
</script>
<style scoped></style>

View File

@@ -0,0 +1,158 @@
<template>
<div>
<b-modal :id="`shopping_${this.modal_id}`" hide-footer @show="loadRecipe">
<template v-slot:modal-title><h4>{{$t('Add_Servings_to_Shopping',{'servings': servings})}}</h4></template>
<loading-spinner v-if="loading"></loading-spinner>
<div class="accordion" role="tablist" v-if="!loading">
<b-card no-body class="mb-1">
<b-card-header header-tag="header" class="p-1" role="tab">
<b-button block v-b-toggle.accordion-0 class="text-left" variant="outline-info">{{recipe.name}}</b-button>
</b-card-header>
<b-collapse id="accordion-0" visible accordion="my-accordion" role="tabpanel">
<ingredients-card
:steps="steps"
:recipe="recipe.id"
:ingredient_factor="ingredient_factor"
:servings="servings"
:show_shopping="true"
:add_shopping_mode="true"
:header="false"
@add-to-shopping="addShopping($event)"
/>
</b-collapse>
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
<template v-for="r in related_recipes">
<b-card no-body class="mb-1" :key="r.recipe.id">
<b-card-header header-tag="header" class="p-1" role="tab">
<b-button btn-sm block v-b-toggle="'accordion-' +r.recipe.id" class="text-left" variant="outline-primary">{{r.recipe.name}}</b-button>
</b-card-header>
<b-collapse :id="'accordion-'+r.recipe.id" accordion="my-accordion" role="tabpanel">
<ingredients-card
:steps="r.steps"
:recipe="r.recipe.id"
:ingredient_factor="ingredient_factor"
:servings="servings"
:show_shopping="true"
:add_shopping_mode="true"
:header="false"
@add-to-shopping="addShopping($event)"
/>
</b-collapse>
</b-card>
</template>
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
</b-card>
</div>
<div class="row mt-3 mb-3">
<div class="col-12 text-right">
<b-button class="mx-2" variant="secondary" @click="$bvModal.hide(`shopping_${modal_id}`)">{{ $t('Cancel') }}
</b-button>
<b-button class="mx-2" variant="success" @click="saveShopping">{{ $t('Save') }}
</b-button>
</div>
</div>
</b-modal>
</div>
</template>
<script>
import Vue from 'vue'
import {BootstrapVue} from 'bootstrap-vue'
Vue.use(BootstrapVue)
const {ApiApiFactory} = require("@/utils/openapi/api");
import {StandardToasts} from "@/utils/utils";
import IngredientsCard from "@/components/IngredientsCard";
import LoadingSpinner from "@/components/LoadingSpinner";
export default {
name: 'ShoppingModal',
components: {IngredientsCard, LoadingSpinner},
mixins: [],
props: {
recipe: {required: true, type: Object},
servings: {type: Number},
modal_id: {required: true, type: Number},
},
data() {
return {
loading: true,
steps: [],
recipe_servings: 0,
add_shopping: [],
related_recipes: []
}
},
mounted() {
},
computed: {
ingredient_factor: function () {
return this.servings / this.recipe.servings || this.recipe_servings
},
},
watch: {
},
methods: {
loadRecipe: function() {
this.add_shopping = []
this.related_recipes = []
let apiClient = new ApiApiFactory()
apiClient.retrieveRecipe(this.recipe.id).then((result) => {
this.steps = result.data.steps
// 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
this.add_shopping = [...this.add_shopping, ...this.steps.map(x => x.ingredients).flat().map(x => x.id)]
this.recipe_servings = result.data?.servings
this.loading = false
})
// get a list of related recipes
apiClient.relatedRecipe(this.recipe.id).then((result) => {
return result.data
}).then((related_recipes) => {
let promises = []
related_recipes.forEach(x => {
promises.push(apiClient.listSteps(x.id).then((recipe_steps) => {
this.related_recipes.push({
'recipe': x,
'steps': recipe_steps.data.results.filter(x => x.ingredients.length > 0)
})
}))
})
return Promise.all(promises)
}).then(() => {
this.add_shopping = [
...this.add_shopping,
...this.related_recipes.map(x => x.steps).flat().map(x => x.ingredients).flat().map(x => x.id)
]
})
},
addShopping: function(e) {
if (e.add) {
this.add_shopping.push(e.item.id)
} else {
this.add_shopping = this.add_shopping.filter(x => x !== e.item.id)
}
},
saveShopping: function() {
// another choice would be to create ShoppingListRecipes for each recipe - this bundles all related recipe under the parent recipe
let shopping_recipe = {
'id': this.recipe.id,
'ingredients': this.add_shopping,
'servings': this.servings
}
let apiClient = new ApiApiFactory()
apiClient.shoppingRecipe(this.recipe.id, shopping_recipe).then((result) => {
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
}).catch((err) => {
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
})
this.$bvModal.hide(`shopping_${this.modal_id}`)
}
}
}
</script>

View File

@@ -1,158 +1,137 @@
<template>
<b-card no-body v-hover>
<a :href="clickUrl()">
<b-card-img-lazy style="height: 15vh; object-fit: cover" class="" :src=recipe_image
v-bind:alt="$t('Recipe_Image')"
top></b-card-img-lazy>
<div class="card-img-overlay h-100 d-flex flex-column justify-content-right float-right text-right pt-2 pr-1">
<a>
<recipe-context-menu :recipe="recipe" class="float-right" v-if="recipe !== null"></recipe-context-menu>
</a>
</div>
<div class="card-img-overlay w-50 d-flex flex-column justify-content-left float-left text-left pt-2"
v-if="recipe.working_time !== 0 || recipe.waiting_time !== 0">
<b-badge pill variant="light" class="mt-1 font-weight-normal" v-if="recipe.working_time !== 0"><i class="fa fa-clock"></i>
{{ recipe.working_time }} {{ $t('min') }}
</b-badge>
<b-badge pill variant="secondary" class="mt-1 font-weight-normal" v-if="recipe.waiting_time !== 0"><i class="fa fa-pause"></i>
{{ recipe.waiting_time }} {{ $t('min') }}
</b-badge>
</div>
</a>
<b-card-body class="p-4">
<h6><a :href="clickUrl()">
<template v-if="recipe !== null">{{ recipe.name }}</template>
<template v-else>{{ meal_plan.title }}</template>
</a></h6>
<b-card-text style="text-overflow: ellipsis;">
<template v-if="recipe !== null">
<recipe-rating :recipe="recipe"></recipe-rating>
<template v-if="recipe.description !== null">
<span v-if="recipe.description.length > text_length">
{{ recipe.description.substr(0, text_length) + "\u2026" }}
</span>
<span v-if="recipe.description.length <= text_length">
{{ recipe.description }}
</span>
</template>
<p class="mt-1">
<last-cooked :recipe="recipe"></last-cooked>
<keywords-component :recipe="recipe" style="margin-top: 4px"></keywords-component>
</p>
<transition name="fade" mode="in-out">
<div class="row mt-3" v-if="detailed">
<div class="col-md-12">
<h6 class="card-title"><i class="fas fa-pepper-hot"></i> {{ $t('Ingredients') }}</h6>
<table class="table table-sm text-wrap">
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
<template v-for="s in recipe.steps">
<template v-for="i in s.ingredients">
<Ingredient-component :detailed="false" :ingredient="i" :ingredient_factor="1" :key="i.id"></Ingredient-component>
</template>
</template>
<!-- eslint-enable vue/no-v-for-template-key-on-child -->
</table>
</div>
<b-card no-body v-hover v-if="recipe">
<a :href="clickUrl()">
<b-card-img-lazy style="height: 15vh; object-fit: cover" class="" :src="recipe_image" v-bind:alt="$t('Recipe_Image')" top></b-card-img-lazy>
<div class="card-img-overlay h-100 d-flex flex-column justify-content-right float-right text-right pt-2 pr-1">
<a>
<recipe-context-menu :recipe="recipe" class="float-right" v-if="recipe !== null"></recipe-context-menu>
</a>
</div>
</transition>
<div class="card-img-overlay w-50 d-flex flex-column justify-content-left float-left text-left pt-2" v-if="recipe.waiting_time !== 0">
<b-badge pill variant="light" class="mt-1 font-weight-normal"><i class="fa fa-clock"></i> {{ recipe.working_time }} {{ $t("min") }} </b-badge>
<b-badge pill variant="secondary" class="mt-1 font-weight-normal"><i class="fa fa-pause"></i> {{ recipe.waiting_time }} {{ $t("min") }} </b-badge>
</div>
</a>
<b-badge pill variant="info" v-if="!recipe.internal">{{ $t('External') }}</b-badge>
<!-- <b-badge pill variant="success"
<b-card-body class="p-4">
<h6>
<a :href="clickUrl()">
<template v-if="recipe !== null">{{ recipe.name }}</template>
<template v-else>{{ meal_plan.title }}</template>
</a>
</h6>
<b-card-text style="text-overflow: ellipsis;">
<template v-if="recipe !== null">
<recipe-rating :recipe="recipe"></recipe-rating>
<template v-if="recipe.description !== null">
<span v-if="recipe.description.length > text_length">
{{ recipe.description.substr(0, text_length) + "\u2026" }}
</span>
<span v-if="recipe.description.length <= text_length">
{{ recipe.description }}
</span>
</template>
<p class="mt-1">
<last-cooked :recipe="recipe"></last-cooked>
<keywords :recipe="recipe" style="margin-top: 4px"></keywords>
</p>
<transition name="fade" mode="in-out">
<div class="row mt-3" v-if="detailed">
<div class="col-md-12">
<h6 class="card-title"><i class="fas fa-pepper-hot"></i> {{ $t("Ingredients") }}</h6>
<ingredients-card :steps="recipe.steps" :header="false" :detailed="false" />
</div>
</div>
</transition>
<b-badge pill variant="info" v-if="!recipe.internal">{{ $t("External") }}</b-badge>
<!-- <b-badge pill variant="success"
v-if="Date.parse(recipe.created_at) > new Date(Date.now() - (7 * (1000 * 60 * 60 * 24)))">
{{ $t('New') }}
</b-badge> -->
</template>
<template v-else>{{ meal_plan.note }}</template>
</b-card-text>
</b-card-body>
</template>
<template v-else>{{ meal_plan.note }}</template>
</b-card-text>
</b-card-body>
<b-card-footer v-if="footer_text !== undefined">
<i v-bind:class="footer_icon"></i> {{ footer_text }}
</b-card-footer>
</b-card>
<b-card-footer v-if="footer_text !== undefined"> <i v-bind:class="footer_icon"></i> {{ footer_text }} </b-card-footer>
</b-card>
</template>
<script>
import RecipeContextMenu from "@/components/RecipeContextMenu";
import {resolveDjangoUrl, ResolveUrlMixin} from "@/utils/utils";
import RecipeRating from "@/components/RecipeRating";
import moment from "moment/moment";
import Vue from "vue";
import LastCooked from "@/components/LastCooked";
import KeywordsComponent from "@/components/KeywordsComponent";
import IngredientComponent from "@/components/IngredientComponent";
import RecipeContextMenu from "@/components/ContextMenu/RecipeContextMenu"
import Keywords from "@/components/Keywords"
import { resolveDjangoUrl, ResolveUrlMixin } from "@/utils/utils"
import RecipeRating from "@/components/RecipeRating"
import moment from "moment/moment"
import Vue from "vue"
import LastCooked from "@/components/LastCooked"
import IngredientsCard from "@/components/IngredientsCard"
Vue.prototype.moment = moment
export default {
name: "RecipeCard",
mixins: [
ResolveUrlMixin,
],
components: {LastCooked, RecipeRating, KeywordsComponent, RecipeContextMenu, IngredientComponent},
props: {
recipe: Object,
meal_plan: Object,
footer_text: String,
footer_icon: String
},
computed: {
detailed: function () {
return this.recipe.steps !== undefined;
name: "RecipeCard",
mixins: [ResolveUrlMixin],
components: { LastCooked, RecipeRating, Keywords, RecipeContextMenu, IngredientsCard },
props: {
recipe: Object,
meal_plan: Object,
footer_text: String,
footer_icon: String,
},
text_length: function () {
if (this.detailed) {
return 200
} else {
return 120
}
computed: {
detailed: function() {
return this.recipe?.steps !== undefined
},
text_length: function() {
if (this.detailed) {
return 200
} else {
return 120
}
},
recipe_image: function() {
if (this.recipe == null || this.recipe.image === null) {
return window.IMAGE_PLACEHOLDER
} else {
return this.recipe.image
}
},
},
methods: {
// TODO: convert this to genericAPI
clickUrl: function() {
if (this.recipe !== null) {
return resolveDjangoUrl("view_recipe", this.recipe.id)
} else {
return resolveDjangoUrl("view_plan_entry", this.meal_plan.id)
}
},
},
directives: {
hover: {
inserted: function(el) {
el.addEventListener("mouseenter", () => {
el.classList.add("shadow")
})
el.addEventListener("mouseleave", () => {
el.classList.remove("shadow")
})
},
},
},
recipe_image: function () {
if (this.recipe == null || this.recipe.image === null) {
return window.IMAGE_PLACEHOLDER
} else {
return this.recipe.image
}
}
},
methods: {
// TODO: convert this to genericAPI
clickUrl: function () {
if (this.recipe !== null) {
return resolveDjangoUrl('view_recipe', this.recipe.id)
} else {
return resolveDjangoUrl('view_plan_entry', this.meal_plan.id)
}
}
},
directives: {
hover: {
inserted: function (el) {
el.addEventListener('mouseenter', () => {
el.classList.add("shadow")
});
el.addEventListener('mouseleave', () => {
el.classList.remove("shadow")
});
}
}
}
}
</script>
<style scoped>
.fade-enter-active, .fade-leave-active {
transition: opacity .5s;
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
opacity: 0;
}
</style>

View File

@@ -0,0 +1,269 @@
<template>
<!-- add alert at top if offline -->
<!-- get autosync time from preferences and put fetching checked items on timer -->
<!-- allow reordering or items -->
<div id="shopping_line_item">
<div class="col-12">
<div class="row">
<div class="col col-md-1">
<div style="position: static;" class=" btn-group">
<div class="dropdown b-dropdown position-static inline-block">
<button
aria-haspopup="true"
aria-expanded="false"
type="button"
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret"
@click.stop="$emit('open-context-menu', $event, entries)"
>
<i class="fas fa-ellipsis-v fa-lg"></i>
</button>
</div>
<input type="checkbox" class="text-right mx-3 mt-2" :checked="formatChecked" @change="updateChecked" :key="entries[0].id" />
</div>
</div>
<div class="col-sm-3">
<div v-if="Object.entries(formatAmount).length == 1">{{ Object.entries(formatAmount)[0][1] }} &ensp; {{ Object.entries(formatAmount)[0][0] }}</div>
<div class="small" v-else v-for="(x, i) in Object.entries(formatAmount)" :key="i">{{ x[1] }} &ensp; {{ x[0] }}</div>
</div>
<div class="col col-md-6">
{{ formatFood }} <span class="small text-muted">{{ formatHint }}</span>
</div>
<div class="col col-md-1">
<b-button size="sm" @click="showDetails = !showDetails" class="mr-2" variant="link">
<div class="text-nowrap">{{ showDetails ? "Hide" : "Show" }} Details</div>
</b-button>
</div>
</div>
<div class="card no-body" v-if="showDetails">
<div v-for="(e, z) in entries" :key="z">
<div class="row ml-2 small">
<div class="col-md-4 overflow-hidden text-nowrap">
<button
aria-haspopup="true"
aria-expanded="false"
type="button"
class="btn btn-link btn-sm m-0 p-0"
style="text-overflow: ellipsis;"
@click.stop="openRecipeCard($event, e)"
@mouseover="openRecipeCard($event, e)"
>
{{ formatOneRecipe(e) }}
</button>
</div>
<div class="col-md-4 text-muted">{{ formatOneMealPlan(e) }}</div>
<div class="col-md-4 text-muted text-right">{{ formatOneCreatedBy(e) }}</div>
</div>
<div class="row ml-2 small">
<div class="col-md-4 offset-md-8 text-muted text-right">{{ formatOneCompletedAt(e) }}</div>
</div>
<div class="row ml-2 light">
<div class="col-sm-1 text-nowrap">
<div style="position: static;" class=" btn-group ">
<div class="dropdown b-dropdown position-static inline-block">
<button
aria-haspopup="true"
aria-expanded="false"
type="button"
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret"
@click.stop="$emit('open-context-menu', $event, e)"
>
<i class="fas fa-ellipsis-v fa-lg"></i>
</button>
</div>
<input type="checkbox" class="text-right mx-3 mt-2" :checked="e.checked" @change="updateChecked($event, e)" />
</div>
</div>
<div class="col-sm-1">{{ formatOneAmount(e) }}</div>
<div class="col-sm-2">{{ formatOneUnit(e) }}</div>
<div class="col-sm-3">{{ formatOneFood(e) }}</div>
<div class="col-sm-4">
<div class="small" v-for="(n, i) in formatOneNote(e)" :key="i">{{ n }}</div>
</div>
</div>
<hr class="w-75" />
</div>
</div>
<hr class="m-1" />
</div>
<ContextMenu ref="recipe_card" triggers="click, hover" :title="$t('Filters')" style="max-width:300">
<template #menu="{ contextData }" v-if="recipe">
<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>
<script>
import Vue from "vue"
import { BootstrapVue } from "bootstrap-vue"
import "bootstrap-vue/dist/bootstrap-vue.css"
import ContextMenu from "@/components/ContextMenu/ContextMenu"
import ContextMenuItem from "@/components/ContextMenu/ContextMenuItem"
import { ApiMixin } from "@/utils/utils"
import RecipeCard from "./RecipeCard.vue"
Vue.use(BootstrapVue)
export default {
// TODO ApiGenerator doesn't capture and share error information - would be nice to share error details when available
// or i'm capturing it incorrectly
name: "ShoppingLineItem",
mixins: [ApiMixin],
components: { RecipeCard, ContextMenu, ContextMenuItem },
props: {
entries: {
type: Array,
},
groupby: { type: String },
},
data() {
return {
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
}
}
})
return amount
},
formatCategory: function() {
return this.formatOneCategory(this.entries[0]) || this.$t("Undefined")
},
formatChecked: function() {
return this.entries.map((x) => x.checked).every((x) => x === true)
},
formatHint: function() {
if (this.groupby == "recipe") {
return this.formatCategory
} else {
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(" ")
}
},
formatNotes: function() {
if (this.entries?.length == 1) {
return this.formatOneNote(this.entries[0]) || ""
}
return ""
},
},
watch: {},
mounted() {
this.servings = this.entries?.[0]?.recipe_mealplan?.servings ?? 0
},
methods: {
// 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 ""
}
return [this.$t("Completed"), "@", this.formatDate(item.completed_at)].join(" ")
},
formatOneFood: function(item) {
return item.food.name
},
formatOneChecked: function(item) {
return item.checked
},
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 [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) {
if (!item) {
let update = { entries: this.entries.map((x) => x.id), checked: !this.formatChecked }
this.$emit("update-checkbox", update)
} else {
this.$emit("update-checkbox", { id: item.id, checked: !item.checked })
}
},
},
}
</script>
<!--style src="vue-multiselect/dist/vue-multiselect.min.css"></style-->
<style>
/* table { border-collapse:collapse } /* Ensure no space between cells */
/* tr.strikeout td { position:relative } /* Setup a new coordinate system */
/* tr.strikeout td:before { /* Create a new element that */
/* content: " "; /* …has no text content */
/* position: absolute; /* …is absolutely positioned */
/* left: 0; top: 50%; width: 100%; /* …with the top across the middle */
/* border-bottom: 1px solid #000; /* …and with a border on the top */
/* } */
</style>

View File

@@ -38,12 +38,11 @@
<div class="col col-md-4"
v-if="step.ingredients.length > 0 && (recipe.steps.length > 1 || force_ingredients)">
<table class="table table-sm">
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
<template v-for="i in step.ingredients">
<Ingredient-component v-bind:ingredient="i" :ingredient_factor="ingredient_factor" :key="i.id"
@checked-state-changed="$emit('checked-state-changed', i)"></Ingredient-component>
</template>
<!-- eslint-enable vue/no-v-for-template-key-on-child -->
<ingredients-card
:steps="[step]"
:ingredient_factor="ingredient_factor"
@checked-state-changed="$emit('checked-state-changed', $event)"
/>
</table>
</div>
<div class="col" :class="{ 'col-md-8': recipe.steps.length > 1, 'col-md-12': recipe.steps.length <= 1,}">
@@ -161,6 +160,7 @@ import {calculateAmount} from "@/utils/utils";
import {GettextMixin} from "@/utils/utils";
import CompileComponent from "@/components/CompileComponent";
import IngredientsCard from "@/components/IngredientsCard";
import Vue from "vue";
import moment from "moment";
import {ResolveUrlMixin} from "@/utils/utils";
@@ -174,10 +174,7 @@ export default {
GettextMixin,
ResolveUrlMixin,
],
components: {
IngredientComponent,
CompileComponent,
},
components: { CompileComponent, IngredientsCard},
props: {
step: Object,
ingredient_factor: Number,

View File

@@ -173,6 +173,15 @@
"Time": "Time",
"Text": "Text",
"Shopping_list": "Shopping List",
"Added_by": "Added By",
"Added_on": "Added On",
"AddToShopping": "Add to shopping list",
"IngredientInShopping": "This ingredient is in your shopping list.",
"NotInShopping": "{food} is not in your shopping list.",
"OnHand": "Have On Hand",
"FoodOnHand": "You have {food} on hand.",
"FoodNotOnHand": "You do not have {food} on hand.",
"Undefined": "Undefined",
"Create_Meal_Plan_Entry": "Create meal plan entry",
"Edit_Meal_Plan_Entry": "Edit meal plan entry",
"Title": "Title",
@@ -194,6 +203,41 @@
"Title_or_Recipe_Required": "Title or recipe selection required",
"Color": "Color",
"New_Meal_Type": "New Meal type",
"AddFoodToShopping": "Add {food} to your shopping list",
"RemoveFoodFromShopping": "Remove {food} from your shopping list",
"DeleteShoppingConfirm": "Are you sure that you want to remove all {food} from the shopping list?",
"IgnoredFood": "{food} is set to ignore shopping.",
"Add_Servings_to_Shopping": "Add {servings} Servings to Shopping",
"Inherit": "Inherit",
"IgnoreInherit": "Do Not Inherit Fields",
"FoodInherit": "Food Inheritable Fields",
"ShowUncategorizedFood": "Show Undefined",
"GroupBy": "Group By",
"SupermarketCategoriesOnly": "Supermarket Categories Only",
"MoveCategory": "Move To: ",
"CountMore": "...+{count} more",
"IgnoreThis": "Never auto-add {food} to shopping",
"DelayFor": "Delay for {hours} hours",
"Warning": "Warning",
"NoCategory": "No category selected.",
"InheritWarning": "{food} is set to inherit, changes may not persist.",
"ShowDelayed": "Show Delayed Items",
"Completed": "Completed",
"OfflineAlert": "You are offline, shopping list may not syncronize.",
"shopping_share": "Share Shopping List",
"shopping_auto_sync": "Autosync",
"mealplan_autoadd_shopping": "Auto Add Meal Plan",
"mealplan_autoexclude_onhand": "Exclude On Hand",
"mealplan_autoinclude_related": "Add Related Recipes",
"default_delay": "Default Delay Hours",
"shopping_share_desc": "Users will see all items you add to your shopping list. They must add you to see items on their list.",
"shopping_auto_sync_desc": "Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but will use mobile data.",
"mealplan_autoadd_shopping_desc": "Automatically add meal plan ingredients to shopping list.",
"mealplan_autoexclude_onhand_desc": "When automatically adding a meal plan to the shopping list, exclude ingredients that are on hand.",
"mealplan_autoinclude_related_desc": "When automatically adding a meal plan to the shopping list, include all related recipes.",
"default_delay_desc": "Default number of hours to delay a shopping list entry.",
"filter_to_supermarket": "Filter to Supermarket",
"filter_to_supermarket_desc": "Filter shopping list to only include supermarket categories.",
"Week_Numbers": "Week numbers",
"Show_Week_Numbers": "Show week numbers ?",
"Export_As_ICal": "Export current period to iCal format",

View File

@@ -1,6 +1,7 @@
import axios from "axios";
import {djangoGettext as _, makeToast} from "@/utils/utils";
import {resolveDjangoUrl} from "@/utils/utils";
import {ApiApiFactory} from "@/utils/openapi/api.ts";
axios.defaults.xsrfCookieName = 'csrftoken'
axios.defaults.xsrfHeaderName = "X-CSRFTOKEN"
@@ -47,4 +48,8 @@ function handleError(error, message) {
makeToast('Error', message, 'danger')
console.log(error)
}
}
}
/*
* Generic class to use OpenAPIs with parameters and provide generic modals
* */

16
vue/src/utils/apiv2.js Normal file
View File

@@ -0,0 +1,16 @@
/*
* Utility functions to use OpenAPIs generically
* */
import {ApiApiFactory} from "@/utils/openapi/api.ts";
import axios from "axios";
axios.defaults.xsrfCookieName = 'csrftoken'
axios.defaults.xsrfHeaderName = "X-CSRFTOKEN"
export class GenericAPI {
constructor(model, action) {
this.model = model;
this.action = action;
this.function_name = action + model
}
}

View File

@@ -67,12 +67,15 @@ export class Models {
merge: true,
badges: {
linked_recipe: true,
on_hand: true,
shopping: true,
},
tags: [{ field: "supermarket_category", label: "name", color: "info" }],
// REQUIRED: unordered array of fields that can be set during create
create: {
// if not defined partialUpdate will use the same parameters, prepending 'id'
params: [["name", "description", "recipe", "ignore_shopping", "supermarket_category"]],
params: [["name", "description", "recipe", "ignore_shopping", "supermarket_category", "on_hand", "inherit", "ignore_inherit"]],
form: {
name: {
form_field: true,
@@ -101,6 +104,12 @@ export class Models {
field: "ignore_shopping",
label: i18n.t("Ignore_Shopping"),
},
onhand: {
form_field: true,
type: "checkbox",
field: "on_hand",
label: i18n.t("OnHand"),
},
shopping_category: {
form_field: true,
type: "lookup",
@@ -109,8 +118,30 @@ export class Models {
label: i18n.t("Shopping_Category"),
allow_create: true,
},
inherit: {
form_field: true,
type: "checkbox",
field: "inherit",
label: i18n.t("Inherit"),
},
ignore_inherit: {
form_field: true,
type: "lookup",
multiple: true,
field: "ignore_inherit",
list: "FOOD_INHERIT_FIELDS",
label: i18n.t("IgnoreInherit"),
},
form_function: "FoodCreateDefault",
},
},
shopping: {
params: ["id", ["id", "amount", "unit", "_delete"]],
},
}
static FOOD_INHERIT_FIELDS = {
name: i18n.t("FoodInherit"),
apiName: "FoodInheritField",
}
static KEYWORD = {
@@ -180,6 +211,12 @@ export class Models {
static SHOPPING_LIST = {
name: i18n.t("Shopping_list"),
apiName: "ShoppingListEntry",
list: {
params: ["id", "checked", "supermarket", "options"],
},
create: {
params: [["amount", "unit", "food", "checked"]],
},
}
static RECIPE_BOOK = {
@@ -370,41 +407,15 @@ export class Models {
name: i18n.t("Recipe"),
apiName: "Recipe",
list: {
params: [
"query",
"keywords",
"foods",
"units",
"rating",
"books",
"steps",
"keywordsOr",
"foodsOr",
"booksOr",
"internal",
"random",
"_new",
"page",
"pageSize",
"options",
],
config: {
foods: { type: "string" },
keywords: { type: "string" },
books: { type: "string" },
},
params: ["query", "keywords", "foods", "units", "rating", "books", "keywordsOr", "foodsOr", "booksOr", "internal", "random", "_new", "page", "pageSize", "options"],
// 'config': {
// 'foods': {'type': 'string'},
// 'keywords': {'type': 'string'},
// 'books': {'type': 'string'},
// }
},
}
static STEP = {
name: i18n.t("Step"),
apiName: "Step",
paginated: true,
list: {
header_component: {
name: "BetaWarning",
},
params: ["query", "page", "pageSize", "options"],
shopping: {
params: ["id", ["id", "list_recipe", "ingredients", "servings"]],
},
}
@@ -461,6 +472,11 @@ export class Models {
},
},
}
static USER = {
name: i18n.t("User"),
apiName: "User",
paginated: false,
}
}
export class Actions {
@@ -639,4 +655,7 @@ export class Actions {
},
},
}
static SHOPPING = {
function: "shopping",
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,7 @@ import Vue from "vue"
import { Actions, Models } from "./models"
export const ToastMixin = {
name: "ToastMixin",
methods: {
makeToast: function (title, message, variant = null) {
return makeToast(title, message, variant)
@@ -147,12 +148,17 @@ export function resolveDjangoUrl(url, params = null) {
/*
* other utilities
* */
export function getUserPreference(pref) {
if (window.USER_PREF === undefined) {
export function getUserPreference(pref = undefined) {
let user_preference
if (document.getElementById("user_preference")) {
user_preference = JSON.parse(document.getElementById("user_preference").textContent)
} else {
return undefined
}
return window.USER_PREF[pref]
if (pref) {
return user_preference[pref]
}
return user_preference
}
export function calculateAmount(amount, factor) {
@@ -214,6 +220,11 @@ export const ApiMixin = {
return {
Models: Models,
Actions: Actions,
FoodCreateDefault: function (form) {
form.inherit_ignore = getUserPreference("food_ignore_default")
form.inherit = form.supermarket_category.length > 0
return form
},
}
},
methods: {
@@ -525,3 +536,11 @@ const specialCases = {
})
},
}
export const formFunctions = {
FoodCreateDefault: function (form) {
form.fields.filter((x) => x.field === "ignore_inherit")[0].value = getUserPreference("food_ignore_default")
form.fields.filter((x) => x.field === "inherit")[0].value = getUserPreference("food_ignore_default").length > 0
return form
},
}

View File

@@ -37,8 +37,8 @@ const pages = {
entry: "./src/apps/MealPlanView/main.js",
chunks: ["chunk-vendors"],
},
checklist_view: {
entry: "./src/apps/ChecklistView/main.js",
shopping_list_view: {
entry: "./src/apps/ShoppingListView/main.js",
chunks: ["chunk-vendors"],
},
}
@@ -47,7 +47,7 @@ module.exports = {
pages: pages,
filenameHashing: false,
productionSourceMap: false,
publicPath: process.env.NODE_ENV === "production" ? "" : "http://localhost:8080/",
publicPath: process.env.NODE_ENV === "production" ? "/static/vue" : "http://localhost:8080/",
outputDir: "../cookbook/static/vue/",
runtimeCompiler: true,
pwa: {
@@ -90,18 +90,9 @@ module.exports = {
},
},
// TODO make this conditional on .env DEBUG = FALSE
config.optimization.minimize(true)
config.optimization.minimize(false)
)
//TODO somehow remov them as they are also added to the manifest config of the service worker
/*
Object.keys(pages).forEach(page => {
config.plugins.delete(`html-${page}`);
config.plugins.delete(`preload-${page}`);
config.plugins.delete(`prefetch-${page}`);
})
*/
config.plugin("BundleTracker").use(BundleTracker, [{ relativePath: true, path: "../vue/" }])
config.resolve.alias.set("__STATIC__", "static")