1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-07-24 23:59:45 +02:00

feat: Filter Recipes By Household (and a ton of bug fixes) (#4207)

Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
Michael Genson 2024-09-22 09:59:20 -05:00 committed by GitHub
parent 2a6922a85c
commit 7c274de778
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 896 additions and 590 deletions

View file

@ -69,50 +69,52 @@
@toggle-dense-view="toggleMobileCards()"
/>
</v-app-bar>
<div v-if="recipes" class="mt-2">
<v-row v-if="!useMobileCards">
<v-col v-for="(recipe, index) in recipes" :key="recipe.slug + index" :sm="6" :md="6" :lg="4" :xl="3">
<v-lazy>
<RecipeCard
:name="recipe.name"
:description="recipe.description"
:slug="recipe.slug"
:rating="recipe.rating"
:image="recipe.image"
:tags="recipe.tags"
:recipe-id="recipe.id"
/>
</v-lazy>
</v-col>
</v-row>
<v-row v-else dense>
<v-col
v-for="recipe in recipes"
:key="recipe.name"
cols="12"
:sm="singleColumn ? '12' : '12'"
:md="singleColumn ? '12' : '6'"
:lg="singleColumn ? '12' : '4'"
:xl="singleColumn ? '12' : '3'"
>
<v-lazy>
<RecipeCardMobile
:name="recipe.name"
:description="recipe.description"
:slug="recipe.slug"
:rating="recipe.rating"
:image="recipe.image"
:tags="recipe.tags"
:recipe-id="recipe.id"
/>
</v-lazy>
</v-col>
</v-row>
<div v-if="recipes && ready">
<div class="mt-2">
<v-row v-if="!useMobileCards">
<v-col v-for="(recipe, index) in recipes" :key="recipe.slug + index" :sm="6" :md="6" :lg="4" :xl="3">
<v-lazy>
<RecipeCard
:name="recipe.name"
:description="recipe.description"
:slug="recipe.slug"
:rating="recipe.rating"
:image="recipe.image"
:tags="recipe.tags"
:recipe-id="recipe.id"
/>
</v-lazy>
</v-col>
</v-row>
<v-row v-else dense>
<v-col
v-for="recipe in recipes"
:key="recipe.name"
cols="12"
:sm="singleColumn ? '12' : '12'"
:md="singleColumn ? '12' : '6'"
:lg="singleColumn ? '12' : '4'"
:xl="singleColumn ? '12' : '3'"
>
<v-lazy>
<RecipeCardMobile
:name="recipe.name"
:description="recipe.description"
:slug="recipe.slug"
:rating="recipe.rating"
:image="recipe.image"
:tags="recipe.tags"
:recipe-id="recipe.id"
/>
</v-lazy>
</v-col>
</v-row>
</div>
<v-card v-intersect="infiniteScroll"></v-card>
<v-fade-transition>
<AppLoader v-if="loading" :loading="loading" />
</v-fade-transition>
</div>
<v-card v-intersect="infiniteScroll"></v-card>
<v-fade-transition>
<AppLoader v-if="loading" :loading="loading" />
</v-fade-transition>
</div>
</template>
@ -223,36 +225,42 @@ export default defineComponent({
const queryFilter = computed(() => {
const orderBy = props.query?.orderBy || preferences.value.orderBy;
return preferences.value.filterNull && orderBy ? `${orderBy} IS NOT NULL` : null;
const orderByFilter = preferences.value.filterNull && orderBy ? `${orderBy} IS NOT NULL` : null;
if (props.query.queryFilter && orderByFilter) {
return `(${props.query.queryFilter}) AND ${orderByFilter}`;
} else if (props.query.queryFilter) {
return props.query.queryFilter;
} else {
return orderByFilter;
}
});
async function fetchRecipes(pageCount = 1) {
return 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 * pageCount,
props.query?.orderBy || preferences.value.orderBy,
props.query?.orderDirection || preferences.value.orderDirection,
props.query,
// filter out recipes that have a null value for the property we're sorting by
// we use a computed queryFilter to filter out recipes that have a null value for the property we're sorting by
queryFilter.value
);
}
onMounted(async () => {
if (props.query) {
await initRecipes();
ready.value = true;
}
await initRecipes();
ready.value = true;
});
let lastQuery: string | undefined;
let lastQuery: string | undefined = JSON.stringify(props.query);
watch(
() => props.query,
async (newValue: RecipeSearchQuery | undefined) => {
const newValueString = JSON.stringify(newValue)
if (newValue && (!ready.value || lastQuery !== newValueString)) {
if (lastQuery !== newValueString) {
lastQuery = newValueString;
ready.value = false;
await initRecipes();
ready.value = true;
}
@ -261,8 +269,12 @@ export default defineComponent({
async function initRecipes() {
page.value = 1;
const newRecipes = await fetchRecipes(2);
if (!newRecipes.length) {
hasMore.value = true;
// we double-up the first call to avoid a bug with large screens that render
// the entire first page without scrolling, preventing additional loading
const newRecipes = await fetchRecipes(page.value + 1);
if (newRecipes.length < perPage) {
hasMore.value = false;
}
@ -274,7 +286,7 @@ export default defineComponent({
const infiniteScroll = useThrottleFn(() => {
useAsync(async () => {
if (!ready.value || !hasMore.value || loading.value) {
if (!hasMore.value || loading.value) {
return;
}
@ -282,9 +294,10 @@ export default defineComponent({
page.value = page.value + 1;
const newRecipes = await fetchRecipes();
if (!newRecipes.length) {
if (newRecipes.length < perPage) {
hasMore.value = false;
} else {
}
if (newRecipes.length) {
context.emit(APPEND_RECIPES_EVENT, newRecipes);
}
@ -379,6 +392,7 @@ export default defineComponent({
displayTitleIcon,
EVENTS,
infiniteScroll,
ready,
loading,
navigateRandom,
preferences,

View file

@ -3,6 +3,8 @@
v-model="selected"
item-key="id"
show-select
sort-by="dateAdded"
sort-desc
:headers="headers"
:items="recipes"
:items-per-page="15"
@ -39,6 +41,9 @@
</v-list-item-content>
</v-list-item>
</template>
<template #item.dateAdded="{ item }">
{{ formatDate(item.dateAdded) }}
</template>
</v-data-table>
</template>
@ -132,6 +137,14 @@ export default defineComponent({
return hdrs;
});
function formatDate(date: string) {
try {
return i18n.d(Date.parse(date), "medium");
} catch {
return "";
}
}
// ============
// Group Members
const api = useUserApi();
@ -160,6 +173,7 @@ export default defineComponent({
groupSlug,
setValue,
headers,
formatDate,
members,
getMember,
};

View file

@ -53,6 +53,14 @@
{{ $t("general.foods") }}
</SearchFilter>
<!-- Household Filter -->
<SearchFilter v-if="households.length > 1" v-model="selectedHouseholds" :items="households" radio>
<v-icon left>
{{ $globals.icons.household }}
</v-icon>
{{ $t("household.households") }}
</SearchFilter>
<!-- Sort Options -->
<v-menu offset-y nudge-bottom="3">
<template #activator="{ on, attrs }">
@ -142,17 +150,25 @@ import { ref, defineComponent, useRouter, onMounted, useContext, computed, Ref,
import { watchDebounced } from "@vueuse/shared";
import SearchFilter from "~/components/Domain/SearchFilter.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import { useCategoryStore, useFoodStore, useTagStore, useToolStore } from "~/composables/store";
import {
useCategoryStore,
usePublicCategoryStore,
useFoodStore,
usePublicFoodStore,
useHouseholdStore,
usePublicHouseholdStore,
useTagStore,
usePublicTagStore,
useToolStore,
usePublicToolStore,
} from "~/composables/store";
import { useUserSearchQuerySession } from "~/composables/use-users/preferences";
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
import { IngredientFood, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
import { NoUndefinedField } from "~/lib/api/types/non-generated";
import { useLazyRecipes } from "~/composables/recipes";
import { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
import { usePublicCategoryStore } from "~/composables/store/use-category-store";
import { usePublicFoodStore } from "~/composables/store/use-food-store";
import { usePublicTagStore } from "~/composables/store/use-tag-store";
import { usePublicToolStore } from "~/composables/store/use-tool-store";
import { HouseholdSummary } from "~/lib/api/types/household";
export default defineComponent({
components: { SearchFilter, RecipeCardSection },
@ -186,6 +202,9 @@ export default defineComponent({
const foods = isOwnGroup.value ? useFoodStore() : usePublicFoodStore(groupSlug.value);
const selectedFoods = ref<IngredientFood[]>([]);
const households = isOwnGroup.value ? useHouseholdStore() : usePublicHouseholdStore(groupSlug.value);
const selectedHouseholds = ref([] as NoUndefinedField<HouseholdSummary>[]);
const tags = isOwnGroup.value ? useTagStore() : usePublicTagStore(groupSlug.value);
const selectedTags = ref<NoUndefinedField<RecipeTag>[]>([]);
@ -199,6 +218,7 @@ export default defineComponent({
search: state.value.search ? state.value.search : "",
categories: toIDArray(selectedCategories.value),
foods: toIDArray(selectedFoods.value),
households: toIDArray(selectedHouseholds.value),
tags: toIDArray(selectedTags.value),
tools: toIDArray(selectedTools.value),
requireAllCategories: state.value.requireAllCategories,
@ -239,10 +259,9 @@ export default defineComponent({
state.value.requireAllFoods = queryDefaults.requireAllFoods;
selectedCategories.value = [];
selectedFoods.value = [];
selectedHouseholds.value = [];
selectedTags.value = [];
selectedTools.value = [];
search();
}
function toggleOrderDirection() {
@ -280,6 +299,7 @@ export default defineComponent({
search: passedQuery.value.search === queryDefaults.search ? undefined : passedQuery.value.search,
orderBy: passedQuery.value.orderBy === queryDefaults.orderBy ? undefined : passedQuery.value.orderBy,
orderDirection: passedQuery.value.orderDirection === queryDefaults.orderDirection ? undefined : passedQuery.value.orderDirection,
households: !passedQuery.value.households?.length || passedQuery.value.households?.length === households.store.value.length ? undefined : passedQuery.value.households,
requireAllCategories: passedQuery.value.requireAllCategories ? "true" : undefined,
requireAllTags: passedQuery.value.requireAllTags ? "true" : undefined,
requireAllTools: passedQuery.value.requireAllTools ? "true" : undefined,
@ -361,13 +381,10 @@ export default defineComponent({
watch(
() => route.value.query,
() => {
if (state.value.ready) {
hydrateSearch();
if (!Object.keys(route.value.query).length) {
reset();
}
},
{
deep: true,
},
}
)
async function hydrateSearch() {
@ -423,9 +440,9 @@ export default defineComponent({
if (query.categories?.length) {
promises.push(
waitUntilAndExecute(
() => categories.items.value.length > 0,
() => categories.store.value.length > 0,
() => {
const result = categories.items.value.filter((item) =>
const result = categories.store.value.filter((item) =>
(query.categories as string[]).includes(item.id as string)
);
@ -440,9 +457,9 @@ export default defineComponent({
if (query.tags?.length) {
promises.push(
waitUntilAndExecute(
() => tags.items.value.length > 0,
() => tags.store.value.length > 0,
() => {
const result = tags.items.value.filter((item) => (query.tags as string[]).includes(item.id as string));
const result = tags.store.value.filter((item) => (query.tags as string[]).includes(item.id as string));
selectedTags.value = result as NoUndefinedField<RecipeTag>[];
}
)
@ -454,9 +471,9 @@ export default defineComponent({
if (query.tools?.length) {
promises.push(
waitUntilAndExecute(
() => tools.items.value.length > 0,
() => tools.store.value.length > 0,
() => {
const result = tools.items.value.filter((item) => (query.tools as string[]).includes(item.id));
const result = tools.store.value.filter((item) => (query.tools as string[]).includes(item.id));
selectedTools.value = result as NoUndefinedField<RecipeTool>[];
}
)
@ -469,13 +486,13 @@ export default defineComponent({
promises.push(
waitUntilAndExecute(
() => {
if (foods.foods.value) {
return foods.foods.value.length > 0;
if (foods.store.value) {
return foods.store.value.length > 0;
}
return false;
},
() => {
const result = foods.foods.value?.filter((item) => (query.foods as string[]).includes(item.id));
const result = foods.store.value?.filter((item) => (query.foods as string[]).includes(item.id));
selectedFoods.value = result ?? [];
}
)
@ -484,6 +501,25 @@ export default defineComponent({
selectedFoods.value = [];
}
if (query.households?.length) {
promises.push(
waitUntilAndExecute(
() => {
if (households.store.value) {
return households.store.value.length > 0;
}
return false;
},
() => {
const result = households.store.value?.filter((item) => (query.households as string[]).includes(item.id));
selectedHouseholds.value = result as NoUndefinedField<HouseholdSummary>[] ?? [];
}
)
);
} else {
selectedHouseholds.value = [];
}
await Promise.allSettled(promises);
};
@ -515,6 +551,7 @@ export default defineComponent({
() => state.value.orderDirection,
selectedCategories,
selectedFoods,
selectedHouseholds,
selectedTags,
selectedTools,
],
@ -533,10 +570,11 @@ export default defineComponent({
search,
reset,
state,
categories: categories.items as unknown as NoUndefinedField<RecipeCategory>[],
tags: tags.items as unknown as NoUndefinedField<RecipeTag>[],
foods: foods.foods,
tools: tools.items as unknown as NoUndefinedField<RecipeTool>[],
categories: categories.store as unknown as NoUndefinedField<RecipeCategory>[],
tags: tags.store as unknown as NoUndefinedField<RecipeTag>[],
foods: foods.store,
tools: tools.store as unknown as NoUndefinedField<RecipeTool>[],
households: households.store as unknown as NoUndefinedField<HouseholdSummary>[],
sortable,
toggleOrderDirection,
@ -545,6 +583,7 @@ export default defineComponent({
selectedCategories,
selectedFoods,
selectedHouseholds,
selectedTags,
selectedTools,
appendRecipes,

View file

@ -289,11 +289,11 @@ export default defineComponent({
createAssignFood,
unitAutocomplete,
createAssignUnit,
foods: foodStore.foods,
foods: foodStore.store,
foodSearch,
toggleTitle,
unitActions: unitStore.actions,
units: unitStore.units,
units: unitStore.store,
unitSearch,
validators,
workingUnitData: unitsData.data,

View file

@ -135,7 +135,7 @@ export default defineComponent({
await store.actions.createOne({ ...state });
}
const newItem = store.items.value.find((item) => item.name === state.name);
const newItem = store.store.value.find((item) => item.name === state.name);
context.emit(CREATED_ITEM_EVENT, newItem);
dialog.value = false;

View file

@ -127,9 +127,9 @@ export default defineComponent({
const items = computed(() => {
if (!props.returnObject) {
return store.items.value.map((item) => item.name);
return store.store.value.map((item) => item.name);
}
return store.items.value;
return store.store.value;
});
function removeByIndex(index: number) {

View file

@ -105,7 +105,7 @@ export default defineComponent({
const recipeHousehold = ref<HouseholdSummary>();
if (user) {
const userApi = useUserApi();
userApi.groups.fetchHousehold(props.recipe.householdId).then(({ data }) => {
userApi.households.getOne(props.recipe.householdId).then(({ data }) => {
recipeHousehold.value = data || undefined;
});
}

View file

@ -11,28 +11,43 @@
<v-card width="400">
<v-card-text>
<v-text-field v-model="state.search" class="mb-2" hide-details dense :label="$tc('search.search')" clearable />
<v-switch
v-if="requireAll != undefined"
v-model="requireAllValue"
dense
small
:label="`${requireAll ? $tc('search.has-all') : $tc('search.has-any')}`"
>
</v-switch>
<div class="d-flex py-4">
<v-switch
v-if="requireAll != undefined"
v-model="requireAllValue"
dense
small
hide-details
class="my-auto"
:label="`${requireAll ? $tc('search.has-all') : $tc('search.has-any')}`"
/>
<v-spacer />
<v-btn
small
color="accent"
class="mr-2 my-auto"
@click="clearSelection"
>
{{ $tc("search.clear-selection") }}
</v-btn>
</div>
<v-card v-if="filtered.length > 0" flat outlined>
<v-radio-group v-model="selectedRadio" class="ma-0 pa-0">
<v-virtual-scroll :items="filtered" height="300" item-height="51">
<template #default="{ item }">
<v-list-item :key="item.id" dense :value="item">
<v-list-item-action>
<v-checkbox v-model="selected" :value="item"></v-checkbox>
</v-list-item-action>
<v-list-item-content>
<v-list-item-title> {{ item.name }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item :key="item.id" dense :value="item">
<v-list-item-action>
<v-radio v-if="radio" :value="item" @click="handleRadioClick(item)" />
<v-checkbox v-else v-model="selected" :value="item" />
</v-list-item-action>
<v-list-item-content>
<v-list-item-title> {{ item.name }} </v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-divider></v-divider>
</template>
</v-virtual-scroll>
</v-radio-group>
</v-card>
<div v-else>
<v-alert type="info" text> {{ $tc('search.no-results') }} </v-alert>
@ -65,6 +80,10 @@ export default defineComponent({
type: Boolean,
default: undefined,
},
radio: {
type: Boolean,
default: false,
},
},
setup(props, context) {
const state = reactive({
@ -86,6 +105,13 @@ export default defineComponent({
},
});
const selectedRadio = computed({
get: () => (selected.value.length > 0 ? selected.value[0] : null),
set: (value) => {
context.emit("input", value ? [value] : []);
},
});
const filtered = computed(() => {
if (!state.search) {
return props.items;
@ -94,11 +120,26 @@ export default defineComponent({
return props.items.filter((item) => item.name.toLowerCase().includes(state.search.toLowerCase()));
});
const handleRadioClick = (item: SelectableItem) => {
if (selectedRadio.value === item) {
selectedRadio.value = null;
}
};
function clearSelection() {
selected.value = [];
selectedRadio.value = null;
state.search = "";
}
return {
requireAllValue,
state,
selected,
selectedRadio,
filtered,
handleRadioClick,
clearSelection,
};
},
});