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:
Michael Genson 2022-08-20 13:59:49 -05:00 committed by GitHub
parent 85448b8a18
commit aaeb162dd5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 231 additions and 232 deletions

View File

@ -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,
}; };

View File

@ -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";

View File

@ -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
}; };
}; };

View File

@ -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() {

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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:

View File

@ -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())

View File

@ -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