From bfc969b4a91c96cb1d0d3d651f8f4bcc34ca68fe Mon Sep 17 00:00:00 2001 From: David Rouyer Date: Tue, 19 Sep 2023 22:56:27 +0200 Subject: [PATCH] feat: infinite scroll --- apps/customer-service/package.json | 1 + .../src/components/tickets/ticket-list.tsx | 63 +++++++++++++++---- packages/api/src/router/ticket.ts | 22 ++++++- yarn.lock | 5 ++ 4 files changed, 75 insertions(+), 16 deletions(-) diff --git a/apps/customer-service/package.json b/apps/customer-service/package.json index f5001530b..7b0a91cf9 100644 --- a/apps/customer-service/package.json +++ b/apps/customer-service/package.json @@ -31,6 +31,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-hook-form": "^7.46.1", + "react-intersection-observer": "^9.5.2", "react-intl": "^6.4.7", "superjson": "^1.13.1", "zod": "^3.22.2" diff --git a/apps/customer-service/src/components/tickets/ticket-list.tsx b/apps/customer-service/src/components/tickets/ticket-list.tsx index 5fa5349e2..0c3c1e45f 100644 --- a/apps/customer-service/src/components/tickets/ticket-list.tsx +++ b/apps/customer-service/src/components/tickets/ticket-list.tsx @@ -1,13 +1,15 @@ 'use client'; -import { FC, useEffect } from 'react'; +import { FC, Fragment, useEffect } from 'react'; import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { PartyPopper } from 'lucide-react'; +import { useInView } from 'react-intersection-observer'; import { FormattedMessage } from 'react-intl'; import { TicketStatus } from '@cs/database/schema/ticket'; import { TicketListItem } from '~/components/tickets/ticket-list-item'; +import { TicketListItemSkeleton } from '~/components/tickets/ticket-list-item-skeleton'; import { api } from '~/utils/api'; export const TicketList: FC<{ @@ -18,31 +20,66 @@ export const TicketList: FC<{ const params = useParams(); const router = useRouter(); const searchParams = useSearchParams(); - - const [ticketsData] = api.ticket.all.useSuspenseQuery({ - filter: filter, - status: status, - orderBy: orderBy, + const { ref, inView } = useInView({ + triggerOnce: true, + threshold: 1, }); + const [data, allTicketsQuery] = api.ticket.all.useSuspenseInfiniteQuery( + { + filter: filter, + status: status, + orderBy: orderBy, + }, + { + getNextPageParam(lastPage) { + return lastPage.nextCursor; + }, + } + ); + + const { isFetching, fetchNextPage, hasNextPage } = allTicketsQuery; + useEffect(() => { - if (!ticketsData || ticketsData.length === 0 || params.id) return; + if (!data.pages || data.pages.length === 0 || params.id) return; - const firstTicketIdFromList = ticketsData?.[0]?.id; + const firstTicketIdFromList = data.pages[0]?.data?.[0]?.id; if (!firstTicketIdFromList) return; router.replace( `/tickets/${firstTicketIdFromList}?${searchParams.toString()}` ); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ticketsData]); + }, [data]); + + useEffect(() => { + if (inView && hasNextPage && !isFetching) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetching, fetchNextPage]); return (
- {ticketsData?.length > 0 ? ( - ticketsData.map((ticket) => ( - - )) + {data?.pages?.length > 0 ? ( + <> +
+ {data?.pages.map((page) => ( + + {page.data.map((ticket) => ( + + ))} + + ))} +
+ {isFetching && ( +
+ + + +
+ )} +
+ ) : (
diff --git a/packages/api/src/router/ticket.ts b/packages/api/src/router/ticket.ts index 26dc34c0f..6bd28d85c 100644 --- a/packages/api/src/router/ticket.ts +++ b/packages/api/src/router/ticket.ts @@ -1,7 +1,7 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import { and, asc, desc, eq, isNull, not, schema } from '@cs/database'; +import { and, asc, desc, eq, gt, isNull, lt, not, schema } from '@cs/database'; import { TicketStatus } from '@cs/database/schema/ticket'; import { TicketActivityType, @@ -19,16 +19,26 @@ export const ticketRouter = createTRPCRouter({ filter: z.enum(['all', 'me', 'unassigned']), status: z.enum([TicketStatus.Open, TicketStatus.Resolved]), orderBy: z.enum(['newest', 'oldest']), + cursor: z.string().nullish(), }) ) - .query(({ ctx, input }) => { - return ctx.db.query.tickets.findMany({ + .query(async ({ ctx, input }) => { + const PAGE_SIZE = 10; + const tickets = await ctx.db.query.tickets.findMany({ orderBy: { newest: desc(schema.tickets.createdAt), oldest: asc(schema.tickets.createdAt), }[input.orderBy], where: and( eq(schema.tickets.status, input.status), + { + newest: input.cursor + ? lt(schema.tickets.createdAt, new Date(input.cursor)) + : undefined, + oldest: input.cursor + ? gt(schema.tickets.createdAt, new Date(input.cursor)) + : undefined, + }[input.orderBy], { all: undefined, me: eq( @@ -39,7 +49,13 @@ export const ticketRouter = createTRPCRouter({ }[input.filter] ), with: { author: true }, + limit: PAGE_SIZE, }); + + const nextCursor = + tickets[tickets.length - 1]?.createdAt.toISOString() ?? null; + + return { data: tickets, nextCursor }; }), byId: protectedProcedure diff --git a/yarn.lock b/yarn.lock index 2385a5a49..95c1051c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4518,6 +4518,11 @@ react-hook-form@^7.46.1: resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.46.1.tgz#39347dbff19d980cb41087ac32a57abdc6045bb3" integrity sha512-0GfI31LRTBd5tqbXMGXT1Rdsv3rnvy0FjEk8Gn9/4tp6+s77T7DPZuGEpBRXOauL+NhyGT5iaXzdIM2R6F/E+w== +react-intersection-observer@^9.5.2: + version "9.5.2" + resolved "https://registry.yarnpkg.com/react-intersection-observer/-/react-intersection-observer-9.5.2.tgz#f68363a1ff292323c0808201b58134307a1626d0" + integrity sha512-EmoV66/yvksJcGa1rdW0nDNc4I1RifDWkT50gXSFnPLYQ4xUptuDD4V7k+Rj1OgVAlww628KLGcxPXFlOkkU/Q== + react-intl@^6.4.7: version "6.4.7" resolved "https://registry.yarnpkg.com/react-intl/-/react-intl-6.4.7.tgz#28ec40350ff791a6a773f5e76b9e12835ae17e19"