mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-10-27 08:14:30 -04:00
fix: Refactor and Optimize Explore Page Search (#6070)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
465
frontend/composables/use-recipe-explorer-search.ts
Normal file
465
frontend/composables/use-recipe-explorer-search.ts
Normal file
@@ -0,0 +1,465 @@
|
||||
import { watchDebounced } from "@vueuse/shared";
|
||||
import type { IngredientFood, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import type { HouseholdSummary } from "~/lib/api/types/household";
|
||||
import type { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
|
||||
import {
|
||||
useCategoryStore,
|
||||
usePublicCategoryStore,
|
||||
useFoodStore,
|
||||
usePublicFoodStore,
|
||||
useHouseholdStore,
|
||||
usePublicHouseholdStore,
|
||||
useTagStore,
|
||||
usePublicTagStore,
|
||||
useToolStore,
|
||||
usePublicToolStore,
|
||||
} from "~/composables/store";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import { useUserSearchQuerySession, useUserSortPreferences } from "~/composables/use-users/preferences";
|
||||
|
||||
// Type for the composable return value
|
||||
interface RecipeExplorerSearchState {
|
||||
state: Ref<{
|
||||
auto: boolean;
|
||||
ready: boolean;
|
||||
search: string;
|
||||
orderBy: string;
|
||||
orderDirection: "asc" | "desc";
|
||||
requireAllCategories: boolean;
|
||||
requireAllTags: boolean;
|
||||
requireAllTools: boolean;
|
||||
requireAllFoods: boolean;
|
||||
}>;
|
||||
selectedCategories: Ref<NoUndefinedField<RecipeCategory>[]>;
|
||||
selectedFoods: Ref<IngredientFood[]>;
|
||||
selectedHouseholds: Ref<NoUndefinedField<HouseholdSummary>[]>;
|
||||
selectedTags: Ref<NoUndefinedField<RecipeTag>[]>;
|
||||
selectedTools: Ref<NoUndefinedField<RecipeTool>[]>;
|
||||
passedQueryWithSeed: ComputedRef<RecipeSearchQuery & { _searchSeed: string }>;
|
||||
search: () => Promise<void>;
|
||||
reset: () => void;
|
||||
toggleOrderDirection: () => void;
|
||||
setOrderBy: (value: string) => void;
|
||||
filterItems: (item: RecipeCategory | RecipeTag | RecipeTool, urlPrefix: string) => void;
|
||||
initialize: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Memo storage for singleton instances
|
||||
const memo: Record<string, RecipeExplorerSearchState> = {};
|
||||
|
||||
function createRecipeExplorerSearchState(groupSlug: ComputedRef<string>): RecipeExplorerSearchState {
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const searchQuerySession = useUserSearchQuerySession();
|
||||
const sortPreferences = useUserSortPreferences();
|
||||
|
||||
// State management
|
||||
const state = ref({
|
||||
auto: true,
|
||||
ready: false,
|
||||
search: "",
|
||||
orderBy: "created_at",
|
||||
orderDirection: "desc" as "asc" | "desc",
|
||||
requireAllCategories: false,
|
||||
requireAllTags: false,
|
||||
requireAllTools: false,
|
||||
requireAllFoods: false,
|
||||
});
|
||||
|
||||
// Store references
|
||||
const categories = isOwnGroup ? useCategoryStore() : usePublicCategoryStore(groupSlug.value);
|
||||
const foods = isOwnGroup ? useFoodStore() : usePublicFoodStore(groupSlug.value);
|
||||
const households = isOwnGroup ? useHouseholdStore() : usePublicHouseholdStore(groupSlug.value);
|
||||
const tags = isOwnGroup ? useTagStore() : usePublicTagStore(groupSlug.value);
|
||||
const tools = isOwnGroup ? useToolStore() : usePublicToolStore(groupSlug.value);
|
||||
|
||||
// Selected items
|
||||
const selectedCategories = ref<NoUndefinedField<RecipeCategory>[]>([]);
|
||||
const selectedFoods = ref<IngredientFood[]>([]);
|
||||
const selectedHouseholds = ref<NoUndefinedField<HouseholdSummary>[]>([]);
|
||||
const selectedTags = ref<NoUndefinedField<RecipeTag>[]>([]);
|
||||
const selectedTools = ref<NoUndefinedField<RecipeTool>[]>([]);
|
||||
|
||||
// Query defaults
|
||||
const queryDefaults = {
|
||||
search: "",
|
||||
orderBy: "created_at",
|
||||
orderDirection: "desc" as "asc" | "desc",
|
||||
requireAllCategories: false,
|
||||
requireAllTags: false,
|
||||
requireAllTools: false,
|
||||
requireAllFoods: false,
|
||||
};
|
||||
|
||||
// Sync sort preferences
|
||||
watch(() => state.value.orderBy, (newValue) => {
|
||||
sortPreferences.value.orderBy = newValue;
|
||||
});
|
||||
|
||||
watch(() => state.value.orderDirection, (newValue) => {
|
||||
sortPreferences.value.orderDirection = newValue;
|
||||
});
|
||||
|
||||
// Utility functions
|
||||
function toIDArray(array: { id: string }[]) {
|
||||
return array.map(item => item.id).sort();
|
||||
}
|
||||
|
||||
function calcPassedQuery(): RecipeSearchQuery {
|
||||
return {
|
||||
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,
|
||||
requireAllTags: state.value.requireAllTags,
|
||||
requireAllTools: state.value.requireAllTools,
|
||||
requireAllFoods: state.value.requireAllFoods,
|
||||
orderBy: state.value.orderBy,
|
||||
orderDirection: state.value.orderDirection,
|
||||
};
|
||||
}
|
||||
|
||||
const passedQuery = ref<RecipeSearchQuery>(calcPassedQuery());
|
||||
|
||||
const passedQueryWithSeed = computed(() => {
|
||||
return {
|
||||
...passedQuery.value,
|
||||
_searchSeed: Date.now().toString(),
|
||||
};
|
||||
});
|
||||
|
||||
// Wait utility for async hydration
|
||||
function waitUntilAndExecute(
|
||||
condition: () => boolean,
|
||||
callback: () => void,
|
||||
opts = { timeout: 2000, interval: 500 },
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const state = {
|
||||
timeout: undefined as number | undefined,
|
||||
interval: undefined as number | undefined,
|
||||
};
|
||||
|
||||
const check = () => {
|
||||
if (condition()) {
|
||||
clearInterval(state.interval);
|
||||
clearTimeout(state.timeout);
|
||||
callback();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
state.interval = setInterval(check, opts.interval) as unknown as number;
|
||||
state.timeout = setTimeout(() => {
|
||||
clearInterval(state.interval);
|
||||
reject(new Error("Timeout"));
|
||||
}, opts.timeout) as unknown as number;
|
||||
});
|
||||
}
|
||||
|
||||
// Main functions
|
||||
function reset() {
|
||||
state.value.search = queryDefaults.search;
|
||||
state.value.orderBy = queryDefaults.orderBy;
|
||||
state.value.orderDirection = queryDefaults.orderDirection;
|
||||
sortPreferences.value.orderBy = queryDefaults.orderBy;
|
||||
sortPreferences.value.orderDirection = queryDefaults.orderDirection;
|
||||
state.value.requireAllCategories = queryDefaults.requireAllCategories;
|
||||
state.value.requireAllTags = queryDefaults.requireAllTags;
|
||||
state.value.requireAllTools = queryDefaults.requireAllTools;
|
||||
state.value.requireAllFoods = queryDefaults.requireAllFoods;
|
||||
selectedCategories.value = [];
|
||||
selectedFoods.value = [];
|
||||
selectedHouseholds.value = [];
|
||||
selectedTags.value = [];
|
||||
selectedTools.value = [];
|
||||
}
|
||||
|
||||
function toggleOrderDirection() {
|
||||
state.value.orderDirection = state.value.orderDirection === "asc" ? "desc" : "asc";
|
||||
sortPreferences.value.orderDirection = state.value.orderDirection;
|
||||
}
|
||||
|
||||
function setOrderBy(value: string) {
|
||||
state.value.orderBy = value;
|
||||
sortPreferences.value.orderBy = value;
|
||||
}
|
||||
|
||||
async function search() {
|
||||
const oldQueryValueString = JSON.stringify(passedQuery.value);
|
||||
const newQueryValue = calcPassedQuery();
|
||||
const newQueryValueString = JSON.stringify(newQueryValue);
|
||||
if (oldQueryValueString === newQueryValueString) {
|
||||
return;
|
||||
}
|
||||
|
||||
passedQuery.value = newQueryValue;
|
||||
const query = {
|
||||
categories: passedQuery.value.categories,
|
||||
foods: passedQuery.value.foods,
|
||||
tags: passedQuery.value.tags,
|
||||
tools: passedQuery.value.tools,
|
||||
// Only add the query param if it's not the default value
|
||||
...{
|
||||
auto: state.value.auto ? undefined : "false",
|
||||
search: passedQuery.value.search === queryDefaults.search ? undefined : passedQuery.value.search,
|
||||
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,
|
||||
requireAllFoods: passedQuery.value.requireAllFoods ? "true" : undefined,
|
||||
},
|
||||
};
|
||||
await router.push({ query });
|
||||
searchQuerySession.value.recipe = JSON.stringify(query);
|
||||
}
|
||||
|
||||
function filterItems(item: RecipeCategory | RecipeTag | RecipeTool, urlPrefix: string) {
|
||||
if (urlPrefix === "categories") {
|
||||
const result = categories.store.value.filter(category => (category.id as string).includes(item.id as string));
|
||||
selectedCategories.value = result as NoUndefinedField<RecipeCategory>[];
|
||||
}
|
||||
else if (urlPrefix === "tags") {
|
||||
const result = tags.store.value.filter(tag => (tag.id as string).includes(item.id as string));
|
||||
selectedTags.value = result as NoUndefinedField<RecipeTag>[];
|
||||
}
|
||||
else if (urlPrefix === "tools") {
|
||||
const result = tools.store.value.filter(tool => (tool.id).includes(item.id || ""));
|
||||
selectedTools.value = result as NoUndefinedField<RecipeTool>[];
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateSearch() {
|
||||
const query = router.currentRoute.value.query;
|
||||
if (query.auto?.length) {
|
||||
state.value.auto = query.auto === "true";
|
||||
}
|
||||
|
||||
if (query.search?.length) {
|
||||
state.value.search = query.search as string;
|
||||
}
|
||||
else {
|
||||
state.value.search = queryDefaults.search;
|
||||
}
|
||||
|
||||
state.value.orderBy = sortPreferences.value.orderBy;
|
||||
state.value.orderDirection = sortPreferences.value.orderDirection as "asc" | "desc";
|
||||
|
||||
if (query.requireAllCategories?.length) {
|
||||
state.value.requireAllCategories = query.requireAllCategories === "true";
|
||||
}
|
||||
else {
|
||||
state.value.requireAllCategories = queryDefaults.requireAllCategories;
|
||||
}
|
||||
|
||||
if (query.requireAllTags?.length) {
|
||||
state.value.requireAllTags = query.requireAllTags === "true";
|
||||
}
|
||||
else {
|
||||
state.value.requireAllTags = queryDefaults.requireAllTags;
|
||||
}
|
||||
|
||||
if (query.requireAllTools?.length) {
|
||||
state.value.requireAllTools = query.requireAllTools === "true";
|
||||
}
|
||||
else {
|
||||
state.value.requireAllTools = queryDefaults.requireAllTools;
|
||||
}
|
||||
|
||||
if (query.requireAllFoods?.length) {
|
||||
state.value.requireAllFoods = query.requireAllFoods === "true";
|
||||
}
|
||||
else {
|
||||
state.value.requireAllFoods = queryDefaults.requireAllFoods;
|
||||
}
|
||||
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
if (query.categories?.length) {
|
||||
promises.push(
|
||||
waitUntilAndExecute(
|
||||
() => categories.store.value.length > 0,
|
||||
() => {
|
||||
const result = categories.store.value.filter(item =>
|
||||
(query.categories as string[]).includes(item.id as string),
|
||||
);
|
||||
selectedCategories.value = result as NoUndefinedField<RecipeCategory>[];
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
else {
|
||||
selectedCategories.value = [];
|
||||
}
|
||||
|
||||
if (query.tags?.length) {
|
||||
promises.push(
|
||||
waitUntilAndExecute(
|
||||
() => tags.store.value.length > 0,
|
||||
() => {
|
||||
const result = tags.store.value.filter(item => (query.tags as string[]).includes(item.id as string));
|
||||
selectedTags.value = result as NoUndefinedField<RecipeTag>[];
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
else {
|
||||
selectedTags.value = [];
|
||||
}
|
||||
|
||||
if (query.tools?.length) {
|
||||
promises.push(
|
||||
waitUntilAndExecute(
|
||||
() => tools.store.value.length > 0,
|
||||
() => {
|
||||
const result = tools.store.value.filter(item => (query.tools as string[]).includes(item.id));
|
||||
selectedTools.value = result as NoUndefinedField<RecipeTool>[];
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
else {
|
||||
selectedTools.value = [];
|
||||
}
|
||||
|
||||
if (query.foods?.length) {
|
||||
promises.push(
|
||||
waitUntilAndExecute(
|
||||
() => {
|
||||
if (foods.store.value) {
|
||||
return foods.store.value.length > 0;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
() => {
|
||||
const result = foods.store.value?.filter(item => (query.foods as string[]).includes(item.id));
|
||||
selectedFoods.value = result ?? [];
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
else {
|
||||
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);
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
// Restore the user's last search query
|
||||
if (searchQuerySession.value.recipe && !(Object.keys(route.query).length > 0)) {
|
||||
try {
|
||||
const query = JSON.parse(searchQuerySession.value.recipe);
|
||||
await router.replace({ query });
|
||||
}
|
||||
catch {
|
||||
searchQuerySession.value.recipe = "";
|
||||
router.replace({ query: {} });
|
||||
}
|
||||
}
|
||||
|
||||
await hydrateSearch();
|
||||
await search();
|
||||
state.value.ready = true;
|
||||
}
|
||||
|
||||
// Watch for route query changes
|
||||
watch(
|
||||
() => route.query,
|
||||
() => {
|
||||
if (!Object.keys(route.query).length) {
|
||||
reset();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Auto-search when parameters change
|
||||
watchDebounced(
|
||||
[
|
||||
() => state.value.search,
|
||||
() => state.value.requireAllCategories,
|
||||
() => state.value.requireAllTags,
|
||||
() => state.value.requireAllTools,
|
||||
() => state.value.requireAllFoods,
|
||||
() => state.value.orderBy,
|
||||
() => state.value.orderDirection,
|
||||
selectedCategories,
|
||||
selectedFoods,
|
||||
selectedHouseholds,
|
||||
selectedTags,
|
||||
selectedTools,
|
||||
],
|
||||
async () => {
|
||||
if (state.value.ready && state.value.auto) {
|
||||
await search();
|
||||
}
|
||||
},
|
||||
{
|
||||
debounce: 500,
|
||||
},
|
||||
);
|
||||
|
||||
const composableInstance: RecipeExplorerSearchState = {
|
||||
// State
|
||||
state,
|
||||
selectedCategories,
|
||||
selectedFoods,
|
||||
selectedHouseholds,
|
||||
selectedTags,
|
||||
selectedTools,
|
||||
|
||||
// Computed
|
||||
passedQueryWithSeed,
|
||||
|
||||
// Methods
|
||||
search,
|
||||
reset,
|
||||
toggleOrderDirection,
|
||||
setOrderBy,
|
||||
filterItems,
|
||||
initialize,
|
||||
};
|
||||
|
||||
return composableInstance;
|
||||
}
|
||||
|
||||
export function useRecipeExplorerSearch(groupSlug: ComputedRef<string>): RecipeExplorerSearchState {
|
||||
const key = groupSlug.value;
|
||||
|
||||
if (!memo[key]) {
|
||||
memo[key] = createRecipeExplorerSearchState(groupSlug);
|
||||
}
|
||||
|
||||
return memo[key];
|
||||
}
|
||||
|
||||
export function clearRecipeExplorerSearchState(groupSlug: string) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete memo[groupSlug];
|
||||
}
|
||||
Reference in New Issue
Block a user