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,34 +28,54 @@ 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) => (
|
||||
{query.data.pages.map((page, index) => (
|
||||
<Fragment key={index}>
|
||||
{page.bookings.map((booking) => (
|
||||
<BookingListItem key={booking.id} {...booking} />
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
empty={() => (
|
||||
{query.status === "success" && query.data.pages[0].bookings.length === 0 && (
|
||||
<EmptyScreen
|
||||
Icon={CalendarIcon}
|
||||
headline={t("no_status_bookings_yet", { status: status })}
|
||||
|
@ -58,7 +85,6 @@ export default function Bookings() {
|
|||
})}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</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