feat: server side sorting for insights routing (#18113)

* feat: server side sorting for insights routing

^ Conflicts:
^	packages/prisma/playground.ts

* fix download csv

* fix hooks locations
This commit is contained in:
Eunjae Lee
2025-01-03 10:58:01 +00:00
committed by GitHub
parent 821a16648f
commit a67b57b216
27 changed files with 797 additions and 344 deletions
@@ -75,7 +75,7 @@ export function DataTable<TData, TValue>({
const columnSizingVars = useColumnSizingVars({ table });
usePersistentColumnResizing({
enabled: Boolean(enableColumnResizing),
enabled: Boolean(enableColumnResizing && identifier),
table,
tableContainerRef,
identifier,
@@ -118,8 +118,10 @@ export function DataTable<TData, TValue>({
width: `var(--header-${kebabCase(header?.id)}-size)`,
}}
className={classNames(
"bg-subtle hover:bg-muted relative flex shrink-0 items-center",
header.column.getCanSort() ? "cursor-pointer select-none" : "",
"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
@@ -10,7 +10,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { ButtonProps } from "@calcom/ui";
import { Button, Input } from "@calcom/ui";
import { useColumnFilters } from "../lib/utils";
import { useColumnFilters } from "../hooks";
interface DataTableToolbarProps extends ComponentPropsWithoutRef<"div"> {
children: React.ReactNode;
@@ -14,9 +14,9 @@ import {
Icon,
} from "@calcom/ui";
import { useDataTable, useFilterValue } from "../../hooks";
import type { FilterableColumn } from "../../lib/types";
import { ZMultiSelectFilterValue } from "../../lib/types";
import { useDataTable, useFilterValue } from "../../lib/utils";
export type MultiSelectFilterOptionsProps = {
column: Extract<FilterableColumn, { type: "multi_select" }>;
@@ -5,9 +5,9 @@ import { useForm, Controller } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Form, Input, Select, Button } from "@calcom/ui";
import { useFilterValue, useDataTable } from "../../hooks";
import type { FilterableColumn, NumberFilterOperator } from "../../lib/types";
import { ZNumberFilterValue } from "../../lib/types";
import { useFilterValue, useDataTable } from "../../lib/utils";
export type NumberFilterOperatorOption = {
label: string;
@@ -14,9 +14,9 @@ import {
Icon,
} from "@calcom/ui";
import { useDataTable, useFilterValue } from "../../hooks";
import type { FilterableColumn } from "../../lib/types";
import { ZSingleSelectFilterValue } from "../../lib/types";
import { useDataTable, useFilterValue } from "../../lib/utils";
export type SingleSelectFilterOptionsProps = {
column: Extract<FilterableColumn, { type: "single_select" }>;
@@ -5,9 +5,9 @@ import { useForm, Controller } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Form, Input, Select, Button } from "@calcom/ui";
import { useFilterValue, useDataTable } from "../../hooks";
import type { FilterableColumn, TextFilterOperator } from "../../lib/types";
import { ZTextFilterValue } from "../../lib/types";
import { useFilterValue, useDataTable } from "../../lib/utils";
export type TextFilterOperatorOption = {
label: string;
@@ -1,6 +1,8 @@
"use client";
import { type Table } from "@tanstack/react-table";
// eslint-disable-next-line no-restricted-imports
import startCase from "lodash/startCase";
import { forwardRef, useState, useMemo, useCallback, Fragment } from "react";
import { classNames } from "@calcom/lib";
@@ -22,8 +24,8 @@ import {
Icon,
} from "@calcom/ui";
import { useDataTable } from "../../hooks";
import type { FilterableColumn, ExternalFilter } from "../../lib/types";
import { convertToTitleCase, useDataTable } from "../../lib/utils";
import { FilterOptions } from "./FilterOptions";
interface ColumnVisiblityProps<TData> {
@@ -164,7 +166,7 @@ function AddFilterButtonComponent<TData>(
key={column.id}
onSelect={() => handleAddFilter(column.id)}
className="px-4 py-2">
{convertToTitleCase(column.title)}
{startCase(column.title)}
</CommandItem>
);
})}
@@ -257,7 +259,7 @@ function ActiveFilters<TData>({ table, externalFilters }: ActiveFiltersProps<TDa
<PopoverTrigger asChild>
<Button color="secondary">
<Icon name={icon} className="mr-2 h-4 w-4" />
{convertToTitleCase(column.title)}
{startCase(column.title)}
<Icon name="chevron-down" className="ml-2 h-4 w-4" />
</Button>
</PopoverTrigger>
@@ -1,3 +1,6 @@
export * from "./useDebouncedWidth";
export * from "./useFetchMoreOnBottomReached";
export * from "./useColumnSizingVars";
export * from "./useFilterValue";
export * from "./useColumnFilters";
export * from "./useDataTable";
@@ -0,0 +1,35 @@
import { useMemo } from "react";
import type { ColumnFilter } from "../lib/types";
import { ZFilterValue } from "../lib/types";
import { isMultiSelectFilterValue } from "../lib/utils";
import { useDataTable } from "./useDataTable";
export function useColumnFilters(): ColumnFilter[] {
const { activeFilters } = useDataTable();
return useMemo(() => {
return (activeFilters || [])
.filter(
(filter) =>
typeof filter === "object" && filter && "f" in filter && "v" in filter && filter.v !== undefined
)
.map((filter) => {
const parsedValue = ZFilterValue.safeParse(filter.v);
if (!parsedValue.success) return null;
return {
id: filter.f,
value: parsedValue.data,
};
})
.filter((filter): filter is ColumnFilter => filter !== null)
.filter((filter) => {
// The empty arrays in `filtersSearchState` keep the filter UI component,
// but we do not send them to the actual query.
// Otherwise, `{ my_column_name: { in: []} }` would result in nothing being returned.
if (isMultiSelectFilterValue(filter.value) && filter.value.data.length === 0) {
return false;
}
return true;
});
}, [activeFilters]);
}
@@ -0,0 +1,11 @@
import { useContext } from "react";
import { DataTableContext } from "../lib/context";
export function useDataTable() {
const context = useContext(DataTableContext);
if (!context) {
throw new Error("useDataTable must be used within a DataTableProvider");
}
return context;
}
@@ -0,0 +1,18 @@
import { useMemo } from "react";
import type { z } from "zod";
import { useDataTable } from "./useDataTable";
export function useFilterValue<T>(columnId: string, schema: z.ZodType<T>) {
const { activeFilters } = useDataTable();
return useMemo(() => {
const value = activeFilters.find((filter) => filter.f === columnId)?.v;
if (schema && value) {
const result = schema.safeParse(value);
if (result.success) {
return result.data;
}
}
return undefined;
}, [activeFilters, columnId, schema]);
}
+1
View File
@@ -2,4 +2,5 @@ export * from "./components";
export * from "./lib/types";
export * from "./lib/utils";
export * from "./lib/context";
export * from "./lib/resizing";
export * from "./hooks";
+14 -4
View File
@@ -1,17 +1,18 @@
"use client";
import type { SortingState, OnChangeFn } from "@tanstack/react-table";
import { useQueryState, parseAsArrayOf, parseAsJson } from "nuqs";
import { createContext, useCallback, useState, type Dispatch, type SetStateAction } from "react";
import { z } from "zod";
import { type FilterValue, ZFilterValue } from "./types";
import { type FilterValue, ZFilterValue, ZSorting } from "./types";
const dataTableFiltersSchema = z.object({
const ZActiveFilter = z.object({
f: z.string(),
v: ZFilterValue.optional(),
});
type ActiveFilter = z.infer<typeof dataTableFiltersSchema>;
type ActiveFilter = z.infer<typeof ZActiveFilter>;
export type DataTableContextType = {
activeFilters: ActiveFilter[];
@@ -20,6 +21,9 @@ export type DataTableContextType = {
updateFilter: (columnId: string, value: FilterValue) => void;
removeFilter: (columnId: string) => void;
sorting: SortingState;
setSorting: OnChangeFn<SortingState>;
displayedExternalFilters: string[];
setDisplayedExternalFilters: Dispatch<SetStateAction<string[]>>;
removeDisplayedExternalFilter: (key: string) => void;
@@ -30,7 +34,11 @@ export const DataTableContext = createContext<DataTableContextType | null>(null)
export function DataTableProvider({ children }: { children: React.ReactNode }) {
const [activeFilters, setActiveFilters] = useQueryState(
"activeFilters",
parseAsArrayOf(parseAsJson(dataTableFiltersSchema.parse)).withDefault([])
parseAsArrayOf(parseAsJson(ZActiveFilter.parse)).withDefault([])
);
const [sorting, setSorting] = useQueryState(
"sorting",
parseAsArrayOf(parseAsJson(ZSorting.parse)).withDefault([])
);
const [displayedExternalFilters, setDisplayedExternalFilters] = useState<string[]>([]);
@@ -70,6 +78,8 @@ export function DataTableProvider({ children }: { children: React.ReactNode }) {
clearAll,
updateFilter,
removeFilter,
sorting,
setSorting,
displayedExternalFilters,
setDisplayedExternalFilters,
removeDisplayedExternalFilter,
+9 -1
View File
@@ -1,4 +1,4 @@
import type { FilterValue } from "./types";
import type { FilterValue, SortingState } from "./types";
import {
isSingleSelectFilterValue,
isMultiSelectFilterValue,
@@ -12,6 +12,14 @@ type makeWhereClauseProps = {
json?: true | { path: string[] };
};
export function makeOrderBy(sorting: SortingState) {
if (!sorting || !sorting.length) return undefined;
return sorting.map((sort) => ({
[sort.id]: sort.desc ? ("desc" as const) : ("asc" as const),
}));
}
export function makeWhereClause(props: makeWhereClauseProps) {
const { columnName, filterValue } = props;
const isJson = props.json === true || (typeof props.json === "object" && props.json.path?.length > 0);
+8 -1
View File
@@ -24,7 +24,7 @@ export type SingleSelectFilterValue = z.infer<typeof ZSingleSelectFilterValue>;
export const ZMultiSelectFilterValue = z.object({
type: z.literal("multi_select"),
data: z.array(z.string()),
data: z.union([z.string(), z.number()]).array(),
});
export type MultiSelectFilterValue = z.infer<typeof ZMultiSelectFilterValue>;
@@ -125,3 +125,10 @@ export type ExternalFilter = {
titleKey: string;
component: () => React.ReactNode;
};
export const ZSorting = z.object({
id: z.string(),
desc: z.boolean(),
});
export type SortingState = Array<z.infer<typeof ZSorting>>;
+20 -65
View File
@@ -1,78 +1,21 @@
"use client";
import { useMemo, useContext } from "react";
import type { z } from "zod";
import { DataTableContext } from "./context";
import type {
SingleSelectFilterValue,
MultiSelectFilterValue,
TextFilterValue,
FilterValue,
NumberFilterValue,
ColumnFilter,
} from "./types";
import {
ZFilterValue,
ZNumberFilterValue,
ZSingleSelectFilterValue,
ZMultiSelectFilterValue,
ZTextFilterValue,
} from "./types";
export function useDataTable() {
const context = useContext(DataTableContext);
if (!context) {
throw new Error("useDataTable must be used within a DataTableProvider");
}
return context;
}
export function useFilterValue<T>(columnId: string, schema: z.ZodType<T>) {
const { activeFilters } = useDataTable();
return useMemo(() => {
const value = activeFilters.find((filter) => filter.f === columnId)?.v;
if (schema && value) {
const result = schema.safeParse(value);
if (result.success) {
return result.data;
}
}
return undefined;
}, [activeFilters, columnId, schema]);
}
export function useColumnFilters(): ColumnFilter[] {
const { activeFilters } = useDataTable();
return useMemo(() => {
return (activeFilters || [])
.filter(
(filter) =>
typeof filter === "object" && filter && "f" in filter && "v" in filter && filter.v !== undefined
)
.map((filter) => {
const parsedValue = ZFilterValue.safeParse(filter.v);
if (!parsedValue.success) return null;
return {
id: filter.f,
value: parsedValue.data,
};
})
.filter((filter): filter is ColumnFilter => filter !== null)
.filter((filter) => {
// The empty arrays in `filtersSearchState` keep the filter UI component,
// but we do not send them to the actual query.
// Otherwise, `{ my_column_name: { in: []} }` would result in nothing being returned.
if (isMultiSelectFilterValue(filter.value) && filter.value.data.length === 0) {
return false;
}
return true;
});
}, [activeFilters]);
}
export const textFilter = (cellValue: unknown, filterValue: TextFilterValue) => {
if (filterValue.data.operator === "isEmpty" && cellValue === undefined) {
if (filterValue.data.operator === "isEmpty" && !cellValue) {
return true;
}
@@ -106,13 +49,29 @@ export const isTextFilterValue = (filterValue: unknown): filterValue is TextFilt
return ZTextFilterValue.safeParse(filterValue).success;
};
const isAllString = (array: (string | number)[]): array is string[] => {
return array.every((value) => typeof value === "string");
};
const isAllNumber = (array: (string | number)[]): array is number[] => {
return array.every((value) => typeof value === "number");
};
export const multiSelectFilter = (cellValue: unknown | undefined, filterValue: MultiSelectFilterValue) => {
const cellValueArray = Array.isArray(cellValue) ? cellValue : [cellValue];
if (!cellValueArray.every((value) => typeof value === "string")) {
return false;
const filterValueArray = filterValue.data;
if (!filterValueArray || filterValueArray.length === 0) {
return true;
}
return filterValue.data.length === 0 ? true : cellValueArray.some((v) => filterValue.data.includes(v));
if (isAllString(filterValueArray) && isAllString(cellValueArray)) {
return cellValueArray.some((v) => filterValueArray.includes(v));
} else if (isAllNumber(filterValueArray) && isAllNumber(cellValueArray)) {
return cellValueArray.some((v) => filterValueArray.includes(v));
}
return false;
};
export const isMultiSelectFilterValue = (filterValue: unknown): filterValue is MultiSelectFilterValue => {
@@ -166,7 +125,3 @@ export const dataTableFilter = (cellValue: unknown, filterValue: FilterValue) =>
}
return false;
};
export const convertToTitleCase = (str: string) => {
return str.replace(/\b\w/g, (char) => char.toUpperCase());
};
@@ -8,12 +8,14 @@ import {
getSortedRowModel,
createColumnHelper,
} from "@tanstack/react-table";
// eslint-disable-next-line no-restricted-imports
import startCase from "lodash/startCase";
import Link from "next/link";
import { useRef, useMemo, useId } from "react";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import type { FilterValue, ExternalFilter } from "@calcom/features/data-table";
import type { ExternalFilter } from "@calcom/features/data-table";
import {
DataTableProvider,
DataTable,
@@ -24,7 +26,6 @@ import {
multiSelectFilter,
textFilter,
dataTableFilter,
convertToTitleCase,
useDataTable,
} from "@calcom/features/data-table";
import classNames from "@calcom/lib/classNames";
@@ -47,33 +48,28 @@ import {
import { useFilterContext } from "../context/provider";
import { ClearFilters } from "../filters/ClearFilters";
import { DateSelect } from "../filters/DateSelect";
import { RoutingDownload } from "../filters/Download";
import { RoutingFormResponsesDownload } from "../filters/Download";
import { RoutingFormFilterList } from "../filters/RoutingFormFilterList";
import { TeamAndSelfList } from "../filters/TeamAndSelfList";
import { UserListInTeam } from "../filters/UserListInTeam";
import {
ZResponseMultipleValues,
ZResponseSingleValue,
ZResponseTextValue,
ZResponseNumericValue,
} from "../lib/types";
import { RoutingKPICards } from "./RoutingKPICards";
type RoutingFormResponse = RouterOutputs["viewer"]["insights"]["routingFormResponses"]["data"][number];
type RoutingFormTableRow = RouterOutputs["viewer"]["insights"]["routingFormResponses"]["data"][number];
type RoutingFormTableRow = {
id: number;
formName: string;
createdAt: Date;
routedToBooking: RoutingFormResponse["routedToBooking"];
[key: string]: any;
const statusOrder: Record<BookingStatus, number> = {
[BookingStatus.ACCEPTED]: 1,
[BookingStatus.PENDING]: 2,
[BookingStatus.AWAITING_HOST]: 3,
[BookingStatus.CANCELLED]: 4,
[BookingStatus.REJECTED]: 5,
};
type FieldCellValue = { label: string; value: string };
const ZResponseValue = z.object({
label: z.string(),
value: z.string(),
});
const ZResponseValues = z.array(ZResponseValue);
type ResponseValues = FieldCellValue[];
function CellWithOverflowX({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<div className={classNames("group relative max-w-[200px]", className)}>
@@ -110,7 +106,7 @@ function BookedByCell({
attendees,
rowId,
}: {
attendees: { email: string; timeZone: string }[] | undefined;
attendees: RoutingFormTableRow["bookingAttendees"] | undefined;
rowId: number;
}) {
const cellId = useId();
@@ -120,8 +116,8 @@ function BookedByCell({
<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 variant="gray" className="whitespace-nowrap" title={attendee.email}>
{attendee.name}
</Badge>
</CellWithOverflowX>
))}
@@ -129,7 +125,15 @@ function BookedByCell({
);
}
function ResponseValueCell({ values, rowId }: { values: ResponseValues; rowId: number }) {
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]" />;
@@ -137,9 +141,9 @@ function ResponseValueCell({ values, rowId }: { values: ResponseValues; rowId: n
<CellWithOverflowX className="flex w-[200px] gap-1">
{values.length > 2 ? (
<>
{values.slice(0, 2).map((value, i: number) => (
{values.slice(0, 2).map((id: string, i: number) => (
<Badge key={`${cellId}-${i}-${rowId}`} variant="gray">
{value.label}
{optionMap[id] ?? id}
</Badge>
))}
<HoverCard>
@@ -149,9 +153,9 @@ function ResponseValueCell({ values, rowId }: { values: ResponseValues; rowId: n
<HoverCardPortal>
<HoverCardContent side="bottom" align="start" className="w-fit">
<div className="flex flex-col gap-1">
{values.slice(2).map((value, i: number) => (
{values.slice(2).map((id: string, i: number) => (
<span key={`${cellId}-overflow-${i}-${rowId}`} className="text-default text-sm">
{value.label}
{optionMap[id] ?? id}
</span>
))}
</div>
@@ -160,9 +164,9 @@ function ResponseValueCell({ values, rowId }: { values: ResponseValues; rowId: n
</HoverCard>
</>
) : (
values.map((value, i: number) => (
values.map((id: string, i: number) => (
<Badge key={`${cellId}-${i}-${rowId}`} variant="gray">
{value.label}
{optionMap[id] ?? id}
</Badge>
))
)}
@@ -170,12 +174,12 @@ function ResponseValueCell({ values, rowId }: { values: ResponseValues; rowId: n
);
}
function BookingStatusBadge({ booking }: { booking: RoutingFormResponse["routedToBooking"] }) {
function BookingStatusBadge({ bookingStatus }: { bookingStatus: BookingStatus | null }) {
let badgeVariant: BadgeProps["variant"] = "success";
if (!booking) return null;
if (!bookingStatus) return null;
switch (booking.status) {
switch (bookingStatus) {
case BookingStatus.REJECTED:
case BookingStatus.AWAITING_HOST:
case BookingStatus.PENDING:
@@ -184,23 +188,23 @@ function BookingStatusBadge({ booking }: { booking: RoutingFormResponse["routedT
break;
}
return <Badge variant={badgeVariant}>{bookingStatusToText(booking.status)}</Badge>;
return <Badge variant={badgeVariant}>{bookingStatusToText(bookingStatus)}</Badge>;
}
function BookingAtCell({
booking,
row,
rowId,
copyToClipboard,
t,
}: {
booking: RoutingFormResponse["routedToBooking"];
row: RoutingFormTableRow;
rowId: number;
copyToClipboard: (text: string) => void;
t: (key: string) => string;
}) {
const cellId = useId();
if (!booking || !booking.user) {
if (!row.bookingUserId || !row.bookingCreatedAt) {
return <div className="w-[250px]" />;
}
@@ -208,9 +212,9 @@ function BookingAtCell({
<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>
<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>
@@ -218,17 +222,17 @@ function BookingAtCell({
<HoverCardContent>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<Avatar size="sm" imageSrc={booking.user.avatarUrl ?? ""} alt={booking.user.name ?? ""} />
<Avatar size="sm" imageSrc={row.bookingUserAvatarUrl ?? ""} alt={row.bookingUserName ?? ""} />
<div>
<p className="text-sm font-medium">{booking.user.name}</p>
<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">{booking.user.email}</span>
<span className="truncate">{row.bookingUserEmail}</span>
<button
className="invisible ml-2 group-hover/booking_status_email:visible"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
copyToClipboard(booking.user?.email ?? "");
copyToClipboard(row.bookingUserEmail ?? "");
}}>
<Icon name="copy" />
</button>
@@ -237,7 +241,7 @@ function BookingAtCell({
</div>
<div className="text-emphasis mt-4 flex items-center gap-2 text-xs">
<span>Status:</span>
<BookingStatusBadge booking={booking} />
<BookingStatusBadge bookingStatus={row.bookingStatus} />
</div>
</div>
</HoverCardContent>
@@ -274,41 +278,47 @@ export function RoutingFormResponsesTableContent({
selectedRoutingFormId,
selectedMemberUserId,
selectedUserId,
selectedBookingStatus,
selectedRoutingFormFilter,
} = filter;
const initialConfigIsReady = !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll);
const [startDate, endDate] = dateRange;
const columnFilters = useColumnFilters();
const teamId = selectedTeamId ?? undefined;
const userId = selectedUserId ?? undefined;
const memberUserId = selectedMemberUserId ?? undefined;
const routingFormId = selectedRoutingFormId ?? undefined;
const {
data: headers,
isLoading: isHeadersLoading,
isSuccess: isHeadersSuccess,
} = trpc.viewer.insights.routingFormResponsesHeaders.useQuery(
{
userId: selectedUserId ?? undefined,
teamId: selectedTeamId ?? undefined,
userId,
teamId,
isAll: isAll ?? false,
routingFormId: selectedRoutingFormId ?? undefined,
routingFormId,
},
{
enabled: initialConfigIsReady,
}
);
const { removeDisplayedExternalFilter, sorting, setSorting } = useDataTable();
const { data, fetchNextPage, isFetching, hasNextPage, isLoading } =
trpc.viewer.insights.routingFormResponses.useInfiniteQuery(
{
teamId: selectedTeamId,
teamId,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
userId: selectedUserId ?? undefined,
memberUserId: selectedMemberUserId ?? undefined,
userId,
memberUserId,
isAll: isAll ?? false,
routingFormId: selectedRoutingFormId ?? undefined,
routingFormId,
columnFilters,
sorting,
limit: 30,
},
{
@@ -321,60 +331,10 @@ export function RoutingFormResponsesTableContent({
}
);
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 (!isHeadersSuccess) 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 fieldHeader = (headers || []).find((h) => h.id === fieldId);
if (fieldHeader?.options) {
if (Array.isArray(field.value)) {
// Map the IDs to their corresponding labels for array values
const labels = field.value
.map((id) => {
const option = fieldHeader.options?.find((opt) => opt.id === id);
if (!option) {
return undefined;
}
return { label: option.label, value: option.id };
})
.filter(Boolean);
row[fieldId] = labels;
} else {
// Handle single value case
const option = fieldHeader.options?.find((opt) => opt.id === field.value);
if (option) {
row[fieldId] = { label: option.label, value: option.id };
}
}
} else {
row[fieldId] = field.value;
}
});
return row;
});
}, [flatData, headers, isHeadersSuccess]);
const statusOrder: Record<BookingStatus, number> = {
[BookingStatus.ACCEPTED]: 1,
[BookingStatus.PENDING]: 2,
[BookingStatus.AWAITING_HOST]: 3,
[BookingStatus.CANCELLED]: 4,
[BookingStatus.REJECTED]: 5,
};
return (data?.pages?.flatMap((page) => page.data) ?? []) as RoutingFormTableRow[];
}, [data, isHeadersSuccess]);
const columnHelper = createColumnHelper<RoutingFormTableRow>();
@@ -382,16 +342,15 @@ export function RoutingFormResponsesTableContent({
if (!isHeadersSuccess) {
return [];
}
return [
columnHelper.accessor("routedToBooking", {
id: "bookedBy",
columnHelper.accessor("bookingAttendees", {
id: "bookingAttendees",
header: t("routing_form_insights_booked_by"),
size: 200,
enableColumnFilter: false,
enableSorting: false,
cell: (info) => {
const row = info.row.original;
return <BookedByCell attendees={row.routedToBooking?.attendees || []} rowId={row.id} />;
return <BookedByCell attendees={info.getValue()} rowId={info.row.original.id} />;
},
}),
@@ -416,26 +375,39 @@ export function RoutingFormResponsesTableContent({
? "text"
: "multi_select";
return columnHelper.accessor(fieldHeader.id, {
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: convertToTitleCase(fieldHeader.label),
header: startCase(fieldHeader.label),
enableSorting: false,
cell: (info) => {
const values = info.getValue();
if (isMultiSelect) {
const result = ZResponseValues.safeParse(values);
if (isMultiSelect || isSingleSelect) {
const result = z.union([ZResponseMultipleValues, ZResponseSingleValue]).safeParse(values);
return (
result.success && <ResponseValueCell values={result.data} rowId={info.row.original.id} />
result.success && (
<ResponseValueCell
optionMap={optionMap}
values={Array.isArray(result.data.value) ? result.data.value : [result.data.value]}
rowId={info.row.original.id}
/>
)
);
} else if (isSingleSelect) {
const result = ZResponseValue.safeParse(values);
} else if (isText || isNumber) {
const result = z.union([ZResponseTextValue, ZResponseNumericValue]).safeParse(values);
return (
result.success && <ResponseValueCell values={[result.data]} rowId={info.row.original.id} />
);
} else if (typeof values !== "object") {
return (
<div className="truncate">
<span title={values}>{values}</span>
</div>
result.success && (
<div className="truncate">
<span title={String(result.data.value)}>{result.data.value}</span>
</div>
)
);
} else {
return null;
@@ -444,79 +416,76 @@ export function RoutingFormResponsesTableContent({
meta: {
filter: { type: filterType },
},
filterFn: (row, id, filterValue: FilterValue) => {
let cellValue: unknown;
if (Array.isArray(fieldHeader.options)) {
cellValue = Array.isArray(row.original[id])
? row.original[id].map((item: FieldCellValue) => item.value)
: row.original[id].value;
} else {
cellValue = row.original[id];
}
filterFn: (row, id, filterValue) => {
const cellValue = row.original.response[id].value;
return dataTableFilter(cellValue, filterValue);
},
});
}) ?? []),
columnHelper.accessor("routedToBooking", {
id: "bookingStatus",
columnHelper.accessor("bookingStatusOrder", {
id: "bookingStatusOrder",
header: t("routing_form_insights_booking_status"),
sortDescFirst: false,
cell: (info) => (
<div className="max-w-[250px]">
<BookingStatusBadge booking={info.getValue()} />
<BookingStatusBadge bookingStatus={info.row.original.bookingStatus} />
</div>
),
meta: {
filter: { type: "multi_select", icon: "circle" },
},
filterFn: (row, id, filterValue) => {
return multiSelectFilter(row.original.routedToBooking?.status, filterValue);
return multiSelectFilter(row.original.bookingStatusOrder, filterValue);
},
sortingFn: (rowA, rowB) => {
const statusA = rowA.original.routedToBooking?.status;
const statusB = rowB.original.routedToBooking?.status;
// Default to highest number (5) + 1 for null/undefined values to sort them last
const orderA = statusA ? statusOrder[statusA] : 6;
const orderB = statusB ? statusOrder[statusB] : 6;
return orderA - orderB;
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("routedToBooking", {
id: "bookingAt",
columnHelper.accessor("bookingCreatedAt", {
id: "bookingCreatedAt",
header: t("routing_form_insights_booking_at"),
enableColumnFilter: false,
cell: (info) => (
<div className="max-w-[250px]">
<BookingAtCell
booking={info.getValue()}
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("routedToBooking", {
id: "assignmentReason",
columnHelper.accessor("bookingAssignmentReason", {
id: "bookingAssignmentReason",
header: t("routing_form_insights_assignment_reason"),
enableSorting: false,
meta: {
filter: { type: "text" },
},
cell: (info) => {
const assignmentReason = info.getValue()?.assignmentReason;
return (
<div className="max-w-[250px]">
{assignmentReason && assignmentReason.length > 0 ? assignmentReason[0].reasonString : ""}
</div>
);
const assignmentReason = info.getValue();
return <div className="max-w-[250px]">{assignmentReason}</div>;
},
filterFn: (row, id, filterValue) => {
return textFilter(row.original.routedToBooking?.assignmentReason?.[0]?.reasonString, filterValue);
const reason = row.original.bookingAssignmentReason;
return textFilter(reason, filterValue);
},
}),
columnHelper.accessor("createdAt", {
id: "submittedAt",
id: "createdAt",
header: t("routing_form_insights_submitted_at"),
enableColumnFilter: false,
cell: (info) => (
@@ -538,8 +507,10 @@ export function RoutingFormResponsesTableContent({
size: 150,
},
state: {
sorting,
columnFilters,
},
onSortingChange: setSorting,
getFacetedUniqueValues: (_, columnId) => () => {
if (!headers) {
return new Map();
@@ -548,10 +519,13 @@ export function RoutingFormResponsesTableContent({
const fieldHeader = headers.find((h) => h.id === columnId);
if (fieldHeader?.options) {
return new Map(fieldHeader.options.map((option) => [{ label: option.label, value: option.id }, 1]));
} else if (columnId === "bookingStatus") {
} else if (columnId === "bookingStatusOrder") {
return new Map(
Object.keys(BookingStatus).map((status) => [
{ value: status, label: bookingStatusToText(status as BookingStatus) },
Object.keys(statusOrder).map((status) => [
{
value: statusOrder[status as BookingStatus],
label: bookingStatusToText(status as BookingStatus),
},
1,
])
);
@@ -567,8 +541,6 @@ export function RoutingFormResponsesTableContent({
isFetching,
});
const { removeDisplayedExternalFilter } = useDataTable();
const externalFilters = useMemo<ExternalFilter[]>(
() => [
{
@@ -610,7 +582,17 @@ export function RoutingFormResponsesTableContent({
<ClearFilters />
<div className="grow" />
<DateSelect />
<RoutingDownload />
<RoutingFormResponsesDownload
startDate={startDate}
endDate={endDate}
teamId={teamId}
userId={userId}
isAll={isAll ?? false}
memberUserId={memberUserId}
routingFormId={routingFormId}
columnFilters={columnFilters}
sorting={sorting}
/>
<DataTableFilters.ColumnVisibilityButton table={table} />
</div>
<RoutingKPICards />
@@ -21,7 +21,7 @@ const RoutingDownload = () => {
const { t } = useLocale();
const [isDownloading, setIsDownloading] = useState(false);
const utils = trpc.useContext();
const utils = trpc.useUtils();
const fetchBatch = async (cursor: number | null = null) => {
const result = await utils.viewer.insights.rawRoutingData.fetch({
@@ -0,0 +1,115 @@
import { useState } from "react";
import type { Dayjs } from "@calcom/dayjs";
import type { ColumnFilter, SortingState } from "@calcom/features/data-table";
import { downloadAsCsv } from "@calcom/lib/csvUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Button, Dropdown, DropdownItem, DropdownMenuContent, DropdownMenuTrigger } from "@calcom/ui";
import { showToast } from "@calcom/ui";
type RoutingData = RouterOutputs["viewer"]["insights"]["routingFormResponsesForDownload"]["data"][number];
type Props = {
teamId: number | undefined;
userId: number | undefined;
memberUserId: number | undefined;
routingFormId: string | undefined;
isAll: boolean;
startDate: Dayjs;
endDate: Dayjs;
columnFilters: ColumnFilter[];
sorting: SortingState;
};
type Batch = {
data: RoutingData[];
nextCursor: number | undefined;
};
export const RoutingFormResponsesDownload = ({
teamId,
userId,
memberUserId,
routingFormId,
isAll,
startDate,
endDate,
columnFilters,
sorting,
}: Props) => {
const { t } = useLocale();
const [isDownloading, setIsDownloading] = useState(false);
const utils = trpc.useUtils();
const fetchBatch = async (
cursor: number | undefined = undefined
): Promise<{
data: RoutingData[];
nextCursor: number | undefined;
}> => {
const { data, nextCursor } = await utils.viewer.insights.routingFormResponsesForDownload.fetch({
teamId,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
userId,
memberUserId,
isAll: isAll ?? false,
routingFormId,
columnFilters,
sorting,
cursor,
});
return {
data,
nextCursor,
};
};
const handleDownloadClick = async () => {
try {
setIsDownloading(true);
let allData: RoutingData[] = [];
let hasMore = true;
let cursor: number | undefined = undefined;
// Fetch data in batches until there's no more data
while (hasMore) {
const result: Batch = await fetchBatch(cursor);
allData = [...allData, ...result.data];
hasMore = result.nextCursor !== undefined;
cursor = result.nextCursor;
}
if (allData.length > 0) {
const filename = `RoutingFormResponses-${startDate.format("YYYY-MM-DD")}-${endDate.format(
"YYYY-MM-DD"
)}.csv`;
downloadAsCsv(allData as Record<string, unknown>[], filename);
}
} catch (error) {
showToast(t("error_downloading_data"), "error");
} finally {
setIsDownloading(false);
}
};
return (
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button
EndIcon="file-down"
color="secondary"
className="self-end sm:self-baseline"
loading={isDownloading}>
{t("download")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownItem onClick={handleDownloadClick}>{t("as_csv")}</DropdownItem>
</DropdownMenuContent>
</Dropdown>
);
};
@@ -1,2 +1,3 @@
export { Download } from "./Download";
export { RoutingDownload } from "./RoutingDownload";
export { RoutingFormResponsesDownload } from "./RoutingFormResponsesDownload";
+32
View File
@@ -0,0 +1,32 @@
import { z } from "zod";
export const ZResponseMultipleValues = z.object({
label: z.string(),
value: z.array(z.string()),
});
export const ZResponseSingleValue = z.object({
label: z.string(),
value: z.string(),
});
export const ZResponseTextValue = z.object({
label: z.string(),
value: z.string(),
});
export const ZResponseNumericValue = z.object({
label: z.string(),
value: z.number(),
});
export const ZResponseValue = z.union([
ZResponseMultipleValues,
ZResponseSingleValue,
ZResponseTextValue,
ZResponseNumericValue,
]);
export type ResponseValue = z.infer<typeof ZResponseValue>;
export const ZResponse = z.record(z.string(), ZResponseValue);
@@ -1,15 +1,26 @@
import { Prisma } from "@prisma/client";
// eslint-disable-next-line no-restricted-imports
import mapKeys from "lodash/mapKeys";
// eslint-disable-next-line no-restricted-imports
import startCase from "lodash/startCase";
import {
zodFields as routingFormFieldsSchema,
routingFormResponseInDbSchema,
} from "@calcom/app-store/routing-forms/zod";
import dayjs from "@calcom/dayjs";
import type { ColumnFilter, TypedColumnFilter } from "@calcom/features/data-table";
import { makeWhereClause } from "@calcom/features/data-table/lib/server";
import type {
ColumnFilter,
TypedColumnFilter,
ColumnFilterType,
SortingState,
} from "@calcom/features/data-table";
import { makeWhereClause, makeOrderBy } from "@calcom/features/data-table/lib/server";
import { readonlyPrisma as prisma } from "@calcom/prisma";
import type { BookingStatus } from "@calcom/prisma/enums";
import { type ResponseValue, ZResponse } from "../lib/types";
type RoutingFormInsightsTeamFilter = {
userId?: number | null;
teamId?: number | null;
@@ -31,6 +42,8 @@ type RoutingFormInsightsFilter = RoutingFormInsightsTeamFilter & {
columnFilters: ColumnFilter[];
};
type WhereForTeamOrAllTeams = Pick<Prisma.App_RoutingForms_FormWhereInput, "id" | "teamId" | "userId">;
class RoutingEventsInsights {
private static async getWhereForTeamOrAllTeams({
userId,
@@ -38,7 +51,7 @@ class RoutingEventsInsights {
isAll,
organizationId,
routingFormId,
}: RoutingFormInsightsTeamFilter) {
}: RoutingFormInsightsTeamFilter): Promise<WhereForTeamOrAllTeams> {
// Get team IDs based on organization if applicable
let teamIds: number[] = [];
if (isAll && organizationId) {
@@ -56,7 +69,7 @@ class RoutingEventsInsights {
}
// Base where condition for forms
const formsWhereCondition: Prisma.App_RoutingForms_FormWhereInput = {
const formsWhereCondition: WhereForTeamOrAllTeams = {
...(teamIds.length > 0
? {
teamId: {
@@ -211,7 +224,12 @@ class RoutingEventsInsights {
userId,
memberUserId,
columnFilters,
}: Omit<RoutingFormInsightsFilter, "fieldFilter" | "bookingStatus"> & { cursor?: number; limit?: number }) {
sorting,
}: Omit<RoutingFormInsightsFilter, "fieldFilter" | "bookingStatus"> & {
sorting: SortingState;
cursor?: number;
limit?: number;
}) {
const formsTeamWhereCondition = await this.getWhereForTeamOrAllTeams({
userId,
teamId,
@@ -220,44 +238,57 @@ class RoutingEventsInsights {
routingFormId,
});
const bookingStatusFilter = columnFilters.find((filter) => filter.id === "bookingStatus");
const assignmentReasonFilter = columnFilters.find((filter) => filter.id === "assignmentReason") as
| TypedColumnFilter<"text">
| undefined;
const fieldFilters = columnFilters.filter(
(filter) => filter.id !== "bookingStatus" && filter.id !== "assignmentReason"
);
let bookingWhereInput: Prisma.BookingWhereInput = {};
if (memberUserId) {
bookingWhereInput.userId = memberUserId;
}
if (bookingStatusFilter) {
bookingWhereInput = {
...bookingWhereInput,
...makeWhereClause({ columnName: "status", filterValue: bookingStatusFilter.value }),
};
}
if (assignmentReasonFilter) {
const operator = assignmentReasonFilter.value.data.operator;
if (operator === "isEmpty") {
bookingWhereInput.assignmentReason = { none: {} };
} else if (operator === "isNotEmpty") {
bookingWhereInput.assignmentReason = {
some: {
reasonString: {
not: "",
},
const getLowercasedFilterValue = <TData extends ColumnFilterType>(filter: TypedColumnFilter<TData>) => {
if (filter.value.type === "text") {
return {
...filter.value,
data: {
...filter.value.data,
operand: filter.value.data.operand.toLowerCase(),
},
};
} else {
bookingWhereInput.assignmentReason = {
some: makeWhereClause({ columnName: "reasonString", filterValue: assignmentReasonFilter.value }),
};
}
}
return filter.value;
};
const responsesWhereCondition: Prisma.App_RoutingForms_FormResponseWhereInput = {
const bookingStatusOrder = columnFilters.find((filter) => filter.id === "bookingStatusOrder");
const bookingAssignmentReason = columnFilters.find(
(filter) => filter.id === "bookingAssignmentReason"
) as TypedColumnFilter<"text"> | undefined;
const assignmentReasonValue = bookingAssignmentReason
? getLowercasedFilterValue(bookingAssignmentReason)
: undefined;
const responseFilters = columnFilters.filter(
(filter) => filter.id !== "bookingStatusOrder" && filter.id !== "bookingAssignmentReason"
);
const whereClause: Prisma.RoutingFormResponseWhereInput = {
...(formsTeamWhereCondition.id !== undefined && {
formId: formsTeamWhereCondition.id as string | Prisma.StringFilter<"RoutingFormResponse">,
}),
...(formsTeamWhereCondition.teamId !== undefined && {
formTeamId: formsTeamWhereCondition.teamId as number | Prisma.IntFilter<"RoutingFormResponse">,
}),
...(formsTeamWhereCondition.userId !== undefined && {
formUserId: formsTeamWhereCondition.userId as number | Prisma.IntFilter<"RoutingFormResponse">,
}),
// bookingStatus
...(bookingStatusOrder &&
makeWhereClause({ columnName: "bookingStatusOrder", filterValue: bookingStatusOrder.value })),
// bookingAssignmentReason
...(assignmentReasonValue &&
makeWhereClause({
columnName: "bookingAssignmentReasonLowercase",
filterValue: assignmentReasonValue,
})),
// memberUserId
...(memberUserId && { bookingUserId: memberUserId }),
// createdAt
...(startDate &&
endDate && {
createdAt: {
@@ -265,77 +296,158 @@ class RoutingEventsInsights {
lte: dayjs(endDate).endOf("day").toDate(),
},
}),
...(Object.keys(bookingWhereInput).length > 0 ? { routedToBooking: bookingWhereInput } : {}),
form: formsTeamWhereCondition,
// AND clause
...(responseFilters.length > 0 && {
AND: responseFilters.map((fieldFilter) => {
return makeWhereClause({
columnName: "responseLowercase",
filterValue: getLowercasedFilterValue(fieldFilter),
json: { path: [fieldFilter.id, "value"] },
});
}),
}),
};
if (fieldFilters.length > 0) {
responsesWhereCondition.AND = fieldFilters.map((fieldFilter) => {
// NOTE: We cannot perform case-insensitive search on `response` column,
// until we normalize this table, use raw sql, or filter at the application level.
return makeWhereClause({
columnName: "response",
filterValue: fieldFilter.value,
json: { path: [fieldFilter.id, "value"] },
});
});
}
const totalResponsePromise = prisma.app_RoutingForms_FormResponse.count({
where: responsesWhereCondition,
const totalResponsePromise = prisma.routingFormResponse.count({
where: whereClause,
});
const responsesPromise = prisma.app_RoutingForms_FormResponse.findMany({
const responsesPromise = prisma.routingFormResponse.findMany({
select: {
id: true,
response: true,
form: {
select: {
id: true,
name: true,
},
},
routedToBooking: {
select: {
uid: true,
status: true,
createdAt: true,
attendees: {
select: {
timeZone: true,
email: true,
},
},
user: {
select: { id: true, name: true, email: true, avatarUrl: true },
},
assignmentReason: {
select: { reasonString: true },
},
},
},
formId: true,
formName: true,
bookingUid: true,
bookingStatus: true,
bookingStatusOrder: true,
bookingCreatedAt: true,
bookingAttendees: true,
bookingUserId: true,
bookingUserName: true,
bookingUserEmail: true,
bookingUserAvatarUrl: true,
bookingAssignmentReason: true,
bookingStartTime: true,
bookingEndTime: true,
createdAt: true,
},
where: responsesWhereCondition,
orderBy: {
createdAt: "desc",
},
where: whereClause,
orderBy: sorting.length > 0 ? makeOrderBy(sorting) : { createdAt: "desc" },
take: limit ? limit + 1 : undefined, // Get one extra item to check if there are more pages
cursor: cursor ? { id: cursor } : undefined,
});
const [totalResponses, responses] = await Promise.all([totalResponsePromise, responsesPromise]);
// Parse response data
const parsedResponses = responses.map((r) => {
const responseData = routingFormResponseInDbSchema.parse(r.response);
return { ...r, response: responseData };
});
const hasNextPage = responses.length > (limit ?? 0);
const responsesToReturn = responses.slice(0, limit ? limit : responses.length);
type Response = Omit<
(typeof responsesToReturn)[number],
"response" | "responseLowercase" | "bookingAttendees"
> & {
response: Record<string, ResponseValue>;
responseLowercase: Record<string, ResponseValue>;
bookingAttendees?: { name: string; email: string; timeZone: string }[];
};
return {
total: totalResponses,
data: parsedResponses,
nextCursor: responses.length > (limit ?? 0) ? responses[responses.length - 1].id : undefined,
data: responsesToReturn as Response[],
nextCursor: hasNextPage ? responsesToReturn[responsesToReturn.length - 1].id : undefined,
};
}
static async getRoutingFormPaginatedResponsesForDownload({
teamId,
startDate,
endDate,
isAll,
organizationId,
routingFormId,
cursor,
limit,
userId,
memberUserId,
columnFilters,
sorting,
}: Omit<RoutingFormInsightsFilter, "fieldFilter" | "bookingStatus"> & {
sorting: SortingState;
cursor?: number;
limit?: number;
}) {
const headersPromise = this.getRoutingFormHeaders({
userId,
teamId,
isAll,
organizationId,
routingFormId,
});
const dataPromise = this.getRoutingFormPaginatedResponses({
teamId,
startDate,
endDate,
isAll,
organizationId,
routingFormId,
cursor,
userId,
memberUserId,
limit,
columnFilters,
sorting,
});
const [headers, data] = await Promise.all([headersPromise, dataPromise]);
const dataWithFlatResponse = data.data.map((item) => {
const { bookingAttendees } = item;
const responseParseResult = ZResponse.safeParse(item.response);
const response = responseParseResult.success ? responseParseResult.data : {};
const fields = (headers || []).reduce((acc, header) => {
const id = header.id;
if (!response[id]) {
acc[header.label] = "";
return acc;
}
if (header.type === "select") {
acc[header.label] = header.options?.find((option) => option.id === response[id].value)?.label;
} else if (header.type === "multiselect" && Array.isArray(response[id].value)) {
acc[header.label] = (response[id].value as string[])
.map((value) => header.options?.find((option) => option.id === value)?.label)
.filter((label): label is string => label !== undefined)
.sort()
.join(", ");
} else {
acc[header.label] = response[id].value as string;
}
return acc;
}, {} as Record<string, string | undefined>);
return {
"Response ID": item.id,
"Form Name": item.formName,
"Submitted At": item.createdAt.toISOString(),
"Has Booking": item.bookingUid !== null,
"Booking Status": item.bookingStatus || "NO_BOOKING",
"Booking Created At": item.bookingCreatedAt?.toISOString() || "",
"Booking Start Time": item.bookingStartTime?.toISOString() || "",
"Booking End Time": item.bookingEndTime?.toISOString() || "",
"Attendee Name": (bookingAttendees as any)?.[0]?.name,
"Attendee Email": (bookingAttendees as any)?.[0]?.email,
"Attendee Timezone": (bookingAttendees as any)?.[0]?.timeZone,
"Assignment Reason": item.bookingAssignmentReason || "",
"Routed To Name": item.bookingUserName || "",
"Routed To Email": item.bookingUserEmail || "",
...mapKeys(fields, (_, key) => startCase(key)),
};
});
return {
data: dataWithFlatResponse,
nextCursor: data.nextCursor,
};
}
@@ -3,7 +3,7 @@ import md5 from "md5";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { ZColumnFilter } from "@calcom/features/data-table";
import { ZColumnFilter, ZSorting } from "@calcom/features/data-table";
import { rawDataInputSchema } from "@calcom/features/insights/server/raw-data.schema";
import { randomString } from "@calcom/lib/random";
import type { readonlyPrisma } from "@calcom/prisma";
@@ -1601,6 +1601,7 @@ export const insightsRouter = router({
cursor: z.number().optional(),
limit: z.number().optional(),
columnFilters: z.array(ZColumnFilter),
sorting: z.array(ZSorting),
})
)
.query(async ({ ctx, input }) => {
@@ -1617,6 +1618,33 @@ export const insightsRouter = router({
memberUserId: input.memberUserId ?? null,
limit: input.limit,
columnFilters: input.columnFilters,
sorting: input.sorting,
});
}),
routingFormResponsesForDownload: userBelongsToTeamProcedure
.input(
rawDataInputSchema.extend({
routingFormId: z.string().optional(),
cursor: z.number().optional(),
limit: z.number().optional(),
columnFilters: z.array(ZColumnFilter),
sorting: z.array(ZSorting),
})
)
.query(async ({ ctx, input }) => {
return await RoutingEventsInsights.getRoutingFormPaginatedResponsesForDownload({
teamId: input.teamId ?? null,
startDate: input.startDate,
endDate: input.endDate,
isAll: input.isAll ?? false,
organizationId: ctx.user.organizationId ?? null,
routingFormId: input.routingFormId ?? null,
cursor: input.cursor,
userId: input.userId ?? null,
memberUserId: input.memberUserId ?? null,
limit: input.limit ?? BATCH_SIZE,
columnFilters: input.columnFilters,
sorting: input.sorting,
});
}),
getRoutingFormFieldOptions: userBelongsToTeamProcedure
@@ -0,0 +1,72 @@
CREATE OR REPLACE VIEW "RoutingFormResponse" AS
WITH attendees_agg AS (
SELECT
a."bookingId",
json_agg(
json_build_object(
'name', a.name,
'timeZone', a."timeZone",
'email', a.email
)
) as attendees
FROM "Attendee" a
GROUP BY a."bookingId"
),
assignment_reasons_agg AS (
SELECT
ar."bookingId",
json_agg(
json_build_object(
'reasonString', ar."reasonString"
)
) as reasons
FROM "AssignmentReason" ar
GROUP BY ar."bookingId"
)
SELECT
r.id,
r.response,
(
SELECT jsonb_object_agg(
key,
CASE
WHEN jsonb_typeof(value->'value') = 'string'
THEN jsonb_build_object(
'label', value->'label',
'value', lower((value->>'value')::text)
)
ELSE value
END
)
FROM jsonb_each(r.response::jsonb)
) as "responseLowercase",
f.id as "formId",
f.name as "formName",
f."teamId" as "formTeamId",
f."userId" as "formUserId",
b.uid as "bookingUid",
b.status as "bookingStatus",
CASE b.status
WHEN 'accepted' THEN 1
WHEN 'pending' THEN 2
WHEN 'awaiting_host' THEN 3
WHEN 'cancelled' THEN 4
WHEN 'rejected' THEN 5
END as "bookingStatusOrder",
b."createdAt" as "bookingCreatedAt",
b."startTime" as "bookingStartTime",
b."endTime" as "bookingEndTime",
att.attendees as "bookingAttendees",
u.id as "bookingUserId",
u.name as "bookingUserName",
u.email as "bookingUserEmail",
u."avatarUrl" as "bookingUserAvatarUrl",
COALESCE((ar.reasons->0)->>'reasonString', '') as "bookingAssignmentReason",
LOWER(COALESCE((ar.reasons->0)->>'reasonString', '')) as "bookingAssignmentReasonLowercase",
r."createdAt" as "createdAt"
FROM "App_RoutingForms_FormResponse" r
LEFT JOIN "App_RoutingForms_Form" f ON r."formId" = f.id
LEFT JOIN "Booking" b ON r."routedToBookingUid" = b.uid
LEFT JOIN attendees_agg att ON b.id = att."bookingId"
LEFT JOIN "users" u ON b."userId" = u.id
LEFT JOIN assignment_reasons_agg ar ON b.id = ar."bookingId";
+24
View File
@@ -1019,6 +1019,30 @@ model App_RoutingForms_FormResponse {
@@index([formId])
}
view RoutingFormResponse {
id Int @unique
response Json
responseLowercase Json
formId String
formName String
formTeamId Int?
formUserId Int?
bookingUid String?
bookingStatus BookingStatus?
bookingStatusOrder Int?
bookingCreatedAt DateTime?
bookingAttendees Json? // Array of {timeZone: string, email: string}
bookingUserId Int?
bookingUserName String?
bookingUserEmail String?
bookingUserAvatarUrl String?
bookingAssignmentReason String?
bookingAssignmentReasonLowercase String?
bookingStartTime DateTime?
bookingEndTime DateTime?
createdAt DateTime
}
model Feedback {
id Int @id @default(autoincrement())
date DateTime @default(now())
+32 -1
View File
@@ -5,7 +5,7 @@ import { v4 as uuidv4 } from "uuid";
import dayjs from "@calcom/dayjs";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { BookingStatus } from "@calcom/prisma/enums";
import { BookingStatus, AssignmentReasonEnum } from "@calcom/prisma/enums";
import { seedAttributes, seedRoutingFormResponses, seedRoutingForms } from "./seed-utils";
@@ -103,6 +103,35 @@ async function createAttendees(bookings: any[]) {
}
}
async function seedBookingAssignments() {
const assignmentReasons = [
AssignmentReasonEnum.ROUTING_FORM_ROUTING,
AssignmentReasonEnum.ROUTING_FORM_ROUTING_FALLBACK,
AssignmentReasonEnum.REASSIGNED,
AssignmentReasonEnum.REROUTED,
AssignmentReasonEnum.SALESFORCE_ASSIGNMENT,
];
// Get all booking IDs
const bookings = await prisma.booking.findMany({
select: {
id: true,
},
});
// Take 20% of bookings randomly
const numberOfBookingsToAssign = Math.floor(bookings.length * 0.2);
const randomBookings = bookings.sort(() => Math.random() - 0.5).slice(0, numberOfBookingsToAssign);
// Create assignment reasons for the random bookings
await prisma.assignmentReason.createMany({
data: randomBookings.map((booking) => ({
bookingId: booking.id,
reasonString: faker.lorem.sentence(),
reasonEnum: assignmentReasons[Math.floor(Math.random() * assignmentReasons.length)],
})),
});
}
const prisma = new PrismaClient();
async function main() {
// First find the organization we want to add insights to
@@ -307,6 +336,8 @@ async function main() {
if (seededForm) {
await seedRoutingFormResponses(seededForm, attributes, insightsTeam.id);
}
await seedBookingAssignments();
}
main()
.then(async () => {
@@ -15,6 +15,10 @@ type GetOptions = {
input: TListMembersSchema;
};
const isAllString = (array: (string | number)[]): array is string[] => {
return array.every((value) => typeof value === "string");
};
export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
const organizationId = ctx.user.organizationId ?? ctx.user.profiles[0].organizationId;
const searchTerm = input.searchTerm;
@@ -103,7 +107,7 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
break;
// We assume that if the filter is not one of the above, it must be an attribute filter
default:
if (filter.value.type === "multi_select") {
if (filter.value.type === "multi_select" && isAllString(filter.value.data)) {
const attributeOptionValues: string[] = [];
filter.value.data.forEach((filterValueItem) => {
attributeOptionValues.push(filterValueItem);