1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-08-02 20:15:24 +02:00

feat(backend): rewrite mealplanner with simple api (#683)

* feat(backend):  new meal-planner feature

* feat(frontend):  new meal plan feature

* refactor(backend): ♻️ refactor base services classes and add mixins for crud

* feat(frontend):  add UI/API for mealplanner

* feat(backend):  add get_today and get_slice options for mealplanner

* test(backend):  add and update group mealplanner tests

* fix(backend): 🐛 Fix recipe_id column type for PG

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-09-12 11:05:09 -08:00 committed by GitHub
parent bdaf758712
commit b542583303
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 869 additions and 255 deletions

View file

@ -12,15 +12,14 @@ export interface CrudAPIInterface {
export interface CrudAPIMethodsInterface {
// CRUD Methods
getAll(): any
createOne(): any
getOne(): any
updateOne(): any
patchOne(): any
deleteOne(): any
getAll(): any;
createOne(): any;
getOne(): any;
updateOne(): any;
patchOne(): any;
deleteOne(): any;
}
export abstract class BaseAPI {
requests: ApiRequestInstance;
@ -33,9 +32,9 @@ export abstract class BaseCRUDAPI<T, U> extends BaseAPI implements CrudAPIInterf
abstract baseRoute: string;
abstract itemRoute(itemId: string | number): string;
async getAll(start = 0, limit = 9999) {
async getAll(start = 0, limit = 9999, params = {}) {
return await this.requests.get<T[]>(this.baseRoute, {
params: { start, limit },
params: { start, limit, ...params },
});
}

View file

@ -0,0 +1,32 @@
import { BaseCRUDAPI } from "./_base";
const prefix = "/api";
const routes = {
mealplan: `${prefix}/groups/mealplans`,
mealplanId: (id: string | number) => `${prefix}/groups/mealplans/${id}`,
};
type PlanEntryType = "breakfast" | "lunch" | "dinner" | "snack";
export interface CreateMealPlan {
date: string;
entryType: PlanEntryType;
title: string;
text: string;
recipeId?: number;
}
export interface UpdateMealPlan extends CreateMealPlan {
id: number;
groupId: number;
}
export interface MealPlan extends UpdateMealPlan {
recipe: any;
}
export class MealPlanAPI extends BaseCRUDAPI<MealPlan, CreateMealPlan> {
baseRoute = routes.mealplan;
itemRoute = routes.mealplanId;
}

View file

@ -10,10 +10,11 @@ import { UtilsAPI } from "./class-interfaces/utils";
import { NotificationsAPI } from "./class-interfaces/event-notifications";
import { FoodAPI } from "./class-interfaces/recipe-foods";
import { UnitAPI } from "./class-interfaces/recipe-units";
import { CookbookAPI } from "./class-interfaces/cookbooks";
import { CookbookAPI } from "./class-interfaces/group-cookbooks";
import { WebhooksAPI } from "./class-interfaces/group-webhooks";
import { AdminAboutAPI } from "./class-interfaces/admin-about";
import { RegisterAPI } from "./class-interfaces/user-registration";
import { MealPlanAPI } from "./class-interfaces/group-mealplan";
import { ApiRequestInstance } from "~/types/api";
class AdminAPI {
@ -48,6 +49,7 @@ class Api {
public cookbooks: CookbookAPI;
public groupWebhooks: WebhooksAPI;
public register: RegisterAPI;
public mealplans: MealPlanAPI;
// Utils
public upload: UploadFile;
@ -70,6 +72,7 @@ class Api {
this.cookbooks = new CookbookAPI(requests);
this.groupWebhooks = new WebhooksAPI(requests);
this.register = new RegisterAPI(requests);
this.mealplans = new MealPlanAPI(requests);
// Admin
this.events = new EventsAPI(requests);

View file

@ -30,6 +30,7 @@
<RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" :is-category="false" />
<RecipeContextMenu :slug="slug" :name="name" />
</v-card-actions>
<slot></slot>
</v-card>
</v-hover>
</v-lazy>
@ -58,11 +59,13 @@ export default {
},
rating: {
type: Number,
required: false,
default: 0,
},
image: {
type: String,
default: null,
required: false,
default: "abc123",
},
route: {
type: Boolean,

View file

@ -9,36 +9,41 @@
@click="$emit('selected')"
>
<v-list-item three-line>
<v-list-item-avatar tile size="125" class="v-mobile-img rounded-sm my-0 ml-n4">
<v-img
v-if="!fallBackImage"
:src="getImage(slug)"
@load="fallBackImage = false"
@error="fallBackImage = true"
></v-img>
<v-icon v-else color="primary" class="icon-position" size="100">
{{ $globals.icons.primary }}
</v-icon>
</v-list-item-avatar>
<slot name="avatar">
<v-list-item-avatar tile size="125" class="v-mobile-img rounded-sm my-0 ml-n4">
<v-img
v-if="!fallBackImage"
:src="getImage(slug)"
@load="fallBackImage = false"
@error="fallBackImage = true"
></v-img>
<v-icon v-else color="primary" class="icon-position" size="100">
{{ $globals.icons.primary }}
</v-icon>
</v-list-item-avatar>
</slot>
<v-list-item-content>
<v-list-item-title class="mb-1">{{ name }} </v-list-item-title>
<v-list-item-subtitle> {{ description }} </v-list-item-subtitle>
<div class="d-flex justify-center align-center">
<RecipeFavoriteBadge v-if="loggedIn" :slug="slug" show-always />
<v-rating
color="secondary"
class="ml-auto"
background-color="secondary lighten-3"
dense
length="5"
size="15"
:value="rating"
></v-rating>
<v-spacer></v-spacer>
<RecipeContextMenu :slug="slug" :menu-icon="$globals.icons.dotsHorizontal" :name="name" />
<slot name="actions">
<RecipeFavoriteBadge v-if="loggedIn" :slug="slug" show-always />
<v-rating
color="secondary"
class="ml-auto"
background-color="secondary lighten-3"
dense
length="5"
size="15"
:value="rating"
></v-rating>
<v-spacer></v-spacer>
<RecipeContextMenu :slug="slug" :menu-icon="$globals.icons.dotsHorizontal" :name="name" />
</slot>
</div>
</v-list-item-content>
</v-list-item>
<slot />
</v-card>
</v-expand-transition>
</v-lazy>

View file

@ -35,7 +35,7 @@
</template>
<script>
import { defineComponent } from "vue-demi";
import { defineComponent } from "@nuxtjs/composition-api";
import { useApiSingleton } from "~/composables/use-api";
const CREATED_ITEM_EVENT = "created-item";
export default defineComponent({

View file

@ -22,7 +22,7 @@
</template>
<script lang="ts">
import { defineComponent } from "vue-demi";
import { defineComponent } from "@nuxtjs/composition-api";
type SelectionValue = "include" | "exclude" | "any";

View file

@ -1,7 +1,7 @@
import { useAsync, ref, reactive, Ref } from "@nuxtjs/composition-api";
import { useAsyncKey } from "./use-utils";
import { useApiSingleton } from "~/composables/use-api";
import { CookBook } from "~/api/class-interfaces/cookbooks";
import { CookBook } from "~/api/class-interfaces/group-cookbooks";
let cookbookStore: Ref<CookBook[] | null> | null = null;

View file

@ -0,0 +1,80 @@
import { useAsync, ref } from "@nuxtjs/composition-api";
import { addDays, subDays, format } from "date-fns";
import { useAsyncKey } from "./use-utils";
import { useApiSingleton } from "~/composables/use-api";
import { CreateMealPlan, UpdateMealPlan } from "~/api/class-interfaces/group-mealplan";
export const useMealplans = function () {
const api = useApiSingleton();
const loading = ref(false);
const validForm = ref(true);
const actions = {
getAll() {
loading.value = true;
const units = useAsync(async () => {
const query = {
start: format(subDays(new Date(), 30), "yyyy-MM-dd"),
limit: format(addDays(new Date(), 30), "yyyy-MM-dd"),
};
// @ts-ignore
const { data } = await api.mealplans.getAll(query.start, query.limit);
return data;
}, useAsyncKey());
loading.value = false;
return units;
},
async refreshAll() {
loading.value = true;
const query = {
start: format(subDays(new Date(), 30), "yyyy-MM-dd"),
limit: format(addDays(new Date(), 30), "yyyy-MM-dd"),
};
// @ts-ignore
const { data } = await api.mealplans.getAll(query.start, query.limit);
if (data) {
mealplans.value = data;
}
loading.value = false;
},
async createOne(payload: CreateMealPlan) {
loading.value = true;
const { data } = await api.mealplans.createOne(payload);
if (data) {
this.refreshAll();
}
loading.value = false;
},
async updateOne(updateData: UpdateMealPlan) {
if (!updateData.id) {
return;
}
loading.value = true;
// @ts-ignore
const { data } = await api.mealplans.updateOne(updateData.id, updateData);
if (data) {
this.refreshAll();
}
loading.value = false;
},
async deleteOne(id: string | number) {
loading.value = true;
const { data } = await api.mealplans.deleteOne(id);
if (data) {
this.refreshAll();
}
},
};
const mealplans = actions.getAll();
return { mealplans, actions, validForm };
};

View file

@ -24,6 +24,7 @@
"@vue/composition-api": "^1.0.5",
"@vueuse/core": "^5.2.0",
"core-js": "^3.15.1",
"date-fns": "^2.23.0",
"fuse.js": "^6.4.6",
"nuxt": "^2.15.7",
"vuedraggable": "^2.24.3",
@ -51,4 +52,4 @@
"resolutions": {
"vite": "2.3.8"
}
}
}

View file

@ -1,16 +1,209 @@
<template>
<div></div>
</template>
<v-container>
<v-card>
<v-card-title class="headline">New Recipe</v-card-title>
<v-card-text>
<v-menu
v-model="pickerMenu"
:close-on-content-click="false"
transition="scale-transition"
offset-y
max-width="290px"
min-width="auto"
>
<template #activator="{ on, attrs }">
<v-text-field
v-model="newMeal.date"
label="Date"
hint="MM/DD/YYYY format"
persistent-hint
:prepend-icon="$globals.icons.calendar"
v-bind="attrs"
readonly
v-on="on"
></v-text-field>
</template>
<v-date-picker v-model="newMeal.date" no-title @input="pickerMenu = false"></v-date-picker>
</v-menu>
<v-autocomplete
v-if="!noteOnly"
v-model="newMeal.recipeId"
label="Meal Recipe"
:items="allRecipes"
item-text="name"
item-value="id"
:return-object="false"
></v-autocomplete>
<template v-else>
<v-text-field v-model="newMeal.title" label="Meal Title"> </v-text-field>
<v-textarea v-model="newMeal.text" label="Meal Note"> </v-textarea>
</template>
</v-card-text>
<v-card-actions>
<v-switch v-model="noteOnly" label="Note Only"></v-switch>
<v-spacer></v-spacer>
<BaseButton @click="actions.createOne(newMeal)" />
</v-card-actions>
</v-card>
<div class="d-flex justify-center my-2 align-center" style="gap: 10px">
<v-btn icon color="info" rounded outlined @click="backOneWeek">
<v-icon>{{ $globals.icons.back }} </v-icon>
</v-btn>
<v-btn rounded outlined readonly style="pointer-events: none">
{{ $d(weekRange.start, "short") }} - {{ $d(weekRange.end, "short") }}
</v-btn>
<v-btn icon color="info" rounded outlined @click="forwardOneWeek">
<v-icon>{{ $globals.icons.forward }} </v-icon>
</v-btn>
</div>
<v-row class="mt-2">
<v-col v-for="(plan, index) in mealsByDate" :key="index" cols="12" sm="12" md="4" lg="3" xl="2">
<p class="h5 text-center">
{{ $d(plan.date, "short") }}
</p>
<draggable
tag="div"
:value="plan.meals"
group="meals"
:data-index="index"
:data-box="plan.date"
style="min-height: 150px"
@end="onMoveCallback"
>
<v-hover v-for="mealplan in plan.meals" :key="mealplan.id" v-model="hover[mealplan.id]" open-delay="100">
<v-card class="my-2">
<v-list-item>
<v-list-item-content>
<v-list-item-title class="mb-1">
{{ mealplan.recipe ? mealplan.recipe.name : mealplan.title }}
</v-list-item-title>
<v-list-item-subtitle>
{{ mealplan.recipe ? mealplan.recipe.description : mealplan.text }}
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-card>
</v-hover>
</draggable>
</v-col>
</v-row>
</v-container>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api"
export default defineComponent({
setup() {
return {}
<script lang="ts">
import { computed, defineComponent, reactive, toRefs } from "@nuxtjs/composition-api";
import { isSameDay, addDays, subDays, parseISO, format } from "date-fns";
import { SortableEvent } from "sortablejs"; // eslint-disable-line
import draggable from "vuedraggable";
import { useMealplans } from "~/composables/use-group-mealplan";
import { useRecipes, allRecipes } from "~/composables/use-recipes";
export default defineComponent({
components: {
draggable,
},
setup() {
const { mealplans, actions } = useMealplans();
useRecipes(true, true);
const state = reactive({
hover: {},
pickerMenu: null,
noteOnly: false,
start: null as Date | null,
today: new Date(),
end: null as Date | null,
});
function filterMealByDate(date: Date) {
if (!mealplans.value) return;
return mealplans.value.filter((meal) => {
const mealDate = parseISO(meal.date);
return isSameDay(mealDate, date);
});
}
})
</script>
<style scoped>
</style>
function forwardOneWeek() {
if (!state.today) return;
// @ts-ignore
state.today = addDays(state.today, +5);
}
function backOneWeek() {
if (!state.today) return;
// @ts-ignore
state.today = addDays(state.today, -5);
}
function onMoveCallback(evt: SortableEvent) {
// Adapted From https://github.com/SortableJS/Vue.Draggable/issues/1029
const ogEvent: DragEvent = (evt as any).originalEvent;
if (ogEvent && ogEvent.type !== "drop") {
// The drop was cancelled, unsure if anything needs to be done?
console.log("Cancel Move Event");
} else {
// A Meal was moved, set the new date value and make a update request and refresh the meals
const fromMealsByIndex = evt.from.getAttribute("data-index");
const toMealsByIndex = evt.to.getAttribute("data-index");
if (fromMealsByIndex) {
// @ts-ignore
const mealData = mealsByDate.value[fromMealsByIndex].meals[evt.oldIndex as number];
// @ts-ignore
const destDate = mealsByDate.value[toMealsByIndex].date;
mealData.date = format(destDate, "yyyy-MM-dd");
actions.updateOne(mealData);
}
}
}
const mealsByDate = computed(() => {
return days.value.map((day) => {
return { date: day, meals: filterMealByDate(day as any) };
});
});
const weekRange = computed(() => {
// @ts-ignore - Not Sure Why This is not working
const end = addDays(state.today, 2);
// @ts-ignore - Not sure why the type is invalid
const start = subDays(state.today, 2);
return { start, end, today: state.today };
});
const days = computed(() => {
if (weekRange.value?.start === null) return [];
return Array.from(Array(8).keys()).map(
// @ts-ignore
(i) => new Date(weekRange.value.start.getTime() + i * 24 * 60 * 60 * 1000)
);
});
const newMeal = reactive({
date: null,
title: "",
text: "",
recipeId: null,
});
return {
mealplans,
actions,
newMeal,
allRecipes,
...toRefs(state),
mealsByDate,
onMoveCallback,
backOneWeek,
forwardOneWeek,
weekRange,
days,
};
},
});
</script>

View file

@ -1,16 +1,16 @@
<template>
<div></div>
</template>
<div></div>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api"
export default defineComponent({
setup() {
return {}
}
})
</script>
import { defineComponent } from "@nuxtjs/composition-api";
export default defineComponent({
setup() {
return {};
},
});
</script>
<style scoped>
</style>
</style>

View file

@ -67,7 +67,7 @@
<script>
import Fuse from "fuse.js";
import { defineComponent } from "vue-demi";
import { defineComponent } from "@nuxtjs/composition-api";
import RecipeSearchFilterSelector from "~/components/Domain/Recipe/RecipeSearchFilterSelector.vue";
import RecipeCategoryTagSelector from "~/components/Domain/Recipe/RecipeCategoryTagSelector.vue";
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";

View file

@ -95,6 +95,7 @@ import {
mdiFoodApple,
mdiBeakerOutline,
mdiArrowLeftBoldOutline,
mdiArrowRightBoldOutline,
} from "@mdi/js";
const icons = {
@ -204,6 +205,9 @@ const icons = {
admin: mdiAccountCog,
group: mdiAccountGroup,
accountPlusOutline: mdiAccountPlusOutline,
forward: mdiArrowRightBoldOutline,
back: mdiArrowLeftBoldOutline,
};
// eslint-disable-next-line no-empty-pattern

View file

@ -16,7 +16,7 @@
"~/*": ["./*"],
"@/*": ["./*"]
},
"types": ["@nuxt/types", "@nuxtjs/axios", "@types/node", "@nuxtjs/i18n", "@nuxtjs/auth-next"]
"types": ["@nuxt/types", "@nuxtjs/axios", "@types/node", "@nuxtjs/i18n", "@nuxtjs/auth-next", "@types/sortablejs"]
},
"exclude": ["node_modules", ".nuxt", "dist"],
"vueCompilerOptions": {

View file

@ -4218,6 +4218,11 @@ cyclist@^1.0.1:
resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9"
integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk=
date-fns@^2.23.0:
version "2.23.0"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.23.0.tgz#4e886c941659af0cf7b30fafdd1eaa37e88788a9"
integrity sha512-5ycpauovVyAk0kXNZz6ZoB9AYMZB4DObse7P3BPWmyEjXNORTI8EJ6X0uaSAq4sCHzM1uajzrkr6HnsLQpxGXA==
de-indent@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d"