From aaeb162dd57505161ba85fb517676b56f693a7cb Mon Sep 17 00:00:00 2001
From: Michael Genson <71845777+michael-genson@users.noreply.github.com>
Date: Sat, 20 Aug 2022 13:59:49 -0500
Subject: [PATCH] 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
---
.../Domain/Recipe/RecipeCardSection.vue | 147 +++++-------------
frontend/composables/recipes/index.ts | 2 +-
frontend/composables/recipes/use-recipes.ts | 81 ++++------
frontend/pages/recipes/all.vue | 28 +---
frontend/pages/recipes/categories/_slug.vue | 24 +--
frontend/pages/recipes/tags/_slug.vue | 44 +++---
frontend/pages/recipes/tools/_slug.vue | 44 ++++--
mealie/repos/repository_recipes.py | 9 ++
mealie/routes/recipe/recipe_crud_routes.py | 2 +
.../test_recipe_repository.py | 82 ++++++++++
10 files changed, 231 insertions(+), 232 deletions(-)
diff --git a/frontend/components/Domain/Recipe/RecipeCardSection.vue b/frontend/components/Domain/Recipe/RecipeCardSection.vue
index 123ce953ad6a..5faad8e30ec9 100644
--- a/frontend/components/Domain/Recipe/RecipeCardSection.vue
+++ b/frontend/components/Domain/Recipe/RecipeCardSection.vue
@@ -15,49 +15,7 @@
{{ $vuetify.breakpoint.xsOnly ? null : $t("general.random") }}
-
-
-
-
- {{ $globals.icons.sort }}
-
- {{ $vuetify.breakpoint.xsOnly ? null : $t("general.sort") }}
-
-
-
-
-
- {{ $globals.icons.orderAlphabeticalAscending }}
-
- {{ $t("general.sort-alphabetically") }}
-
-
-
- {{ $globals.icons.star }}
-
- {{ $t("general.rating") }}
-
-
-
- {{ $globals.icons.newBox }}
-
- {{ $t("general.created") }}
-
-
-
- {{ $globals.icons.update }}
-
- {{ $t("general.updated") }}
-
-
-
- {{ $globals.icons.shuffleVariant }}
-
- {{ $t("general.shuffle") }}
-
-
-
-
+
@@ -147,12 +105,10 @@
-
+
+
+
+
@@ -172,11 +128,10 @@ import { useThrottleFn } from "@vueuse/core";
import RecipeCard from "./RecipeCard.vue";
import RecipeCardMobile from "./RecipeCardMobile.vue";
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 { useUserSortPreferences } from "~/composables/use-users/preferences";
-const SORT_EVENT = "sort";
const REPLACE_RECIPES_EVENT = "replaceRecipes";
const APPEND_RECIPES_EVENT = "appendRecipes";
@@ -206,16 +161,22 @@ export default defineComponent({
type: Array as () => Recipe[],
default: () => [],
},
- usePagination: {
- type: Boolean,
- default: false,
+ categorySlug: {
+ type: String,
+ default: null,
+ },
+ tagSlug: {
+ type: String,
+ default: null,
+ },
+ toolSlug: {
+ type: String,
+ default: null,
},
},
setup(props, context) {
const preferences = useUserSortPreferences();
- const utils = useSorter();
-
const EVENTS = {
az: "az",
rating: "rating",
@@ -252,26 +213,30 @@ export default defineComponent({
const hasMore = ref(true);
const ready = ref(false);
const loading = ref(false);
+ const category = ref(props.categorySlug);
+ const tag = ref(props.tagSlug);
+ const tool = ref(props.toolSlug);
const { fetchMore } = useLazyRecipes();
onMounted(async () => {
- if (props.usePagination) {
- const newRecipes = await fetchMore(
- page.value,
+ const newRecipes = await fetchMore(
+ 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
- perPage.value*2,
- preferences.value.orderBy,
- preferences.value.orderDirection
- );
+ // 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,
+ preferences.value.orderBy,
+ preferences.value.orderDirection,
+ category.value,
+ tag.value,
+ tool.value,
+ );
- // since we doubled the first call, we also need to advance the page
- page.value = page.value + 1;
+ // since we doubled the first call, we also need to advance the page
+ page.value = page.value + 1;
- context.emit(REPLACE_RECIPES_EVENT, newRecipes);
- ready.value = true;
- }
+ context.emit(REPLACE_RECIPES_EVENT, newRecipes);
+ ready.value = true;
});
const infiniteScroll = useThrottleFn(() => {
@@ -287,7 +252,10 @@ export default defineComponent({
page.value,
perPage.value,
preferences.value.orderBy,
- preferences.value.orderDirection
+ preferences.value.orderDirection,
+ category.value,
+ tag.value,
+ tool.value,
);
if (!newRecipes.length) {
hasMore.value = false;
@@ -299,12 +267,6 @@ export default defineComponent({
}, useAsyncKey());
}, 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) {
if (state.sortLoading || loading.value) {
return;
@@ -351,7 +313,10 @@ export default defineComponent({
page.value,
perPage.value,
preferences.value.orderBy,
- preferences.value.orderDirection
+ preferences.value.orderDirection,
+ category.value,
+ tag.value,
+ tool.value,
);
context.emit(REPLACE_RECIPES_EVENT, newRecipes);
@@ -360,33 +325,6 @@ export default defineComponent({
}, 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() {
preferences.value.useMobileCards = !preferences.value.useMobileCards;
}
@@ -400,7 +338,6 @@ export default defineComponent({
navigateRandom,
preferences,
sortRecipes,
- sortRecipesFrontend,
toggleMobileCards,
useMobileCards,
};
diff --git a/frontend/composables/recipes/index.ts b/frontend/composables/recipes/index.ts
index 364ffe915575..e632cc4cfc21 100644
--- a/frontend/composables/recipes/index.ts
+++ b/frontend/composables/recipes/index.ts
@@ -1,6 +1,6 @@
export { useFraction } from "./use-fraction";
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 { useRecipeSearch } from "./use-recipe-search";
export { useTools } from "./use-recipe-tools";
diff --git a/frontend/composables/recipes/use-recipes.ts b/frontend/composables/recipes/use-recipes.ts
index d6b62f05b168..176507ea609d 100644
--- a/frontend/composables/recipes/use-recipes.ts
+++ b/frontend/composables/recipes/use-recipes.ts
@@ -6,69 +6,46 @@ import { Recipe } from "~/types/api-types/recipe";
export const allRecipes = ref([]);
export const recentRecipes = ref([]);
-const rand = (n: number) => Math.floor(Math.random() * n);
-
-function swap(t: Array, i: number, j: number) {
- const q = t[i];
- t[i] = t[j];
- t[j] = q;
- return t;
-}
-
-export const useSorter = () => {
- function sortAToZ(list: Array) {
- 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) {
- list.sort((a, b) => ((a.dateAdded ?? "") > (b.dateAdded ?? "") ? -1 : 1));
- }
- function sortByUpdated(list: Array) {
- list.sort((a, b) => ((a.dateUpdated ?? "") > (b.dateUpdated ?? "") ? -1 : 1));
- }
- function sortByRating(list: Array) {
- list.sort((a, b) => ((a.rating ?? 0) > (b.rating ?? 0) ? -1 : 1));
- }
-
- function randomRecipe(list: Array): Recipe {
- return list[Math.floor(Math.random() * list.length)];
- }
-
- function shuffle(list: Array) {
- 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 () {
const api = useUserApi();
const recipes = ref([]);
- async function fetchMore(page: number, perPage: number, orderBy: string | null = null, orderDirection = "desc") {
- const { data } = await api.recipes.getAll(page, perPage, { orderBy, orderDirection });
+ 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, "categories": category, "tags": tag, "tools": tool });
return data ? data.items : [];
}
+ function appendRecipes(val: Array) {
+ val.forEach((recipe) => {
+ recipes.value.push(recipe);
+ });
+ }
+
+ function assignSorted(val: Array) {
+ 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) {
+ recipes.value = val;
+ }
+
return {
recipes,
fetchMore,
+ appendRecipes,
+ assignSorted,
+ removeRecipe,
+ replaceRecipes
};
};
diff --git a/frontend/pages/recipes/all.vue b/frontend/pages/recipes/all.vue
index bfd22736b246..40c4984d5806 100644
--- a/frontend/pages/recipes/all.vue
+++ b/frontend/pages/recipes/all.vue
@@ -4,7 +4,6 @@
:icon="$globals.icons.primary"
:title="$t('page.all-recipes')"
:recipes="recipes"
- :use-pagination="true"
@sortRecipes="assignSorted"
@replaceRecipes="replaceRecipes"
@appendRecipes="appendRecipes"
@@ -17,36 +16,11 @@
import { defineComponent } from "@nuxtjs/composition-api";
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
import { useLazyRecipes } from "~/composables/recipes";
-import { Recipe } from "~/types/api-types/recipe";
export default defineComponent({
components: { RecipeCardSection },
setup() {
- const { recipes, fetchMore } = useLazyRecipes();
-
- function appendRecipes(val: Array) {
- val.forEach((recipe) => {
- recipes.value.push(recipe);
- });
- }
-
- function assignSorted(val: Array) {
- 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) {
- recipes.value = val;
- }
-
+ const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes();
return { appendRecipes, assignSorted, recipes, removeRecipe, replaceRecipes };
},
head() {
diff --git a/frontend/pages/recipes/categories/_slug.vue b/frontend/pages/recipes/categories/_slug.vue
index 95048d9fb994..a2a8f425c5dd 100644
--- a/frontend/pages/recipes/categories/_slug.vue
+++ b/frontend/pages/recipes/categories/_slug.vue
@@ -4,8 +4,12 @@
v-if="category"
:icon="$globals.icons.tags"
:title="category.name"
- :recipes="category.recipes"
- @sort="assignSorted"
+ :recipes="recipes"
+ :category-slug="category.slug"
+ @sortRecipes="assignSorted"
+ @replaceRecipes="replaceRecipes"
+ @appendRecipes="appendRecipes"
+ @delete="removeRecipe"
>
@@ -54,13 +58,15 @@
diff --git a/frontend/pages/recipes/tags/_slug.vue b/frontend/pages/recipes/tags/_slug.vue
index 7a8cc5c5f13e..0262266334b3 100644
--- a/frontend/pages/recipes/tags/_slug.vue
+++ b/frontend/pages/recipes/tags/_slug.vue
@@ -1,11 +1,15 @@
@@ -16,7 +20,7 @@
- {{ tags.name }}
+ {{ tag.name }}
Click to Edit
@@ -54,13 +58,15 @@
diff --git a/frontend/pages/recipes/tools/_slug.vue b/frontend/pages/recipes/tools/_slug.vue
index f8fc37bd52e7..27f46cdd622b 100644
--- a/frontend/pages/recipes/tools/_slug.vue
+++ b/frontend/pages/recipes/tools/_slug.vue
@@ -1,6 +1,16 @@
-
+
@@ -10,7 +20,7 @@
- {{ tools.name }}
+ {{ tool.name }}
Click to Edit
@@ -48,13 +58,15 @@
diff --git a/mealie/repos/repository_recipes.py b/mealie/repos/repository_recipes.py
index 6b221d1ea278..413c1b42730f 100644
--- a/mealie/repos/repository_recipes.py
+++ b/mealie/repos/repository_recipes.py
@@ -136,6 +136,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
load_food=False,
categories: Optional[list[UUID4 | str]] = None,
tags: Optional[list[UUID4 | str]] = None,
+ tools: Optional[list[UUID4 | str]] = None,
) -> RecipePagination:
q = self.session.query(self.model)
@@ -169,6 +170,14 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
else:
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)
try:
diff --git a/mealie/routes/recipe/recipe_crud_routes.py b/mealie/routes/recipe/recipe_crud_routes.py
index b32ddf62ce68..7a2f64778de2 100644
--- a/mealie/routes/recipe/recipe_crud_routes.py
+++ b/mealie/routes/recipe/recipe_crud_routes.py
@@ -213,12 +213,14 @@ class RecipeController(BaseRecipeController):
q: RecipePaginationQuery = Depends(RecipePaginationQuery),
categories: 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(
pagination=q,
load_food=q.load_food,
categories=categories,
tags=tags,
+ tools=tools,
)
response.set_pagination_guides(router.url_path_for("get_all"), q.dict())
diff --git a/tests/unit_tests/repository_tests/test_recipe_repository.py b/tests/unit_tests/repository_tests/test_recipe_repository.py
index fdc2a1df8c2e..fffca83cc4ed 100644
--- a/tests/unit_tests/repository_tests/test_recipe_repository.py
+++ b/tests/unit_tests/repository_tests/test_recipe_repository.py
@@ -4,6 +4,7 @@ from mealie.repos.repository_factory import AllRepositories
from mealie.repos.repository_recipes import RepositoryRecipes
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_tool import RecipeToolSave
from tests.utils.factories import random_string
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]
for tag in created_tags:
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