feat(frontend): food filter and add back search dialog (#794)

* return ingredients and foods on summary

* filter on foods

* update search page to TS and comp-api

* add additional search fields

* feat(frontend):  add back search dialog

* update docs

* formatting

* update sidebar - remove dropdown

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-11-08 21:12:13 -09:00 committed by GitHub
parent 60275447f0
commit d4bf81dee6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 592 additions and 241 deletions

View File

@ -41,6 +41,10 @@
### 🥙 Recipes ### 🥙 Recipes
**Search**
- Search now includes the ability to fuzzy search ingredients
- Search now includes an additional filter for "Foods" which will filter (Include/Exclude) matches based on your selection.
**Recipe General** **Recipe General**
- Recipes are now only viewable by group members - Recipes are now only viewable by group members
- You can now import multiple URLs at a time pre-tagged using the bulk importer. This task runs in the background so no need to wait for it to finish. - You can now import multiple URLs at a time pre-tagged using the bulk importer. This task runs in the background so no need to wait for it to finish.

View File

@ -1,66 +1,64 @@
<template> <template>
<v-lazy> <v-expand-transition>
<v-expand-transition> <v-card
<v-card :ripple="false"
:ripple="false" class="mx-auto"
class="mx-auto" hover
hover :to="$listeners.selected ? undefined : `/recipe/${slug}`"
:to="$listeners.selected ? undefined : `/recipe/${slug}`" @click="$emit('selected')"
@click="$emit('selected')" >
> <v-list-item three-line>
<v-list-item three-line> <slot name="avatar">
<slot name="avatar"> <v-list-item-avatar tile size="125" class="v-mobile-img rounded-sm my-0 ml-n4">
<v-list-item-avatar tile size="125" class="v-mobile-img rounded-sm my-0 ml-n4"> <v-img
<v-img v-if="!fallBackImage"
v-if="!fallBackImage" :src="getImage(slug)"
:src="getImage(slug)" @load="fallBackImage = false"
@load="fallBackImage = false" @error="fallBackImage = true"
@error="fallBackImage = true" ></v-img>
></v-img> <v-icon v-else color="primary" class="icon-position" size="100">
<v-icon v-else color="primary" class="icon-position" size="100"> {{ $globals.icons.primary }}
{{ $globals.icons.primary }} </v-icon>
</v-icon> </v-list-item-avatar>
</v-list-item-avatar> </slot>
</slot> <v-list-item-content>
<v-list-item-content> <v-list-item-title class="mb-1">{{ name }} </v-list-item-title>
<v-list-item-title class="mb-1">{{ name }} </v-list-item-title> <v-list-item-subtitle> {{ description }} </v-list-item-subtitle>
<v-list-item-subtitle> {{ description }} </v-list-item-subtitle> <div class="d-flex justify-center align-center">
<div class="d-flex justify-center align-center"> <slot name="actions">
<slot name="actions"> <RecipeFavoriteBadge v-if="loggedIn" :slug="slug" show-always />
<RecipeFavoriteBadge v-if="loggedIn" :slug="slug" show-always /> <v-rating
<v-rating color="secondary"
color="secondary" class="ml-auto"
class="ml-auto" background-color="secondary lighten-3"
background-color="secondary lighten-3" dense
dense length="5"
length="5" size="15"
size="15" :value="rating"
:value="rating" ></v-rating>
></v-rating> <v-spacer></v-spacer>
<v-spacer></v-spacer> <RecipeContextMenu
<RecipeContextMenu :slug="slug"
:slug="slug" :menu-icon="$globals.icons.dotsHorizontal"
:menu-icon="$globals.icons.dotsHorizontal" :name="name"
:name="name" :recipe-id="recipeId"
:recipe-id="recipeId" :use-items="{
:use-items="{ delete: true,
delete: true, edit: true,
edit: true, download: true,
download: true, mealplanner: true,
mealplanner: true, print: false,
print: false, share: true,
share: true, }"
}" @deleted="$emit('delete', slug)"
@deleted="$emit('delete', slug)" />
/> </slot>
</slot> </div>
</div> </v-list-item-content>
</v-list-item-content> </v-list-item>
</v-list-item> <slot />
<slot /> </v-card>
</v-card> </v-expand-transition>
</v-expand-transition>
</v-lazy>
</template> </template>
<script> <script>

View File

@ -0,0 +1,171 @@
<template>
<div>
<slot v-bind="{ open, close }"> </slot>
<v-dialog v-model="dialog" max-width="988px" content-class="top-dialog" :scrollable="false">
<v-app-bar sticky dark color="primary lighten-1" :rounded="!$vuetify.breakpoint.xs">
<v-text-field
id="arrow-search"
v-model="search"
autofocus
solo
flat
autocomplete="off"
background-color="primary lighten-1"
color="white"
dense
class="mx-2 arrow-search"
hide-details
single-line
placeholder="Search"
:prepend-inner-icon="$globals.icons.search"
></v-text-field>
<v-btn v-if="$vuetify.breakpoint.xs" x-small fab light @click="dialog = false">
<v-icon>
{{ $globals.icons.close }}
</v-icon>
</v-btn>
</v-app-bar>
<v-card class="mt-1 pa-1 scroll" max-height="700px" relative :loading="loading">
<v-card-actions>
<div class="mr-auto">
{{ $t("search.results") }}
</div>
<router-link to="/search"> {{ $t("search.advanced-search") }} </router-link>
</v-card-actions>
<RecipeCardMobile
v-for="(recipe, index) in results.slice(0, 10)"
:key="index"
:tabindex="index"
class="ma-1 arrow-nav"
:name="recipe.name"
:description="recipe.description || ''"
:slug="recipe.slug"
:rating="recipe.rating"
:image="recipe.image"
:recipe-id="recipe.id"
:route="true"
v-on="$listeners.selected ? { selected: () => handleSelect(recipe) } : {}"
/>
</v-card>
</v-dialog>
</div>
</template>
<script lang="ts">
import { defineComponent, toRefs, reactive, ref } from "@nuxtjs/composition-api";
import { watch } from "vue-demi";
import RecipeCardMobile from "./RecipeCardMobile.vue";
import { useRecipes, allRecipes, useRecipeSearch } from "~/composables/recipes";
import { RecipeSummary } from "~/types/api-types/recipe";
const SELECTED_EVENT = "selected";
export default defineComponent({
components: {
RecipeCardMobile,
},
setup(_, context) {
const { refreshRecipes } = useRecipes(true, false);
const state = reactive({
loading: false,
selectedIndex: -1,
searchResults: [],
});
// ===========================================================================
// Dialong State Management
const dialog = ref(false);
// Reset or Grab Recipes on Change
watch(dialog, async (val) => {
if (!val) {
search.value = "";
state.selectedIndex = -1;
} else if (allRecipes.value && allRecipes.value.length <= 0) {
state.loading = true;
await refreshRecipes();
state.loading = false;
}
});
function open() {
dialog.value = true;
}
function close() {
dialog.value = false;
}
// ===========================================================================
// Basic Search
const { search, results } = useRecipeSearch(allRecipes);
// ===========================================================================
// Select Handler
function handleSelect(recipe: RecipeSummary) {
close();
context.emit(SELECTED_EVENT, recipe);
}
return { allRecipes, refreshRecipes, ...toRefs(state), dialog, open, close, handleSelect, search, results };
},
data() {
return {};
},
computed: {},
watch: {
$route() {
this.dialog = false;
},
dialog() {
if (!this.dialog) {
document.removeEventListener("keyup", this.onUpDown);
} else {
document.addEventListener("keyup", this.onUpDown);
}
},
},
methods: {
onUpDown(e: KeyboardEvent) {
if (e.key === "Enter") {
console.log(document.activeElement);
// (document.activeElement as HTMLElement).click();
} else if (e.key === "ArrowUp") {
e.preventDefault();
this.selectedIndex--;
} else if (e.key === "ArrowDown") {
e.preventDefault();
this.selectedIndex++;
} else {
return;
}
this.selectRecipe();
},
selectRecipe() {
const recipeCards = document.getElementsByClassName("arrow-nav");
if (recipeCards) {
if (this.selectedIndex < 0) {
this.selectedIndex = -1;
document.getElementById("arrow-search")?.focus();
return;
}
if (this.selectedIndex >= recipeCards.length) {
this.selectedIndex = recipeCards.length - 1;
}
(recipeCards[this.selectedIndex] as HTMLElement).focus();
}
},
},
});
</script>
<style>
.scroll {
overflow-y: scroll;
}
</style>

View File

@ -10,24 +10,29 @@
<div btn class="pl-2"> <div btn class="pl-2">
<v-toolbar-title style="cursor: pointer" @click="$router.push('/')"> Mealie </v-toolbar-title> <v-toolbar-title style="cursor: pointer" @click="$router.push('/')"> Mealie </v-toolbar-title>
</div> </div>
<RecipeDialogSearch ref="domSearchDialog" />
{{ value }}
<v-spacer></v-spacer> <v-spacer></v-spacer>
<!-- <v-tooltip bottom>
<template #activator="{ on, attrs }"> <div v-if="!$vuetify.breakpoint.xs" style="max-width: 500px" @click="activateSearch">
<v-btn icon class="mr-1" small v-bind="attrs" v-on="on"> <v-text-field
<v-icon v-text="isDark ? $globals.icons.weatherSunny : $globals.icons.weatherNight"> </v-icon> readonly
</v-btn> class="mt-6 rounded-xl"
</template> rounded
<span>{{ isDark ? $t("settings.theme.switch-to-light-mode") : $t("settings.theme.switch-to-dark-mode") }}</span> dark
</v-tooltip> --> solo
<!-- <div v-if="false" style="width: 350px"></div> dense
<div v-else> flat
<v-btn icon @click="$refs.recipeSearch.open()"> :prepend-inner-icon="$globals.icons.search"
<v-icon> {{ $globals.icons.search }} </v-icon> background-color="primary lighten-1"
</v-btn> color="white"
</div> --> placeholder="Press '/'"
>
</v-text-field>
</div>
<v-btn v-else icon @click="activateSearch">
<v-icon> {{ $globals.icons.search }}</v-icon>
</v-btn>
<!-- Navigation Menu --> <!-- Navigation Menu -->
<template v-if="menu"> <template v-if="menu">
@ -44,19 +49,47 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api"; import { defineComponent, ref } from "@nuxtjs/composition-api";
import RecipeDialogSearch from "~/components/Domain/Recipe/RecipeDialogSearch.vue";
export default defineComponent({ export default defineComponent({
components: { RecipeDialogSearch },
props: { props: {
value: {
type: Boolean,
default: null,
},
menu: { menu: {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
}, },
setup() {
const domSearchDialog = ref(null);
function activateSearch() {
// @ts-ignore
domSearchDialog?.value?.open();
}
return {
activateSearch,
domSearchDialog,
};
},
mounted() {
document.addEventListener("keyup", this.handleKeyEvent);
},
beforeUnmount() {
document.removeEventListener("keyup", this.handleKeyEvent);
},
methods: {
handleKeyEvent(e: any) {
if (
e.key === "/" &&
// @ts-ignore
!document.activeElement.id.startsWith("input")
) {
e.preventDefault();
this.activateSearch();
}
},
},
}); });
</script> </script>

View File

@ -5,3 +5,4 @@ export { useUnits } from "./use-recipe-units";
export { useRecipes, recentRecipes, allRecipes, useLazyRecipes, useSorter } from "./use-recipes"; export { useRecipes, recentRecipes, allRecipes, useLazyRecipes, useSorter } from "./use-recipes";
export { useTags, useCategories, allCategories, allTags } from "./use-tags-categories"; export { useTags, useCategories, allCategories, allTags } from "./use-tags-categories";
export { parseIngredientText } from "./use-recipe-ingredients"; export { parseIngredientText } from "./use-recipe-ingredients";
export { useRecipeSearch } from "./use-recipe-search";

View File

@ -0,0 +1,46 @@
import { computed, reactive, ref, Ref } from "@nuxtjs/composition-api";
import Fuse from "fuse.js";
import { Recipe } from "~/types/api-types/recipe";
export const useRecipeSearch = (recipes: Ref<Recipe[] | null>) => {
const localState = reactive({
options: {
shouldSort: true,
threshold: 0.6,
location: 0,
distance: 100,
findAllMatches: true,
maxPatternLength: 32,
minMatchCharLength: 2,
keys: ["name", "description", "recipeIngredient.note", "recipeIngredient.food.name"],
},
});
const search = ref("");
const fuse = computed(() => {
return new Fuse(recipes.value || [], localState.options);
});
const fuzzyRecipes = computed(() => {
if (search.value.trim() === "") {
return recipes.value;
}
const result = fuse.value.search(search.value.trim());
return result.map((x) => x.item);
});
const results = computed(() => {
if (!fuzzyRecipes.value) {
return [];
}
if (fuzzyRecipes.value.length > 0 && search.value.length != null && search.value.length >= 1) {
return fuzzyRecipes.value;
} else {
return recipes.value;
}
});
return { results, search };
};

View File

@ -24,16 +24,14 @@ export function useRouteQuery<T extends string | string[]>(name: string, default
return computed<any>({ return computed<any>({
get() { get() {
console.log("Getter");
const data = route.value.query[name]; const data = route.value.query[name];
if (data == null) return defaultValue ?? null; if (data == null) return defaultValue ?? null;
return data; return data;
}, },
set(v) { set(v) {
nextTick(() => { nextTick(() => {
console.log("Setter");
// @ts-ignore // @ts-ignore
router.value.replace({ query: { ...route.value.query, [name]: v } }); router.replace({ query: { ...route.value.query, [name]: v } });
}); });
}, },
}); });

View File

@ -146,23 +146,10 @@ export default defineComponent({
], ],
topLinks: [ topLinks: [
{ {
icon: this.$globals.icons.calendar, icon: this.$globals.icons.calendarMultiselect,
restricted: true,
title: this.$t("meal-plan.meal-planner"), title: this.$t("meal-plan.meal-planner"),
children: [ to: "/meal-plan/planner",
{ restricted: true,
icon: this.$globals.icons.calendarMultiselect,
title: this.$t("meal-plan.planner"),
to: "/meal-plan/planner",
restricted: true,
},
{
icon: this.$globals.icons.calendarWeek,
title: this.$t("meal-plan.dinner-this-week"),
to: "/meal-plan/this-week",
restricted: true,
},
],
}, },
{ {
icon: this.$globals.icons.formatListCheck, icon: this.$globals.icons.formatListCheck,

View File

@ -120,7 +120,6 @@ export default defineComponent({
watch(token, (newToken) => { watch(token, (newToken) => {
if (newToken) { if (newToken) {
console.log(token);
form.groupToken = newToken; form.groupToken = newToken;
} }
}); });

View File

@ -1,76 +1,128 @@
<template> <template>
<v-container> <v-container fluid>
<v-row dense> <v-container fluid class="pa-0">
<v-col> <v-row dense>
<v-text-field <v-col>
v-model="searchString" <v-text-field
outlined v-model="searchString"
color="primary accent-3" outlined
:placeholder="$t('search.search-placeholder')" autofocus
:append-icon="$globals.icons.search" color="primary accent-3"
> :placeholder="$t('search.search-placeholder')"
</v-text-field> :prepend-inner-icon="$globals.icons.search"
</v-col> clearable
<v-col cols="12" md="2" sm="12"> >
<v-text-field v-model="maxResults" class="mt-0 pt-0" :label="$t('search.max-results')" type="number" outlined /> </v-text-field>
</v-col> </v-col>
</v-row> <v-col cols="12" md="2" sm="12">
<v-text-field
v-model="maxResults"
class="mt-0 pt-0"
:label="$t('search.max-results')"
type="number"
outlined
/>
</v-col>
</v-row>
<ToggleState> <ToggleState>
<template #activator="{ state, toggle }"> <template #activator="{ state, toggle }">
<v-switch :value="state" color="info" class="ma-0 pa-0" label="Advanced" @input="toggle" @click="toggle"> <v-switch :value="state" color="info" class="ma-0 pa-0" label="Advanced" @input="toggle" @click="toggle">
Advanced Advanced
</v-switch> </v-switch>
</template> </template>
<template #default="{ state }"> <template #default="{ state }">
<v-expand-transition> <v-expand-transition>
<v-row v-show="state" dense class="my-0 dense flex-row align-center justify-space-around"> <v-row v-show="state" dense class="my-0 dense flex-row align-center justify-space-around">
<v-col> <v-col>
<h3 class="pl-2 text-center headline"> <h3 class="pl-2 text-center headline">
{{ $t("category.category-filter") }} {{ $t("category.category-filter") }}
</h3> </h3>
<RecipeSearchFilterSelector class="mb-1" @update="updateCatParams" /> <RecipeSearchFilterSelector class="mb-1" @update="updateCatParams" />
<RecipeCategoryTagSelector <RecipeCategoryTagSelector
v-model="includeCategories" v-model="includeCategories"
:solo="true" :solo="true"
:dense="false" :dense="false"
:return-object="false" :return-object="false"
/> />
</v-col> </v-col>
<v-col> <v-col>
<h3 class="pl-2 text-center headline"> <h3 class="pl-2 text-center headline">
{{ $t("search.tag-filter") }} {{ $t("search.tag-filter") }}
</h3> </h3>
<RecipeSearchFilterSelector class="mb-1" @update="updateTagParams" /> <RecipeSearchFilterSelector class="mb-1" @update="updateTagParams" />
<RecipeCategoryTagSelector <RecipeCategoryTagSelector
v-model="includeTags" v-model="includeTags"
:solo="true" :solo="true"
:dense="false" :dense="false"
:return-object="false" :return-object="false"
:tag-selector="true" :tag-selector="true"
/> />
</v-col> </v-col>
</v-row> <v-col>
</v-expand-transition> <h3 class="pl-2 text-center headline">Food Filter</h3>
</template> <RecipeSearchFilterSelector class="mb-1" @update="updateFoodParams" />
</ToggleState> <v-autocomplete
v-model="includeFoods"
<RecipeCardSection hide-details
class="mt-n5" chips
:title-icon="$globals.icons.magnify" deletable-chips
:recipes="showRecipes.slice(0, maxResults)" solo
@sort="assignFuzzy" multiple
/> :items="foods || []"
item-text="name"
class="mx-1 py-0 mb-8"
:prepend-inner-icon="$globals.icons.foods"
label="Choose Food"
>
<template #selection="data">
<v-chip
:key="data.index"
class="ma-1"
:input-value="data.selected"
close
label
color="accent"
dark
@click:close="includeFoods.splice(data.index, 1)"
>
{{ data.item.name || data.item }}
</v-chip>
</template>
</v-autocomplete>
</v-col>
</v-row>
</v-expand-transition>
</template>
</ToggleState>
</v-container>
<v-container>
<RecipeCardSection
class="mt-n5"
:title-icon="$globals.icons.magnify"
:recipes="showRecipes.slice(0, maxResults)"
@sort="assignFuzzy"
/>
</v-container>
</v-container> </v-container>
</template> </template>
<script> <script lang="ts">
import Fuse from "fuse.js"; import Fuse from "fuse.js";
import { defineComponent } from "@nuxtjs/composition-api"; import { defineComponent, toRefs, computed } from "@nuxtjs/composition-api";
import { reactive } from "vue-demi";
import RecipeSearchFilterSelector from "~/components/Domain/Recipe/RecipeSearchFilterSelector.vue"; import RecipeSearchFilterSelector from "~/components/Domain/Recipe/RecipeSearchFilterSelector.vue";
import RecipeCategoryTagSelector from "~/components/Domain/Recipe/RecipeCategoryTagSelector.vue"; import RecipeCategoryTagSelector from "~/components/Domain/Recipe/RecipeCategoryTagSelector.vue";
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue"; import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
import { useRecipes, allRecipes } from "~/composables/recipes"; import { useRecipes, allRecipes, useFoods } from "~/composables/recipes";
import { RecipeSummary } from "~/types/api-types/recipe";
import { Tag } from "~/api/class-interfaces/tags";
import { useRouteQuery } from "~/composables/use-router";
interface GenericFilter {
exclude: boolean;
matchAny: boolean;
}
export default defineComponent({ export default defineComponent({
components: { components: {
@ -81,23 +133,36 @@ export default defineComponent({
setup() { setup() {
const { assignSorted } = useRecipes(true); const { assignSorted } = useRecipes(true);
return { assignSorted, allRecipes }; // ================================================================
}, // Global State
data() {
return { const state = reactive({
maxResults: 21, maxResults: 21,
searchResults: [],
// Filters
includeCategories: [] as string[],
catFilter: { catFilter: {
exclude: false, exclude: false,
matchAny: false, matchAny: false,
}, } as GenericFilter,
includeTags: [] as string[],
tagFilter: { tagFilter: {
exclude: false, exclude: false,
matchAny: false, matchAny: false,
}, } as GenericFilter,
sortedResults: [],
includeCategories: [], includeFoods: [] as string[],
includeTags: [], foodFilter: {
exclude: false,
matchAny: false,
} as GenericFilter,
// Recipes Holders
searchResults: [] as RecipeSummary[],
sortedResults: [] as RecipeSummary[],
// Search Options
options: { options: {
shouldSort: true, shouldSort: true,
threshold: 0.6, threshold: 0.6,
@ -106,67 +171,74 @@ export default defineComponent({
findAllMatches: true, findAllMatches: true,
maxPatternLength: 32, maxPatternLength: 32,
minMatchCharLength: 2, minMatchCharLength: 2,
keys: ["name", "description"], keys: ["name", "description", "recipeIngredient.note", "recipeIngredient.food.name"],
}, },
}; });
},
head() { // ================================================================
return { // Search Functions
title: this.$t("search.search"),
}; const searchString = useRouteQuery("q", "");
},
computed: { const filteredRecipes = computed(() => {
searchString: { if (!allRecipes.value) {
set(q) { return [];
this.$router.replace({ query: { ...this.$route.query, q } }); }
}, // TODO: Fix Type Declarations for RecipeSummary
get() { return allRecipes.value.filter((recipe: RecipeSummary) => {
return this.$route.query.q || ""; const includesTags = check(
}, state.includeTags,
},
filteredRecipes() { // @ts-ignore
return this.allRecipes.filter((recipe) => { recipe.tags.map((x: Tag) => x.name),
const includesTags = this.check( state.tagFilter.matchAny,
this.includeTags, state.tagFilter.exclude
recipe.tags.map((x) => x.name),
this.tagFilter.matchAny,
this.tagFilter.exclude
); );
const includesCats = this.check( const includesCats = check(
this.includeCategories, state.includeCategories,
// @ts-ignore
recipe.recipeCategory.map((x) => x.name), recipe.recipeCategory.map((x) => x.name),
this.catFilter.matchAny, state.catFilter.matchAny,
this.catFilter.exclude state.catFilter.exclude
); );
return [includesTags, includesCats].every((x) => x === true); const includesFoods = check(
state.includeFoods,
// @ts-ignore
recipe.recipeIngredient.map((x) => x?.food?.name || ""),
state.foodFilter.matchAny,
state.foodFilter.exclude
);
return [includesTags, includesCats, includesFoods].every((x) => x === true);
}); });
}, });
fuse() {
return new Fuse(this.filteredRecipes, this.options); const fuse = computed(() => {
}, return new Fuse(filteredRecipes.value, state.options);
fuzzyRecipes() { });
if (this.searchString.trim() === "") {
return this.filteredRecipes; const fuzzyRecipes = computed(() => {
if (searchString.value.trim() === "") {
return filteredRecipes.value;
} }
const result = this.fuse.search(this.searchString.trim()); const result = fuse.value.search(searchString.value.trim());
return result.map((x) => x.item); return result.map((x) => x.item);
}, });
isSearching() {
return this.searchString && this.searchString.length > 0; const showRecipes = computed(() => {
}, if (state.sortedResults.length > 0) {
showRecipes() { return state.sortedResults;
if (this.sortedResults.length > 0) {
return this.sortedResults;
} else { } else {
return this.fuzzyRecipes; return fuzzyRecipes.value;
} }
}, });
},
methods: { // ================================================================
assignFuzzy(val) { // Utility Functions
this.sortedResults = val;
}, function check(filterBy: string[], recipeList: string[], matchAny: boolean, exclude: boolean) {
check(filterBy, recipeList, matchAny, exclude) {
let isMatch = true; let isMatch = true;
if (filterBy.length === 0) return isMatch; if (filterBy.length === 0) return isMatch;
@ -179,14 +251,41 @@ export default defineComponent({
return exclude ? !isMatch : isMatch; return exclude ? !isMatch : isMatch;
} else; } else;
return false; return false;
}, }
updateTagParams(params) { function assignFuzzy(val: RecipeSummary[]) {
this.tagFilter = params; state.sortedResults = val;
}, }
updateCatParams(params) { function updateTagParams(params: GenericFilter) {
this.catFilter = params; state.tagFilter = params;
}, }
function updateCatParams(params: GenericFilter) {
state.catFilter = params;
}
function updateFoodParams(params: GenericFilter) {
state.foodFilter = params;
}
const { foods } = useFoods();
return {
...toRefs(state),
allRecipes,
assignFuzzy,
assignSorted,
check,
foods,
searchString,
showRecipes,
updateCatParams,
updateFoodParams,
updateTagParams,
};
},
head() {
return {
title: this.$t("search.search") as string,
};
}, },
}); });
</script> </script>

View File

@ -131,8 +131,8 @@ export interface RecipeSummary {
slug?: string; slug?: string;
image?: unknown; image?: unknown;
description?: string; description?: string;
recipeCategory?: string[]; recipeCategory: string[];
tags?: string[]; tags: string[];
rating?: number; rating?: number;
dateAdded?: string; dateAdded?: string;
dateUpdated?: string; dateUpdated?: string;

View File

@ -3,6 +3,7 @@ from typing import Any
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from mealie.db.models.recipe.ingredient import RecipeIngredient
from mealie.db.models.recipe.recipe import RecipeModel from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.recipe.settings import RecipeSettings from mealie.db.models.recipe.settings import RecipeSettings
from mealie.schema.recipe import Recipe from mealie.schema.recipe import Recipe
@ -64,7 +65,11 @@ class RecipeDataAccessModel(AccessModel[Recipe, RecipeModel]):
def summary(self, group_id, start=0, limit=99999) -> Any: def summary(self, group_id, start=0, limit=99999) -> Any:
return ( return (
self.session.query(RecipeModel) self.session.query(RecipeModel)
.options(joinedload(RecipeModel.recipe_category), joinedload(RecipeModel.tags)) .options(
joinedload(RecipeModel.recipe_category),
joinedload(RecipeModel.tags),
joinedload(RecipeModel.recipe_ingredient).options(joinedload(RecipeIngredient.food)),
)
.filter(RecipeModel.group_id == group_id) .filter(RecipeModel.group_id == group_id)
.offset(start) .offset(start)
.limit(limit) .limit(limit)

View File

@ -76,6 +76,8 @@ class RecipeSummary(CamelModel):
rating: Optional[int] rating: Optional[int]
org_url: Optional[str] = Field(None, alias="orgURL") org_url: Optional[str] = Field(None, alias="orgURL")
recipe_ingredient: Optional[list[RecipeIngredient]] = []
date_added: Optional[datetime.date] date_added: Optional[datetime.date]
date_updated: Optional[datetime.datetime] date_updated: Optional[datetime.datetime]

View File

@ -60,7 +60,15 @@ class RecipeService(CrudHttpMixins[CreateRecipe, Recipe, Recipe], UserHttpServic
def get_all(self, start=0, limit=None): def get_all(self, start=0, limit=None):
items = self.db.recipes.summary(self.user.group_id, start=start, limit=limit) items = self.db.recipes.summary(self.user.group_id, start=start, limit=limit)
return [RecipeSummary.construct(**x.__dict__) for x in items]
new_items = []
for item in items:
# Pydantic/FastAPI can't seem to serialize the ingredient field on thier own.
new_item = item.__dict__
new_item["recipe_ingredient"] = [x.__dict__ for x in item.recipe_ingredient]
new_items.append(new_item)
return [RecipeSummary.construct(**x) for x in new_items]
def create_one(self, create_data: Union[Recipe, CreateRecipe]) -> Recipe: def create_one(self, create_data: Union[Recipe, CreateRecipe]) -> Recipe:
create_data = recipe_creation_factory(self.user, name=create_data.name, additional_attrs=create_data.dict()) create_data = recipe_creation_factory(self.user, name=create_data.name, additional_attrs=create_data.dict())