diff --git a/apps/web/playwright/filter-helpers.ts b/apps/web/playwright/filter-helpers.ts index 2384813eec..0e834d0b01 100644 --- a/apps/web/playwright/filter-helpers.ts +++ b/apps/web/playwright/filter-helpers.ts @@ -9,18 +9,65 @@ export async function selectFilter(page: Page, columnId: string) { await page.getByTestId(`add-filter-item-${columnId}`).click(); } +export async function openFilter(page: Page, columnId: string) { + await page.getByTestId(`filter-popover-trigger-${columnId}`).click(); +} + /** * Apply a filter with a specific value */ -export async function applyFilter(page: Page, columnId: string, value: string) { +export async function applySelectFilter(page: Page, columnId: string, value: string) { const existingFilter = page.getByTestId(`filter-popover-trigger-${columnId}`); if (!(await existingFilter.isVisible())) { await selectFilter(page, columnId); } + await openFilter(page, columnId); + await selectOptionValue(page, columnId, value); + await page.keyboard.press("Escape"); +} - await page.getByTestId(`filter-popover-trigger-${columnId}`).click(); - +export async function selectOptionValue(page: Page, columnId: string, value: string) { await page.getByTestId(`select-filter-options-${columnId}`).getByRole("option", { name: value }).click(); +} + +export async function applyTextFilter(page: Page, columnId: string, operator: string, operand?: string) { + const existingFilter = page.getByTestId(`filter-popover-trigger-${columnId}`); + if (!(await existingFilter.isVisible())) { + await selectFilter(page, columnId); + } + await openFilter(page, columnId); + + await page.getByTestId(`text-filter-options-select-${columnId}`).click(); + await page + .locator(`[data-testid="text-filter-options-${columnId}"] [id^="react-select-"]`) + .getByText(operator) + .first() + .click(); + if (operand) { + await page.keyboard.press("Tab"); + await page.keyboard.type(operand); + } + await page.keyboard.press("Enter"); + + await page.keyboard.press("Escape"); +} + +export async function applyNumberFilter(page: Page, columnId: string, operator: string, operand: number) { + const existingFilter = page.getByTestId(`filter-popover-trigger-${columnId}`); + if (!(await existingFilter.isVisible())) { + await selectFilter(page, columnId); + } + await openFilter(page, columnId); + + await page.getByTestId(`number-filter-options-select-${columnId}`).click(); + await page + .locator(`[data-testid="number-filter-options-${columnId}"] [id^="react-select-"]`) + .getByText(operator) + .first() + .click(); + await page.keyboard.press("Tab"); + await page.keyboard.type(String(operand)); + await page.keyboard.press("Enter"); await page.keyboard.press("Escape"); } diff --git a/apps/web/playwright/filter-segment.e2e.ts b/apps/web/playwright/filter-segment.e2e.ts index 055598f8ef..815f237e78 100644 --- a/apps/web/playwright/filter-segment.e2e.ts +++ b/apps/web/playwright/filter-segment.e2e.ts @@ -3,7 +3,7 @@ import { expect } from "@playwright/test"; import { MembershipRole } from "@calcom/prisma/enums"; import { - applyFilter, + applySelectFilter, createFilterSegment, selectSegment, deleteSegment, @@ -56,7 +56,7 @@ test.describe("Filter Segment Functionality", () => { const segmentName = "Admin Users"; await test.step("Can apply and save a role filter as a segment", async () => { - await applyFilter(page, "role", "admin"); + await applySelectFilter(page, "role", "admin"); await expect(page.getByText(adminUser.email)).toBeVisible(); await expect(page.getByText(memberUser.email)).toBeHidden(); @@ -110,7 +110,7 @@ test.describe("Filter Segment Functionality", () => { const dataTable = page.getByTestId("user-list-data-table"); await expect(dataTable).toBeVisible(); - await applyFilter(page, "role", "admin"); + await applySelectFilter(page, "role", "admin"); const segmentName = "Admin Users Persistent"; await createFilterSegment(page, segmentName); @@ -158,7 +158,7 @@ test.describe("Filter Segment Functionality", () => { const segmentName = "Team Admin Filter"; await test.step("Can create a team scope filter segment", async () => { - await applyFilter(page, "role", "admin"); + await applySelectFilter(page, "role", "admin"); await createFilterSegment(page, segmentName, { teamScope: true, diff --git a/apps/web/playwright/insights-routing-filters.e2e.ts b/apps/web/playwright/insights-routing-filters.e2e.ts new file mode 100644 index 0000000000..1410f787ec --- /dev/null +++ b/apps/web/playwright/insights-routing-filters.e2e.ts @@ -0,0 +1,711 @@ +import { expect } from "@playwright/test"; +import { v4 as uuidv4 } from "uuid"; + +import { BookingStatus, MembershipRole } from "@calcom/prisma/enums"; + +import { + selectFilter, + openFilter, + clearFilters, + applySelectFilter, + applyTextFilter, + applyNumberFilter, + selectOptionValue, +} from "./filter-helpers"; +import { test } from "./lib/fixtures"; + +test.afterEach(({ users }) => users.deleteAll()); + +test.describe.configure({ mode: "parallel" }); + +test.describe("Insights > Routing Filters", () => { + test("formId filter: should filter by selected routing form", async ({ + page, + users, + routingForms, + prisma, + }) => { + const owner = await users.create(undefined, { + hasTeam: true, + isUnpublished: true, + isOrg: true, + hasSubteam: true, + }); + await owner.apiLogin(); + + const membership = await owner.getFirstTeamMembership(); + + const formName1 = "Test Form 1"; + const form1 = await routingForms.create({ + name: formName1, + userId: owner.id, + teamId: membership.teamId, + fields: [ + { + type: "text", + label: "Name", + identifier: "name", + required: true, + }, + ], + }); + + const formName2 = "Test Form 2"; + const form2 = await routingForms.create({ + name: formName2, + userId: owner.id, + teamId: membership.teamId, + fields: [ + { + type: "text", + label: "Email", + identifier: "email", + required: true, + }, + ], + }); + + await page.goto(`/insights/routing`); + + await openFilter(page, "formId"); + await selectOptionValue(page, "formId", formName1); + await page.keyboard.press("Escape"); + + await expect(page.getByText(formName1)).toBeVisible(); + await expect(page.getByText(formName2)).toBeHidden(); + + await openFilter(page, "formId"); + await selectOptionValue(page, "formId", formName2); + await page.keyboard.press("Escape"); + + await expect(page.getByText(formName2)).toBeVisible(); + await expect(page.getByText(formName1)).toBeHidden(); + }); + + test("bookingUserId filter: should filter by user", async ({ page, users, routingForms, prisma }) => { + const owner = await users.create( + { name: "owner" }, + { + hasTeam: true, + isUnpublished: true, + isOrg: true, + hasSubteam: true, + } + ); + const membership = await owner.getFirstTeamMembership(); + + const user1Name = "User One"; + const user1 = await users.create({ name: user1Name }); + const user2 = await users.create({ name: "User Two" }); + await prisma.membership.createMany({ + data: [ + { + teamId: membership.teamId, + userId: user1.id, + role: MembershipRole.MEMBER, + accepted: true, + }, + { + teamId: membership.teamId, + userId: user2.id, + role: MembershipRole.MEMBER, + accepted: true, + }, + ], + }); + + await owner.apiLogin(); + + const formName = "Filter User Test Form"; + const form = await routingForms.create({ + name: formName, + userId: owner.id, + teamId: membership.teamId, + fields: [ + { + type: "text", + label: "Name", + identifier: "name", + required: true, + }, + ], + }); + + const booking1 = await prisma.booking.create({ + data: { + uid: `booking-${uuidv4()}`, + title: "Test Booking 1", + startTime: new Date(), + endTime: new Date(Date.now() + 60 * 60 * 1000), + userId: user1.id, + status: BookingStatus.ACCEPTED, + }, + }); + + const booking2 = await prisma.booking.create({ + data: { + uid: `booking-${uuidv4()}`, + title: "Test Booking 2", + startTime: new Date(), + endTime: new Date(Date.now() + 60 * 60 * 1000), + userId: user2.id, + status: BookingStatus.ACCEPTED, + }, + }); + + const fieldId = (form.fields as any)[0].id; + await prisma.app_RoutingForms_FormResponse.create({ + data: { + formFillerId: "test-filler-1", + formId: form.id, + response: { + [fieldId]: { + label: "Name", + value: "John Doe", + }, + }, + routedToBookingUid: booking1.uid, + }, + }); + + await prisma.app_RoutingForms_FormResponse.create({ + data: { + formFillerId: "test-filler-2", + formId: form.id, + response: { + [fieldId]: { + label: "Name", + value: "Jane Smith", + }, + }, + routedToBookingUid: booking2.uid, + }, + }); + + await page.goto(`/insights/routing`); + + await applySelectFilter(page, "bookingUserId", user1Name); + + await expect(page.getByText("John Doe")).toBeVisible(); + await expect(page.getByText("Jane Smith")).toBeHidden(); + + await prisma.booking.delete({ where: { id: booking1.id } }); + await prisma.booking.delete({ where: { id: booking2.id } }); + }); + + // We will add it back when we enable the bookingAttendees filter on /insights/routing + test.skip("bookingAttendees filter: should filter by attendee name or email", async ({ + page, + users, + routingForms, + prisma, + }) => { + const owner = await users.create(undefined, { + hasTeam: true, + isUnpublished: true, + isOrg: true, + hasSubteam: true, + }); + await owner.apiLogin(); + + const membership = await owner.getFirstTeamMembership(); + + const formName = "Attendee Filter Test Form"; + const form = await routingForms.create({ + name: formName, + userId: owner.id, + teamId: membership.teamId, + fields: [ + { + type: "text", + label: "Name", + identifier: "name", + required: true, + }, + ], + }); + const fieldId = (form.fields as any)[0].id; + + const booking1 = await prisma.booking.create({ + data: { + uid: `booking-${uuidv4()}`, + title: "Test Booking 1", + startTime: new Date(), + endTime: new Date(Date.now() + 60 * 60 * 1000), + userId: owner.id, + status: BookingStatus.ACCEPTED, + attendees: { + create: [ + { + email: "john.doe@example.com", + name: "John Doe", + timeZone: "UTC", + }, + ], + }, + }, + }); + + const booking2 = await prisma.booking.create({ + data: { + uid: `booking-${uuidv4()}`, + title: "Test Booking 2", + startTime: new Date(), + endTime: new Date(Date.now() + 60 * 60 * 1000), + userId: owner.id, + status: BookingStatus.ACCEPTED, + attendees: { + create: [ + { + email: "jane.smith@example.com", + name: "Jane Smith", + timeZone: "UTC", + }, + ], + }, + }, + }); + + await prisma.app_RoutingForms_FormResponse.create({ + data: { + formFillerId: "test-filler-1", + formId: form.id, + response: { [fieldId]: { label: "Name", value: "Response 1" } }, + routedToBookingUid: booking1.uid, + }, + }); + + await prisma.app_RoutingForms_FormResponse.create({ + data: { + formFillerId: "test-filler-2", + formId: form.id, + response: { [fieldId]: { label: "Name", value: "Response 2" } }, + routedToBookingUid: booking2.uid, + }, + }); + + await page.goto(`/insights/routing`); + + await selectFilter(page, "bookingAttendees"); + await openFilter(page, "bookingAttendees"); + await page.getByPlaceholder("Filter attendees by name or email").fill("John"); + await page.keyboard.press("Enter"); + + await expect(page.getByText("Response 1")).toBeVisible(); + await expect(page.getByText("Response 2")).toBeHidden(); + + await clearFilters(page); + await selectFilter(page, "bookingAttendees"); + await openFilter(page, "bookingAttendees"); + await page.getByPlaceholder("Filter attendees by name or email").fill("jane.smith@example"); + await page.keyboard.press("Enter"); + + await expect(page.getByText("Response 2")).toBeVisible(); + await expect(page.getByText("Response 1")).toBeHidden(); + + await clearFilters(page); + await selectFilter(page, "bookingAttendees"); + await openFilter(page, "bookingAttendees"); + await page.getByPlaceholder("Filter attendees by name or email").fill("JANE"); + await page.keyboard.press("Enter"); + + await expect(page.getByText("Response 2")).toBeVisible(); + await expect(page.getByText("Response 1")).toBeHidden(); + + await prisma.booking.delete({ where: { id: booking1.id } }); + await prisma.booking.delete({ where: { id: booking2.id } }); + }); + + test("TEXT field: should filter with different text operators", async ({ + page, + users, + routingForms, + prisma, + }) => { + const owner = await users.create(undefined, { + hasTeam: true, + isUnpublished: true, + isOrg: true, + hasSubteam: true, + }); + await owner.apiLogin(); + + const membership = await owner.getFirstTeamMembership(); + + const textFieldId = uuidv4(); + const formName = "Text Filter Test Form"; + const form = await routingForms.create({ + name: formName, + userId: owner.id, + teamId: membership.teamId, + fields: [ + { + type: "text", + label: "Description", + identifier: "description", + required: true, + }, + ], + }); + const fieldId = (form.fields as any)[0].id; + + await prisma.app_RoutingForms_FormResponse.create({ + data: { + formFillerId: "test-filler-1", + formId: form.id, + response: { + [fieldId]: { + label: "Description", + value: "This is a test description", + }, + }, + }, + }); + + await prisma.app_RoutingForms_FormResponse.create({ + data: { + formFillerId: "test-filler-2", + formId: form.id, + response: { + [fieldId]: { + label: "Description", + value: "Another description for testing", + }, + }, + }, + }); + + await prisma.app_RoutingForms_FormResponse.create({ + data: { + formFillerId: "test-filler-3", + formId: form.id, + response: { + [fieldId]: { + label: "Description", + value: "", + }, + }, + }, + }); + + await page.goto(`/insights/routing`); + + await applyTextFilter(page, fieldId, "Contains", "test"); + + await expect(page.getByText("This is a test description")).toBeVisible(); + await expect(page.getByText("Another description for testing")).toBeVisible(); + + await clearFilters(page); + await applyTextFilter(page, fieldId, "Is", "This is a test description"); + + await expect(page.getByText("This is a test description")).toBeVisible(); + await expect(page.getByText("Another description for testing")).toBeHidden(); + + await clearFilters(page); + await applyTextFilter(page, fieldId, "Is empty"); + + await expect(page.getByText("This is a test description")).toBeHidden(); + await expect(page.getByText("Another description for testing")).toBeHidden(); + + await clearFilters(page); + await applyTextFilter(page, fieldId, "Contains", "TEST"); + + await expect(page.getByText("This is a test description")).toBeVisible(); + await expect(page.getByText("Another description for testing")).toBeVisible(); + }); + + test("NUMBER field: should filter with different number operators", async ({ + page, + users, + routingForms, + prisma, + }) => { + const owner = await users.create(undefined, { + hasTeam: true, + isUnpublished: true, + isOrg: true, + hasSubteam: true, + }); + await owner.apiLogin(); + + const membership = await owner.getFirstTeamMembership(); + + const formName = "Number Filter Test Form"; + const form = await routingForms.create({ + name: formName, + userId: owner.id, + teamId: membership.teamId, + fields: [ + { + type: "number", + label: "Rating", + identifier: "rating", + required: true, + }, + ], + }); + const fieldId = (form.fields as any)[0].id; + + await prisma.app_RoutingForms_FormResponse.create({ + data: { + formFillerId: "test-filler-1", + formId: form.id, + response: { + [fieldId]: { + label: "Rating", + value: 1, + }, + }, + }, + }); + + await prisma.app_RoutingForms_FormResponse.create({ + data: { + formFillerId: "test-filler-2", + formId: form.id, + response: { + [fieldId]: { + label: "Rating", + value: 3, + }, + }, + }, + }); + + await prisma.app_RoutingForms_FormResponse.create({ + data: { + formFillerId: "test-filler-3", + formId: form.id, + response: { + [fieldId]: { + label: "Rating", + value: 5, + }, + }, + }, + }); + + await page.goto(`/insights/routing`); + + await applyNumberFilter(page, fieldId, "=", 3); + + const getByNumber = (number: number) => + page.locator(`[data-testid="data-table-td-${fieldId}"]`).getByText(String(number)); + + await expect(getByNumber(3)).toBeVisible(); + await expect(getByNumber(1)).toBeHidden(); + await expect(getByNumber(5)).toBeHidden(); + + await clearFilters(page); + await applyNumberFilter(page, fieldId, ">", 3); + + await expect(getByNumber(5)).toBeVisible(); + await expect(getByNumber(1)).toBeHidden(); + await expect(getByNumber(3)).toBeHidden(); + + await clearFilters(page); + await applyNumberFilter(page, fieldId, "≤", 3); + + await expect(getByNumber(1)).toBeVisible(); + await expect(getByNumber(3)).toBeVisible(); + await expect(getByNumber(5)).toBeHidden(); + }); + + test("SINGLE_SELECT and MULTI_SELECT fields: should filter correctly", async ({ + page, + users, + routingForms, + prisma, + }) => { + const owner = await users.create(undefined, { + hasTeam: true, + isUnpublished: true, + isOrg: true, + hasSubteam: true, + }); + await owner.apiLogin(); + + const membership = await owner.getFirstTeamMembership(); + + const singleSelectFieldId = uuidv4(); + const multiSelectFieldId = uuidv4(); + const formName = "Select Filter Test Form"; + + const locationOptionId1 = uuidv4(); + const locationOptionId2 = uuidv4(); + + const skillOptionId1 = uuidv4(); + const skillOptionId2 = uuidv4(); + const skillOptionId3 = uuidv4(); + + const form = await routingForms.create({ + name: formName, + userId: owner.id, + teamId: membership.teamId, + fields: [ + { + type: "select", + label: "Location", + identifier: "location", + required: true, + options: [ + { + id: locationOptionId1, + label: "New York", + }, + { + id: locationOptionId2, + label: "London", + }, + ], + }, + { + type: "multiselect", + label: "Skills", + identifier: "skills", + required: true, + options: [ + { + id: skillOptionId1, + label: "JavaScript", + }, + { + id: skillOptionId2, + label: "TypeScript", + }, + { + id: skillOptionId3, + label: "React", + }, + ], + }, + ], + }); + const locationFieldId = (form.fields as any)[0].id; + const skillFieldId = (form.fields as any)[1].id; + + await prisma.app_RoutingForms_FormResponse.create({ + data: { + formFillerId: "test-filler-1", + formId: form.id, + response: { + [locationFieldId]: { + label: "Location", + value: locationOptionId1, + }, + [skillFieldId]: { + label: "Skills", + value: [skillOptionId1, skillOptionId2], + }, + }, + }, + }); + + await prisma.app_RoutingForms_FormResponse.create({ + data: { + formFillerId: "test-filler-2", + formId: form.id, + response: { + [locationFieldId]: { + label: "Location", + value: locationOptionId2, + }, + [skillFieldId]: { + label: "Skills", + value: [skillOptionId2, skillOptionId3], + }, + }, + }, + }); + + await page.goto(`/insights/routing`); + + const getByColumnValue = (columnId: string, value: string) => + page.locator(`[data-testid="data-table-td-${columnId}"]`).getByText(value); + + await applySelectFilter(page, locationFieldId, "New York"); + await expect(getByColumnValue(locationFieldId, "New York")).toBeVisible(); + await expect(getByColumnValue(locationFieldId, "London")).toBeHidden(); + + await clearFilters(page); + await applySelectFilter(page, skillFieldId, "JavaScript"); + await applySelectFilter(page, skillFieldId, "TypeScript"); + await expect(getByColumnValue(skillFieldId, "JavaScript")).toBeVisible(); + await expect(getByColumnValue(skillFieldId, "TypeScript")).toBeVisible(); + await expect(getByColumnValue(skillFieldId, "React")).toBeHidden(); + }); + + test("createdAt filter: should filter by date range", async ({ page, users, routingForms, prisma }) => { + const owner = await users.create(undefined, { + hasTeam: true, + isUnpublished: true, + isOrg: true, + hasSubteam: true, + }); + await owner.apiLogin(); + + const membership = await owner.getFirstTeamMembership(); + + const formName = "Date Range Filter Test Form"; + const form = await routingForms.create({ + name: formName, + userId: owner.id, + teamId: membership.teamId, + fields: [ + { + type: "text", + label: "Name", + identifier: "name", + required: true, + }, + ], + }); + const fieldId = (form.fields as any)[0].id; + + const oldDate = new Date(); + oldDate.setDate(oldDate.getDate() - 10); + + await prisma.app_RoutingForms_FormResponse.create({ + data: { + formFillerId: "test-filler-old", + formId: form.id, + response: { + [fieldId]: { + label: "Name", + value: "Old Response", + }, + }, + createdAt: oldDate, + }, + }); + + await prisma.app_RoutingForms_FormResponse.create({ + data: { + formFillerId: "test-filler-recent", + formId: form.id, + response: { + [fieldId]: { + label: "Name", + value: "Recent Response", + }, + }, + }, + }); + + await page.goto(`/insights/routing`); + + await openFilter(page, "createdAt"); + await page.getByTestId("date-range-options-w").click(); // Last 7 Days + await page.keyboard.press("Escape"); + + await expect(page.getByText("Recent Response")).toBeVisible(); + await expect(page.getByText("Old Response")).toBeHidden(); + + await openFilter(page, "createdAt"); + await page.getByTestId("date-range-options-t").click(); // Last 30 Days + await page.keyboard.press("Escape"); + + await expect(page.getByText("Recent Response")).toBeVisible(); + await expect(page.getByText("Old Response")).toBeVisible(); + }); +}); diff --git a/packages/features/data-table/components/DataTable.tsx b/packages/features/data-table/components/DataTable.tsx index 39a7bb2553..b853625ab2 100644 --- a/packages/features/data-table/components/DataTable.tsx +++ b/packages/features/data-table/components/DataTable.tsx @@ -312,6 +312,7 @@ function DataTableBody({ return ( +
{ @@ -51,6 +51,7 @@ export function NumberFilterOptions({ column }: NumberFilterOptionsProps) { render={({ field: { value } }) => (