Files
calendar/packages/features/insights/components/RoutingFormResponsesTable.tsx
T
Eunjae LeeandGitHub fdc8e084ea fix: stats on /insights/routing (#18985)
* fix: replace filter implementation on /insights/routing

* remove unused functions

* fix type error

* refactor: extract insights parameters

* remove FiltersProvider
2025-01-31 17:16:53 +01:00

603 lines
19 KiB
TypeScript

"use client";
import { keepPreviousData } from "@tanstack/react-query";
import {
useReactTable,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
createColumnHelper,
} from "@tanstack/react-table";
// eslint-disable-next-line no-restricted-imports
import startCase from "lodash/startCase";
import Link from "next/link";
import { useMemo, useId } from "react";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import {
DataTableWrapper,
DataTableFilters,
DataTableSkeleton,
dataTableFilter,
useDataTable,
DateRangeFilter,
ColumnFilterType,
type FilterableColumn,
} from "@calcom/features/data-table";
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 { RoutingFormFieldType } from "@calcom/routing-forms/lib/FieldTypes";
import { trpc, type RouterOutputs } from "@calcom/trpc";
import {
Badge,
Avatar,
Icon,
HoverCard,
HoverCardContent,
HoverCardTrigger,
type BadgeProps,
HoverCardPortal,
} from "@calcom/ui";
import { RoutingFormResponsesDownload } from "../filters/Download";
import { OrgTeamsFilter } from "../filters/OrgTeamsFilter";
import { useInsightsParameters } from "../hooks/useInsightsParameters";
import {
ZResponseMultipleValues,
ZResponseSingleValue,
ZResponseTextValue,
ZResponseNumericValue,
} from "../lib/types";
import { RoutingKPICards } from "./RoutingKPICards";
type RoutingFormTableRow = RouterOutputs["viewer"]["insights"]["routingFormResponses"]["data"][number];
const statusOrder: Record<BookingStatus, number> = {
[BookingStatus.ACCEPTED]: 1,
[BookingStatus.PENDING]: 2,
[BookingStatus.AWAITING_HOST]: 3,
[BookingStatus.CANCELLED]: 4,
[BookingStatus.REJECTED]: 5,
};
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: RoutingFormTableRow["bookingAttendees"] | 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" title={attendee.email}>
{attendee.name}
</Badge>
</CellWithOverflowX>
))}
</div>
);
}
function ResponseValueCell({
optionMap,
values,
rowId,
}: {
optionMap: Record<string, string>;
values: string[];
rowId: number;
}) {
const cellId = useId();
if (values.length === 0) return <div className="h-6 w-[200px]" />;
return (
<CellWithOverflowX className="flex w-[200px] gap-1">
{values.length > 2 ? (
<>
{values.slice(0, 2).map((id: string, i: number) => (
<Badge key={`${cellId}-${i}-${rowId}`} variant="gray">
{optionMap[id] ?? id}
</Badge>
))}
<HoverCard>
<HoverCardTrigger>
<Badge variant="gray">+{values.length - 2}</Badge>
</HoverCardTrigger>
<HoverCardPortal>
<HoverCardContent side="bottom" align="start" className="w-fit">
<div className="flex flex-col gap-1">
{values.slice(2).map((id: string, i: number) => (
<span key={`${cellId}-overflow-${i}-${rowId}`} className="text-default text-sm">
{optionMap[id] ?? id}
</span>
))}
</div>
</HoverCardContent>
</HoverCardPortal>
</HoverCard>
</>
) : (
values.map((id: string, i: number) => (
<Badge key={`${cellId}-${i}-${rowId}`} variant="gray">
{optionMap[id] ?? id}
</Badge>
))
)}
</CellWithOverflowX>
);
}
function BookingStatusBadge({ bookingStatus }: { bookingStatus: BookingStatus | null }) {
let badgeVariant: BadgeProps["variant"] = "success";
if (!bookingStatus) return null;
switch (bookingStatus) {
case BookingStatus.REJECTED:
case BookingStatus.AWAITING_HOST:
case BookingStatus.PENDING:
case BookingStatus.CANCELLED:
badgeVariant = "warning";
break;
}
return <Badge variant={badgeVariant}>{bookingStatusToText(bookingStatus)}</Badge>;
}
function BookingAtCell({
row,
rowId,
copyToClipboard,
t,
}: {
row: RoutingFormTableRow;
rowId: number;
copyToClipboard: (text: string) => void;
t: (key: string) => string;
}) {
const cellId = useId();
if (!row.bookingUserId || !row.bookingCreatedAt) {
return <div className="w-[250px]" />;
}
return (
<HoverCard>
<HoverCardTrigger asChild>
<div className="flex items-center gap-2" key={`${cellId}-booking-${rowId}`}>
<Avatar size="xs" imageSrc={row.bookingUserAvatarUrl ?? ""} alt={row.bookingUserName ?? ""} />
<Link href={`/booking/${row.bookingUid}`}>
<Badge variant="gray">{dayjs(row.bookingCreatedAt).format("MMM D, YYYY HH:mm")}</Badge>
</Link>
</div>
</HoverCardTrigger>
<HoverCardPortal>
<HoverCardContent>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<Avatar size="sm" imageSrc={row.bookingUserAvatarUrl ?? ""} alt={row.bookingUserName ?? ""} />
<div>
<p className="text-sm font-medium">{row.bookingUserName}</p>
<p className="group/booking_status_email text-subtle flex items-center text-xs">
<span className="truncate">{row.bookingUserEmail}</span>
<button
className="invisible ml-2 group-hover/booking_status_email:visible"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
copyToClipboard(row.bookingUserEmail ?? "");
}}>
<Icon name="copy" />
</button>
</p>
</div>
</div>
<div className="text-emphasis mt-4 flex items-center gap-2 text-xs">
<span>Status:</span>
<BookingStatusBadge bookingStatus={row.bookingStatus} />
</div>
</div>
</HoverCardContent>
</HoverCardPortal>
</HoverCard>
);
}
export type RoutingFormTableType = ReturnType<typeof useReactTable<RoutingFormTableRow>>;
const createdAtColumn: Extract<FilterableColumn, { type: ColumnFilterType.DATE_RANGE }> = {
id: "createdAt",
title: "createdAt",
type: ColumnFilterType.DATE_RANGE,
};
export function RoutingFormResponsesTable() {
const { t } = useLocale();
const { copyToClipboard } = useCopy();
const { isAll, teamId, userId, memberUserIds, routingFormId, startDate, endDate, columnFilters } =
useInsightsParameters();
const {
data: headers,
isLoading: isHeadersLoading,
isSuccess: isHeadersSuccess,
} = trpc.viewer.insights.routingFormResponsesHeaders.useQuery({
userId,
teamId,
isAll,
routingFormId,
});
const { data: forms } = trpc.viewer.insights.getRoutingFormsForFilters.useQuery({
userId,
teamId,
isAll,
});
const { data: users } = trpc.viewer.insights.userList.useQuery({
teamId: teamId ?? -1,
isAll,
});
const { sorting } = useDataTable();
const { data, fetchNextPage, isFetching, hasNextPage, isLoading } =
trpc.viewer.insights.routingFormResponses.useInfiniteQuery(
{
teamId,
startDate,
endDate,
userId,
memberUserIds,
isAll,
routingFormId,
columnFilters,
sorting,
limit: 30,
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
placeholderData: keepPreviousData,
trpc: {
context: { skipBatch: true },
},
}
);
const processedData = useMemo(() => {
if (!isHeadersSuccess) return [];
return (data?.pages?.flatMap((page) => page.data) ?? []) as RoutingFormTableRow[];
}, [data, isHeadersSuccess]);
const columnHelper = createColumnHelper<RoutingFormTableRow>();
const columns = useMemo(() => {
if (!isHeadersSuccess) {
return [];
}
return [
columnHelper.accessor("formId", {
id: "formId",
header: t("routing_forms"),
enableColumnFilter: true,
enableSorting: false,
meta: {
filter: { type: ColumnFilterType.SINGLE_SELECT },
},
cell: () => null,
filterFn: (row, id, filterValue) => {
const cellValue = row.original.formId;
return dataTableFilter(cellValue, filterValue);
},
}),
columnHelper.accessor("bookingUserId", {
id: "bookingUserId",
header: t("people"),
enableColumnFilter: true,
enableSorting: false,
meta: {
filter: { type: ColumnFilterType.MULTI_SELECT },
},
cell: () => null,
filterFn: (row, id, filterValue) => {
const cellValue = row.original.bookingUserId;
return dataTableFilter(cellValue, filterValue);
},
}),
columnHelper.accessor("bookingAttendees", {
id: "bookingAttendees",
header: t("routing_form_insights_booked_by"),
size: 200,
enableColumnFilter: false,
enableSorting: false,
cell: (info) => {
return <BookedByCell attendees={info.getValue()} rowId={info.row.original.id} />;
},
}),
...((headers || []).map((fieldHeader) => {
const isText = [
RoutingFormFieldType.TEXT,
RoutingFormFieldType.EMAIL,
RoutingFormFieldType.PHONE,
RoutingFormFieldType.TEXTAREA,
].includes(fieldHeader.type as RoutingFormFieldType);
const isNumber = fieldHeader.type === RoutingFormFieldType.NUMBER;
const isSingleSelect = fieldHeader.type === RoutingFormFieldType.SINGLE_SELECT;
const isMultiSelect = fieldHeader.type === RoutingFormFieldType.MULTI_SELECT;
const filterType = isSingleSelect
? ColumnFilterType.SINGLE_SELECT
: isNumber
? ColumnFilterType.NUMBER
: isText
? ColumnFilterType.TEXT
: ColumnFilterType.MULTI_SELECT;
const optionMap =
fieldHeader.options?.reduce((acc, option) => {
if (option.id) {
acc[option.id] = option.label;
}
return acc;
}, {} as Record<string, string>) ?? {};
return columnHelper.accessor(`response.${fieldHeader.id}`, {
id: fieldHeader.id,
header: startCase(fieldHeader.label),
enableSorting: false,
cell: (info) => {
const values = info.getValue();
if (isMultiSelect || isSingleSelect) {
const result = z.union([ZResponseMultipleValues, ZResponseSingleValue]).safeParse(values);
return (
result.success && (
<ResponseValueCell
optionMap={optionMap}
values={Array.isArray(result.data.value) ? result.data.value : [result.data.value]}
rowId={info.row.original.id}
/>
)
);
} else if (isText || isNumber) {
const result = z.union([ZResponseTextValue, ZResponseNumericValue]).safeParse(values);
return (
result.success && (
<div className="truncate">
<span title={String(result.data.value)}>{result.data.value}</span>
</div>
)
);
} else {
return null;
}
},
meta: {
filter: { type: filterType },
},
filterFn: (row, id, filterValue) => {
const cellValue = row.original.response[id].value;
return dataTableFilter(cellValue, filterValue);
},
});
}) ?? []),
columnHelper.accessor("bookingStatusOrder", {
id: "bookingStatusOrder",
header: t("routing_form_insights_booking_status"),
sortDescFirst: false,
cell: (info) => (
<div className="max-w-[250px]">
<BookingStatusBadge bookingStatus={info.row.original.bookingStatus} />
</div>
),
meta: {
filter: { type: ColumnFilterType.MULTI_SELECT, icon: "circle" },
},
filterFn: (row, id, filterValue) => {
const cellValue = row.original.bookingStatusOrder;
return dataTableFilter(cellValue, filterValue);
},
sortingFn: (rowA, rowB) => {
const statusA = rowA.original.bookingStatusOrder ?? 6; // put it at the end if bookingStatusOrder is null
const statusB = rowB.original.bookingStatusOrder ?? 6;
return statusA - statusB;
},
}),
columnHelper.accessor("bookingCreatedAt", {
id: "bookingCreatedAt",
header: t("routing_form_insights_booking_at"),
enableColumnFilter: false,
cell: (info) => (
<div className="max-w-[250px]">
<BookingAtCell
row={info.row.original}
rowId={info.row.original.id}
copyToClipboard={copyToClipboard}
t={t}
/>
</div>
),
sortingFn: (rowA, rowB) => {
const dateA = rowA.original.bookingCreatedAt;
const dateB = rowB.original.bookingCreatedAt;
if (!dateA && !dateB) return 0;
if (!dateA) return -1;
if (!dateB) return 1;
if (!(dateA instanceof Date) || !(dateB instanceof Date)) return 0;
return dateA.getTime() - dateB.getTime();
},
}),
columnHelper.accessor("bookingAssignmentReason", {
id: "bookingAssignmentReason",
header: t("routing_form_insights_assignment_reason"),
enableColumnFilter: true,
enableSorting: false,
meta: {
filter: { type: ColumnFilterType.TEXT },
},
cell: (info) => {
const assignmentReason = info.getValue();
return <div className="max-w-[250px]">{assignmentReason}</div>;
},
filterFn: (row, id, filterValue) => {
const reason = row.original.bookingAssignmentReason;
return dataTableFilter(reason, filterValue);
},
}),
columnHelper.accessor("createdAt", {
id: "createdAt",
header: t("routing_form_insights_submitted_at"),
// exclude from "Filters" component
// because we already have a DateRangeFilter component
enableColumnFilter: false,
cell: (info) => (
<div className="whitespace-nowrap">
<Badge variant="gray">{dayjs(info.getValue()).format("MMM D, YYYY HH:mm")}</Badge>
</div>
),
filterFn: (row, id, filterValue) => {
const createdAt = row.original.createdAt;
return dataTableFilter(createdAt, filterValue);
},
}),
];
}, [isHeadersSuccess, headers, t, copyToClipboard]);
const table = useReactTable<RoutingFormTableRow>({
data: processedData,
columns,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
defaultColumn: {
size: 150,
},
initialState: {
columnVisibility: {
formId: false,
bookingUserId: false,
},
},
getFacetedUniqueValues: (_, columnId) => () => {
if (!headers) {
return new Map();
}
const fromArrayToMap = (array: { label: string; value: string | number }[]) => {
return new Map(array.map((option) => [{ label: option.label, value: option.value }, 1]));
};
const fieldHeader = headers.find((h) => h.id === columnId);
if (fieldHeader?.options) {
return fromArrayToMap(
fieldHeader.options
.filter((option): option is { id: string; label: string } => option.id !== null)
.map((option) => ({
label: option.label,
value: option.id,
}))
);
} else if (columnId === "bookingStatusOrder") {
return fromArrayToMap(
Object.keys(statusOrder).map((status) => ({
value: statusOrder[status as BookingStatus],
label: bookingStatusToText(status as BookingStatus),
}))
);
} else if (columnId === "formId") {
return fromArrayToMap(
forms?.map((form) => ({
label: form.name,
value: form.id,
})) ?? []
);
} else if (columnId === "bookingUserId") {
return fromArrayToMap(
users?.map((user) => ({
label: user.name ?? user.email,
value: user.id,
})) ?? []
);
}
return new Map();
},
});
if ((isHeadersLoading && !headers) || ((isFetching || isLoading) && !data)) {
return <DataTableSkeleton columns={4} columnWidths={[200, 200, 250, 250]} />;
}
return (
<>
<div className="flex-1">
<DataTableWrapper
table={table}
isPending={isFetching && !data}
hasNextPage={hasNextPage}
fetchNextPage={fetchNextPage}
isFetching={isFetching}
ToolbarLeft={
<>
<OrgTeamsFilter />
<DataTableFilters.AddFilterButton table={table} />
<DataTableFilters.ActiveFilters table={table} />
<DataTableFilters.ClearFiltersButton exclude={["createdAt"]} />
</>
}
ToolbarRight={
<>
<DateRangeFilter column={createdAtColumn} />
<RoutingFormResponsesDownload sorting={sorting} />
<DataTableFilters.ColumnVisibilityButton table={table} />
</>
}>
<RoutingKPICards />
</DataTableWrapper>
</div>
</>
);
}