fix: 2148 infinite scroll on search (#2173)

* refactor recipe card section to use unified query construct

* rework search page so it uses lazy-loading of RecipeCardSection

* remove RecipeQuery again

* prettier reformatting

* remove recipes/all page

* remove max results setting from search

* fix typing issues
This commit is contained in:
Sören 2023-02-26 20:20:26 +01:00 committed by GitHub
parent afbee3a078
commit 541cdc79aa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 150 additions and 200 deletions

View File

@ -53,7 +53,7 @@
<v-icon left>
{{ $globals.icons.chefHat }}
</v-icon>
<v-list-item-title>{{ $t('general.last-made') }}</v-list-item-title>
<v-list-item-title>{{ $t("general.last-made") }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
@ -129,6 +129,7 @@ import {
useAsync,
useContext,
useRouter,
watch,
} from "@nuxtjs/composition-api";
import { useThrottleFn } from "@vueuse/core";
import RecipeCard from "./RecipeCard.vue";
@ -137,6 +138,7 @@ import { useAsyncKey } from "~/composables/use-utils";
import { useLazyRecipes } from "~/composables/recipes";
import { Recipe } from "~/lib/api/types/recipe";
import { useUserSortPreferences } from "~/composables/use-users/preferences";
import { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
const REPLACE_RECIPES_EVENT = "replaceRecipes";
const APPEND_RECIPES_EVENT = "appendRecipes";
@ -167,26 +169,10 @@ export default defineComponent({
type: Array as () => Recipe[],
default: () => [],
},
cookbookSlug: {
type: String,
query: {
type: Object as () => RecipeSearchQuery,
default: null,
},
categorySlug: {
type: String,
default: null,
},
tagSlug: {
type: String,
default: null,
},
toolSlug: {
type: String,
default: null,
},
skipLoad: {
type: Boolean,
default: false
}
},
setup(props, context) {
const preferences = useUserSortPreferences();
@ -224,45 +210,59 @@ export default defineComponent({
}
const page = ref(1);
const perPage = ref(32);
const perPage = 32;
const hasMore = ref(true);
const ready = ref(false);
const loading = ref(false);
const cookbook = ref<string>(props.cookbookSlug);
const category = ref<string>(props.categorySlug);
const tag = ref<string>(props.tagSlug);
const tool = ref<string>(props.toolSlug);
const { fetchMore } = useLazyRecipes();
onMounted(async () => {
if (props.skipLoad) {
return;
}
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,
cookbook.value,
category.value,
tag.value,
tool.value,
// filter out recipes that have a null value for the property we're sorting by
preferences.value.filterNull && preferences.value.orderBy ? `${preferences.value.orderBy} <> null` : null
);
// 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;
const queryFilter = computed(() => {
const orderBy = props.query?.orderBy || preferences.value.orderBy;
return preferences.value.filterNull && orderBy ? `${orderBy} <> null` : null;
});
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
queryFilter.value
);
}
onMounted(async () => {
if (props.query) {
const newRecipes = await fetchRecipes(2);
// 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;
}
});
watch(
() => props.query,
async (newValue: RecipeSearchQuery | undefined) => {
if (newValue) {
page.value = 1;
const newRecipes = await fetchRecipes(2);
// 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;
}
}
);
const infiniteScroll = useThrottleFn(() => {
useAsync(async () => {
if (!ready.value || !hasMore.value || loading.value) {
@ -272,19 +272,7 @@ export default defineComponent({
loading.value = true;
page.value = page.value + 1;
const newRecipes = await fetchMore(
page.value,
perPage.value,
preferences.value.orderBy,
preferences.value.orderDirection,
cookbook.value,
category.value,
tag.value,
tool.value,
// filter out recipes that have a null value for the property we're sorting by
preferences.value.filterNull && preferences.value.orderBy ? `${preferences.value.orderBy} <> null` : null
);
const newRecipes = await fetchRecipes();
if (!newRecipes.length) {
hasMore.value = false;
} else {
@ -300,7 +288,13 @@ export default defineComponent({
return;
}
function setter(orderBy: string, ascIcon: string, descIcon: string, defaultOrderDirection = "asc", filterNull = false) {
function setter(
orderBy: string,
ascIcon: string,
descIcon: string,
defaultOrderDirection = "asc",
filterNull = false
) {
if (preferences.value.orderBy !== orderBy) {
preferences.value.orderBy = orderBy;
preferences.value.orderDirection = defaultOrderDirection;
@ -313,19 +307,37 @@ export default defineComponent({
switch (sortType) {
case EVENTS.az:
setter("name", $globals.icons.sortAlphabeticalAscending, $globals.icons.sortAlphabeticalDescending, "asc", false);
setter(
"name",
$globals.icons.sortAlphabeticalAscending,
$globals.icons.sortAlphabeticalDescending,
"asc",
false
);
break;
case EVENTS.rating:
setter("rating", $globals.icons.sortAscending, $globals.icons.sortDescending, "desc", true);
break;
case EVENTS.created:
setter("created_at", $globals.icons.sortCalendarAscending, $globals.icons.sortCalendarDescending, "desc", false);
setter(
"created_at",
$globals.icons.sortCalendarAscending,
$globals.icons.sortCalendarDescending,
"desc",
false
);
break;
case EVENTS.updated:
setter("update_at", $globals.icons.sortClockAscending, $globals.icons.sortClockDescending, "desc", false);
break;
case EVENTS.lastMade:
setter("last_made", $globals.icons.sortCalendarAscending, $globals.icons.sortCalendarDescending, "desc", true);
setter(
"last_made",
$globals.icons.sortCalendarAscending,
$globals.icons.sortCalendarDescending,
"desc",
true
);
break;
default:
console.log("Unknown Event", sortType);
@ -341,19 +353,7 @@ export default defineComponent({
loading.value = true;
// fetch new recipes
const newRecipes = await fetchMore(
page.value,
perPage.value,
preferences.value.orderBy,
preferences.value.orderDirection,
cookbook.value,
category.value,
tag.value,
tool.value,
// filter out recipes that have a null value for the property we're sorting by
preferences.value.filterNull && preferences.value.orderBy ? `${preferences.value.orderBy} <> null` : null
);
const newRecipes = await fetchRecipes();
context.emit(REPLACE_RECIPES_EVENT, newRecipes);
state.sortLoading = false;

View File

@ -1,6 +1,7 @@
import { Ref, useAsync } from "@nuxtjs/composition-api";
import { useAsyncKey } from "../use-utils";
import { BaseCRUDAPI } from "~/lib/api/base/base-clients";
import { QueryValue } from "~/lib/api/base/route";
type BoundT = {
id?: string | number;
@ -25,7 +26,7 @@ export function useStoreActions<T extends BoundT>(
allRef: Ref<T[] | null> | null,
loading: Ref<boolean>
): StoreActions<T> {
function getAll(page = 1, perPage = -1, params = {} as any) {
function getAll(page = 1, perPage = -1, params = {} as Record<string, QueryValue>) {
params.orderBy ??= "name";
params.orderDirection ??= "asc";

View File

@ -1,7 +1,8 @@
import { useAsync, ref } from "@nuxtjs/composition-api";
import { useAsyncKey } from "../use-utils";
import { useUserApi } from "~/composables/api";
import { Recipe } from "~/lib/api/types/recipe";
import {Recipe} from "~/lib/api/types/recipe";
import {RecipeSearchQuery} from "~/lib/api/user/recipes/recipe";
export const allRecipes = ref<Recipe[]>([]);
export const recentRecipes = ref<Recipe[]>([]);
@ -16,19 +17,22 @@ export const useLazyRecipes = function () {
perPage: number,
orderBy: string | null = null,
orderDirection = "desc",
cookbook: string | null = null,
category: string | null = null,
tag: string | null = null,
tool: string | null = null,
query: RecipeSearchQuery | null = null,
queryFilter: string | null = null,
) {
const { data } = await api.recipes.getAll(page, perPage, {
orderBy,
orderDirection,
cookbook,
categories: category,
tags: tag,
tools: tool,
search: query?.search,
cookbook: query?.cookbook,
categories: query?.categories,
requireAllCategories: query?.requireAllCategories,
tags: query?.tags,
requireAllTags: query?.requireAllTags,
tools: query?.tools,
requireAllTools: query?.requireAllTools,
foods: query?.foods,
requireAllFoods: query?.requireAllFoods,
queryFilter,
});
return data ? data.items : [];

View File

@ -169,11 +169,6 @@ export default defineComponent({
to: "/shopping-lists",
restricted: true,
},
{
icon: $globals.icons.viewModule,
to: "/recipes/all",
title: i18n.t("sidebar.all-recipes"),
},
{
icon: $globals.icons.tags,
to: "/recipes/categories",

View File

@ -50,7 +50,6 @@ export default defineComponent({
const buttons = [
{ icon: $globals.icons.home, to: "/", text: i18n.t("general.home") },
{ icon: $globals.icons.primary, to: "/recipes/all", text: i18n.t("page.all-recipes") },
];
return {

View File

@ -1,5 +1,6 @@
import { Recipe } from "../types/recipe";
import { ApiRequestInstance, PaginationData } from "~/lib/api/types/non-generated";
import { QueryValue, route } from "~/lib/api/base/route";
export interface CrudAPIInterface {
requests: ApiRequestInstance;
@ -21,14 +22,14 @@ export abstract class BaseAPI {
export abstract class BaseCRUDAPI<CreateType, ReadType, UpdateType = CreateType>
extends BaseAPI
implements CrudAPIInterface {
implements CrudAPIInterface
{
abstract baseRoute: string;
abstract itemRoute(itemId: string | number): string;
async getAll(page = 1, perPage = -1, params = {} as any) {
return await this.requests.get<PaginationData<ReadType>>(this.baseRoute, {
params: { page, perPage, ...params },
});
async getAll(page = 1, perPage = -1, params = {} as Record<string, QueryValue>) {
params = Object.fromEntries(Object.entries(params).filter(([_, v]) => v !== null && v !== undefined));
return await this.requests.get<PaginationData<ReadType>>(route(this.baseRoute, { page, perPage, ...params }));
}
async createOne(payload: CreateType) {

View File

@ -21,7 +21,6 @@ export type QueryValue = string | string[] | number | number[] | boolean | null
*/
export function route(rest: string, params: Record<string, QueryValue> | null = null): string {
const url = new URL(parts.prefix + rest, parts.host);
if (params) {
for (const [key, value] of Object.entries(params)) {
if (Array.isArray(value)) {

View File

@ -1,9 +1,9 @@
/* tslint:disable */
/* eslint-disable */
/**
/* This file was automatically generated from pydantic models by running pydantic2ts.
/* Do not modify it by hand - just update the pydantic models and then re-run the script
*/
/* This file was automatically generated from pydantic models by running pydantic2ts.
/* Do not modify it by hand - just update the pydantic models and then re-run the script
*/
export type ExportTypes = "json";
export type RegisteredParser = "nlp" | "brute";

View File

@ -55,9 +55,9 @@ const routes = {
recipesSlugTimelineEventId: (slug: string, id: string) => `${prefix}/recipes/${slug}/timeline/events/${id}`,
};
export type RecipeSearchQuery ={
export type RecipeSearchQuery = {
search: string;
orderDirection? : "asc" | "desc";
orderDirection?: "asc" | "desc";
groupId?: string;
queryFilter?: string;
@ -76,11 +76,10 @@ export type RecipeSearchQuery ={
foods?: string[];
requireAllFoods?: boolean;
page: number;
perPage: number;
page?: number;
perPage?: number;
orderBy?: string;
}
};
export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
baseRoute: string = routes.recipesBase;
@ -96,7 +95,7 @@ export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
this.share = new RecipeShareApi(requests);
}
async search(rsq : RecipeSearchQuery) {
async search(rsq: RecipeSearchQuery) {
return await this.requests.get<PaginationData<Recipe>>(route(routes.recipesBase, rsq));
}
@ -176,7 +175,10 @@ export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
}
async updateTimelineEvent(recipeSlug: string, eventId: string, payload: RecipeTimelineEventUpdate) {
return await this.requests.put<RecipeTimelineEventOut, RecipeTimelineEventUpdate>(routes.recipesSlugTimelineEventId(recipeSlug, eventId), payload);
return await this.requests.put<RecipeTimelineEventOut, RecipeTimelineEventUpdate>(
routes.recipesSlugTimelineEventId(recipeSlug, eventId),
payload
);
}
async deleteTimelineEvent(recipeSlug: string, eventId: string) {
@ -184,8 +186,11 @@ export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
}
async getAllTimelineEvents(recipeSlug: string, page = 1, perPage = -1, params = {} as any) {
return await this.requests.get<PaginationData<RecipeTimelineEventOut>>(routes.recipesSlugTimelineEvent(recipeSlug), {
params: { page, perPage, ...params },
});
return await this.requests.get<PaginationData<RecipeTimelineEventOut>>(
routes.recipesSlugTimelineEvent(recipeSlug),
{
params: { page, perPage, ...params },
}
);
}
}

View File

@ -14,7 +14,7 @@
<RecipeCardSection
class="mb-5 mx-1"
:recipes="recipes"
:cookbook-slug="slug"
:query="{ cookbook: slug }"
@sortRecipes="assignSorted"
@replaceRecipes="replaceRecipes"
@appendRecipes="appendRecipes"

View File

@ -98,15 +98,6 @@
</template>
<v-card>
<v-card-text>
<v-text-field
v-model="state.maxResults"
class="mt-0 pt-0"
:label="$tc('search.max-results')"
type="number"
outlined
dense
hide-details
/>
<v-switch v-model="state.auto" label="Auto Search" single-line></v-switch>
<v-btn block color="primary" @click="reset">
{{ $tc("general.reset") }}
@ -116,7 +107,7 @@
</v-menu>
</div>
<div v-if="!state.auto" class="search-button-container">
<v-btn :loading="state.loading" x-large color="primary" type="submit" block>
<v-btn x-large color="primary" type="submit" block>
<v-icon left>
{{ $globals.icons.search }}
</v-icon>
@ -131,38 +122,41 @@
class="mt-n5"
:icon="$globals.icons.search"
:title="$tc('search.results')"
:recipes="state.results"
/>
:recipes="recipes"
:query="passedQuery"
@sortRecipes="assignSorted"
@replaceRecipes="replaceRecipes"
@appendRecipes="appendRecipes"
@delete="removeRecipe"
></RecipeCardSection>
</v-container>
</v-container>
</template>
<script lang="ts">
import { ref, defineComponent, useRouter, onMounted, useContext, computed } from "@nuxtjs/composition-api";
// eslint-disable-next-line import/namespace
import { watchDebounced } from "@vueuse/shared";
import SearchFilter from "~/components/Domain/SearchFilter.vue";
import { useUserApi } from "~/composables/api";
import { useCategoryStore, useFoodStore, useTagStore, useToolStore } from "~/composables/store";
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
import { IngredientFood, RecipeCategory, RecipeSummary, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
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";
export default defineComponent({
components: { SearchFilter, RecipeCardSection },
setup() {
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes();
const router = useRouter();
const api = useUserApi();
const { $globals, i18n } = useContext();
const state = ref({
auto: true,
loading: false,
search: "",
orderBy: "created_at",
orderDirection: "desc" as "asc" | "desc",
maxResults: 21,
results: [] as RecipeSummary[],
// and/or
requireAllCategories: false,
@ -183,9 +177,10 @@ export default defineComponent({
const tools = useToolStore();
const selectedTools = ref<NoUndefinedField<RecipeTool>[]>([]);
const passedQuery = ref<RecipeSearchQuery | null>(null);
function reset() {
state.value.search = "";
state.value.maxResults = 21;
state.value.orderBy = "created_at";
state.value.orderDirection = "desc";
state.value.requireAllCategories = false;
@ -213,7 +208,6 @@ export default defineComponent({
}
async function search() {
state.value.loading = true;
await router.push({
query: {
categories: toIDArray(selectedCategories.value),
@ -225,7 +219,6 @@ export default defineComponent({
...{
auto: state.value.auto ? undefined : "false",
search: state.value.search === "" ? undefined : state.value.search,
maxResults: state.value.maxResults === 21 ? undefined : state.value.maxResults.toString(),
orderBy: state.value.orderBy === "createdAt" ? undefined : state.value.orderBy,
orderDirection: state.value.orderDirection === "desc" ? undefined : state.value.orderDirection,
requireAllCategories: state.value.requireAllCategories ? "true" : undefined,
@ -236,35 +229,19 @@ export default defineComponent({
},
});
const { data, error } = await api.recipes.search({
passedQuery.value = {
search: state.value.search,
page: 1,
orderBy: state.value.orderBy,
orderDirection: state.value.orderDirection,
perPage: state.value.maxResults,
categories: toIDArray(selectedCategories.value),
foods: toIDArray(selectedFoods.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,
});
if (error) {
console.error(error);
state.value.loading = false;
state.value.results = [];
return;
}
if (data) {
state.value.results = data.items;
}
state.value.loading = false;
orderBy: state.value.orderBy,
orderDirection: state.value.orderDirection,
};
}
function waitUntilAndExecute(
@ -345,10 +322,6 @@ export default defineComponent({
state.value.search = query.search as string;
}
if (query.maxResults) {
state.value.maxResults = parseInt(query.maxResults as string);
}
if (query.orderBy) {
state.value.orderBy = query.orderBy as string;
}
@ -429,7 +402,6 @@ export default defineComponent({
() => state.value.requireAllFoods,
() => state.value.orderBy,
() => state.value.orderDirection,
() => state.value.maxResults,
selectedCategories,
selectedFoods,
selectedTags,
@ -462,6 +434,12 @@ export default defineComponent({
selectedFoods,
selectedTags,
selectedTools,
appendRecipes,
assignSorted,
recipes,
removeRecipe,
replaceRecipes,
passedQuery,
};
},
});

View File

@ -1,32 +0,0 @@
<template>
<v-container>
<RecipeCardSection
:icon="$globals.icons.primary"
:title="$t('page.all-recipes')"
:recipes="recipes"
@sortRecipes="assignSorted"
@replaceRecipes="replaceRecipes"
@appendRecipes="appendRecipes"
@delete="removeRecipe"
></RecipeCardSection>
</v-container>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
import { useLazyRecipes } from "~/composables/recipes";
export default defineComponent({
components: { RecipeCardSection },
setup() {
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes();
return { appendRecipes, assignSorted, recipes, removeRecipe, replaceRecipes };
},
head() {
return {
title: this.$t("page.all-recipes") as string,
};
},
});
</script>

View File

@ -5,7 +5,7 @@
:icon="$globals.icons.tags"
:title="category.name"
:recipes="recipes"
:category-slug="category.slug"
:query="{ categories: [category.slug] }"
@sortRecipes="assignSorted"
@replaceRecipes="replaceRecipes"
@appendRecipes="appendRecipes"

View File

@ -5,7 +5,7 @@
:icon="$globals.icons.tags"
:title="tag.name"
:recipes="recipes"
:tag-slug="tag.slug"
:query="{ tags: [tag.slug] }"
@sortRecipes="assignSorted"
@replaceRecipes="replaceRecipes"
@appendRecipes="appendRecipes"

View File

@ -5,7 +5,7 @@
:icon="$globals.icons.potSteam"
:title="tool.name"
:recipes="recipes"
:tool-slug="tool.slug"
:query="{ tools: [tool.slug] }"
@sortRecipes="assignSorted"
@replaceRecipes="replaceRecipes"
@appendRecipes="appendRecipes"

View File

@ -301,7 +301,7 @@ export default defineComponent({
}
const statsTo: { [key: string]: string } = {
totalRecipes: "/recipes/all",
totalRecipes: "/",
totalUsers: "/group/members",
totalCategories: "/recipes/categories",
totalTags: "/recipes/tags",