diff --git a/e2e/anvil-catalog/anvilcatalog-filters.spec.ts b/e2e/anvil-catalog/anvilcatalog-filters.spec.ts index fad641cc9..88c39bed0 100644 --- a/e2e/anvil-catalog/anvilcatalog-filters.spec.ts +++ b/e2e/anvil-catalog/anvilcatalog-filters.spec.ts @@ -1,74 +1,267 @@ -import { test } from "@playwright/test"; +import { expect, Locator, Page, test } from "@playwright/test"; import { - testDeselectFiltersThroughSearchBar, - testSelectFiltersThroughSearchBar, -} from "../testFunctions"; -import { - ANVIL_CATALOG_FILTERS, - ANVIL_CATALOG_TABS, - CONSENT_CODE_INDEX, - DBGAP_ID_INDEX, - TERRA_WORKSPACE_INDEX, -} from "./anvilcatalog-tabs"; - -const filterList = [CONSENT_CODE_INDEX, DBGAP_ID_INDEX, TERRA_WORKSPACE_INDEX]; - -test('Check that selecting filters through the "Search all Filters" textbox works correctly on the Consortia tab', async ({ - page, -}) => { - await testSelectFiltersThroughSearchBar( - page, - ANVIL_CATALOG_TABS.CONSORTIA, - filterList.map((i: number) => ANVIL_CATALOG_FILTERS[i]) - ); -}); + KEYBOARD_KEYS, + MUI_CLASSES, + TEST_IDS, +} from "../features/common/constants"; +import { ANVIL_CATALOG_CATEGORY_NAMES } from "./constants"; -test('Check that selecting filters through the "Search all Filters" textbox works correctly on the Studies tab', async ({ - page, -}) => { - await testSelectFiltersThroughSearchBar( - page, - ANVIL_CATALOG_TABS.STUDIES, - filterList.map((i: number) => ANVIL_CATALOG_FILTERS[i]) - ); -}); +const ENTITIES = [ + { name: "Consortia", url: "/data/consortia" }, + { name: "Studies", url: "/data/studies" }, + { name: "Workspaces", url: "/data/workspaces" }, +]; -test('Check that selecting filters through the "Search all Filters" textbox works correctly on the Workspaces tab', async ({ - page, -}) => { - await testSelectFiltersThroughSearchBar( - page, - ANVIL_CATALOG_TABS.WORKSPACES, - filterList.map((i: number) => ANVIL_CATALOG_FILTERS[i]) - ); -}); +const FACET_NAMES = [ + ANVIL_CATALOG_CATEGORY_NAMES.CONSENT_CODE, + ANVIL_CATALOG_CATEGORY_NAMES.DBGAP_ID, + ANVIL_CATALOG_CATEGORY_NAMES.TERRA_WORKSPACE_NAME, +]; -test('Check that deselecting filters through the "Search all Filters" textbox works correctly on the Consortia tab', async ({ - page, -}) => { - await testDeselectFiltersThroughSearchBar( - page, - ANVIL_CATALOG_TABS.CONSORTIA, - filterList.map((i: number) => ANVIL_CATALOG_FILTERS[i]) - ); -}); +test.describe("AnVIL Catalog filter search", () => { + for (const entity of ENTITIES) { + test.describe(`${entity.name} tab`, () => { + let filters: Locator; + let searchAllFilters: Locator; -test('Check that deselecting filters through the "Search all Filters" textbox works correctly on the Studies tab', async ({ - page, -}) => { - await testDeselectFiltersThroughSearchBar( - page, - ANVIL_CATALOG_TABS.STUDIES, - filterList.map((i: number) => ANVIL_CATALOG_FILTERS[i]) - ); -}); + test.beforeEach(async ({ page }) => { + await page.goto(entity.url); + filters = page.getByTestId(TEST_IDS.FILTERS); + searchAllFilters = page.getByTestId(TEST_IDS.SEARCH_ALL_FILTERS); + await filters.waitFor(); + }); + + test("selects filters through search bar", async ({ page }) => { + for (const filterName of FACET_NAMES) { + // Open filter dropdown, note first option name, close + await openFilterDropdown(filters, filterName); + const optionName = await getFirstOptionName(page); + await page.keyboard.press(KEYBOARD_KEYS.ESCAPE); + await expectFilterPopoverClosed(page); + + // Search for the option and select it + await fillSearchAllFilters(searchAllFilters, optionName); + const filterItem = namedFilterItem(page, optionName); + await expectFilterItemNotSelected(filterItem); + await filterItem.click(); + await expectFilterItemSelected(filterItem); + await page.keyboard.press(KEYBOARD_KEYS.ESCAPE); + await expectAutocompletePopperClosed(page); -test('Check that deselecting filters through the "Search all Filters" textbox works correctly on the Workspaces tab', async ({ - page, -}) => { - await testDeselectFiltersThroughSearchBar( - page, - ANVIL_CATALOG_TABS.WORKSPACES, - filterList.map((i: number) => ANVIL_CATALOG_FILTERS[i]) - ); + // Verify filter tag appeared + await expect(filterTag(filters, optionName)).toBeVisible(); + + // Clean up: remove filter tag + await filterTag(filters, optionName).dispatchEvent("click"); + await expect(filterTag(filters, optionName)).not.toBeVisible(); + } + }); + + test("deselects filters through search bar", async ({ page }) => { + for (const filterName of FACET_NAMES) { + // Select the first option through the dropdown + const optionName = await selectFirstOption(filters, page, filterName); + await expect(filterTag(filters, optionName)).toBeVisible(); + + // Search for the option and deselect it + await fillSearchAllFilters(searchAllFilters, optionName); + const filterItem = namedFilterItem(page, optionName); + await expectFilterItemSelected(filterItem); + await filterItem.click(); + await expectFilterItemNotSelected(filterItem); + await page.keyboard.press(KEYBOARD_KEYS.ESCAPE); + await expectAutocompletePopperClosed(page); + + // Verify filter tag disappeared + await expect(filterTag(filters, optionName)).not.toBeVisible(); + } + }); + }); + } }); + +/* ——————————————————————————— helpers ——————————————————————————— */ + +/** + * Escapes regex special characters in a string. + * @param s - The string to escape. + * @returns A string with all RegExp special characters escaped. + */ +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Waits for the autocomplete popper to be fully unmounted from the DOM. + * @param page - Page. + */ +async function expectAutocompletePopperClosed(page: Page): Promise { + await expect(page.locator(MUI_CLASSES.AUTOCOMPLETE_POPPER)).toHaveCount(0); +} + +/** + * Waits for the autocomplete popper to be visible. + * @param page - Page. + */ +async function expectAutocompletePopperOpen(page: Page): Promise { + await expect(page.locator(MUI_CLASSES.AUTOCOMPLETE_POPPER)).toBeVisible(); +} + +/** + * Asserts that a filter item is not selected. + * @param filterItem - A filter-item locator. + */ +async function expectFilterItemNotSelected(filterItem: Locator): Promise { + await expect(filterItem).not.toHaveClass(/Mui-selected/); +} + +/** + * Asserts that a filter item is selected. + * @param filterItem - A filter-item locator. + */ +async function expectFilterItemSelected(filterItem: Locator): Promise { + await expect(filterItem).toHaveClass(/Mui-selected/); +} + +/** + * Waits for all filter popovers to be fully unmounted from the DOM. + * @param page - Page. + */ +async function expectFilterPopoverClosed(page: Page): Promise { + await expect(filterPopover(page)).toHaveCount(0); +} + +/** + * Waits for the filter popover to be visible. + * @param page - Page. + */ +async function expectFilterPopoverOpen(page: Page): Promise { + await expect(filterPopover(page)).toBeVisible(); +} + +/** + * Extracts the display name from a filter item element. + * @param filterItem - A locator for the filter-item element. + * @returns The display name of the filter option. + */ +async function extractOptionName(filterItem: Locator): Promise { + return ( + await filterItem.getByTestId(TEST_IDS.FILTER_TERM).innerText() + ).trim(); +} + +/** + * Fills the "Search all filters" input and waits for the results to appear. + * @param searchAllFilters - The search-all-filters container locator. + * @param text - The text to type into the search input. + */ +async function fillSearchAllFilters( + searchAllFilters: Locator, + text: string +): Promise { + await expectAutocompletePopperClosed(searchAllFilters.page()); + const input = searchAllFilters.getByRole("combobox"); + await input.fill(text); + await expectAutocompletePopperOpen(searchAllFilters.page()); +} + +/** + * Returns a locator for the filter popover. + * @param page - Page. + * @returns A locator for the filter popover. + */ +function filterPopover(page: Page): Locator { + return page.getByTestId(TEST_IDS.FILTER_POPOVER); +} + +/** + * Returns a regex matching a sidebar filter button, e.g. "Consent Code (5)". + * @param filterName - The name of the filter. + * @returns A RegExp matching the sidebar button text. + */ +function filterRegex(filterName: string): RegExp { + return new RegExp(escapeRegExp(filterName) + "\\s+\\(\\d+\\)\\s*"); +} + +/** + * Returns a locator for a filter tag (MuiChip) within the filters container. + * @param filters - The filters container locator. + * @param name - The filter option name to match. + * @returns A locator for the filter tag chip. + */ +function filterTag(filters: Locator, name: string): Locator { + return filters.locator(MUI_CLASSES.CHIP, { hasText: name }); +} + +/** + * Returns a locator for the first filter item in the open popover. + * @param page - Page. + * @returns A locator for the first filter item. + */ +function firstFilterItem(page: Page): Locator { + return filterPopover(page).getByTestId(TEST_IDS.FILTER_ITEM).first(); +} + +/** + * Returns the name of the first filter item in the open popover. + * @param page - Page. + * @returns The display name of the first option. + */ +async function getFirstOptionName(page: Page): Promise { + return extractOptionName(firstFilterItem(page)); +} + +/** + * Returns a locator for a named filter item in the autocomplete popper. + * @param page - Page. + * @param optionName - The display name of the filter option. + * @returns A locator for the matching filter item. + */ +function namedFilterItem(page: Page, optionName: string): Locator { + return page + .locator(MUI_CLASSES.AUTOCOMPLETE_POPPER) + .getByTestId(TEST_IDS.FILTER_ITEM) + .filter({ hasText: RegExp(`^${escapeRegExp(optionName)}\\s*\\d+\\s*`) }) + .first(); +} + +/** + * Opens a filter dropdown by clicking its sidebar button. + * Uses dispatchEvent because the filter menu sometimes intercepts regular clicks. + * @param filters - The filters container locator. + * @param filterName - The name of the sidebar filter to open. + */ +async function openFilterDropdown( + filters: Locator, + filterName: string +): Promise { + await expectFilterPopoverClosed(filters.page()); + const button = filters.getByText(filterRegex(filterName)); + await expect(button).toBeVisible(); + await button.dispatchEvent("click"); + await expectFilterPopoverOpen(filters.page()); +} + +/** + * Opens a sidebar filter dropdown, selects its first option, and returns the + * option name. Waits for the item to be selected before returning. + * @param filters - The filters container locator. + * @param page - Page (needed for popover content). + * @param filterName - The name of the sidebar filter to open. + * @returns The display name of the selected option. + */ +async function selectFirstOption( + filters: Locator, + page: Page, + filterName: string +): Promise { + await openFilterDropdown(filters, filterName); + const option = firstFilterItem(page); + const name = await extractOptionName(option); + await expectFilterItemNotSelected(option); + await option.click(); + await expectFilterItemSelected(option); + await page.keyboard.press(KEYBOARD_KEYS.ESCAPE); + await expectFilterPopoverClosed(page); + return name; +} diff --git a/e2e/anvil-catalog/anvilcatalog-tabs.ts b/e2e/anvil-catalog/anvilcatalog-tabs.ts index 8b11f2250..33d014a37 100644 --- a/e2e/anvil-catalog/anvilcatalog-tabs.ts +++ b/e2e/anvil-catalog/anvilcatalog-tabs.ts @@ -10,25 +10,6 @@ import { } from "./constants"; const ANVIL_CATALOG_SEARCH_FILTERS_PLACEHOLDER_TEXT = "Search all filters..."; -export const ANVIL_CATALOG_FILTERS = [ - "Consent Code", - "Consortium", - "Data Type", - "dbGap Id", - "Disease (indication)", - "Study Design", - "Study", - "Terra Workspace Name", -]; - -export const CONSENT_CODE_INDEX = 0; -export const CONSORTIUM_INDEX = 1; -export const DATA_TYPE_INDEX = 2; -export const DBGAP_ID_INDEX = 3; -export const DISEASE_INDICATION_INDEX = 4; -export const STUDY_DESIGN_INDEX = 5; -export const STUDY_INDEX = 6; -export const TERRA_WORKSPACE_INDEX = 7; export const ANVIL_CATALOG_TABS: AnvilCatalogTabCollection = { CONSORTIA: { diff --git a/e2e/anvil-catalog/constants.ts b/e2e/anvil-catalog/constants.ts index 317a15740..3e349c970 100644 --- a/e2e/anvil-catalog/constants.ts +++ b/e2e/anvil-catalog/constants.ts @@ -1,3 +1,9 @@ +export const ANVIL_CATALOG_CATEGORY_NAMES = { + CONSENT_CODE: "Consent Code", + DBGAP_ID: "dbGap Id", + TERRA_WORKSPACE_NAME: "Terra Workspace Name", +}; + export const ANVIL_CATALOG_COLUMN_NAMES = { CONSENT_CODE: "Consent Code", CONSENT_CODES: "Consent Codes", diff --git a/e2e/features/common/constants.ts b/e2e/features/common/constants.ts index ee4cc0b06..8cabac8c5 100644 --- a/e2e/features/common/constants.ts +++ b/e2e/features/common/constants.ts @@ -4,8 +4,10 @@ export const KEYBOARD_KEYS = { export const MUI_CLASSES = { ALERT: ".MuiAlert-root", + AUTOCOMPLETE_POPPER: ".MuiAutocomplete-popper", BUTTON: ".MuiButton-root", BUTTON_GROUP: ".MuiButtonGroup-root", + CHIP: ".MuiChip-root", FORM_CONTROL: ".MuiFormControl-root", ICON_BUTTON: ".MuiIconButton-root", LIST: ".MuiList-root", diff --git a/e2e/testFunctions.ts b/e2e/testFunctions.ts index 517c7f8ac..618e81dac 100644 --- a/e2e/testFunctions.ts +++ b/e2e/testFunctions.ts @@ -62,25 +62,6 @@ export const getFirstRowNthColumnCellLocator = ( return getMthRowNthColumnCellLocator(page, 0, columnIndex); }; -/** - * Get a locator to the cell in the first row's nth column - * @param page - a Playwright page object - * @param columnIndex - the zero-indexed column to return - * @returns a Playwright locator object to the selected cell - **/ -export const getLastRowNthColumnTextLocator = ( - page: Page, - columnIndex: number -): Locator => { - return page - .getByRole("rowgroup") - .nth(1) - .getByRole("row") - .last() - .getByRole("cell") - .nth(columnIndex); -}; - /** * Tests that the tab url goes to a valid page and that the correct tab (and only * the correct tab) appears selected @@ -716,94 +697,6 @@ function escapeRegExp(string: string): string { return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -/** - * Run a test that gets the first filter option of each of the filters specified in - * filterNames, then attempts to select each through the filter search bar. - * @param page - a Playwright page object - * @param tab - the Tab object to run the test on - * @param filterNames - an array of potential filter names on the selected tab - */ -export async function testSelectFiltersThroughSearchBar( - page: Page, - tab: TabDescription, - filterNames: string[] -): Promise { - await page.goto(tab.url); - for (const filterName of filterNames) { - // Get the first filter option - await expect(page.getByText(filterRegex(filterName))).toBeVisible(); - // (dispatchevent necessary because the filter menu sometimes interrupts the click event) - await page.getByText(filterRegex(filterName)).dispatchEvent("click"); - const firstFilterOptionLocator = getFirstFilterOptionLocator(page); - const filterOptionName = await getFilterOptionName( - firstFilterOptionLocator - ); - await page.locator("body").click(); - // Search for the filter option - const searchFiltersInputLocator = page.getByPlaceholder( - tab.searchFiltersPlaceholderText, - { exact: true } - ); - await expect(searchFiltersInputLocator).toBeVisible(); - await searchFiltersInputLocator.fill(filterOptionName); - // Select a filter option with a matching name - await getNamedFilterOptionLocator(page, filterOptionName).first().click(); - await page.locator("body").click(); - const filterTagLocator = getFilterTagLocator(page, filterOptionName); - // Check the filter tag is selected and click it to reset the filter - await expect(filterTagLocator).toBeVisible(); - await filterTagLocator.dispatchEvent("click"); - } -} - -/** - * Run a test that selects the first filter option of each of the filters specified in - * filterNames, then attempts to deselect each through the filter search bar. - * @param page - a Playwright page object - * @param tab - the Tab object to run the test on - * @param filterNames - an array of potential filter names on the selected tab - */ -export async function testDeselectFiltersThroughSearchBar( - page: Page, - tab: TabDescription, - filterNames: string[] -): Promise { - await page.goto(tab.url); - for (const filterName of filterNames) { - // Select each filter option - await expect(page.getByText(filterRegex(filterName))).toBeVisible(); - // (dispatchevent necessary because the filter menu sometimes interrupts the click event) - await page.getByText(filterRegex(filterName)).dispatchEvent("click"); - const firstFilterOptionLocator = getFirstFilterOptionLocator(page); - const filterOptionName = await getFilterOptionName( - firstFilterOptionLocator - ); - await firstFilterOptionLocator.click(); - // Wait for the checkbox to be checked, confirming the filter state update completed. - await expect(firstFilterOptionLocator.getByRole("checkbox")).toBeChecked(); - await page.waitForLoadState("load"); - await page.locator("body").click(); - // Search for and check the selected filter - const searchFiltersInputLocator = page.getByPlaceholder( - tab.searchFiltersPlaceholderText, - { exact: true } - ); - await expect(searchFiltersInputLocator).toBeVisible(); - await searchFiltersInputLocator.fill(filterOptionName); - const filterOptionLocator = getNamedFilterOptionLocator( - page, - filterOptionName - ); - await expect(filterOptionLocator).toBeVisible(); - const checkboxLocator = filterOptionLocator.getByRole("checkbox"); - await expect(checkboxLocator).toBeChecked(); - await checkboxLocator.click(); - await page.locator("body").click(); - const filterTagLocator = getFilterTagLocator(page, filterOptionName); - await expect(filterTagLocator).not.toBeVisible(); - } -} - /** * Make an export request that leaves only the minimal number of checkboxes selected * @param page - a Playwright page object