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 { CalendarIcon } from "@heroicons/react/outline";
|
||||||
import { useRouter } from "next/router";
|
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 { useLocale } from "@lib/hooks/useLocale";
|
||||||
import { inferQueryInput, trpc } from "@lib/trpc";
|
import { inferQueryInput, trpc } from "@lib/trpc";
|
||||||
|
|
||||||
import BookingsShell from "@components/BookingsShell";
|
import BookingsShell from "@components/BookingsShell";
|
||||||
import EmptyScreen from "@components/EmptyScreen";
|
import EmptyScreen from "@components/EmptyScreen";
|
||||||
|
import Loader from "@components/Loader";
|
||||||
import Shell from "@components/Shell";
|
import Shell from "@components/Shell";
|
||||||
import BookingListItem from "@components/booking/BookingListItem";
|
import BookingListItem from "@components/booking/BookingListItem";
|
||||||
|
import { Alert } from "@components/ui/Alert";
|
||||||
|
import Button from "@components/ui/Button";
|
||||||
|
|
||||||
type BookingListingStatus = inferQueryInput<"viewer.bookings">["status"];
|
type BookingListingStatus = inferQueryInput<"viewer.bookings">["status"];
|
||||||
|
|
||||||
export default function Bookings() {
|
export default function Bookings() {
|
||||||
|
const router = useRouter();
|
||||||
|
const status = router.query?.status as BookingListingStatus;
|
||||||
|
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
const descriptionByStatus: Record<BookingListingStatus, string> = {
|
const descriptionByStatus: Record<BookingListingStatus, string> = {
|
||||||
|
@ -21,44 +28,63 @@ export default function Bookings() {
|
||||||
cancelled: t("cancelled_bookings"),
|
cancelled: t("cancelled_bookings"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const router = useRouter();
|
const query = trpc.useInfiniteQuery(["viewer.bookings", { status, limit: 10 }], {
|
||||||
const status = router.query?.status as BookingListingStatus;
|
|
||||||
|
|
||||||
const query = trpc.useQuery(["viewer.bookings", { status }], {
|
|
||||||
// first render has status `undefined`
|
// first render has status `undefined`
|
||||||
enabled: !!status,
|
enabled: !!status,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buttonInView = useInViewObserver(() => {
|
||||||
|
if (!query.isFetching && query.hasNextPage && query.status === "success") {
|
||||||
|
query.fetchNextPage();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Shell heading={t("bookings")} subtitle={t("bookings_description")}>
|
<Shell heading={t("bookings")} subtitle={t("bookings_description")}>
|
||||||
<BookingsShell>
|
<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="-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">
|
<div className="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
||||||
<QueryCell
|
{query.status === "error" && (
|
||||||
query={query}
|
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
|
||||||
success={({ data }) => (
|
)}
|
||||||
<div className="my-6 border border-gray-200 overflow-hidden border-b rounded-sm">
|
{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">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<tbody className="bg-white divide-y divide-gray-200" data-testid="bookings">
|
<tbody className="bg-white divide-y divide-gray-200" data-testid="bookings">
|
||||||
{data.map((booking) => (
|
{query.data.pages.map((page, index) => (
|
||||||
<BookingListItem key={booking.id} {...booking} />
|
<Fragment key={index}>
|
||||||
|
{page.bookings.map((booking) => (
|
||||||
|
<BookingListItem key={booking.id} {...booking} />
|
||||||
|
))}
|
||||||
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="text-center p-4" ref={buttonInView.ref}>
|
||||||
empty={() => (
|
<Button
|
||||||
<EmptyScreen
|
loading={query.isFetchingNextPage}
|
||||||
Icon={CalendarIcon}
|
disabled={!query.hasNextPage}
|
||||||
headline={t("no_status_bookings_yet", { status: status })}
|
onClick={() => query.fetchNextPage()}>
|
||||||
description={t("no_status_bookings_yet_description", {
|
{query.hasNextPage ? t("load_more_results") : t("no_more_results")}
|
||||||
status: status,
|
</Button>
|
||||||
description: descriptionByStatus[status],
|
</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>
|
</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}}",
|
"integration_meeting_id": "{{integrationName}} meeting ID: {{meetingId}}",
|
||||||
"confirmed_event_type_subject": "Confirmed: {{eventType}} with {{name}} on {{date}}",
|
"confirmed_event_type_subject": "Confirmed: {{eventType}} with {{name}} on {{date}}",
|
||||||
"new_event_request": "New event request: {{attendeeName}} - {{date}} - {{eventType}}",
|
"new_event_request": "New event request: {{attendeeName}} - {{date}} - {{eventType}}",
|
||||||
|
|
|
@ -226,8 +226,14 @@ const loggedInViewerRouter = createProtectedRouter()
|
||||||
.query("bookings", {
|
.query("bookings", {
|
||||||
input: z.object({
|
input: z.object({
|
||||||
status: z.enum(["upcoming", "past", "cancelled"]),
|
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 }) {
|
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 { prisma, user } = ctx;
|
||||||
const bookingListingByStatus = input.status;
|
const bookingListingByStatus = input.status;
|
||||||
const bookingListingFilters: Record<typeof bookingListingByStatus, Prisma.BookingWhereInput[]> = {
|
const bookingListingFilters: Record<typeof bookingListingByStatus, Prisma.BookingWhereInput[]> = {
|
||||||
|
@ -237,8 +243,8 @@ const loggedInViewerRouter = createProtectedRouter()
|
||||||
};
|
};
|
||||||
const bookingListingOrderby: Record<typeof bookingListingByStatus, Prisma.BookingOrderByInput> = {
|
const bookingListingOrderby: Record<typeof bookingListingByStatus, Prisma.BookingOrderByInput> = {
|
||||||
upcoming: { startTime: "desc" },
|
upcoming: { startTime: "desc" },
|
||||||
past: { startTime: "asc" },
|
past: { startTime: "desc" },
|
||||||
cancelled: { startTime: "asc" },
|
cancelled: { startTime: "desc" },
|
||||||
};
|
};
|
||||||
const passedBookingsFilter = bookingListingFilters[bookingListingByStatus];
|
const passedBookingsFilter = bookingListingFilters[bookingListingByStatus];
|
||||||
const orderBy = bookingListingOrderby[bookingListingByStatus];
|
const orderBy = bookingListingOrderby[bookingListingByStatus];
|
||||||
|
@ -281,6 +287,8 @@ const loggedInViewerRouter = createProtectedRouter()
|
||||||
status: true,
|
status: true,
|
||||||
},
|
},
|
||||||
orderBy,
|
orderBy,
|
||||||
|
take: take + 1,
|
||||||
|
skip,
|
||||||
});
|
});
|
||||||
|
|
||||||
const bookings = bookingsQuery.reverse().map((booking) => {
|
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", {
|
.query("integrations", {
|
||||||
|
|
Loading…
Reference in a new issue