Files
calendar/packages/features/insights/components/RoutingFormResponsesTable.tsx
T
sean-brydonandGitHub dfdb04acf4 chore: routing table nits (#17738)
* portal date preset selection with a high zindex

* increase height of data-table

* skeleton fixes
2024-11-19 23:04:45 +00:00

468 lines
14 KiB
TypeScript

"use client";
import { keepPreviousData } from "@tanstack/react-query";
import {
useReactTable,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
createColumnHelper,
} from "@tanstack/react-table";
import Link from "next/link";
import { useRef, useMemo, useId } from "react";
import dayjs from "@calcom/dayjs";
import classNames from "@calcom/lib/classNames";
import { useCopy } from "@calcom/lib/hooks/useCopy";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { BookingStatus } from "@calcom/prisma/enums";
import { trpc, type RouterOutputs } from "@calcom/trpc";
import {
DataTable,
useFetchMoreOnBottomReached,
Badge,
Avatar,
Icon,
HoverCard,
HoverCardContent,
HoverCardTrigger,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@calcom/ui";
import type { BadgeProps } from "@calcom/ui/components/badge/Badge";
import { useFilterContext } from "../context/provider";
type RoutingFormResponse = RouterOutputs["viewer"]["insights"]["routingFormResponses"]["data"][number];
type RoutingFormTableRow = {
id: number;
formName: string;
createdAt: Date;
routedToBooking: RoutingFormResponse["routedToBooking"];
[key: string]: any;
};
function CellWithOverflowX({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<div className={classNames("group relative max-w-[200px]", className)}>
<div
className="no-scrollbar flex gap-1 overflow-x-auto whitespace-nowrap"
ref={(el) => {
// Only add shadow if the scroll width is greater than 200px
if (!el) return;
const nextElement = el.nextElementSibling;
if (!nextElement) return;
if (el.scrollWidth > 200) {
nextElement.classList.remove("hidden");
} else {
nextElement.classList.add("hidden");
}
}}>
{children}
</div>
<div className="from-default absolute right-0 top-0 hidden h-full w-8 bg-gradient-to-l to-transparent " />
</div>
);
}
// Upper case the first letter of each word and replace underscores with spaces
function bookingStatusToText(status: BookingStatus) {
return status
.toLowerCase()
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
function BookedByCell({
attendees,
rowId,
}: {
attendees: { email: string; timeZone: string }[] | undefined;
rowId: number;
}) {
const cellId = useId();
if (!attendees || attendees.length === 0) return <div className="min-w-[200px]" />;
return (
<div className="flex min-w-[200px] flex-wrap gap-1">
{attendees.map((attendee) => (
<CellWithOverflowX key={`${cellId}-${attendee.email}-${rowId}`} className="w-[200px]">
<Badge variant="gray" className="whitespace-nowrap">
{attendee.email}
</Badge>
</CellWithOverflowX>
))}
</div>
);
}
function ResponseValueCell({ value, rowId }: { value: string[]; rowId: number }) {
const cellId = useId();
if (value.length === 0) return <div className="h-6 w-[200px]" />;
return (
<CellWithOverflowX className="flex w-[200px] gap-1">
{value.length > 2 ? (
<>
{value.slice(0, 2).map((v: string, i: number) => (
<Badge key={`${cellId}-${i}-${rowId}`} variant="gray">
{v}
</Badge>
))}
<HoverCard>
<HoverCardTrigger>
<Badge variant="gray">+{value.length - 2}</Badge>
</HoverCardTrigger>
<HoverCardContent side="bottom" align="start" className="w-fit">
<div className="flex flex-col gap-1">
{value.slice(2).map((v: string, i: number) => (
<span key={`${cellId}-overflow-${i}-${rowId}`} className="text-default text-sm">
{v}
</span>
))}
</div>
</HoverCardContent>
</HoverCard>
</>
) : (
value.map((v: string, i: number) => (
<Badge key={`${cellId}-${i}-${rowId}`} variant="gray">
{v}
</Badge>
))
)}
</CellWithOverflowX>
);
}
function BookingStatusBadge({ booking }: { booking: RoutingFormResponse["routedToBooking"] }) {
let badgeVariant: BadgeProps["variant"] = "success";
if (!booking) return null;
switch (booking.status) {
case BookingStatus.REJECTED:
case BookingStatus.AWAITING_HOST:
case BookingStatus.PENDING:
case BookingStatus.CANCELLED:
badgeVariant = "warning";
break;
}
return <Badge variant={badgeVariant}>{bookingStatusToText(booking.status)}</Badge>;
}
function BookingAtCell({
booking,
rowId,
copyToClipboard,
t,
}: {
booking: RoutingFormResponse["routedToBooking"];
rowId: number;
copyToClipboard: (text: string) => void;
t: (key: string) => string;
}) {
const cellId = useId();
if (!booking || !booking.user) {
return <div className="w-[250px]" />;
}
return (
<HoverCard>
<HoverCardTrigger asChild>
<div className="flex items-center gap-2" key={`${cellId}-booking-${rowId}`}>
<Avatar size="xs" imageSrc={booking.user.avatarUrl ?? ""} alt={booking.user.name ?? ""} />
<Link href={`/booking/${booking.uid}`}>
<Badge variant="gray">{dayjs(booking.createdAt).format("MMM D, YYYY HH:mm")}</Badge>
</Link>
</div>
</HoverCardTrigger>
<HoverCardContent>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<Avatar size="sm" imageSrc={booking.user.avatarUrl ?? ""} alt={booking.user.name ?? ""} />
<div>
<p className="text-sm font-medium">{booking.user.name}</p>
<p className="group/booking_status_email text-subtle flex items-center text-xs">
<span className="truncate">{booking.user.email}</span>
<button
className="invisible ml-2 group-hover/booking_status_email:visible"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
copyToClipboard(booking.user?.email ?? "");
}}>
<Icon name="copy" />
</button>
</p>
</div>
</div>
<div className="text-emphasis mt-4 flex items-center gap-2 text-xs">
<span>Status:</span>
<BookingStatusBadge booking={booking} />
</div>
</div>
</HoverCardContent>
</HoverCard>
);
}
export type RoutingFormTableType = ReturnType<typeof useReactTable<RoutingFormTableRow>>;
export function RoutingFormResponsesTable({
children,
}: {
children?: React.ReactNode | ((table: RoutingFormTableType) => React.ReactNode);
}) {
const { t } = useLocale();
const { filter } = useFilterContext();
const tableContainerRef = useRef<HTMLDivElement>(null);
const { copyToClipboard, isCopied } = useCopy();
const {
dateRange,
selectedTeamId,
isAll,
initialConfig,
selectedRoutingFormId,
selectedMemberUserId,
selectedBookingStatus,
selectedRoutingFormFilter,
} = filter;
const initialConfigIsReady = !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll);
const [startDate, endDate] = dateRange;
const { data: headers, isLoading: isHeadersLoading } =
trpc.viewer.insights.routingFormResponsesHeaders.useQuery(
{
teamId: selectedTeamId ?? undefined,
isAll: isAll ?? false,
routingFormId: selectedRoutingFormId ?? undefined,
},
{
enabled: initialConfigIsReady,
}
);
const { data, fetchNextPage, isFetching, hasNextPage, isLoading } =
trpc.viewer.insights.routingFormResponses.useInfiniteQuery(
{
teamId: selectedTeamId,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
userId: selectedMemberUserId ?? undefined,
isAll: isAll ?? false,
routingFormId: selectedRoutingFormId ?? undefined,
bookingStatus: selectedBookingStatus ?? undefined,
fieldFilter: selectedRoutingFormFilter ?? undefined,
limit: 10,
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
placeholderData: keepPreviousData,
trpc: {
context: { skipBatch: true },
},
enabled: initialConfigIsReady,
}
);
const flatData = useMemo(() => data?.pages?.flatMap((page) => page.data) ?? [], [data]);
const totalDBRowCount = data?.pages?.[0]?.total ?? 0;
const totalFetched = flatData.length;
const processedData = useMemo(() => {
if (isHeadersLoading) return [];
return flatData.map((response) => {
const row: RoutingFormTableRow = {
id: response.id,
formName: response.form.name,
formId: response.form.id,
createdAt: response.createdAt,
routedToBooking: response.routedToBooking,
};
Object.entries(response.response).forEach(([fieldId, field]) => {
const header = headers?.find((h) => h.id === fieldId);
if (header?.options) {
if (Array.isArray(field.value)) {
// Map the IDs to their corresponding labels for array values
const labels = field.value.map((id) => {
const option = header.options?.find((opt) => opt.id === id);
return option?.label ?? id;
});
row[fieldId] = labels;
} else {
// Handle single value case
const option = header.options?.find((opt) => opt.id === field.value);
row[fieldId] = option?.label ?? field.value;
}
} else {
row[fieldId] = field.value;
}
});
return row;
});
}, [flatData, headers, isHeadersLoading]);
const columnHelper = createColumnHelper<RoutingFormTableRow>();
const columns = useMemo(
() => [
columnHelper.accessor("bookedAttendees", {
id: "bookedBy",
header: t("routing_form_insights_booked_by"),
size: 200,
cell: (info) => {
const row = info.row.original;
return <BookedByCell attendees={row.routedToBooking?.attendees || []} rowId={row.id} />;
},
}),
...(headers?.map((header) => {
return columnHelper.accessor(header.id, {
id: header.id,
header: header.label,
size: 200,
cell: (info) => {
let value = info.getValue();
value = Array.isArray(value) ? value : [value];
return (
<div className="max-w-[200px]">
<ResponseValueCell value={value} rowId={info.row.original.id} />
</div>
);
},
});
}) ?? []),
columnHelper.accessor("routedToBooking", {
id: "bookingStatus",
header: t("routing_form_insights_booking_status"),
size: 250,
cell: (info) => (
<div className="max-w-[250px]">
<BookingStatusBadge booking={info.getValue()} />
</div>
),
}),
columnHelper.accessor("routedToBooking", {
id: "bookingAt",
header: t("routing_form_insights_booking_at"),
size: 250,
cell: (info) => (
<div className="max-w-[250px]">
<BookingAtCell
booking={info.getValue()}
rowId={info.row.original.id}
copyToClipboard={copyToClipboard}
t={t}
/>
</div>
),
}),
columnHelper.accessor("createdAt", {
id: "submittedAt",
header: t("routing_form_insights_submitted_at"),
size: 250,
cell: (info) => (
<div className="whitespace-nowrap">
<Badge variant="gray">{dayjs(info.getValue()).format("MMM D, YYYY HH:mm")}</Badge>
</div>
),
}),
],
[headers, t, copyToClipboard]
);
const table = useReactTable<RoutingFormTableRow>({
data: processedData,
columns,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
defaultColumn: {
size: 200,
},
});
const fetchMoreOnBottomReached = useFetchMoreOnBottomReached(
tableContainerRef,
fetchNextPage,
isFetching,
totalFetched,
totalDBRowCount
);
if (isHeadersLoading || ((isFetching || isLoading) && !data)) {
return (
<div
className="grid h-[85dvh]"
style={{ gridTemplateRows: "auto 1fr auto", gridTemplateAreas: "'header' 'body' 'footer'" }}>
<div
className="scrollbar-thin border-subtle relative h-full overflow-auto rounded-md border"
style={{ gridArea: "body" }}>
<Table>
<TableHeader className="bg-subtle sticky top-0 z-10">
<TableRow>
{[...Array(4)].map((_, index) => (
<TableHead key={`skeleton-header-${index}`}>
<div className="bg-subtle h-4 w-[200px] animate-pulse rounded-md" />
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{[...Array(10)].map((_, rowIndex) => (
<TableRow key={`skeleton-row-${rowIndex}`}>
{[...Array(4)].map((_, colIndex) => (
<TableCell key={`skeleton-cell-${rowIndex}-${colIndex}`}>
<div
className={classNames(
"bg-subtle h-6 animate-pulse rounded-md",
colIndex === 0
? "w-[200px]"
: colIndex === 2
? "w-[250px]"
: colIndex === 3
? "w-[250px]"
: "w-[200px]"
)}
/>
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}
return (
<div className="flex-1">
<DataTable
table={table}
tableContainerRef={tableContainerRef}
onScroll={(e) => {
if (hasNextPage) {
fetchMoreOnBottomReached(e.target as HTMLDivElement);
}
}}
isPending={isFetching && !data}>
{typeof children === "function" ? children(table) : children}
</DataTable>
</div>
);
}