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,5 +1,4 @@
<template> <template>
<v-lazy>
<v-expand-transition> <v-expand-transition>
<v-card <v-card
:ripple="false" :ripple="false"
@ -60,7 +59,6 @@
<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"
background-color="primary lighten-1"
color="white"
placeholder="Press '/'"
>
</v-text-field>
</div>
<v-btn v-else icon @click="activateSearch">
<v-icon> {{ $globals.icons.search }}</v-icon> <v-icon> {{ $globals.icons.search }}</v-icon>
</v-btn> </v-btn>
</div> -->
<!-- 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

@ -145,25 +145,12 @@ export default defineComponent({
}, },
], ],
topLinks: [ topLinks: [
{
icon: this.$globals.icons.calendar,
restricted: true,
title: this.$t("meal-plan.meal-planner"),
children: [
{ {
icon: this.$globals.icons.calendarMultiselect, icon: this.$globals.icons.calendarMultiselect,
title: this.$t("meal-plan.planner"), title: this.$t("meal-plan.meal-planner"),
to: "/meal-plan/planner", to: "/meal-plan/planner",
restricted: true, 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,
title: this.$t("shopping-list.shopping-lists"), title: this.$t("shopping-list.shopping-lists"),

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,18 +1,27 @@
<template> <template>
<v-container> <v-container fluid>
<v-container fluid class="pa-0">
<v-row dense> <v-row dense>
<v-col> <v-col>
<v-text-field <v-text-field
v-model="searchString" v-model="searchString"
outlined outlined
autofocus
color="primary accent-3" color="primary accent-3"
:placeholder="$t('search.search-placeholder')" :placeholder="$t('search.search-placeholder')"
:append-icon="$globals.icons.search" :prepend-inner-icon="$globals.icons.search"
clearable
> >
</v-text-field> </v-text-field>
</v-col> </v-col>
<v-col cols="12" md="2" sm="12"> <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-model="maxResults"
class="mt-0 pt-0"
:label="$t('search.max-results')"
type="number"
outlined
/>
</v-col> </v-col>
</v-row> </v-row>
@ -50,11 +59,44 @@
:tag-selector="true" :tag-selector="true"
/> />
</v-col> </v-col>
<v-col>
<h3 class="pl-2 text-center headline">Food Filter</h3>
<RecipeSearchFilterSelector class="mb-1" @update="updateFoodParams" />
<v-autocomplete
v-model="includeFoods"
hide-details
chips
deletable-chips
solo
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-row>
</v-expand-transition> </v-expand-transition>
</template> </template>
</ToggleState> </ToggleState>
</v-container>
<v-container>
<RecipeCardSection <RecipeCardSection
class="mt-n5" class="mt-n5"
:title-icon="$globals.icons.magnify" :title-icon="$globals.icons.magnify"
@ -62,15 +104,25 @@
@sort="assignFuzzy" @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 {
title: this.$t("search.search"),
};
},
computed: {
searchString: {
set(q) {
this.$router.replace({ query: { ...this.$route.query, q } });
},
get() {
return this.$route.query.q || "";
},
},
filteredRecipes() {
return this.allRecipes.filter((recipe) => {
const includesTags = this.check(
this.includeTags,
recipe.tags.map((x) => x.name),
this.tagFilter.matchAny,
this.tagFilter.exclude
);
const includesCats = this.check(
this.includeCategories,
recipe.recipeCategory.map((x) => x.name),
this.catFilter.matchAny,
this.catFilter.exclude
);
return [includesTags, includesCats].every((x) => x === true);
}); });
},
fuse() { // ================================================================
return new Fuse(this.filteredRecipes, this.options); // Search Functions
},
fuzzyRecipes() { const searchString = useRouteQuery("q", "");
if (this.searchString.trim() === "") {
return this.filteredRecipes; const filteredRecipes = computed(() => {
if (!allRecipes.value) {
return [];
} }
const result = this.fuse.search(this.searchString.trim()); // TODO: Fix Type Declarations for RecipeSummary
return allRecipes.value.filter((recipe: RecipeSummary) => {
const includesTags = check(
state.includeTags,
// @ts-ignore
recipe.tags.map((x: Tag) => x.name),
state.tagFilter.matchAny,
state.tagFilter.exclude
);
const includesCats = check(
state.includeCategories,
// @ts-ignore
recipe.recipeCategory.map((x) => x.name),
state.catFilter.matchAny,
state.catFilter.exclude
);
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);
});
});
const fuse = computed(() => {
return new Fuse(filteredRecipes.value, state.options);
});
const fuzzyRecipes = computed(() => {
if (searchString.value.trim() === "") {
return filteredRecipes.value;
}
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())