fix: apply standard pagination to /bookings (#19973)

* fix: apply standard pagination to /bookings

* rename params

* fix type error

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
This commit is contained in:
Eunjae Lee
2025-03-13 12:32:07 +00:00
committed by GitHub
co-authored by Benny Joo Udit Takkar
parent 209a401113
commit 367e2666c6
6 changed files with 204 additions and 256 deletions
@@ -1,42 +0,0 @@
// eslint-disable-next-line no-restricted-imports
import { debounce } from "lodash";
import { useCallback, useEffect, useRef } from "react";
// Dynamically adjusts DataTable height on mobile to prevent nested scrolling
// and ensure the table fits within the viewport without overflowing (hacky)
export function useStretchedHeightToBottom(ref: React.RefObject<HTMLDivElement>) {
const lastOffsetY = useRef<number>();
const lastWindowHeight = useRef<number>();
const BOTTOM_NAV_HEIGHT = 64;
const BUFFER = 32;
const updateHeight = useCallback(
debounce(() => {
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
if (rect.top !== lastOffsetY.current || window.innerHeight !== lastWindowHeight.current) {
lastOffsetY.current = rect.top;
lastWindowHeight.current = window.innerHeight;
let height = window.innerHeight - lastOffsetY.current - BUFFER;
if (window.innerWidth < 640) {
height = height - BOTTOM_NAV_HEIGHT;
}
ref.current.style.height = `${height}px`;
}
}, 200),
[ref.current]
);
useEffect(() => {
const handleResize = () => {
updateHeight();
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, [updateHeight]);
updateHeight();
}
@@ -12,6 +12,7 @@ import { useMemo, useRef } from "react";
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
import dayjs from "@calcom/dayjs";
import {
useDataTable,
DataTableProvider,
DataTableWrapper,
DataTableFilters,
@@ -33,7 +34,6 @@ import BookingListItem from "@components/booking/BookingListItem";
import SkeletonLoader from "@components/booking/SkeletonLoader";
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
import { useStretchedHeightToBottom } from "~/bookings/hooks/useStretchedHeightToBottom";
import type { validStatuses } from "~/bookings/lib/validStatuses";
type BookingListingStatus = (typeof validStatuses)[number];
@@ -109,7 +109,6 @@ function BookingsContent({ status }: BookingsProps) {
const { t } = useLocale();
const user = useMeQuery().data;
const tableContainerRef = useRef<HTMLDivElement>(null);
useStretchedHeightToBottom(tableContainerRef);
const eventTypeIds = useFilterValue("eventTypeId", ZMultiSelectFilterValue)?.data as number[] | undefined;
const teamIds = useFilterValue("teamId", ZMultiSelectFilterValue)?.data as number[] | undefined;
@@ -118,26 +117,24 @@ function BookingsContent({ status }: BookingsProps) {
const attendeeName = useFilterValue("attendeeName", ZTextFilterValue);
const attendeeEmail = useFilterValue("attendeeEmail", ZTextFilterValue);
const query = trpc.viewer.bookings.get.useInfiniteQuery(
{
limit: 10,
filters: {
status,
eventTypeIds,
teamIds,
userIds,
attendeeName,
attendeeEmail,
afterStartDate: dateRange?.startDate
? dayjs(dateRange?.startDate).startOf("day").toISOString()
: undefined,
beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined,
},
const { limit, offset } = useDataTable();
const query = trpc.viewer.bookings.get.useQuery({
limit,
offset,
filters: {
status,
eventTypeIds,
teamIds,
userIds,
attendeeName,
attendeeEmail,
afterStartDate: dateRange?.startDate
? dayjs(dateRange?.startDate).startOf("day").toISOString()
: undefined,
beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined,
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
});
const columns = useMemo(() => {
const columnHelper = createColumnHelper<RowData>();
@@ -262,7 +259,7 @@ function BookingsContent({ status }: BookingsProps) {
];
}, [user, status, t]);
const isEmpty = useMemo(() => !query.data?.pages[0]?.bookings.length, [query.data]);
const isEmpty = useMemo(() => !query.data?.bookings.length, [query.data]);
const flatData = useMemo<RowData[]>(() => {
const shownBookings: Record<string, BookingOutput[]> = {};
@@ -289,37 +286,33 @@ function BookingsContent({ status }: BookingsProps) {
};
return (
query.data?.pages.flatMap((page) =>
page.bookings.filter(filterBookings).map((booking) => ({
type: "data",
booking,
recurringInfo: page.recurringInfo.find(
(info) => info.recurringEventId === booking.recurringEventId
),
isToday: false,
}))
) || []
query.data?.bookings.filter(filterBookings).map((booking) => ({
type: "data",
booking,
recurringInfo: query.data?.recurringInfo.find(
(info) => info.recurringEventId === booking.recurringEventId
),
isToday: false,
})) || []
);
}, [query.data]);
const bookingsToday = useMemo<RowData[]>(() => {
return (
query.data?.pages.flatMap((page) =>
page.bookings
.filter(
(booking: BookingOutput) =>
dayjs(booking.startTime).tz(user?.timeZone).format("YYYY-MM-DD") ===
dayjs().tz(user?.timeZone).format("YYYY-MM-DD")
)
.map((booking) => ({
type: "data" as const,
booking,
recurringInfo: page.recurringInfo.find(
(info) => info.recurringEventId === booking.recurringEventId
),
isToday: true,
}))
) || []
query.data?.bookings
.filter(
(booking: BookingOutput) =>
dayjs(booking.startTime).tz(user?.timeZone).format("YYYY-MM-DD") ===
dayjs().tz(user?.timeZone).format("YYYY-MM-DD")
)
.map((booking) => ({
type: "data" as const,
booking,
recurringInfo: query.data?.recurringInfo.find(
(info) => info.recurringEventId === booking.recurringEventId
),
isToday: true,
})) ?? []
);
}, [query.data]);
@@ -379,17 +372,16 @@ function BookingsContent({ status }: BookingsProps) {
<WipeMyCalActionButton bookingStatus={status} bookingsEmpty={isEmpty} />
)}
<DataTableWrapper
className="mb-6"
tableContainerRef={tableContainerRef}
table={table}
testId={`${status}-bookings`}
bodyTestId="bookings"
hideHeader={true}
isPending={query.isPending}
hasNextPage={query.hasNextPage}
fetchNextPage={query.fetchNextPage}
isFetching={query.isFetching}
totalRowCount={query.data?.totalCount}
variant="compact"
paginationMode="infinite"
paginationMode="standard"
ToolbarLeft={
<>
<DataTableFilters.AddFilterButton table={table} hideWhenFilterApplied />
@@ -12,7 +12,6 @@ import {
useDataTable,
useColumnFilters,
} from "@calcom/features/data-table";
import classNames from "@calcom/ui/classNames";
type BaseDataTableWrapperProps<TData> = {
testId?: string;
@@ -110,7 +109,7 @@ export function DataTableWrapper<TData>({
return (
<>
{(ToolbarLeft || ToolbarRight || children) && (
<div className={classNames("grid w-full items-center gap-2 py-4", className)}>
<div className="grid w-full items-center gap-2 pb-4">
<div className="flex w-full flex-col gap-2">
<div className="flex w-full flex-wrap justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">{ToolbarLeft}</div>
+2 -10
View File
@@ -77,7 +77,7 @@ const getAllUserBookings = async ({ ctx, filters, bookingListingByStatus, take,
const combinedFilters = bookingListingByStatus.map((status) => bookingListingFilters[status]);
const { bookings, recurringInfo } = await getBookings({
const { bookings, recurringInfo, totalCount } = await getBookings({
user,
prisma,
passedBookingsStatusFilter: {
@@ -89,18 +89,10 @@ const getAllUserBookings = async ({ ctx, filters, bookingListingByStatus, take,
skip,
});
const bookingsFetched = bookings.length;
let nextCursor: typeof skip | null = skip;
if (bookingsFetched > take) {
nextCursor += bookingsFetched;
} else {
nextCursor = null;
}
return {
bookings,
recurringInfo,
nextCursor,
totalCount,
};
};
@@ -29,24 +29,24 @@ const log = logger.getSubLogger({ prefix: ["bookings.get"] });
export const getHandler = async ({ ctx, input }: GetOptions) => {
// 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 take = input.limit;
const skip = input.offset;
const { prisma, user } = ctx;
const defaultStatus = "upcoming";
const bookingListingByStatus = [input.filters.status || defaultStatus];
const { bookings, recurringInfo, nextCursor } = await getAllUserBookings({
const { bookings, recurringInfo, totalCount } = await getAllUserBookings({
ctx: { user: { id: user.id, email: user.email }, prisma: prisma },
bookingListingByStatus: bookingListingByStatus,
take: take,
skip: skip,
take,
skip,
filters: input.filters,
});
return {
bookings,
recurringInfo,
nextCursor,
totalCount,
};
};
@@ -183,167 +183,174 @@ export async function getBookings({
getUserIdsWhereUserIsOrgAdminOrOwner(prisma, membershipConditionWhereUserIsAdminOwner),
]);
const plainBookings = await prisma.booking.findMany({
where: {
OR: [
{
userId: user.id,
const whereClause = {
OR: [
{
userId: user.id,
},
{
attendees: {
some: {
email: user.email,
},
},
{
attendees: {
some: {
},
{
eventTypeId: {
in: eventTypeIdsWhereUserIsAdminOrOwener,
},
},
{
userId: {
in: userIdsWhereUserIsOrgAdminOrOwener,
},
},
{
seatsReferences: {
some: {
attendee: {
email: user.email,
},
},
},
{
eventTypeId: {
in: eventTypeIdsWhereUserIsAdminOrOwener,
},
},
{
userId: {
in: userIdsWhereUserIsOrgAdminOrOwener,
},
},
{
seatsReferences: {
some: {
attendee: {
email: user.email,
},
],
AND: [
passedBookingsStatusFilter,
...(eventTypeIdsFromTeamIdsFilter
? [
{
eventTypeId: {
in: eventTypeIdsFromTeamIdsFilter,
},
},
},
},
],
AND: [
passedBookingsStatusFilter,
...(eventTypeIdsFromTeamIdsFilter
? [
{
eventTypeId: {
in: eventTypeIdsFromTeamIdsFilter,
},
},
]
: []),
...(filters?.userIds && filters.userIds.length > 0
? [
{
OR: [
{
userId: {
in: filters.userIds,
},
]
: []),
...(filters?.userIds && filters.userIds.length > 0
? [
{
OR: [
{
userId: {
in: filters.userIds,
},
...(attendeeEmailsFromUserIdsFilter?.length
? [
{
attendees: {
some: {
email: {
in: attendeeEmailsFromUserIdsFilter,
},
},
...(attendeeEmailsFromUserIdsFilter?.length
? [
{
attendees: {
some: {
email: {
in: attendeeEmailsFromUserIdsFilter,
},
},
},
]
: []),
],
},
]
: []),
...(eventTypeIdsFromEventTypeIdsFilter
? [
{
eventTypeId: { in: eventTypeIdsFromEventTypeIdsFilter },
},
]
: []),
},
]
: []),
],
},
]
: []),
...(eventTypeIdsFromEventTypeIdsFilter
? [
{
eventTypeId: { in: eventTypeIdsFromEventTypeIdsFilter },
},
]
: []),
...(typeof filters?.attendeeEmail === "string"
? [
{
attendees: { some: { email: filters.attendeeEmail.trim() } },
...(typeof filters?.attendeeEmail === "string"
? [
{
attendees: { some: { email: filters.attendeeEmail.trim() } },
},
]
: []),
...(isTextFilterValue(filters?.attendeeEmail)
? [
{
attendees: {
some: makeWhereClause({
columnName: "email",
filterValue: filters.attendeeEmail,
}),
},
]
: []),
...(isTextFilterValue(filters?.attendeeEmail)
? [
{
attendees: {
some: makeWhereClause({
columnName: "email",
filterValue: filters.attendeeEmail,
}),
},
},
]
: []),
},
]
: []),
...(typeof filters?.attendeeName === "string"
? [
{
attendees: { some: { name: filters.attendeeName.trim() } },
...(typeof filters?.attendeeName === "string"
? [
{
attendees: { some: { name: filters.attendeeName.trim() } },
},
]
: []),
...(isTextFilterValue(filters?.attendeeName)
? [
{
attendees: {
some: makeWhereClause({
columnName: "name",
filterValue: filters.attendeeName,
}),
},
]
: []),
...(isTextFilterValue(filters?.attendeeName)
? [
{
attendees: {
some: makeWhereClause({
columnName: "name",
filterValue: filters.attendeeName,
}),
},
},
]
: []),
},
]
: []),
...(filters?.afterStartDate
? [
{
startTime: {
gte: dayjs.utc(filters.afterStartDate).toDate(),
},
...(filters?.afterStartDate
? [
{
startTime: {
gte: dayjs.utc(filters.afterStartDate).toDate(),
},
]
: []),
...(filters?.beforeEndDate
? [
{
endTime: {
lte: dayjs.utc(filters.beforeEndDate).toDate(),
},
},
]
: []),
...(filters?.beforeEndDate
? [
{
endTime: {
lte: dayjs.utc(filters.beforeEndDate).toDate(),
},
]
: []),
...(filters?.afterUpdatedDate
? [
{
updatedAt: {
gte: dayjs.utc(filters.afterUpdatedDate).toDate(),
},
},
]
: []),
...(filters?.afterUpdatedDate
? [
{
updatedAt: {
gte: dayjs.utc(filters.afterUpdatedDate).toDate(),
},
]
: []),
...(filters?.beforeUpdatedDate
? [
{
updatedAt: {
lte: dayjs.utc(filters.beforeUpdatedDate).toDate(),
},
},
]
: []),
...(filters?.beforeUpdatedDate
? [
{
updatedAt: {
lte: dayjs.utc(filters.beforeUpdatedDate).toDate(),
},
]
: []),
],
},
select: bookingSelect,
orderBy,
take: take + 1,
skip,
});
},
]
: []),
],
};
const [plainBookings, totalCount] = await Promise.all([
prisma.booking.findMany({
where: whereClause,
select: bookingSelect,
orderBy,
take,
skip,
}),
prisma.booking.count({
where: whereClause,
}),
]);
const [
recurringInfoBasic,
@@ -446,7 +453,7 @@ export async function getBookings({
};
})
);
return { bookings, recurringInfo };
return { bookings, recurringInfo, totalCount };
}
async function getEventTypeIdsFromTeamIdsFilter(prisma: PrismaClient, teamIds?: number[]) {
@@ -15,8 +15,8 @@ export const ZGetInputSchema = z.object({
afterUpdatedDate: z.string().optional(),
beforeUpdatedDate: z.string().optional(),
}),
limit: z.number().min(1).max(100).nullish(),
cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type
limit: z.number().min(1).max(100),
offset: z.number(),
});
export type TGetInputSchema = z.infer<typeof ZGetInputSchema>;