mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 02:03:35 -04:00 
			
		
		
		
	feat: unify recipe card sections (#1560)
* removed unused import * moved categories/tags to new recipe card section * nuked old frontend sort code minor refactoring * bug fixes * added backend recipes filter for tools * removed debug log * removed unusued props * fixed sort for recipes by tool * added tests for getting recipes by tool
This commit is contained in:
		| @@ -15,49 +15,7 @@ | |||||||
|         {{ $vuetify.breakpoint.xsOnly ? null : $t("general.random") }} |         {{ $vuetify.breakpoint.xsOnly ? null : $t("general.random") }} | ||||||
|       </v-btn> |       </v-btn> | ||||||
|  |  | ||||||
|       <v-menu v-if="$listeners.sort" offset-y left> |       <v-menu offset-y left> | ||||||
|         <template #activator="{ on, attrs }"> |  | ||||||
|           <v-btn text :icon="$vuetify.breakpoint.xsOnly" v-bind="attrs" :loading="sortLoading" v-on="on"> |  | ||||||
|             <v-icon :left="!$vuetify.breakpoint.xsOnly"> |  | ||||||
|               {{ $globals.icons.sort }} |  | ||||||
|             </v-icon> |  | ||||||
|             {{ $vuetify.breakpoint.xsOnly ? null : $t("general.sort") }} |  | ||||||
|           </v-btn> |  | ||||||
|         </template> |  | ||||||
|         <v-list> |  | ||||||
|           <v-list-item @click="sortRecipesFrontend(EVENTS.az)"> |  | ||||||
|             <v-icon left> |  | ||||||
|               {{ $globals.icons.orderAlphabeticalAscending }} |  | ||||||
|             </v-icon> |  | ||||||
|             <v-list-item-title>{{ $t("general.sort-alphabetically") }}</v-list-item-title> |  | ||||||
|           </v-list-item> |  | ||||||
|           <v-list-item @click="sortRecipesFrontend(EVENTS.rating)"> |  | ||||||
|             <v-icon left> |  | ||||||
|               {{ $globals.icons.star }} |  | ||||||
|             </v-icon> |  | ||||||
|             <v-list-item-title>{{ $t("general.rating") }}</v-list-item-title> |  | ||||||
|           </v-list-item> |  | ||||||
|           <v-list-item @click="sortRecipesFrontend(EVENTS.created)"> |  | ||||||
|             <v-icon left> |  | ||||||
|               {{ $globals.icons.newBox }} |  | ||||||
|             </v-icon> |  | ||||||
|             <v-list-item-title>{{ $t("general.created") }}</v-list-item-title> |  | ||||||
|           </v-list-item> |  | ||||||
|           <v-list-item @click="sortRecipesFrontend(EVENTS.updated)"> |  | ||||||
|             <v-icon left> |  | ||||||
|               {{ $globals.icons.update }} |  | ||||||
|             </v-icon> |  | ||||||
|             <v-list-item-title>{{ $t("general.updated") }}</v-list-item-title> |  | ||||||
|           </v-list-item> |  | ||||||
|           <v-list-item @click="sortRecipesFrontend(EVENTS.shuffle)"> |  | ||||||
|             <v-icon left> |  | ||||||
|               {{ $globals.icons.shuffleVariant }} |  | ||||||
|             </v-icon> |  | ||||||
|             <v-list-item-title>{{ $t("general.shuffle") }}</v-list-item-title> |  | ||||||
|           </v-list-item> |  | ||||||
|         </v-list> |  | ||||||
|       </v-menu> |  | ||||||
|       <v-menu v-if="$listeners.sortRecipes" offset-y left> |  | ||||||
|         <template #activator="{ on, attrs }"> |         <template #activator="{ on, attrs }"> | ||||||
|           <v-btn text :icon="$vuetify.breakpoint.xsOnly" v-bind="attrs" :loading="sortLoading" v-on="on"> |           <v-btn text :icon="$vuetify.breakpoint.xsOnly" v-bind="attrs" :loading="sortLoading" v-on="on"> | ||||||
|             <v-icon :left="!$vuetify.breakpoint.xsOnly"> |             <v-icon :left="!$vuetify.breakpoint.xsOnly"> | ||||||
| @@ -147,12 +105,10 @@ | |||||||
|         </v-col> |         </v-col> | ||||||
|       </v-row> |       </v-row> | ||||||
|     </div> |     </div> | ||||||
|     <div v-if="usePagination"> |     <v-card v-intersect="infiniteScroll"></v-card> | ||||||
|       <v-card v-intersect="infiniteScroll"></v-card> |     <v-fade-transition> | ||||||
|       <v-fade-transition> |       <AppLoader v-if="loading" :loading="loading" /> | ||||||
|         <AppLoader v-if="loading" :loading="loading" /> |     </v-fade-transition> | ||||||
|       </v-fade-transition> |  | ||||||
|     </div> |  | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| @@ -172,11 +128,10 @@ import { useThrottleFn } from "@vueuse/core"; | |||||||
| import RecipeCard from "./RecipeCard.vue"; | import RecipeCard from "./RecipeCard.vue"; | ||||||
| import RecipeCardMobile from "./RecipeCardMobile.vue"; | import RecipeCardMobile from "./RecipeCardMobile.vue"; | ||||||
| import { useAsyncKey } from "~/composables/use-utils"; | import { useAsyncKey } from "~/composables/use-utils"; | ||||||
| import { useLazyRecipes, useSorter } from "~/composables/recipes"; | import { useLazyRecipes } from "~/composables/recipes"; | ||||||
| import { Recipe } from "~/types/api-types/recipe"; | import { Recipe } from "~/types/api-types/recipe"; | ||||||
| import { useUserSortPreferences } from "~/composables/use-users/preferences"; | import { useUserSortPreferences } from "~/composables/use-users/preferences"; | ||||||
|  |  | ||||||
| const SORT_EVENT = "sort"; |  | ||||||
| const REPLACE_RECIPES_EVENT = "replaceRecipes"; | const REPLACE_RECIPES_EVENT = "replaceRecipes"; | ||||||
| const APPEND_RECIPES_EVENT = "appendRecipes"; | const APPEND_RECIPES_EVENT = "appendRecipes"; | ||||||
|  |  | ||||||
| @@ -206,16 +161,22 @@ export default defineComponent({ | |||||||
|       type: Array as () => Recipe[], |       type: Array as () => Recipe[], | ||||||
|       default: () => [], |       default: () => [], | ||||||
|     }, |     }, | ||||||
|     usePagination: { |     categorySlug: { | ||||||
|       type: Boolean, |       type: String, | ||||||
|       default: false, |       default: null, | ||||||
|  |     }, | ||||||
|  |     tagSlug: { | ||||||
|  |       type: String, | ||||||
|  |       default: null, | ||||||
|  |     }, | ||||||
|  |     toolSlug: { | ||||||
|  |       type: String, | ||||||
|  |       default: null, | ||||||
|     }, |     }, | ||||||
|   }, |   }, | ||||||
|   setup(props, context) { |   setup(props, context) { | ||||||
|     const preferences = useUserSortPreferences(); |     const preferences = useUserSortPreferences(); | ||||||
|  |  | ||||||
|     const utils = useSorter(); |  | ||||||
|  |  | ||||||
|     const EVENTS = { |     const EVENTS = { | ||||||
|       az: "az", |       az: "az", | ||||||
|       rating: "rating", |       rating: "rating", | ||||||
| @@ -252,26 +213,30 @@ export default defineComponent({ | |||||||
|     const hasMore = ref(true); |     const hasMore = ref(true); | ||||||
|     const ready = ref(false); |     const ready = ref(false); | ||||||
|     const loading = ref(false); |     const loading = ref(false); | ||||||
|  |     const category = ref<string>(props.categorySlug); | ||||||
|  |     const tag = ref<string>(props.tagSlug); | ||||||
|  |     const tool = ref<string>(props.toolSlug); | ||||||
|  |  | ||||||
|     const { fetchMore } = useLazyRecipes(); |     const { fetchMore } = useLazyRecipes(); | ||||||
|  |  | ||||||
|     onMounted(async () => { |     onMounted(async () => { | ||||||
|       if (props.usePagination) { |       const newRecipes = await fetchMore( | ||||||
|         const newRecipes = await fetchMore( |         page.value, | ||||||
|           page.value, |  | ||||||
|  |  | ||||||
|           // we double-up the first call to avoid a bug with large screens that render the entire first page without scrolling, preventing additional loading |         // we double-up the first call to avoid a bug with large screens that render the entire first page without scrolling, preventing additional loading | ||||||
|           perPage.value*2, |         perPage.value*2, | ||||||
|           preferences.value.orderBy, |         preferences.value.orderBy, | ||||||
|           preferences.value.orderDirection |         preferences.value.orderDirection, | ||||||
|         ); |         category.value, | ||||||
|  |         tag.value, | ||||||
|  |         tool.value, | ||||||
|  |       ); | ||||||
|  |  | ||||||
|         // since we doubled the first call, we also need to advance the page |       // since we doubled the first call, we also need to advance the page | ||||||
|         page.value = page.value + 1; |       page.value = page.value + 1; | ||||||
|  |  | ||||||
|         context.emit(REPLACE_RECIPES_EVENT, newRecipes); |       context.emit(REPLACE_RECIPES_EVENT, newRecipes); | ||||||
|         ready.value = true; |       ready.value = true; | ||||||
|       } |  | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     const infiniteScroll = useThrottleFn(() => { |     const infiniteScroll = useThrottleFn(() => { | ||||||
| @@ -287,7 +252,10 @@ export default defineComponent({ | |||||||
|           page.value, |           page.value, | ||||||
|           perPage.value, |           perPage.value, | ||||||
|           preferences.value.orderBy, |           preferences.value.orderBy, | ||||||
|           preferences.value.orderDirection |           preferences.value.orderDirection, | ||||||
|  |           category.value, | ||||||
|  |           tag.value, | ||||||
|  |           tool.value, | ||||||
|         ); |         ); | ||||||
|         if (!newRecipes.length) { |         if (!newRecipes.length) { | ||||||
|           hasMore.value = false; |           hasMore.value = false; | ||||||
| @@ -299,12 +267,6 @@ export default defineComponent({ | |||||||
|       }, useAsyncKey()); |       }, useAsyncKey()); | ||||||
|     }, 500); |     }, 500); | ||||||
|  |  | ||||||
|     /** |  | ||||||
|      * sortRecipes helps filter using the API. This will eventually replace the sortRecipesFrontend function which pulls all recipes |  | ||||||
|      * (without pagination) and does the sorting in the frontend. |  | ||||||
|      * TODO: remove sortRecipesFrontend and remove duplicate "sortRecipes" section in the template (above) |  | ||||||
|      * @param sortType |  | ||||||
|      */ |  | ||||||
|     function sortRecipes(sortType: string) { |     function sortRecipes(sortType: string) { | ||||||
|       if (state.sortLoading || loading.value) { |       if (state.sortLoading || loading.value) { | ||||||
|         return; |         return; | ||||||
| @@ -351,7 +313,10 @@ export default defineComponent({ | |||||||
|           page.value, |           page.value, | ||||||
|           perPage.value, |           perPage.value, | ||||||
|           preferences.value.orderBy, |           preferences.value.orderBy, | ||||||
|           preferences.value.orderDirection |           preferences.value.orderDirection, | ||||||
|  |           category.value, | ||||||
|  |           tag.value, | ||||||
|  |           tool.value, | ||||||
|         ); |         ); | ||||||
|         context.emit(REPLACE_RECIPES_EVENT, newRecipes); |         context.emit(REPLACE_RECIPES_EVENT, newRecipes); | ||||||
|  |  | ||||||
| @@ -360,33 +325,6 @@ export default defineComponent({ | |||||||
|       }, useAsyncKey()); |       }, useAsyncKey()); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     function sortRecipesFrontend(sortType: string) { |  | ||||||
|       state.sortLoading = true; |  | ||||||
|       const sortTarget = [...props.recipes]; |  | ||||||
|       switch (sortType) { |  | ||||||
|         case EVENTS.az: |  | ||||||
|           utils.sortAToZ(sortTarget); |  | ||||||
|           break; |  | ||||||
|         case EVENTS.rating: |  | ||||||
|           utils.sortByRating(sortTarget); |  | ||||||
|           break; |  | ||||||
|         case EVENTS.created: |  | ||||||
|           utils.sortByCreated(sortTarget); |  | ||||||
|           break; |  | ||||||
|         case EVENTS.updated: |  | ||||||
|           utils.sortByUpdated(sortTarget); |  | ||||||
|           break; |  | ||||||
|         case EVENTS.shuffle: |  | ||||||
|           utils.shuffle(sortTarget); |  | ||||||
|           break; |  | ||||||
|         default: |  | ||||||
|           console.log("Unknown Event", sortType); |  | ||||||
|           return; |  | ||||||
|       } |  | ||||||
|       context.emit(SORT_EVENT, sortTarget); |  | ||||||
|       state.sortLoading = false; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     function toggleMobileCards() { |     function toggleMobileCards() { | ||||||
|       preferences.value.useMobileCards = !preferences.value.useMobileCards; |       preferences.value.useMobileCards = !preferences.value.useMobileCards; | ||||||
|     } |     } | ||||||
| @@ -400,7 +338,6 @@ export default defineComponent({ | |||||||
|       navigateRandom, |       navigateRandom, | ||||||
|       preferences, |       preferences, | ||||||
|       sortRecipes, |       sortRecipes, | ||||||
|       sortRecipesFrontend, |  | ||||||
|       toggleMobileCards, |       toggleMobileCards, | ||||||
|       useMobileCards, |       useMobileCards, | ||||||
|     }; |     }; | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| export { useFraction } from "./use-fraction"; | export { useFraction } from "./use-fraction"; | ||||||
| export { useRecipe } from "./use-recipe"; | export { useRecipe } from "./use-recipe"; | ||||||
| export { useRecipes, recentRecipes, allRecipes, useLazyRecipes, useSorter } from "./use-recipes"; | export { useRecipes, recentRecipes, allRecipes, useLazyRecipes } from "./use-recipes"; | ||||||
| export { parseIngredientText } from "./use-recipe-ingredients"; | export { parseIngredientText } from "./use-recipe-ingredients"; | ||||||
| export { useRecipeSearch } from "./use-recipe-search"; | export { useRecipeSearch } from "./use-recipe-search"; | ||||||
| export { useTools } from "./use-recipe-tools"; | export { useTools } from "./use-recipe-tools"; | ||||||
|   | |||||||
| @@ -6,69 +6,46 @@ import { Recipe } from "~/types/api-types/recipe"; | |||||||
| export const allRecipes = ref<Recipe[]>([]); | export const allRecipes = ref<Recipe[]>([]); | ||||||
| export const recentRecipes = ref<Recipe[]>([]); | export const recentRecipes = ref<Recipe[]>([]); | ||||||
|  |  | ||||||
| const rand = (n: number) => Math.floor(Math.random() * n); |  | ||||||
|  |  | ||||||
| function swap(t: Array<unknown>, i: number, j: number) { |  | ||||||
|   const q = t[i]; |  | ||||||
|   t[i] = t[j]; |  | ||||||
|   t[j] = q; |  | ||||||
|   return t; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export const useSorter = () => { |  | ||||||
|   function sortAToZ(list: Array<Recipe>) { |  | ||||||
|     list.sort((a, b) => { |  | ||||||
|       const textA: string = a.name?.toUpperCase() ?? ""; |  | ||||||
|       const textB: string = b.name?.toUpperCase() ?? ""; |  | ||||||
|       return textA < textB ? -1 : textA > textB ? 1 : 0; |  | ||||||
|     }); |  | ||||||
|   } |  | ||||||
|   function sortByCreated(list: Array<Recipe>) { |  | ||||||
|     list.sort((a, b) => ((a.dateAdded ?? "") > (b.dateAdded ?? "") ? -1 : 1)); |  | ||||||
|   } |  | ||||||
|   function sortByUpdated(list: Array<Recipe>) { |  | ||||||
|     list.sort((a, b) => ((a.dateUpdated ?? "") > (b.dateUpdated ?? "") ? -1 : 1)); |  | ||||||
|   } |  | ||||||
|   function sortByRating(list: Array<Recipe>) { |  | ||||||
|     list.sort((a, b) => ((a.rating ?? 0) > (b.rating ?? 0) ? -1 : 1)); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   function randomRecipe(list: Array<Recipe>): Recipe { |  | ||||||
|     return list[Math.floor(Math.random() * list.length)]; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   function shuffle(list: Array<Recipe>) { |  | ||||||
|     let last = list.length; |  | ||||||
|     let n; |  | ||||||
|     while (last > 0) { |  | ||||||
|       n = rand(last); |  | ||||||
|       swap(list, n, --last); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   return { |  | ||||||
|     sortAToZ, |  | ||||||
|     sortByCreated, |  | ||||||
|     sortByUpdated, |  | ||||||
|     sortByRating, |  | ||||||
|     randomRecipe, |  | ||||||
|     shuffle, |  | ||||||
|   }; |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| export const useLazyRecipes = function () { | export const useLazyRecipes = function () { | ||||||
|   const api = useUserApi(); |   const api = useUserApi(); | ||||||
|  |  | ||||||
|   const recipes = ref<Recipe[]>([]); |   const recipes = ref<Recipe[]>([]); | ||||||
|  |  | ||||||
|   async function fetchMore(page: number, perPage: number, orderBy: string | null = null, orderDirection = "desc") { |   async function fetchMore(page: number, perPage: number, orderBy: string | null = null, orderDirection = "desc", category: string | null = null, tag: string | null = null, tool: string | null = null) { | ||||||
|     const { data } = await api.recipes.getAll(page, perPage, { orderBy, orderDirection }); |     const { data } = await api.recipes.getAll(page, perPage, { orderBy, orderDirection, "categories": category, "tags": tag, "tools": tool }); | ||||||
|     return data ? data.items : []; |     return data ? data.items : []; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   function appendRecipes(val: Array<Recipe>) { | ||||||
|  |     val.forEach((recipe) => { | ||||||
|  |       recipes.value.push(recipe); | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   function assignSorted(val: Array<Recipe>) { | ||||||
|  |     recipes.value = val; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   function removeRecipe(slug: string) { | ||||||
|  |     for (let i = 0; i < recipes?.value?.length; i++) { | ||||||
|  |       if (recipes?.value[i].slug === slug) { | ||||||
|  |         recipes?.value.splice(i, 1); | ||||||
|  |         break; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   function replaceRecipes(val: Array<Recipe>) { | ||||||
|  |     recipes.value = val; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   return { |   return { | ||||||
|     recipes, |     recipes, | ||||||
|     fetchMore, |     fetchMore, | ||||||
|  |     appendRecipes, | ||||||
|  |     assignSorted, | ||||||
|  |     removeRecipe, | ||||||
|  |     replaceRecipes | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -4,7 +4,6 @@ | |||||||
|       :icon="$globals.icons.primary" |       :icon="$globals.icons.primary" | ||||||
|       :title="$t('page.all-recipes')" |       :title="$t('page.all-recipes')" | ||||||
|       :recipes="recipes" |       :recipes="recipes" | ||||||
|       :use-pagination="true" |  | ||||||
|       @sortRecipes="assignSorted" |       @sortRecipes="assignSorted" | ||||||
|       @replaceRecipes="replaceRecipes" |       @replaceRecipes="replaceRecipes" | ||||||
|       @appendRecipes="appendRecipes" |       @appendRecipes="appendRecipes" | ||||||
| @@ -17,36 +16,11 @@ | |||||||
| import { defineComponent } from "@nuxtjs/composition-api"; | import { defineComponent } from "@nuxtjs/composition-api"; | ||||||
| import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue"; | import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue"; | ||||||
| import { useLazyRecipes } from "~/composables/recipes"; | import { useLazyRecipes } from "~/composables/recipes"; | ||||||
| import { Recipe } from "~/types/api-types/recipe"; |  | ||||||
|  |  | ||||||
| export default defineComponent({ | export default defineComponent({ | ||||||
|   components: { RecipeCardSection }, |   components: { RecipeCardSection }, | ||||||
|   setup() { |   setup() { | ||||||
|     const { recipes, fetchMore } = useLazyRecipes(); |     const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(); | ||||||
|  |  | ||||||
|     function appendRecipes(val: Array<Recipe>) { |  | ||||||
|       val.forEach((recipe) => { |  | ||||||
|         recipes.value.push(recipe); |  | ||||||
|       }); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     function assignSorted(val: Array<Recipe>) { |  | ||||||
|       recipes.value = val; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     function removeRecipe(slug: string) { |  | ||||||
|       for (let i = 0; i < recipes?.value?.length; i++) { |  | ||||||
|         if (recipes?.value[i].slug === slug) { |  | ||||||
|           recipes?.value.splice(i, 1); |  | ||||||
|           break; |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     function replaceRecipes(val: Array<Recipe>) { |  | ||||||
|       recipes.value = val; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     return { appendRecipes, assignSorted, recipes, removeRecipe, replaceRecipes }; |     return { appendRecipes, assignSorted, recipes, removeRecipe, replaceRecipes }; | ||||||
|   }, |   }, | ||||||
|   head() { |   head() { | ||||||
|   | |||||||
| @@ -4,8 +4,12 @@ | |||||||
|       v-if="category" |       v-if="category" | ||||||
|       :icon="$globals.icons.tags" |       :icon="$globals.icons.tags" | ||||||
|       :title="category.name" |       :title="category.name" | ||||||
|       :recipes="category.recipes" |       :recipes="recipes" | ||||||
|       @sort="assignSorted" |       :category-slug="category.slug" | ||||||
|  |       @sortRecipes="assignSorted" | ||||||
|  |       @replaceRecipes="replaceRecipes" | ||||||
|  |       @appendRecipes="appendRecipes" | ||||||
|  |       @delete="removeRecipe" | ||||||
|     > |     > | ||||||
|       <template #title> |       <template #title> | ||||||
|         <v-btn icon class="mr-1"> |         <v-btn icon class="mr-1"> | ||||||
| @@ -54,13 +58,15 @@ | |||||||
|  |  | ||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
| import { defineComponent, useAsync, useRoute, reactive, toRefs, useRouter } from "@nuxtjs/composition-api"; | import { defineComponent, useAsync, useRoute, reactive, toRefs, useRouter } from "@nuxtjs/composition-api"; | ||||||
|  | import { useLazyRecipes } from "~/composables/recipes"; | ||||||
| import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue"; | import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue"; | ||||||
| import { useUserApi } from "~/composables/api"; | import { useUserApi } from "~/composables/api"; | ||||||
| import { Recipe } from "~/types/api-types/recipe"; |  | ||||||
|  |  | ||||||
| export default defineComponent({ | export default defineComponent({ | ||||||
|   components: { RecipeCardSection }, |   components: { RecipeCardSection }, | ||||||
|   setup() { |   setup() { | ||||||
|  |     const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(); | ||||||
|  |  | ||||||
|     const api = useUserApi(); |     const api = useUserApi(); | ||||||
|     const route = useRoute(); |     const route = useRoute(); | ||||||
|     const router = useRouter(); |     const router = useRouter(); | ||||||
| @@ -105,6 +111,11 @@ export default defineComponent({ | |||||||
|       reset, |       reset, | ||||||
|       ...toRefs(state), |       ...toRefs(state), | ||||||
|       updateCategory, |       updateCategory, | ||||||
|  |       appendRecipes, | ||||||
|  |       assignSorted, | ||||||
|  |       recipes, | ||||||
|  |       removeRecipe, | ||||||
|  |       replaceRecipes, | ||||||
|     }; |     }; | ||||||
|   }, |   }, | ||||||
|   head() { |   head() { | ||||||
| @@ -112,12 +123,5 @@ export default defineComponent({ | |||||||
|       title: this.$t("category.categories") as string, |       title: this.$t("category.categories") as string, | ||||||
|     }; |     }; | ||||||
|   }, |   }, | ||||||
|   methods: { |  | ||||||
|     assignSorted(val: Array<Recipe>) { |  | ||||||
|       if (this.category) { |  | ||||||
|         this.category.recipes = val; |  | ||||||
|       } |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
| }); | }); | ||||||
| </script> | </script> | ||||||
|   | |||||||
| @@ -1,11 +1,15 @@ | |||||||
| <template> | <template> | ||||||
|   <v-container> |   <v-container> | ||||||
|     <RecipeCardSection |     <RecipeCardSection | ||||||
|       v-if="tags" |       v-if="tag" | ||||||
|       :icon="$globals.icons.tags" |       :icon="$globals.icons.tags" | ||||||
|       :title="tags.name" |       :title="tag.name" | ||||||
|       :recipes="tags.recipes" |       :recipes="recipes" | ||||||
|       @sort="assignSorted" |       :tag-slug="tag.slug" | ||||||
|  |       @sortRecipes="assignSorted" | ||||||
|  |       @replaceRecipes="replaceRecipes" | ||||||
|  |       @appendRecipes="appendRecipes" | ||||||
|  |       @delete="removeRecipe" | ||||||
|     > |     > | ||||||
|       <template #title> |       <template #title> | ||||||
|         <v-btn icon class="mr-1"> |         <v-btn icon class="mr-1"> | ||||||
| @@ -16,7 +20,7 @@ | |||||||
|  |  | ||||||
|         <template v-if="edit"> |         <template v-if="edit"> | ||||||
|           <v-text-field |           <v-text-field | ||||||
|             v-model="tags.name" |             v-model="tag.name" | ||||||
|             autofocus |             autofocus | ||||||
|             single-line |             single-line | ||||||
|             dense |             dense | ||||||
| @@ -41,7 +45,7 @@ | |||||||
|           <v-tooltip top> |           <v-tooltip top> | ||||||
|             <template #activator="{ on, attrs }"> |             <template #activator="{ on, attrs }"> | ||||||
|               <v-toolbar-title v-bind="attrs" style="cursor: pointer" class="headline" v-on="on" @click="edit = true"> |               <v-toolbar-title v-bind="attrs" style="cursor: pointer" class="headline" v-on="on" @click="edit = true"> | ||||||
|                 {{ tags.name }} |                 {{ tag.name }} | ||||||
|               </v-toolbar-title> |               </v-toolbar-title> | ||||||
|             </template> |             </template> | ||||||
|             <span> Click to Edit </span> |             <span> Click to Edit </span> | ||||||
| @@ -54,13 +58,15 @@ | |||||||
|  |  | ||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
| import { defineComponent, useAsync, useRoute, reactive, toRefs, useRouter } from "@nuxtjs/composition-api"; | import { defineComponent, useAsync, useRoute, reactive, toRefs, useRouter } from "@nuxtjs/composition-api"; | ||||||
|  | import { useLazyRecipes } from "~/composables/recipes"; | ||||||
| import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue"; | import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue"; | ||||||
| import { useUserApi } from "~/composables/api"; | import { useUserApi } from "~/composables/api"; | ||||||
| import { Recipe } from "~/types/api-types/recipe"; |  | ||||||
|  |  | ||||||
| export default defineComponent({ | export default defineComponent({ | ||||||
|   components: { RecipeCardSection }, |   components: { RecipeCardSection }, | ||||||
|   setup() { |   setup() { | ||||||
|  |     const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(); | ||||||
|  |  | ||||||
|     const api = useUserApi(); |     const api = useUserApi(); | ||||||
|     const route = useRoute(); |     const route = useRoute(); | ||||||
|     const router = useRouter(); |     const router = useRouter(); | ||||||
| @@ -71,7 +77,7 @@ export default defineComponent({ | |||||||
|       edit: false, |       edit: false, | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     const tags = useAsync(async () => { |     const tag = useAsync(async () => { | ||||||
|       const { data } = await api.tags.bySlug(slug); |       const { data } = await api.tags.bySlug(slug); | ||||||
|       if (data) { |       if (data) { | ||||||
|         state.initialValue = data.name; |         state.initialValue = data.name; | ||||||
| @@ -82,18 +88,18 @@ export default defineComponent({ | |||||||
|     function reset() { |     function reset() { | ||||||
|       state.edit = false; |       state.edit = false; | ||||||
|  |  | ||||||
|       if (tags.value) { |       if (tag.value) { | ||||||
|         tags.value.name = state.initialValue; |         tag.value.name = state.initialValue; | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     async function updateTags() { |     async function updateTags() { | ||||||
|       state.edit = false; |       state.edit = false; | ||||||
|  |  | ||||||
|       if (!tags.value) { |       if (!tag.value) { | ||||||
|         return; |         return; | ||||||
|       } |       } | ||||||
|       const { data } = await api.tags.updateOne(tags.value.id, tags.value); |       const { data } = await api.tags.updateOne(tag.value.id, tag.value); | ||||||
|  |  | ||||||
|       if (data) { |       if (data) { | ||||||
|         router.push("/recipes/tags/" + data.slug); |         router.push("/recipes/tags/" + data.slug); | ||||||
| @@ -101,10 +107,15 @@ export default defineComponent({ | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     return { |     return { | ||||||
|       tags, |       tag, | ||||||
|       reset, |       reset, | ||||||
|       ...toRefs(state), |       ...toRefs(state), | ||||||
|       updateTags, |       updateTags, | ||||||
|  |       appendRecipes, | ||||||
|  |       assignSorted, | ||||||
|  |       recipes, | ||||||
|  |       removeRecipe, | ||||||
|  |       replaceRecipes, | ||||||
|     }; |     }; | ||||||
|   }, |   }, | ||||||
|   head() { |   head() { | ||||||
| @@ -112,12 +123,5 @@ export default defineComponent({ | |||||||
|       title: this.$t("tag.tags") as string, |       title: this.$t("tag.tags") as string, | ||||||
|     }; |     }; | ||||||
|   }, |   }, | ||||||
|   methods: { |  | ||||||
|     assignSorted(val: Array<Recipe>) { |  | ||||||
|       if (this.tags) { |  | ||||||
|         this.tags.recipes = val; |  | ||||||
|       } |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
| }); | }); | ||||||
| </script> | </script> | ||||||
|   | |||||||
| @@ -1,6 +1,16 @@ | |||||||
| <template> | <template> | ||||||
|   <v-container> |   <v-container> | ||||||
|     <RecipeCardSection v-if="tools" :title="tools.name" :recipes="tools.recipes" @sort="assignSorted"> |     <RecipeCardSection | ||||||
|  |       v-if="tool" | ||||||
|  |       :icon="$globals.icons.potSteam" | ||||||
|  |       :title="tool.name" | ||||||
|  |       :recipes="recipes" | ||||||
|  |       :tool-slug="tool.slug" | ||||||
|  |       @sortRecipes="assignSorted" | ||||||
|  |       @replaceRecipes="replaceRecipes" | ||||||
|  |       @appendRecipes="appendRecipes" | ||||||
|  |       @delete="removeRecipe" | ||||||
|  |     > | ||||||
|       <template #title> |       <template #title> | ||||||
|         <v-btn icon class="mr-1"> |         <v-btn icon class="mr-1"> | ||||||
|           <v-icon dark large @click="reset"> |           <v-icon dark large @click="reset"> | ||||||
| @@ -10,7 +20,7 @@ | |||||||
|  |  | ||||||
|         <template v-if="edit"> |         <template v-if="edit"> | ||||||
|           <v-text-field |           <v-text-field | ||||||
|             v-model="tools.name" |             v-model="tool.name" | ||||||
|             autofocus |             autofocus | ||||||
|             single-line |             single-line | ||||||
|             dense |             dense | ||||||
| @@ -35,7 +45,7 @@ | |||||||
|           <v-tooltip top> |           <v-tooltip top> | ||||||
|             <template #activator="{ on, attrs }"> |             <template #activator="{ on, attrs }"> | ||||||
|               <v-toolbar-title v-bind="attrs" style="cursor: pointer" class="headline" v-on="on" @click="edit = true"> |               <v-toolbar-title v-bind="attrs" style="cursor: pointer" class="headline" v-on="on" @click="edit = true"> | ||||||
|                 {{ tools.name }} |                 {{ tool.name }} | ||||||
|               </v-toolbar-title> |               </v-toolbar-title> | ||||||
|             </template> |             </template> | ||||||
|             <span> Click to Edit </span> |             <span> Click to Edit </span> | ||||||
| @@ -48,13 +58,15 @@ | |||||||
|  |  | ||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
| import { defineComponent, useAsync, useRoute, reactive, toRefs, useRouter } from "@nuxtjs/composition-api"; | import { defineComponent, useAsync, useRoute, reactive, toRefs, useRouter } from "@nuxtjs/composition-api"; | ||||||
|  | import { useLazyRecipes } from "~/composables/recipes"; | ||||||
| import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue"; | import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue"; | ||||||
| import { useUserApi } from "~/composables/api"; | import { useUserApi } from "~/composables/api"; | ||||||
| import { Recipe } from "~/types/api-types/recipe"; |  | ||||||
|  |  | ||||||
| export default defineComponent({ | export default defineComponent({ | ||||||
|   components: { RecipeCardSection }, |   components: { RecipeCardSection }, | ||||||
|   setup() { |   setup() { | ||||||
|  |     const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(); | ||||||
|  |  | ||||||
|     const api = useUserApi(); |     const api = useUserApi(); | ||||||
|     const route = useRoute(); |     const route = useRoute(); | ||||||
|     const router = useRouter(); |     const router = useRouter(); | ||||||
| @@ -65,7 +77,7 @@ export default defineComponent({ | |||||||
|       edit: false, |       edit: false, | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     const tools = useAsync(async () => { |     const tool = useAsync(async () => { | ||||||
|       const { data } = await api.tools.bySlug(slug); |       const { data } = await api.tools.bySlug(slug); | ||||||
|       if (data) { |       if (data) { | ||||||
|         state.initialValue = data.name; |         state.initialValue = data.name; | ||||||
| @@ -76,18 +88,18 @@ export default defineComponent({ | |||||||
|     function reset() { |     function reset() { | ||||||
|       state.edit = false; |       state.edit = false; | ||||||
|  |  | ||||||
|       if (tools.value) { |       if (tool.value) { | ||||||
|         tools.value.name = state.initialValue; |         tool.value.name = state.initialValue; | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     async function updateTools() { |     async function updateTools() { | ||||||
|       state.edit = false; |       state.edit = false; | ||||||
|  |  | ||||||
|       if (!tools.value) { |       if (!tool.value) { | ||||||
|         return; |         return; | ||||||
|       } |       } | ||||||
|       const { data } = await api.tools.updateOne(tools.value.id, tools.value); |       const { data } = await api.tools.updateOne(tool.value.id, tool.value); | ||||||
|  |  | ||||||
|       if (data) { |       if (data) { | ||||||
|         router.push("/recipes/tools/" + data.slug); |         router.push("/recipes/tools/" + data.slug); | ||||||
| @@ -95,10 +107,15 @@ export default defineComponent({ | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     return { |     return { | ||||||
|       tools, |       tool, | ||||||
|       reset, |       reset, | ||||||
|       ...toRefs(state), |       ...toRefs(state), | ||||||
|       updateTools, |       updateTools, | ||||||
|  |       appendRecipes, | ||||||
|  |       assignSorted, | ||||||
|  |       recipes, | ||||||
|  |       removeRecipe, | ||||||
|  |       replaceRecipes, | ||||||
|     }; |     }; | ||||||
|   }, |   }, | ||||||
|   head() { |   head() { | ||||||
| @@ -106,12 +123,5 @@ export default defineComponent({ | |||||||
|       title: this.$t("tool.tools") as string, |       title: this.$t("tool.tools") as string, | ||||||
|     }; |     }; | ||||||
|   }, |   }, | ||||||
|   methods: { |  | ||||||
|     assignSorted(val: Array<Recipe>) { |  | ||||||
|       if (this.tools) { |  | ||||||
|         this.tools.recipes = val; |  | ||||||
|       } |  | ||||||
|     }, |  | ||||||
|   }, |  | ||||||
| }); | }); | ||||||
| </script> | </script> | ||||||
|   | |||||||
| @@ -136,6 +136,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]): | |||||||
|         load_food=False, |         load_food=False, | ||||||
|         categories: Optional[list[UUID4 | str]] = None, |         categories: Optional[list[UUID4 | str]] = None, | ||||||
|         tags: Optional[list[UUID4 | str]] = None, |         tags: Optional[list[UUID4 | str]] = None, | ||||||
|  |         tools: Optional[list[UUID4 | str]] = None, | ||||||
|     ) -> RecipePagination: |     ) -> RecipePagination: | ||||||
|         q = self.session.query(self.model) |         q = self.session.query(self.model) | ||||||
|  |  | ||||||
| @@ -169,6 +170,14 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]): | |||||||
|                 else: |                 else: | ||||||
|                     q = q.filter(RecipeModel.tags.any(Tag.slug == tag)) |                     q = q.filter(RecipeModel.tags.any(Tag.slug == tag)) | ||||||
|  |  | ||||||
|  |         if tools: | ||||||
|  |             for tool in tools: | ||||||
|  |                 if isinstance(tool, UUID): | ||||||
|  |                     q = q.filter(RecipeModel.tools.any(Tool.id == tool)) | ||||||
|  |  | ||||||
|  |                 else: | ||||||
|  |                     q = q.filter(RecipeModel.tools.any(Tool.slug == tool)) | ||||||
|  |  | ||||||
|         q, count, total_pages = self.add_pagination_to_query(q, pagination) |         q, count, total_pages = self.add_pagination_to_query(q, pagination) | ||||||
|  |  | ||||||
|         try: |         try: | ||||||
|   | |||||||
| @@ -213,12 +213,14 @@ class RecipeController(BaseRecipeController): | |||||||
|         q: RecipePaginationQuery = Depends(RecipePaginationQuery), |         q: RecipePaginationQuery = Depends(RecipePaginationQuery), | ||||||
|         categories: Optional[list[UUID4 | str]] = Query(None), |         categories: Optional[list[UUID4 | str]] = Query(None), | ||||||
|         tags: Optional[list[UUID4 | str]] = Query(None), |         tags: Optional[list[UUID4 | str]] = Query(None), | ||||||
|  |         tools: Optional[list[UUID4 | str]] = Query(None), | ||||||
|     ): |     ): | ||||||
|         response = self.repo.page_all( |         response = self.repo.page_all( | ||||||
|             pagination=q, |             pagination=q, | ||||||
|             load_food=q.load_food, |             load_food=q.load_food, | ||||||
|             categories=categories, |             categories=categories, | ||||||
|             tags=tags, |             tags=tags, | ||||||
|  |             tools=tools, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) |         response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) | ||||||
|   | |||||||
| @@ -4,6 +4,7 @@ from mealie.repos.repository_factory import AllRepositories | |||||||
| from mealie.repos.repository_recipes import RepositoryRecipes | from mealie.repos.repository_recipes import RepositoryRecipes | ||||||
| from mealie.schema.recipe.recipe import Recipe, RecipeCategory, RecipePaginationQuery, RecipeSummary | from mealie.schema.recipe.recipe import Recipe, RecipeCategory, RecipePaginationQuery, RecipeSummary | ||||||
| from mealie.schema.recipe.recipe_category import CategoryOut, CategorySave, TagSave | from mealie.schema.recipe.recipe_category import CategoryOut, CategorySave, TagSave | ||||||
|  | from mealie.schema.recipe.recipe_tool import RecipeToolSave | ||||||
| from tests.utils.factories import random_string | from tests.utils.factories import random_string | ||||||
| from tests.utils.fixture_schemas import TestUser | from tests.utils.fixture_schemas import TestUser | ||||||
|  |  | ||||||
| @@ -276,3 +277,84 @@ def test_recipe_repo_pagination_by_tags(database: AllRepositories, unique_user: | |||||||
|         tag_ids = [tag.id for tag in recipe_summary.tags] |         tag_ids = [tag.id for tag in recipe_summary.tags] | ||||||
|         for tag in created_tags: |         for tag in created_tags: | ||||||
|             assert tag.id in tag_ids |             assert tag.id in tag_ids | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_recipe_repo_pagination_by_tools(database: AllRepositories, unique_user: TestUser): | ||||||
|  |     slug1, slug2 = [random_string(10) for _ in range(2)] | ||||||
|  |  | ||||||
|  |     tools = [ | ||||||
|  |         RecipeToolSave(group_id=unique_user.group_id, name=slug1, slug=slug1), | ||||||
|  |         RecipeToolSave(group_id=unique_user.group_id, name=slug2, slug=slug2), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     created_tools = [database.tools.create(tool) for tool in tools] | ||||||
|  |  | ||||||
|  |     # Bootstrap the database with recipes | ||||||
|  |     recipes = [] | ||||||
|  |  | ||||||
|  |     for i in range(10): | ||||||
|  |         # None of the tools | ||||||
|  |         recipes.append( | ||||||
|  |             Recipe( | ||||||
|  |                 user_id=unique_user.user_id, | ||||||
|  |                 group_id=unique_user.group_id, | ||||||
|  |                 name=random_string(), | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         # Only one of the tools | ||||||
|  |         recipes.append( | ||||||
|  |             Recipe( | ||||||
|  |                 user_id=unique_user.user_id, | ||||||
|  |                 group_id=unique_user.group_id, | ||||||
|  |                 name=random_string(), | ||||||
|  |                 tools=[created_tools[i % 2]], | ||||||
|  |             ), | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         # Both of the tools | ||||||
|  |         recipes.append( | ||||||
|  |             Recipe( | ||||||
|  |                 user_id=unique_user.user_id, | ||||||
|  |                 group_id=unique_user.group_id, | ||||||
|  |                 name=random_string(), | ||||||
|  |                 tools=created_tools, | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     for recipe in recipes: | ||||||
|  |         database.recipes.create(recipe) | ||||||
|  |  | ||||||
|  |     pagination_query = RecipePaginationQuery( | ||||||
|  |         page=1, | ||||||
|  |         per_page=-1, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     # Get all recipes with only one tool by UUID | ||||||
|  |     tool_id = created_tools[0].id | ||||||
|  |     recipes_with_one_tool = database.recipes.page_all(pagination_query, tools=[tool_id]).items | ||||||
|  |     assert len(recipes_with_one_tool) == 15 | ||||||
|  |  | ||||||
|  |     for recipe_summary in recipes_with_one_tool: | ||||||
|  |         tool_ids = [tool.id for tool in recipe_summary.tools] | ||||||
|  |         assert tool_id in tool_ids | ||||||
|  |  | ||||||
|  |     # Get all recipes with only one tool by slug | ||||||
|  |     tool_slug = created_tools[1].slug | ||||||
|  |     recipes_with_one_tool = database.recipes.page_all(pagination_query, tools=[tool_slug]).items | ||||||
|  |     assert len(recipes_with_one_tool) == 15 | ||||||
|  |  | ||||||
|  |     for recipe_summary in recipes_with_one_tool: | ||||||
|  |         tool_slugs = [tool.slug for tool in recipe_summary.tools] | ||||||
|  |         assert tool_slug in tool_slugs | ||||||
|  |  | ||||||
|  |     # Get all recipes with both tools | ||||||
|  |     recipes_with_both_tools = database.recipes.page_all( | ||||||
|  |         pagination_query, tools=[tool.id for tool in created_tools] | ||||||
|  |     ).items | ||||||
|  |     assert len(recipes_with_both_tools) == 10 | ||||||
|  |  | ||||||
|  |     for recipe_summary in recipes_with_both_tools: | ||||||
|  |         tool_ids = [tool.id for tool in recipe_summary.tools] | ||||||
|  |         for tool in created_tools: | ||||||
|  |             assert tool.id in tool_ids | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user