feat: add booking status filter to /insights (#22842)

Co-authored-by: Alex van Andel <me@alexvanandel.com>
This commit is contained in:
Eunjae Lee
2025-08-04 13:48:17 +00:00
committed by GitHub
co-authored by Alex van Andel
parent cf4e7a5d2c
commit a2205d497d
11 changed files with 243 additions and 62 deletions
+7 -15
View File
@@ -203,32 +203,24 @@ test.describe("Insights", async () => {
await page.keyboard.press("Escape");
// Choose User filter item from dropdown
await addFilter(page, "bookingUserId");
await addFilter(page, "userId");
// Wait for the URL to include bookingUserId
await page.waitForURL((url) => url.toString().includes("bookingUserId"));
// Wait for the URL to include userId
await page.waitForURL((url) => url.toString().includes("userId"));
// Click User filter to see a user list
await openFilter(page, "bookingUserId");
await openFilter(page, "userId");
await page
.locator('[data-testid="select-filter-options-bookingUserId"]')
.getByRole("option")
.nth(0)
.click();
await page.locator('[data-testid="select-filter-options-userId"]').getByRole("option").nth(0).click();
await page
.locator('[data-testid="select-filter-options-bookingUserId"]')
.getByRole("option")
.nth(1)
.click();
await page.locator('[data-testid="select-filter-options-userId"]').getByRole("option").nth(1).click();
// press escape button to close the filter
await page.keyboard.press("Escape");
await clearFilters(page);
await expect(page.url()).not.toContain("bookingUserId");
await expect(page.url()).not.toContain("userId");
});
test("should test download button", async ({ page, users }) => {
@@ -3429,5 +3429,6 @@
"usage_based_expiration_description": "This link can be used for {{count}} booking",
"usage_based_generic_expiration_description": "This link can be configured to expire after a set number of bookings",
"usage_based_expiration_description_plural": "This link can be used for {{count}} bookings",
"booking_status": "Booking status",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -21,8 +21,8 @@ import { trpc } from "@calcom/trpc";
import { RoutingFormResponsesDownload } from "../../filters/Download";
import { OrgTeamsFilter } from "../../filters/OrgTeamsFilter";
import { useInsightsColumns } from "../../hooks/useInsightsColumns";
import { useInsightsFacetedUniqueValues } from "../../hooks/useInsightsFacetedUniqueValues";
import { useInsightsParameters } from "../../hooks/useInsightsParameters";
import { useInsightsRoutingFacetedUniqueValues } from "../../hooks/useInsightsRoutingFacetedUniqueValues";
import type { RoutingFormTableRow } from "../../lib/types";
import { RoutingKPICards } from "./RoutingKPICards";
@@ -49,7 +49,12 @@ export function RoutingFormResponsesTable() {
routingFormId,
});
const getInsightsFacetedUniqueValues = useInsightsFacetedUniqueValues({ headers, userId, teamId, isAll });
const getInsightsFacetedUniqueValues = useInsightsRoutingFacetedUniqueValues({
headers,
userId,
teamId,
isAll,
});
const { sorting, limit, offset, ctaContainerRef, updateFilter } = useDataTable();
@@ -0,0 +1,76 @@
import type { Table } from "@tanstack/react-table";
import { useCallback } from "react";
import { convertFacetedValuesToMap, type FacetedValue } from "@calcom/features/data-table";
import { BookingStatus } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc";
import { bookingStatusToText } from "../lib/bookingStatusToText";
const statusOrder: Record<BookingStatus, number> = {
[BookingStatus.ACCEPTED]: 1,
[BookingStatus.PENDING]: 2,
[BookingStatus.AWAITING_HOST]: 3,
[BookingStatus.CANCELLED]: 4,
[BookingStatus.REJECTED]: 5,
};
export const useInsightsBookingFacetedUniqueValues = ({
userId,
teamId,
isAll,
}: {
userId: number | undefined;
teamId: number | undefined;
isAll: boolean;
}) => {
const { data: users } = trpc.viewer.insights.userList.useQuery(
{
teamId,
isAll,
},
{
refetchOnWindowFocus: false,
}
);
const { data: eventTypes } = trpc.viewer.insights.eventTypeList.useQuery(
{
teamId,
userId,
isAll,
},
{
refetchOnWindowFocus: false,
}
);
return useCallback(
(_: Table<any>, columnId: string) => (): Map<FacetedValue, number> => {
if (columnId === "status") {
return convertFacetedValuesToMap(
Object.keys(statusOrder).map((status) => ({
value: status.toLowerCase(),
label: bookingStatusToText(status as BookingStatus),
}))
);
} else if (columnId === "userId") {
return convertFacetedValuesToMap(
users?.map((user) => ({
label: user.name ?? user.email,
value: user.id,
})) ?? []
);
} else if (columnId === "eventTypeId") {
return convertFacetedValuesToMap(
eventTypes?.map((eventType) => ({
value: eventType.id,
label: eventType.teamId ? `${eventType.title} (${eventType.team?.name})` : eventType.title,
})) ?? []
);
}
return new Map<FacetedValue, number>();
},
[users, eventTypes]
);
};
@@ -1,8 +1,9 @@
import { useMemo } from "react";
import dayjs from "@calcom/dayjs";
import { ZSingleSelectFilterValue, ZDateRangeFilterValue } from "@calcom/features/data-table";
import { ZDateRangeFilterValue } from "@calcom/features/data-table";
import { useChangeTimeZoneWithPreservedLocalTime } from "@calcom/features/data-table/hooks/useChangeTimeZoneWithPreservedLocalTime";
import { useColumnFilters } from "@calcom/features/data-table/hooks/useColumnFilters";
import { useDataTable } from "@calcom/features/data-table/hooks/useDataTable";
import { useFilterValue } from "@calcom/features/data-table/hooks/useFilterValue";
import { getDefaultStartDate, getDefaultEndDate } from "@calcom/features/data-table/lib/dateRange";
@@ -14,8 +15,6 @@ export function useInsightsBookingParameters() {
const { scope, selectedTeamId } = useInsightsOrgTeams();
const { timeZone } = useDataTable();
const memberUserId = useFilterValue("bookingUserId", ZSingleSelectFilterValue)?.data as number | undefined;
const eventTypeId = useFilterValue("eventTypeId", ZSingleSelectFilterValue)?.data as number | undefined;
const createdAtRange = useFilterValue("createdAt", ZDateRangeFilterValue)?.data;
// TODO for future: this preserving local time & startOf & endOf should be handled
// from DateRangeFilter out of the box.
@@ -34,6 +33,7 @@ export function useInsightsBookingParameters() {
.toISOString();
}, [createdAtRange?.endDate])
);
const columnFilters = useColumnFilters({ exclude: ["createdAt"] });
return {
scope,
@@ -41,7 +41,6 @@ export function useInsightsBookingParameters() {
startDate,
endDate,
timeZone: timeZone || CURRENT_TIMEZONE,
eventTypeId,
memberUserId,
columnFilters,
};
}
@@ -3,25 +3,24 @@ import { useMemo } from "react";
import { ColumnFilterType } from "@calcom/features/data-table";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { BookingStatus } from "@calcom/prisma/enums";
import type { HeaderRow, RoutingFormTableRow } from "../lib/types";
import { useInsightsFacetedUniqueValues } from "./useInsightsFacetedUniqueValues";
import { useInsightsBookingFacetedUniqueValues } from "./useInsightsBookingFacetedUniqueValues";
import { useInsightsOrgTeams } from "./useInsightsOrgTeams";
type DummyTableRow = {
bookingUserId: RoutingFormTableRow["bookingUserId"];
userId: number | null;
eventTypeId: number | null;
status: BookingStatus;
};
const emptyData: DummyTableRow[] = [];
const dummyHeaders: HeaderRow[] = [];
export const useInsightsBookings = () => {
const { t } = useLocale();
const { isAll, teamId, userId } = useInsightsOrgTeams();
const getInsightsFacetedUniqueValues = useInsightsFacetedUniqueValues({
headers: dummyHeaders,
const getInsightsFacetedUniqueValues = useInsightsBookingFacetedUniqueValues({
userId,
teamId,
isAll,
@@ -30,18 +29,6 @@ export const useInsightsBookings = () => {
const columns = useMemo(() => {
const columnHelper = createColumnHelper<DummyTableRow>();
return [
columnHelper.accessor("bookingUserId", {
id: "bookingUserId",
header: t("user"),
enableColumnFilter: true,
enableSorting: false,
meta: {
filter: {
type: ColumnFilterType.SINGLE_SELECT,
},
},
cell: () => null,
}),
columnHelper.accessor("eventTypeId", {
id: "eventTypeId",
header: t("event_type"),
@@ -55,6 +42,31 @@ export const useInsightsBookings = () => {
enableSorting: false,
cell: () => null,
}),
columnHelper.accessor("status", {
id: "status",
header: t("booking_status"),
size: 200,
meta: {
filter: {
type: ColumnFilterType.MULTI_SELECT,
},
},
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
}),
columnHelper.accessor("userId", {
id: "userId",
header: t("member"),
enableColumnFilter: true,
enableSorting: false,
meta: {
filter: {
type: ColumnFilterType.SINGLE_SELECT,
},
},
cell: () => null,
}),
];
}, [t]);
@@ -16,7 +16,7 @@ const statusOrder: Record<BookingStatus, number> = {
[BookingStatus.REJECTED]: 5,
};
export const useInsightsFacetedUniqueValues = ({
export const useInsightsRoutingFacetedUniqueValues = ({
headers,
userId,
teamId,
@@ -76,6 +76,5 @@ export const bookingRepositoryBaseInputSchema = z.object({
startDate: z.string(),
endDate: z.string(),
timeZone: z.string(),
eventTypeId: z.coerce.number().optional().nullable(),
memberUserId: z.coerce.number().optional().nullable(),
columnFilters: z.array(ZColumnFilter).optional(),
});
@@ -320,7 +320,7 @@ function createInsightsBookingService(
input: z.infer<typeof bookingRepositoryBaseInputSchema>,
dateTarget: "createdAt" | "startTime" = "createdAt"
) {
const { scope, selectedTeamId, eventTypeId, memberUserId, startDate, endDate } = input;
const { scope, selectedTeamId, startDate, endDate, columnFilters } = input;
return new InsightsBookingService({
prisma: ctx.insightsDb,
@@ -331,8 +331,7 @@ function createInsightsBookingService(
...(selectedTeamId && { teamId: selectedTeamId }),
},
filters: {
...(eventTypeId && { eventTypeId }),
...(memberUserId && { memberUserId }),
...(columnFilters && { columnFilters }),
dateRange: {
target: dateTarget,
startDate,
@@ -2,6 +2,7 @@ import type { Team, User, Membership } from "@prisma/client";
import { Prisma } from "@prisma/client";
import { describe, expect, it } from "vitest";
import { ColumnFilterType } from "@calcom/features/data-table/lib/types";
import prisma from "@calcom/prisma";
import { BookingStatus, MembershipRole } from "@calcom/prisma/enums";
@@ -376,7 +377,15 @@ describe("InsightsBookingService Integration Tests", () => {
orgId: testData.org.id,
},
filters: {
eventTypeId: testData.eventType.id,
columnFilters: [
{
id: "eventTypeId",
value: {
type: ColumnFilterType.SINGLE_SELECT,
data: testData.eventType.id,
},
},
],
},
});
@@ -388,7 +397,7 @@ describe("InsightsBookingService Integration Tests", () => {
await testData.cleanup();
});
it("should build memberUserId filter conditions", async () => {
it("should build userId filter conditions", async () => {
const testData = await createTestData();
const service = new InsightsBookingService({
@@ -399,7 +408,15 @@ describe("InsightsBookingService Integration Tests", () => {
orgId: testData.org.id,
},
filters: {
memberUserId: testData.user.id,
columnFilters: [
{
id: "userId",
value: {
type: ColumnFilterType.SINGLE_SELECT,
data: testData.user.id,
},
},
],
},
});
@@ -420,8 +437,22 @@ describe("InsightsBookingService Integration Tests", () => {
orgId: testData.org.id,
},
filters: {
eventTypeId: testData.eventType.id,
memberUserId: testData.user.id,
columnFilters: [
{
id: "eventTypeId",
value: {
type: ColumnFilterType.SINGLE_SELECT,
data: testData.eventType.id,
},
},
{
id: "userId",
value: {
type: ColumnFilterType.SINGLE_SELECT,
data: testData.user.id,
},
},
],
},
});
@@ -432,6 +463,40 @@ describe("InsightsBookingService Integration Tests", () => {
await testData.cleanup();
});
it("should build status filter conditions", async () => {
const testData = await createTestData();
const service = new InsightsBookingService({
prisma,
options: {
scope: "user",
userId: testData.user.id,
orgId: testData.org.id,
},
filters: {
columnFilters: [
{
id: "status",
value: {
type: ColumnFilterType.MULTI_SELECT,
data: ["pending", "accepted"],
},
},
],
},
});
const conditions = await service.getFilterConditions();
expect(conditions).toEqual(
Prisma.sql`"status" IN (${Prisma.join([
Prisma.sql`${"pending"}::"BookingStatus"`,
Prisma.sql`${"accepted"}::"BookingStatus"`,
])})`
);
await testData.cleanup();
});
});
describe("getBaseConditions", () => {
@@ -473,7 +538,15 @@ describe("InsightsBookingService Integration Tests", () => {
orgId: testData.org.id,
},
filters: {
eventTypeId: userEventType.id,
columnFilters: [
{
id: "eventTypeId",
value: {
type: ColumnFilterType.SINGLE_SELECT,
data: userEventType.id,
},
},
],
},
});
+35 -10
View File
@@ -3,6 +3,9 @@ import md5 from "md5";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { ZColumnFilter } from "@calcom/features/data-table/lib/types";
import type { ColumnFilter } from "@calcom/features/data-table/lib/types";
import { isSingleSelectFilterValue, isMultiSelectFilterValue } from "@calcom/features/data-table/lib/utils";
import type { DateRange } from "@calcom/features/insights/server/insightsDateUtils";
import type { readonlyPrisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
@@ -105,8 +108,6 @@ export type InsightsBookingServiceOptions = z.infer<typeof insightsBookingServic
export type InsightsBookingServiceFilterOptions = z.infer<typeof insightsBookingServiceFilterOptionsSchema>;
export const insightsBookingServiceFilterOptionsSchema = z.object({
eventTypeId: z.number().optional(),
memberUserId: z.number().optional(),
dateRange: z
.object({
target: z.enum(["createdAt", "startTime"]),
@@ -114,6 +115,7 @@ export const insightsBookingServiceFilterOptionsSchema = z.object({
endDate: z.string(),
})
.optional(),
columnFilters: z.array(ZColumnFilter).optional(),
});
const NOTHING_CONDITION = Prisma.sql`1=0`;
@@ -238,14 +240,14 @@ export class InsightsBookingService {
return null;
}
if (this.filters.eventTypeId) {
conditions.push(
Prisma.sql`("eventTypeId" = ${this.filters.eventTypeId}) OR ("eventParentId" = ${this.filters.eventTypeId})`
);
}
if (this.filters.memberUserId) {
conditions.push(Prisma.sql`"userId" = ${this.filters.memberUserId}`);
// Process columnFilters using type-safe utility functions
if (this.filters.columnFilters) {
for (const filter of this.filters.columnFilters) {
const condition = this.buildColumnFilterCondition(filter);
if (condition) {
conditions.push(condition);
}
}
}
// Use dateRange object for date filtering
@@ -276,6 +278,29 @@ export class InsightsBookingService {
});
}
private buildColumnFilterCondition(filter: ColumnFilter): Prisma.Sql | null {
const { id, value } = filter;
if (!value) {
return null;
}
if (id === "eventTypeId" && isSingleSelectFilterValue(value) && typeof value.data === "number") {
return Prisma.sql`("eventTypeId" = ${value.data}) OR ("eventParentId" = ${value.data})`;
}
if (id === "userId" && isSingleSelectFilterValue(value) && typeof value.data === "number") {
return Prisma.sql`"userId" = ${value.data}`;
}
if (id === "status" && isMultiSelectFilterValue(value)) {
const statusValues = value.data.map((status) => Prisma.sql`${status}::"BookingStatus"`);
return Prisma.sql`"status" IN (${Prisma.join(statusValues)})`;
}
return null;
}
async buildAuthorizationConditions(): Promise<Prisma.Sql> {
if (!this.options) {
return NOTHING_CONDITION;