feat: add booking status filter to /insights (#22842)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
This commit is contained in:
co-authored by
Alex van Andel
parent
cf4e7a5d2c
commit
a2205d497d
@@ -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]);
|
||||
|
||||
|
||||
+1
-1
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user