feat: add infinite scroll on bookings tabs (#1059)
* feat: add infinite scroll on bookings tabs * bookings page infinite scroll PR comments (#1060) * check if `InteractionObserver` is supported * revert query cell and use bespoke behaviour * Update pages/bookings/[status].tsx Co-authored-by: Mihai C <34626017+mihaic195@users.noreply.github.com> * load more button * make inview as a callback Co-authored-by: Mihai C <34626017+mihaic195@users.noreply.github.com> * mt-6 * fix: translation strings and remove unnecessary stuff Co-authored-by: Alex Johansson <alexander@n1s.se>
This commit is contained in:
		
							parent
							
								
									e38086b8fe
								
							
						
					
					
						commit
						f91de82daf
					
				
					 4 changed files with 117 additions and 28 deletions
				
			
		
							
								
								
									
										42
									
								
								lib/hooks/useInViewObserver.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								lib/hooks/useInViewObserver.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,42 @@ | |||
| import React from "react"; | ||||
| 
 | ||||
| const isInteractionObserverSupported = typeof window !== "undefined" && "IntersectionObserver" in window; | ||||
| 
 | ||||
| export const useInViewObserver = (onInViewCallback: () => void) => { | ||||
|   const [node, setRef] = React.useState<any>(null); | ||||
| 
 | ||||
|   const onInViewCallbackRef = React.useRef(onInViewCallback); | ||||
|   onInViewCallbackRef.current = onInViewCallback; | ||||
| 
 | ||||
|   React.useEffect(() => { | ||||
|     if (!isInteractionObserverSupported) { | ||||
|       // Skip interaction check if not supported in browser
 | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     let observer: IntersectionObserver; | ||||
|     if (node && node.parentElement) { | ||||
|       observer = new IntersectionObserver( | ||||
|         ([entry]) => { | ||||
|           if (entry.isIntersecting) { | ||||
|             onInViewCallbackRef.current(); | ||||
|           } | ||||
|         }, | ||||
|         { | ||||
|           root: document.body, | ||||
|         } | ||||
|       ); | ||||
|       observer.observe(node); | ||||
|     } | ||||
| 
 | ||||
|     return () => { | ||||
|       if (observer) { | ||||
|         observer.disconnect(); | ||||
|       } | ||||
|     }; | ||||
|   }, [node]); | ||||
| 
 | ||||
|   return { | ||||
|     ref: setRef, | ||||
|   }; | ||||
| }; | ||||
|  | @ -1,18 +1,25 @@ | |||
| import { CalendarIcon } from "@heroicons/react/outline"; | ||||
| import { useRouter } from "next/router"; | ||||
| import { Fragment } from "react"; | ||||
| 
 | ||||
| import { QueryCell } from "@lib/QueryCell"; | ||||
| import { useInViewObserver } from "@lib/hooks/useInViewObserver"; | ||||
| import { useLocale } from "@lib/hooks/useLocale"; | ||||
| import { inferQueryInput, trpc } from "@lib/trpc"; | ||||
| 
 | ||||
| import BookingsShell from "@components/BookingsShell"; | ||||
| import EmptyScreen from "@components/EmptyScreen"; | ||||
| import Loader from "@components/Loader"; | ||||
| import Shell from "@components/Shell"; | ||||
| import BookingListItem from "@components/booking/BookingListItem"; | ||||
| import { Alert } from "@components/ui/Alert"; | ||||
| import Button from "@components/ui/Button"; | ||||
| 
 | ||||
| type BookingListingStatus = inferQueryInput<"viewer.bookings">["status"]; | ||||
| 
 | ||||
| export default function Bookings() { | ||||
|   const router = useRouter(); | ||||
|   const status = router.query?.status as BookingListingStatus; | ||||
| 
 | ||||
|   const { t } = useLocale(); | ||||
| 
 | ||||
|   const descriptionByStatus: Record<BookingListingStatus, string> = { | ||||
|  | @ -21,44 +28,63 @@ export default function Bookings() { | |||
|     cancelled: t("cancelled_bookings"), | ||||
|   }; | ||||
| 
 | ||||
|   const router = useRouter(); | ||||
|   const status = router.query?.status as BookingListingStatus; | ||||
| 
 | ||||
|   const query = trpc.useQuery(["viewer.bookings", { status }], { | ||||
|   const query = trpc.useInfiniteQuery(["viewer.bookings", { status, limit: 10 }], { | ||||
|     // first render has status `undefined`
 | ||||
|     enabled: !!status, | ||||
|     getNextPageParam: (lastPage) => lastPage.nextCursor, | ||||
|   }); | ||||
| 
 | ||||
|   const buttonInView = useInViewObserver(() => { | ||||
|     if (!query.isFetching && query.hasNextPage && query.status === "success") { | ||||
|       query.fetchNextPage(); | ||||
|     } | ||||
|   }); | ||||
| 
 | ||||
|   return ( | ||||
|     <Shell heading={t("bookings")} subtitle={t("bookings_description")}> | ||||
|       <BookingsShell> | ||||
|         <div className="-mx-4 sm:mx-auto flex flex-col"> | ||||
|         <div className="flex flex-col -mx-4 sm:mx-auto"> | ||||
|           <div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"> | ||||
|             <div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"> | ||||
|               <QueryCell | ||||
|                 query={query} | ||||
|                 success={({ data }) => ( | ||||
|                   <div className="my-6 border border-gray-200 overflow-hidden border-b rounded-sm"> | ||||
|             <div className="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"> | ||||
|               {query.status === "error" && ( | ||||
|                 <Alert severity="error" title={t("something_went_wrong")} message={query.error.message} /> | ||||
|               )} | ||||
|               {query.status === "loading" || (query.status === "idle" && <Loader />)} | ||||
|               {query.status === "success" && query.data.pages[0].bookings.length > 0 && ( | ||||
|                 <> | ||||
|                   <div className="mt-6 overflow-hidden border border-b border-gray-200 rounded-sm"> | ||||
|                     <table className="min-w-full divide-y divide-gray-200"> | ||||
|                       <tbody className="bg-white divide-y divide-gray-200" data-testid="bookings"> | ||||
|                         {data.map((booking) => ( | ||||
|                           <BookingListItem key={booking.id} {...booking} /> | ||||
|                         {query.data.pages.map((page, index) => ( | ||||
|                           <Fragment key={index}> | ||||
|                             {page.bookings.map((booking) => ( | ||||
|                               <BookingListItem key={booking.id} {...booking} /> | ||||
|                             ))} | ||||
|                           </Fragment> | ||||
|                         ))} | ||||
|                       </tbody> | ||||
|                     </table> | ||||
|                   </div> | ||||
|                 )} | ||||
|                 empty={() => ( | ||||
|                   <EmptyScreen | ||||
|                     Icon={CalendarIcon} | ||||
|                     headline={t("no_status_bookings_yet", { status: status })} | ||||
|                     description={t("no_status_bookings_yet_description", { | ||||
|                       status: status, | ||||
|                       description: descriptionByStatus[status], | ||||
|                     })} | ||||
|                   /> | ||||
|                 )} | ||||
|               /> | ||||
|                   <div className="text-center p-4" ref={buttonInView.ref}> | ||||
|                     <Button | ||||
|                       loading={query.isFetchingNextPage} | ||||
|                       disabled={!query.hasNextPage} | ||||
|                       onClick={() => query.fetchNextPage()}> | ||||
|                       {query.hasNextPage ? t("load_more_results") : t("no_more_results")} | ||||
|                     </Button> | ||||
|                   </div> | ||||
|                 </> | ||||
|               )} | ||||
|               {query.status === "success" && query.data.pages[0].bookings.length === 0 && ( | ||||
|                 <EmptyScreen | ||||
|                   Icon={CalendarIcon} | ||||
|                   headline={t("no_status_bookings_yet", { status: status })} | ||||
|                   description={t("no_status_bookings_yet_description", { | ||||
|                     status: status, | ||||
|                     description: descriptionByStatus[status], | ||||
|                   })} | ||||
|                 /> | ||||
|               )} | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|  |  | |||
|  | @ -1,4 +1,6 @@ | |||
| { | ||||
|   "no_more_results": "No more results", | ||||
|   "load_more_results": "Load more results", | ||||
|   "integration_meeting_id": "{{integrationName}} meeting ID: {{meetingId}}", | ||||
|   "confirmed_event_type_subject": "Confirmed: {{eventType}} with {{name}} on {{date}}", | ||||
|   "new_event_request": "New event request: {{attendeeName}} - {{date}} - {{eventType}}", | ||||
|  |  | |||
|  | @ -226,8 +226,14 @@ const loggedInViewerRouter = createProtectedRouter() | |||
|   .query("bookings", { | ||||
|     input: z.object({ | ||||
|       status: z.enum(["upcoming", "past", "cancelled"]), | ||||
|       limit: z.number().min(1).max(100).nullish(), | ||||
|       cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type
 | ||||
|     }), | ||||
|     async resolve({ ctx, input }) { | ||||
|       // using offset actually because cursor pagination requires a unique column
 | ||||
|       // for orderBy, but we don't use a unique column in our orderBy
 | ||||
|       const take = input.limit ?? 10; | ||||
|       const skip = input.cursor ?? 0; | ||||
|       const { prisma, user } = ctx; | ||||
|       const bookingListingByStatus = input.status; | ||||
|       const bookingListingFilters: Record<typeof bookingListingByStatus, Prisma.BookingWhereInput[]> = { | ||||
|  | @ -237,8 +243,8 @@ const loggedInViewerRouter = createProtectedRouter() | |||
|       }; | ||||
|       const bookingListingOrderby: Record<typeof bookingListingByStatus, Prisma.BookingOrderByInput> = { | ||||
|         upcoming: { startTime: "desc" }, | ||||
|         past: { startTime: "asc" }, | ||||
|         cancelled: { startTime: "asc" }, | ||||
|         past: { startTime: "desc" }, | ||||
|         cancelled: { startTime: "desc" }, | ||||
|       }; | ||||
|       const passedBookingsFilter = bookingListingFilters[bookingListingByStatus]; | ||||
|       const orderBy = bookingListingOrderby[bookingListingByStatus]; | ||||
|  | @ -281,6 +287,8 @@ const loggedInViewerRouter = createProtectedRouter() | |||
|           status: true, | ||||
|         }, | ||||
|         orderBy, | ||||
|         take: take + 1, | ||||
|         skip, | ||||
|       }); | ||||
| 
 | ||||
|       const bookings = bookingsQuery.reverse().map((booking) => { | ||||
|  | @ -291,7 +299,18 @@ const loggedInViewerRouter = createProtectedRouter() | |||
|         }; | ||||
|       }); | ||||
| 
 | ||||
|       return bookings; | ||||
|       let nextCursor: typeof skip | null = skip; | ||||
|       if (bookings.length > take) { | ||||
|         bookings.shift(); | ||||
|         nextCursor += bookings.length; | ||||
|       } else { | ||||
|         nextCursor = null; | ||||
|       } | ||||
| 
 | ||||
|       return { | ||||
|         bookings, | ||||
|         nextCursor, | ||||
|       }; | ||||
|     }, | ||||
|   }) | ||||
|   .query("integrations", { | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue
	
	 Mihai C
						Mihai C