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:
Mihai C 2021-10-28 18:02:22 +03:00 committed by GitHub
parent e38086b8fe
commit f91de82daf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 117 additions and 28 deletions

View 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,
};
};

View file

@ -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>

View file

@ -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}}",

View file

@ -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", {