From 79d695236fa88179662b280295da5b534a115aed Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 12 May 2025 12:52:54 -0400 Subject: [PATCH] test: add e2e tests for filter segment functionality (#21160) --- apps/web/playwright/bookings-list.e2e.ts | 4 +- apps/web/playwright/filter-helpers.ts | 123 +++++++++++ apps/web/playwright/filter-segment.e2e.ts | 205 ++++++++++++++++++ apps/web/playwright/insights.e2e.ts | 4 +- .../filters/BaseSelectFilterOptions.tsx | 2 +- .../filters/MultiSelectFilterOptions.tsx | 2 +- .../filters/SingleSelectFilterOptions.tsx | 2 +- .../segment/FilterSegmentSelect.tsx | 26 ++- .../segment/SaveFilterSegmentButton.tsx | 11 +- 9 files changed, 365 insertions(+), 14 deletions(-) create mode 100644 apps/web/playwright/filter-helpers.ts create mode 100644 apps/web/playwright/filter-segment.e2e.ts diff --git a/apps/web/playwright/bookings-list.e2e.ts b/apps/web/playwright/bookings-list.e2e.ts index 7c4e645c5f..1b1b191252 100644 --- a/apps/web/playwright/bookings-list.e2e.ts +++ b/apps/web/playwright/bookings-list.e2e.ts @@ -362,7 +362,7 @@ test.describe("Bookings", () => { (response) => response.url().includes("/api/trpc/bookings/get?batch=1") && response.status() === 200 ); await page - .locator(`[data-testid="multi-select-options-userId"] [role="option"]:has-text("${thirdUser.name}")`) + .locator(`[data-testid="select-filter-options-userId"] [role="option"]:has-text("${thirdUser.name}")`) .click(); await bookingsGetResponse2; await expect(page.locator('text="Cancel event"').nth(0)).toBeVisible(); @@ -465,7 +465,7 @@ test.describe("Bookings", () => { await page.locator('[data-testid="add-filter-item-userId"]').click(); await page.locator('[data-testid="filter-popover-trigger-userId"]').click(); await page - .locator(`[data-testid="multi-select-options-userId"] [role="option"]:has-text("${anotherUser}")`) + .locator(`[data-testid="select-filter-options-userId"] [role="option"]:has-text("${anotherUser}")`) .click(); await page.waitForResponse((response) => /\/api\/trpc\/bookings\/get.*/.test(response.url())); diff --git a/apps/web/playwright/filter-helpers.ts b/apps/web/playwright/filter-helpers.ts new file mode 100644 index 0000000000..2384813eec --- /dev/null +++ b/apps/web/playwright/filter-helpers.ts @@ -0,0 +1,123 @@ +import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; + +/** + * Select a filter from the filter dropdown + */ +export async function selectFilter(page: Page, columnId: string) { + await page.getByTestId("add-filter-button").click(); + await page.getByTestId(`add-filter-item-${columnId}`).click(); +} + +/** + * Apply a filter with a specific value + */ +export async function applyFilter(page: Page, columnId: string, value: string) { + const existingFilter = page.getByTestId(`filter-popover-trigger-${columnId}`); + if (!(await existingFilter.isVisible())) { + await selectFilter(page, columnId); + } + + await page.getByTestId(`filter-popover-trigger-${columnId}`).click(); + + await page.getByTestId(`select-filter-options-${columnId}`).getByRole("option", { name: value }).click(); + + await page.keyboard.press("Escape"); +} + +/** + * Clear all filters + */ +export async function clearFilters(page: Page) { + await page.getByTestId("clear-filters-button").click(); +} + +/** + * Create a filter segment + */ +export async function createFilterSegment( + page: Page, + name: string, + options: { teamScope?: boolean; teamName?: string } = {} +) { + await page.getByTestId("save-filter-segment-button").click(); + + await page.getByTestId("save-filter-segment-name").fill(name); + + if (options.teamScope) { + await page.getByLabel("Save for team").click(); + if (options.teamName) { + await page.getByTestId("save-filter-segment-team-select").click(); + await page + .locator('[data-testid="save-filter-segment-dialog"] [id^="react-select-"]') + .getByText(options.teamName) + .click(); + } + } + + await page.getByTestId("save-filter-segment-dialog").getByRole("button", { name: "Save" }).click(); + + await expect(page.getByText("Filter segment saved")).toBeVisible(); +} + +/** + * Select a segment from the dropdown + */ +export async function selectSegment(page: Page, segmentName: string) { + await page.getByTestId("filter-segment-select").click(); + + await page + .locator('[data-testid="filter-segment-select-content"] [role="menuitem"]') + .filter({ hasText: segmentName }) + .click(); +} + +/** + * Open submenu of a certain segment + */ +export async function openSegmentSubmenu(page: Page, segmentName: string) { + await page.getByTestId("filter-segment-select").click(); + + await page + .locator('[data-testid="filter-segment-select-content"] [role="menuitem"]') + .filter({ hasText: segmentName }) + .locator('[data-testid="filter-segment-select-submenu-button"]') + .click(); +} + +/** + * Delete a segment + */ +export async function deleteSegment(page: Page, segmentName: string) { + openSegmentSubmenu(page, segmentName); + + await page.getByTestId("filter-segment-select-submenu-content").getByText("Delete").click(); + + await page + .locator('[role="dialog"]') + .filter({ hasText: "Delete Segment" }) + .getByRole("button", { name: "Delete" }) + .click(); + + await page.keyboard.press("Escape"); + await expect(page.getByText("Filter segment deleted")).toBeVisible(); +} + +/** + * List all available segments + */ +export async function listSegments(page: Page): Promise { + await page.getByTestId("filter-segment-select").click(); + + const menuItems = page.locator('[data-testid="filter-segment-select-content"] [role="menuitem"]'); + const count = await menuItems.count(); + + const segments: string[] = []; + for (let i = 0; i < count; i++) { + const text = await menuItems.nth(i).innerText(); + segments.push(text); + } + + await page.keyboard.press("Escape"); + return segments; +} diff --git a/apps/web/playwright/filter-segment.e2e.ts b/apps/web/playwright/filter-segment.e2e.ts new file mode 100644 index 0000000000..055598f8ef --- /dev/null +++ b/apps/web/playwright/filter-segment.e2e.ts @@ -0,0 +1,205 @@ +import { expect } from "@playwright/test"; + +import { MembershipRole } from "@calcom/prisma/enums"; + +import { + applyFilter, + createFilterSegment, + selectSegment, + deleteSegment, + listSegments, + clearFilters, + openSegmentSubmenu, +} from "./filter-helpers"; +import { test } from "./lib/fixtures"; + +test.describe.configure({ mode: "parallel" }); + +test.afterEach(async ({ users, orgs }) => { + await users.deleteAll(); + await orgs.deleteAll(); +}); + +test.describe("Filter Segment Functionality", () => { + test("Admin can create, use, and delete filter segments in organization members list", async ({ + page, + users, + orgs, + }) => { + const orgOwner = await users.create(undefined, { + hasTeam: true, + isOrg: true, + }); + const { team: org } = await orgOwner.getOrgMembership(); + + const memberUser = await users.create({ + roleInOrganization: MembershipRole.MEMBER, + organizationId: org.id, + + username: "member-user", + }); + + const adminUser = await users.create({ + roleInOrganization: MembershipRole.ADMIN, + organizationId: org.id, + + username: "admin-user", + }); + + await orgOwner.apiLogin(); + + await page.goto(`/settings/organizations/${org.slug}/members`); + + const dataTable = page.getByTestId("user-list-data-table"); + await expect(dataTable).toBeVisible(); + + const segmentName = "Admin Users"; + + await test.step("Can apply and save a role filter as a segment", async () => { + await applyFilter(page, "role", "admin"); + + await expect(page.getByText(adminUser.email)).toBeVisible(); + await expect(page.getByText(memberUser.email)).toBeHidden(); + + await createFilterSegment(page, segmentName); + + await clearFilters(page); + + await expect(page.getByText(adminUser.email)).toBeVisible(); + await expect(page.getByText(memberUser.email)).toBeVisible(); + + await selectSegment(page, segmentName); + + await expect(page.getByText(adminUser.email)).toBeVisible(); + await expect(page.getByText(memberUser.email)).toBeHidden(); + }); + + await test.step("Can delete a filter segment", async () => { + await deleteSegment(page, "Admin Users"); + + const segments = await listSegments(page); + expect(segments.includes(segmentName)).toBe(false); + }); + }); + + test("Filter segments persist across page reloads", async ({ page, users, orgs }) => { + const orgOwner = await users.create(undefined, { + hasTeam: true, + isOrg: true, + }); + const { team: org } = await orgOwner.getOrgMembership(); + + const memberUser = await users.create({ + roleInOrganization: MembershipRole.MEMBER, + organizationId: org.id, + + username: "member-user", + }); + + const adminUser = await users.create({ + roleInOrganization: MembershipRole.ADMIN, + organizationId: org.id, + + username: "admin-user", + }); + + await orgOwner.apiLogin(); + + await page.goto(`/settings/organizations/${org.slug}/members`); + + const dataTable = page.getByTestId("user-list-data-table"); + await expect(dataTable).toBeVisible(); + + await applyFilter(page, "role", "admin"); + const segmentName = "Admin Users Persistent"; + await createFilterSegment(page, segmentName); + + await page.reload(); + await expect(dataTable).toBeVisible(); + + await selectSegment(page, segmentName); + + await expect(page.getByText(adminUser.email)).toBeVisible(); + await expect(page.getByText(memberUser.email)).toBeHidden(); + + await deleteSegment(page, segmentName); + }); + + test("Admin can create and use team scope filter segments", async ({ page, users, prisma }) => { + const orgOwner = await users.create(undefined, { + hasTeam: true, + isOrg: true, + + hasSubteam: true, + }); + const { team: org } = await orgOwner.getOrgMembership(); + const { team: subTeam } = await orgOwner.getFirstTeamMembership(); + + const memberUser = await users.create({ + roleInOrganization: MembershipRole.MEMBER, + organizationId: org.id, + + username: "org-member", + }); + + const adminUser = await users.create({ + roleInOrganization: MembershipRole.ADMIN, + organizationId: org.id, + + username: "org-admin", + }); + + await orgOwner.apiLogin(); + + await page.goto(`/settings/organizations/${org.slug}/members`); + + const dataTable = page.getByTestId("user-list-data-table"); + await expect(dataTable).toBeVisible(); + const segmentName = "Team Admin Filter"; + + await test.step("Can create a team scope filter segment", async () => { + await applyFilter(page, "role", "admin"); + + await createFilterSegment(page, segmentName, { + teamScope: true, + teamName: subTeam.name, + }); + + await clearFilters(page); + await selectSegment(page, segmentName); + await expect(page.getByText(adminUser.email)).toBeVisible(); + await expect(page.getByText(memberUser.email)).toBeHidden(); + }); + + await test.step("Regular member can see but not modify team segments", async () => { + const regularMember = await users.create({ + roleInOrganization: MembershipRole.MEMBER, + organizationId: org.id, + username: "regular-member", + }); + await prisma.membership.create({ + data: { + createdAt: new Date(), + teamId: subTeam.id, + userId: regularMember.id, + role: MembershipRole.MEMBER, + accepted: true, + }, + }); + + await regularMember.apiLogin(); + + await page.goto(`/settings/organizations/${org.slug}/members`); + await expect(dataTable).toBeVisible(); + + await selectSegment(page, "Team Admin Filter"); + await expect(page.getByText(adminUser.email)).toBeVisible(); + await expect(page.getByText(memberUser.email)).toBeHidden(); + + await openSegmentSubmenu(page, segmentName); + await expect( + page.getByTestId("filter-segment-select-submenu-content").getByText("Delete") + ).toBeHidden(); + }); + }); +}); diff --git a/apps/web/playwright/insights.e2e.ts b/apps/web/playwright/insights.e2e.ts index ac51e31c53..973e195204 100644 --- a/apps/web/playwright/insights.e2e.ts +++ b/apps/web/playwright/insights.e2e.ts @@ -212,13 +212,13 @@ test.describe("Insights", async () => { await page.getByTestId("filter-popover-trigger-bookingUserId").click(); await page - .locator('[data-testid="single-select-options-bookingUserId"]') + .locator('[data-testid="select-filter-options-bookingUserId"]') .getByRole("option") .nth(0) .click(); await page - .locator('[data-testid="single-select-options-bookingUserId"]') + .locator('[data-testid="select-filter-options-bookingUserId"]') .getByRole("option") .nth(1) .click(); diff --git a/packages/features/data-table/components/filters/BaseSelectFilterOptions.tsx b/packages/features/data-table/components/filters/BaseSelectFilterOptions.tsx index fb9f80da6e..2f274206fc 100644 --- a/packages/features/data-table/components/filters/BaseSelectFilterOptions.tsx +++ b/packages/features/data-table/components/filters/BaseSelectFilterOptions.tsx @@ -123,7 +123,7 @@ export function BaseSelectFilterOptions< return ( - + {t("no_options_available")} {options.map((option, index) => { diff --git a/packages/features/data-table/components/filters/MultiSelectFilterOptions.tsx b/packages/features/data-table/components/filters/MultiSelectFilterOptions.tsx index b6c57461fc..72445dfff5 100644 --- a/packages/features/data-table/components/filters/MultiSelectFilterOptions.tsx +++ b/packages/features/data-table/components/filters/MultiSelectFilterOptions.tsx @@ -16,7 +16,7 @@ export function MultiSelectFilterOptions({ column }: MultiSelectFilterOptionsPro column={column} filterValueSchema={ZMultiSelectFilterValue} - testIdPrefix="multi-select-options" + testIdPrefix="select-filter-options" isOptionSelected={(filterValue, optionValue) => { if (!filterValue?.data) return false; return filterValue.data.includes(optionValue); diff --git a/packages/features/data-table/components/filters/SingleSelectFilterOptions.tsx b/packages/features/data-table/components/filters/SingleSelectFilterOptions.tsx index 7cfaa76b70..756cad64cc 100644 --- a/packages/features/data-table/components/filters/SingleSelectFilterOptions.tsx +++ b/packages/features/data-table/components/filters/SingleSelectFilterOptions.tsx @@ -16,7 +16,7 @@ export function SingleSelectFilterOptions({ column }: SingleSelectFilterOptionsP column={column} filterValueSchema={ZSingleSelectFilterValue} - testIdPrefix="single-select-options" + testIdPrefix="select-filter-options" isOptionSelected={(filterValue, optionValue) => filterValue?.data === optionValue} onOptionSelect={(column, filterValue, optionValue) => { updateFilter(column.id, { type: ColumnFilterType.SINGLE_SELECT, data: optionValue }); diff --git a/packages/features/data-table/components/segment/FilterSegmentSelect.tsx b/packages/features/data-table/components/segment/FilterSegmentSelect.tsx index eb33cfb147..0134f5ceca 100644 --- a/packages/features/data-table/components/segment/FilterSegmentSelect.tsx +++ b/packages/features/data-table/components/segment/FilterSegmentSelect.tsx @@ -111,12 +111,16 @@ export function FilterSegmentSelect() { <> - - + {segmentGroups.length === 0 &&

{t("no_segments")}

} {segmentGroups.map((group, index) => ( @@ -198,10 +202,24 @@ function DropdownItemWithSubmenu({
- - +
{selectedSegment ? ( @@ -200,7 +204,7 @@ export function SaveFilterSegmentButton() { {saveMode === "create" && (
- +
)} @@ -225,6 +229,7 @@ export function SaveFilterSegmentButton() { }))} onChange={(option) => setSelectedTeamId(parseInt(option?.value || "0"))} placeholder={t("select_team")} + data-testid="save-filter-segment-team-select" required />