refactor: use DataTable for /bookings (#18589)

* refactor: use DataTable for /bookings

* remove unnecessary animation (for better scroll performance)

* add consistent search box
This commit is contained in:
Eunjae Lee authored and GitHub committed 2025-01-15 14:29:50 +00:00
1 parent cce6e83578
commit 4f494d5a32
8 files changed
+226 -156

No files matched your search

+14 -14
View File
@@ -511,9 +511,9 @@ function BookingListItem(booking: BookingItemProps) {
</DialogFooter>
</DialogContent>
</Dialog>
<tr data-testid="booking-item" className="hover:bg-muted group transition ">
<div data-testid="booking-item" className="hover:bg-muted group w-full">
<div className="flex flex-col sm:flex-row">
<td className="hidden align-top ltr:pl-3 rtl:pr-6 sm:table-cell sm:min-w-[12rem]">
<div className="hidden align-top ltr:pl-3 rtl:pr-6 sm:table-cell sm:min-w-[12rem]">
<div className="flex h-full items-center">
{eventTypeColor && (
<div className="h-[70%] w-0.5" style={{ backgroundColor: eventTypeColor }} />
@@ -562,8 +562,10 @@ function BookingListItem(booking: BookingItemProps) {
</div>
</Link>
</div>
</td>
<td data-testid="title-and-attendees" className={`w-full px-4${isRejected ? " line-through" : ""}`}>
</div>
<div
data-testid="title-and-attendees"
className={`w-full px-4${isRejected ? " line-through" : ""}`}>
<Link href={bookingLink}>
{/* Time and Badges for mobile */}
<div className="w-full pb-2 pt-4 sm:hidden">
@@ -648,8 +650,8 @@ function BookingListItem(booking: BookingItemProps) {
)}
</div>
</Link>
</td>
<td className="flex w-full flex-col flex-wrap items-end justify-end space-x-2 space-y-2 py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:flex-row sm:flex-nowrap sm:items-start sm:space-y-0 sm:pl-0">
</div>
<div className="flex w-full flex-col flex-wrap items-end justify-end space-x-2 space-y-2 py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:flex-row sm:flex-nowrap sm:items-start sm:space-y-0 sm:pl-0">
{isUpcoming && !isCancelled ? (
<>
{isPending && <TableActions actions={pendingActions} />}
@@ -674,7 +676,7 @@ function BookingListItem(booking: BookingItemProps) {
<TableActions actions={chargeCardActions} />
</div>
)}
</td>
</div>
</div>
<BookingItemBadges
booking={booking}
@@ -683,7 +685,7 @@ function BookingListItem(booking: BookingItemProps) {
userTimeFormat={userTimeFormat}
userTimeZone={userTimeZone}
/>
</tr>
</div>
{isBookingReroutable(parsedBooking) && (
<RerouteDialog
@@ -712,7 +714,7 @@ const BookingItemBadges = ({
const { t } = useLocale();
return (
<div className="hidden h-9 flex-row pb-4 pl-6 sm:flex">
<div className="hidden h-9 flex-row items-center pb-4 pl-6 sm:flex">
{isPending && (
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
{t("unconfirmed")}
@@ -1251,11 +1253,9 @@ const AssignmentReasonTooltip = ({ assignmentReason }: { assignmentReason: Assig
const badgeTitle = assignmentReasonBadgeTitleMap(assignmentReason.reasonEnum);
return (
<Tooltip content={<p>{assignmentReason.reasonString}</p>}>
<div className="-mt-1">
<Badge className="ltr:mr-2 rtl:ml-2" variant="gray">
{t(badgeTitle)}
</Badge>
</div>
<Badge className="ltr:mr-2 rtl:ml-2" variant="gray">
{t(badgeTitle)}
</Badge>
</Tooltip>
);
};
@@ -1,8 +1,14 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import {
useReactTable,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
createColumnHelper,
} from "@tanstack/react-table";
import type { ReactElement } from "react";
import { Fragment, useState } from "react";
import { Fragment, useMemo, useState } from "react";
import type { z } from "zod";
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
@@ -11,13 +17,13 @@ import { FilterToggle } from "@calcom/features/bookings/components/FilterToggle"
import { FiltersContainer } from "@calcom/features/bookings/components/FiltersContainer";
import type { filterQuerySchema } from "@calcom/features/bookings/lib/useFilterQuery";
import { useFilterQuery } from "@calcom/features/bookings/lib/useFilterQuery";
import { DataTableProvider, DataTableWrapper } from "@calcom/features/data-table";
import Shell from "@calcom/features/shell/Shell";
import { useInViewObserver } from "@calcom/lib/hooks/useInViewObserver";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import type { HorizontalTabItemProps, VerticalTabItemProps } from "@calcom/ui";
import { Alert, Button, EmptyScreen, HorizontalTabs } from "@calcom/ui";
import { Alert, EmptyScreen, HorizontalTabs } from "@calcom/ui";
import useMeQuery from "@lib/hooks/useMeQuery";
@@ -67,7 +73,24 @@ const descriptionByStatus: Record<NonNullable<BookingListingStatus>, string> = {
unconfirmed: "unconfirmed_bookings",
};
export default function Bookings({ status }: { status: (typeof validStatuses)[number] }) {
type BookingsProps = {
status: (typeof validStatuses)[number];
};
export default function Bookings(props: BookingsProps) {
return (
<DataTableProvider>
<BookingsContent {...props} />
</DataTableProvider>
);
}
type RowData = {
booking: BookingOutput;
recurringInfo?: RecurringInfo;
};
function BookingsContent({ status }: BookingsProps) {
const { data: filterQuery } = useFilterQuery();
const { t } = useLocale();
@@ -88,13 +111,32 @@ export default function Bookings({ status }: { status: (typeof validStatuses)[nu
}
);
// Animate page (tab) transitions to look smoothing
const columns = useMemo(() => {
const columnHelper = createColumnHelper<RowData>();
const buttonInView = useInViewObserver(() => {
if (!query.isFetching && query.hasNextPage && query.status === "success") {
query.fetchNextPage();
}
});
return [
columnHelper.display({
id: "custom-view",
cell: (props) => {
const { booking, recurringInfo } = props.row.original;
return (
<BookingListItem
key={booking.id}
loggedInUser={{
userId: user?.id,
userTimeZone: user?.timeZone,
userTimeFormat: user?.timeFormat,
userEmail: user?.email,
}}
listingStatus={status}
recurringInfo={recurringInfo}
{...booking}
/>
);
},
}),
];
}, [user, status]);
const isEmpty = !query.data?.pages[0]?.bookings.length;
@@ -121,6 +163,27 @@ export default function Bookings({ status }: { status: (typeof validStatuses)[nu
return true;
};
const flatData = useMemo(() => {
return (
query.data?.pages.flatMap((page) =>
page.bookings.filter(filterBookings).map((booking) => ({
booking,
recurringInfo: page.recurringInfo.find(
(info) => info.recurringEventId === booking.recurringEventId
),
}))
) || []
);
}, [query.data]);
const table = useReactTable<RowData>({
data: flatData,
columns,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
});
let recurringInfoToday: RecurringInfo | undefined;
const bookingsToday =
@@ -137,8 +200,6 @@ export default function Bookings({ status }: { status: (typeof validStatuses)[nu
})
)[0] || [];
const [animationParentRef] = useAutoAnimate<HTMLDivElement>();
return (
<div className="flex flex-col">
<div className="flex flex-row flex-wrap justify-between">
@@ -147,7 +208,7 @@ export default function Bookings({ status }: { status: (typeof validStatuses)[nu
</div>
<FiltersContainer isFiltersVisible={isFiltersVisible} />
<main className="w-full">
<div className="flex w-full flex-col" ref={animationParentRef}>
<div className="flex w-full flex-col">
{query.status === "error" && (
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
)}
@@ -159,69 +220,41 @@ export default function Bookings({ status }: { status: (typeof validStatuses)[nu
<WipeMyCalActionButton bookingStatus={status} bookingsEmpty={isEmpty} />
<p className="text-subtle mb-2 text-xs font-medium uppercase leading-4">{t("today")}</p>
<div className="border-subtle overflow-hidden rounded-md border">
<table className="w-full max-w-full table-fixed">
<tbody className="bg-default divide-subtle divide-y" data-testid="today-bookings">
<Fragment>
{bookingsToday.map((booking: BookingOutput) => (
<BookingListItem
key={booking.id}
loggedInUser={{
userId: user?.id,
userTimeZone: user?.timeZone,
userTimeFormat: user?.timeFormat,
userEmail: user?.email,
}}
listingStatus={status}
recurringInfo={recurringInfoToday}
{...booking}
/>
))}
</Fragment>
</tbody>
</table>
<div
className="bg-default divide-subtle w-full max-w-full divide-y"
data-testid="today-bookings">
<Fragment>
{bookingsToday.map((booking: BookingOutput) => (
<BookingListItem
key={booking.id}
loggedInUser={{
userId: user?.id,
userTimeZone: user?.timeZone,
userTimeFormat: user?.timeFormat,
userEmail: user?.email,
}}
listingStatus={status}
recurringInfo={recurringInfoToday}
{...booking}
/>
))}
</Fragment>
</div>
</div>
</div>
)}
<div className="pt-2 xl:pt-0">
<div className="border-subtle overflow-hidden rounded-md border">
<table data-testid={`${status}-bookings`} className="w-full max-w-full table-fixed">
<tbody className="bg-default divide-subtle divide-y" data-testid="bookings">
{query.data.pages.map((page, index) => (
<Fragment key={index}>
{page.bookings.filter(filterBookings).map((booking: BookingOutput) => {
const recurringInfo = page.recurringInfo.find(
(info) => info.recurringEventId === booking.recurringEventId
);
return (
<BookingListItem
key={booking.id}
loggedInUser={{
userId: user?.id,
userTimeZone: user?.timeZone,
userTimeFormat: user?.timeFormat,
userEmail: user?.email,
}}
listingStatus={status}
recurringInfo={recurringInfo}
{...booking}
/>
);
})}
</Fragment>
))}
</tbody>
</table>
</div>
<div className="text-default p-4 text-center" ref={buttonInView.ref}>
<Button
color="minimal"
loading={query.isFetchingNextPage}
disabled={!query.hasNextPage}
onClick={() => query.fetchNextPage()}>
{query.hasNextPage ? t("load_more_results") : t("no_more_results")}
</Button>
</div>
</div>
<DataTableWrapper
table={table}
testId={`${status}-bookings`}
bodyTestId="bookings"
hideHeader={true}
isPending={query.isFetching && !flatData}
hasNextPage={query.hasNextPage}
fetchNextPage={query.fetchNextPage}
isFetching={query.isFetching}
variant="compact"
/>
</>
)}
{query.status === "success" && isEmpty && (
@@ -54,6 +54,8 @@ export const PeopleFilter = () => {
return (
<AnimatedPopover text={getTextForPopover()} prefix={`${t("people")}: `}>
<FilterCheckboxFieldsContainer>
<FilterSearchField onChange={(e) => setSearchText(e.target.value)} placeholder={t("search")} />
<FilterCheckboxField
id="all"
icon={<Icon name="user" className="h-4 w-4" />}
@@ -62,7 +64,6 @@ export const PeopleFilter = () => {
label={t("all_users_filter_label")}
/>
<Divider />
<FilterSearchField onChange={(e) => setSearchText(e.target.value)} placeholder={t("search")} />
{filteredMembers?.map((member) => (
<FilterCheckboxField
@@ -23,7 +23,9 @@ export type DataTableProps<TData, TValue> = {
onScroll?: (e: Pick<React.UIEvent<HTMLDivElement, UIEvent>, "target">) => void;
tableOverlay?: React.ReactNode;
variant?: "default" | "compact";
"data-testid"?: string;
testId?: string;
bodyTestId?: string;
hideHeader?: boolean;
children?: React.ReactNode;
identifier?: string;
enableColumnResizing?: boolean;
@@ -37,8 +39,11 @@ export function DataTable<TData, TValue>({
onRowMouseclick,
onScroll,
children,
hideHeader,
identifier: _identifier,
enableColumnResizing,
testId,
bodyTestId,
...rest
}: DataTableProps<TData, TValue> & React.ComponentPropsWithoutRef<"div">) {
const pathname = usePathname() as string | null;
@@ -89,7 +94,7 @@ export function DataTable<TData, TValue>({
gridTemplateAreas: "'header' 'body' 'footer'",
...rest.style,
}}
data-testid={rest["data-testid"] ?? "data-table"}>
data-testid={testId ?? "data-table"}>
<div
ref={tableContainerRef}
onScroll={onScroll}
@@ -104,66 +109,69 @@ export function DataTable<TData, TValue>({
...columnSizingVars,
...(Boolean(enableColumnResizing) && { width: table.getTotalSize() }),
}}>
<TableHeader className="sticky top-0 z-10">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="hover:bg-subtle flex w-full">
{headerGroup.headers.map((header) => {
const meta = header.column.columnDef.meta;
return (
<TableHead
key={header.id}
style={{
...(meta?.sticky?.position === "left" && { left: `${meta.sticky.gap || 0}px` }),
...(meta?.sticky?.position === "right" && { right: `${meta.sticky.gap || 0}px` }),
width: `var(--header-${kebabCase(header?.id)}-size)`,
}}
className={classNames(
"relative flex shrink-0 items-center",
header.column.getCanSort()
? "bg-subtle hover:bg-muted cursor-pointer select-none"
: "",
meta?.sticky && "top-0 z-20 sm:sticky"
)}>
<div
className="flex h-full w-full items-center overflow-hidden"
onClick={header.column.getToggleSortingHandler()}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getIsSorted() && (
<Icon
name="arrow-up"
className="ml-2 h-4 w-4"
style={{
transform:
header.column.getIsSorted() === "asc" ? "rotate(0deg)" : "rotate(180deg)",
transition: "transform 0.2s ease-in-out",
}}
{!hideHeader && (
<TableHeader className="sticky top-0 z-10">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="hover:bg-subtle flex w-full">
{headerGroup.headers.map((header) => {
const meta = header.column.columnDef.meta;
return (
<TableHead
key={header.id}
style={{
...(meta?.sticky?.position === "left" && { left: `${meta.sticky.gap || 0}px` }),
...(meta?.sticky?.position === "right" && { right: `${meta.sticky.gap || 0}px` }),
width: `var(--header-${kebabCase(header?.id)}-size)`,
}}
className={classNames(
"relative flex shrink-0 items-center",
header.column.getCanSort()
? "bg-subtle hover:bg-muted cursor-pointer select-none"
: "",
meta?.sticky && "top-0 z-20 sm:sticky"
)}>
<div
className="flex h-full w-full items-center overflow-hidden"
onClick={header.column.getToggleSortingHandler()}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getIsSorted() && (
<Icon
name="arrow-up"
className="ml-2 h-4 w-4"
style={{
transform:
header.column.getIsSorted() === "asc" ? "rotate(0deg)" : "rotate(180deg)",
transition: "transform 0.2s ease-in-out",
}}
/>
)}
</div>
{Boolean(enableColumnResizing) && header.column.getCanResize() && (
<div
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={classNames(
"bg-inverted absolute right-0 top-0 h-full w-[5px] cursor-col-resize touch-none select-none opacity-0 hover:opacity-50",
header.column.getIsResizing() && "!opacity-75"
)}
/>
)}
</div>
{Boolean(enableColumnResizing) && header.column.getCanResize() && (
<div
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={classNames(
"bg-inverted absolute right-0 top-0 h-full w-[5px] cursor-col-resize touch-none select-none opacity-0 hover:opacity-50",
header.column.getIsResizing() && "!opacity-75"
)}
/>
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
)}
{/* When resizing any column we will render this special memoized version of our table body */}
{table.getState().columnSizingInfo.isResizingColumn ? (
<MemoizedTableBody
table={table}
rowVirtualizer={rowVirtualizer}
rows={rows}
testId={bodyTestId}
variant={variant}
isPending={isPending}
onRowMouseclick={onRowMouseclick}
@@ -173,6 +181,7 @@ export function DataTable<TData, TValue>({
table={table}
rowVirtualizer={rowVirtualizer}
rows={rows}
testId={bodyTestId}
variant={variant}
isPending={isPending}
onRowMouseclick={onRowMouseclick}
@@ -191,6 +200,7 @@ const MemoizedTableBody = memo(
prev.table.options.data === next.table.options.data &&
prev.rowVirtualizer === next.rowVirtualizer &&
prev.rows === next.rows &&
prev.testId === next.testId &&
prev.variant === next.variant &&
prev.isPending === next.isPending &&
prev.onRowMouseclick === next.onRowMouseclick
@@ -200,6 +210,7 @@ type DataTableBodyProps<TData> = {
table: ReactTableType<TData>;
rowVirtualizer: Virtualizer<HTMLDivElement, Element>;
rows: Row<TData>[];
testId?: string;
variant?: "default" | "compact";
isPending?: boolean;
onRowMouseclick?: (row: Row<TData>) => void;
@@ -209,13 +220,17 @@ function DataTableBody<TData>({
table,
rowVirtualizer,
rows,
testId,
variant,
isPending,
onRowMouseclick,
}: DataTableBodyProps<TData>) {
const virtualRows = rowVirtualizer.getVirtualItems();
return (
<TableBody className="relative grid" style={{ height: `${rowVirtualizer.getTotalSize()}px` }}>
<TableBody
className="relative grid"
data-testid={testId}
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}>
{virtualRows && !isPending ? (
virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index] as Row<TData>;
@@ -232,11 +247,7 @@ function DataTableBody<TData>({
transform: `translateY(${virtualRow.start}px)`, //this should always be a `style` as it changes on scroll
width: "100%",
}}
className={classNames(
onRowMouseclick && "hover:cursor-pointer",
variant === "compact" && "!border-0",
"group"
)}>
className={classNames(onRowMouseclick && "hover:cursor-pointer", "group")}>
{row.getVisibleCells().map((cell) => {
const column = table.getColumn(cell.column.id);
const meta = column?.columnDef.meta;
@@ -250,7 +261,7 @@ function DataTableBody<TData>({
}}
className={classNames(
"flex shrink-0 items-center overflow-hidden",
variant === "compact" && "p-1.5",
variant === "compact" && "p-0",
meta?.sticky &&
"bg-default group-hover:!bg-muted group-data-[state=selected]:bg-subtle sm:sticky"
)}>
@@ -12,25 +12,31 @@ import {
export type DataTableWrapperProps<TData, TValue> = {
testId?: string;
bodyTestId?: string;
table: ReactTableType<TData>;
isPending: boolean;
hasNextPage: boolean;
fetchNextPage: () => void;
isFetching: boolean;
hideHeader?: boolean;
variant?: "default" | "compact";
totalDBRowCount?: number;
ToolbarLeft?: React.ReactNode;
ToolbarRight?: React.ReactNode;
children: React.ReactNode;
children?: React.ReactNode;
};
export function DataTableWrapper<TData, TValue>({
testId,
bodyTestId,
table,
isPending,
hasNextPage,
fetchNextPage,
isFetching,
totalDBRowCount,
variant,
hideHeader,
ToolbarLeft,
ToolbarRight,
children,
@@ -45,11 +51,14 @@ export function DataTableWrapper<TData, TValue>({
return (
<DataTable
data-testid={testId}
testId={testId}
bodyTestId={bodyTestId}
table={table}
tableContainerRef={tableContainerRef}
isPending={isPending}
enableColumnResizing={true}
hideHeader={hideHeader}
variant={variant}
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}>
<DataTableToolbar.Root>
<div className="flex w-full flex-col gap-2 sm:flex-row">
@@ -659,7 +659,7 @@ function MemberListContent(props: Props) {
return (
<>
<DataTable
data-testid="team-member-list-container"
testId="team-member-list-container"
table={table}
tableContainerRef={tableContainerRef}
isPending={isPending}
@@ -1,6 +1,6 @@
import { useSession } from "next-auth/react";
import type { InputHTMLAttributes, ReactNode } from "react";
import { forwardRef } from "react";
import { forwardRef, useState } from "react";
import { classNames } from "@calcom/lib";
import { getOrgOrTeamAvatar } from "@calcom/lib/defaultAvatarImage";
@@ -8,7 +8,15 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { AnimatedPopover, Avatar, Divider, Icon, Tooltip, VerticalDivider } from "@calcom/ui";
import {
FilterSearchField,
AnimatedPopover,
Avatar,
Divider,
Icon,
Tooltip,
VerticalDivider,
} from "@calcom/ui";
import { filterQuerySchema } from "../lib/getTeamsFiltersFromQuery";
@@ -31,6 +39,7 @@ export const TeamsFilter = ({
}) => {
const { t } = useLocale();
const session = useSession();
const [search, setSearch] = useState("");
const { data: query, pushItemToKey, removeItemByKeyAndValue, removeAllQueryParams } = useFilterQuery();
@@ -72,6 +81,12 @@ export const TeamsFilter = ({
popoverTriggerClassNames={popoverTriggerClassNames}
prefix={`${t("teams")}: `}>
<FilterCheckboxFieldsContainer>
<FilterSearchField
placeholder={t("search")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<FilterCheckboxField
id="all"
icon={<Icon name="layers" className="h-4 w-4" />}
@@ -98,6 +113,7 @@ export const TeamsFilter = ({
<Divider />
{teams
?.filter((team) => !team?.isOrganization)
.filter((team) => team.name.toLowerCase().includes(search.toLowerCase()))
.map((team) => (
<FilterCheckboxField
key={team.id}
@@ -311,7 +311,7 @@ function UserListTableContent({ oAuthClientId }: PlatformManagedUsersTableProps)
return (
<>
<DataTable
data-testid="managed-user-list-data-table"
testId="managed-user-list-data-table"
table={table}
tableContainerRef={tableContainerRef}
isPending={isPending}