|
1 | | -import { test } from "@playwright/test"; |
| 1 | +import { expect, Locator, Page, test } from "@playwright/test"; |
2 | 2 | import { |
3 | | - testDeselectFiltersThroughSearchBar, |
4 | | - testSelectFiltersThroughSearchBar, |
5 | | -} from "../testFunctions"; |
6 | | -import { |
7 | | - ANVIL_CATALOG_FILTERS, |
8 | | - ANVIL_CATALOG_TABS, |
9 | | - CONSENT_CODE_INDEX, |
10 | | - DBGAP_ID_INDEX, |
11 | | - TERRA_WORKSPACE_INDEX, |
12 | | -} from "./anvilcatalog-tabs"; |
13 | | - |
14 | | -const filterList = [CONSENT_CODE_INDEX, DBGAP_ID_INDEX, TERRA_WORKSPACE_INDEX]; |
15 | | - |
16 | | -test('Check that selecting filters through the "Search all Filters" textbox works correctly on the Consortia tab', async ({ |
17 | | - page, |
18 | | -}) => { |
19 | | - await testSelectFiltersThroughSearchBar( |
20 | | - page, |
21 | | - ANVIL_CATALOG_TABS.CONSORTIA, |
22 | | - filterList.map((i: number) => ANVIL_CATALOG_FILTERS[i]) |
23 | | - ); |
24 | | -}); |
| 3 | + KEYBOARD_KEYS, |
| 4 | + MUI_CLASSES, |
| 5 | + TEST_IDS, |
| 6 | +} from "../features/common/constants"; |
| 7 | +import { ANVIL_CATALOG_CATEGORY_NAMES } from "./constants"; |
25 | 8 |
|
26 | | -test('Check that selecting filters through the "Search all Filters" textbox works correctly on the Studies tab', async ({ |
27 | | - page, |
28 | | -}) => { |
29 | | - await testSelectFiltersThroughSearchBar( |
30 | | - page, |
31 | | - ANVIL_CATALOG_TABS.STUDIES, |
32 | | - filterList.map((i: number) => ANVIL_CATALOG_FILTERS[i]) |
33 | | - ); |
34 | | -}); |
| 9 | +const ENTITIES = [ |
| 10 | + { name: "Consortia", url: "/data/consortia" }, |
| 11 | + { name: "Studies", url: "/data/studies" }, |
| 12 | + { name: "Workspaces", url: "/data/workspaces" }, |
| 13 | +]; |
35 | 14 |
|
36 | | -test('Check that selecting filters through the "Search all Filters" textbox works correctly on the Workspaces tab', async ({ |
37 | | - page, |
38 | | -}) => { |
39 | | - await testSelectFiltersThroughSearchBar( |
40 | | - page, |
41 | | - ANVIL_CATALOG_TABS.WORKSPACES, |
42 | | - filterList.map((i: number) => ANVIL_CATALOG_FILTERS[i]) |
43 | | - ); |
44 | | -}); |
| 15 | +const FACET_NAMES = [ |
| 16 | + ANVIL_CATALOG_CATEGORY_NAMES.CONSENT_CODE, |
| 17 | + ANVIL_CATALOG_CATEGORY_NAMES.DBGAP_ID, |
| 18 | + ANVIL_CATALOG_CATEGORY_NAMES.TERRA_WORKSPACE_NAME, |
| 19 | +]; |
45 | 20 |
|
46 | | -test('Check that deselecting filters through the "Search all Filters" textbox works correctly on the Consortia tab', async ({ |
47 | | - page, |
48 | | -}) => { |
49 | | - await testDeselectFiltersThroughSearchBar( |
50 | | - page, |
51 | | - ANVIL_CATALOG_TABS.CONSORTIA, |
52 | | - filterList.map((i: number) => ANVIL_CATALOG_FILTERS[i]) |
53 | | - ); |
54 | | -}); |
| 21 | +test.describe("AnVIL Catalog filter search", () => { |
| 22 | + for (const entity of ENTITIES) { |
| 23 | + test.describe(`${entity.name} tab`, () => { |
| 24 | + let filters: Locator; |
| 25 | + let searchAllFilters: Locator; |
55 | 26 |
|
56 | | -test('Check that deselecting filters through the "Search all Filters" textbox works correctly on the Studies tab', async ({ |
57 | | - page, |
58 | | -}) => { |
59 | | - await testDeselectFiltersThroughSearchBar( |
60 | | - page, |
61 | | - ANVIL_CATALOG_TABS.STUDIES, |
62 | | - filterList.map((i: number) => ANVIL_CATALOG_FILTERS[i]) |
63 | | - ); |
64 | | -}); |
| 27 | + test.beforeEach(async ({ page }) => { |
| 28 | + await page.goto(entity.url); |
| 29 | + filters = page.getByTestId(TEST_IDS.FILTERS); |
| 30 | + searchAllFilters = page.getByTestId(TEST_IDS.SEARCH_ALL_FILTERS); |
| 31 | + await filters.waitFor(); |
| 32 | + }); |
| 33 | + |
| 34 | + test("selects filters through search bar", async ({ page }) => { |
| 35 | + for (const filterName of FACET_NAMES) { |
| 36 | + // Open filter dropdown, note first option name, close |
| 37 | + await openFilterDropdown(filters, filterName); |
| 38 | + const optionName = await getFirstOptionName(page); |
| 39 | + await page.keyboard.press(KEYBOARD_KEYS.ESCAPE); |
| 40 | + await expectFilterPopoverClosed(page); |
| 41 | + |
| 42 | + // Search for the option and select it |
| 43 | + await fillSearchAllFilters(searchAllFilters, optionName); |
| 44 | + const filterItem = namedFilterItem(page, optionName); |
| 45 | + await expectFilterItemNotSelected(filterItem); |
| 46 | + await filterItem.click(); |
| 47 | + await expectFilterItemSelected(filterItem); |
| 48 | + await page.keyboard.press(KEYBOARD_KEYS.ESCAPE); |
| 49 | + await expectAutocompletePopperClosed(page); |
65 | 50 |
|
66 | | -test('Check that deselecting filters through the "Search all Filters" textbox works correctly on the Workspaces tab', async ({ |
67 | | - page, |
68 | | -}) => { |
69 | | - await testDeselectFiltersThroughSearchBar( |
70 | | - page, |
71 | | - ANVIL_CATALOG_TABS.WORKSPACES, |
72 | | - filterList.map((i: number) => ANVIL_CATALOG_FILTERS[i]) |
73 | | - ); |
| 51 | + // Verify filter tag appeared |
| 52 | + await expect(filterTag(filters, optionName)).toBeVisible(); |
| 53 | + |
| 54 | + // Clean up: remove filter tag |
| 55 | + await filterTag(filters, optionName).dispatchEvent("click"); |
| 56 | + await expect(filterTag(filters, optionName)).not.toBeVisible(); |
| 57 | + } |
| 58 | + }); |
| 59 | + |
| 60 | + test("deselects filters through search bar", async ({ page }) => { |
| 61 | + for (const filterName of FACET_NAMES) { |
| 62 | + // Select the first option through the dropdown |
| 63 | + const optionName = await selectFirstOption(filters, page, filterName); |
| 64 | + await expect(filterTag(filters, optionName)).toBeVisible(); |
| 65 | + |
| 66 | + // Search for the option and deselect it |
| 67 | + await fillSearchAllFilters(searchAllFilters, optionName); |
| 68 | + const filterItem = namedFilterItem(page, optionName); |
| 69 | + await expectFilterItemSelected(filterItem); |
| 70 | + await filterItem.click(); |
| 71 | + await expectFilterItemNotSelected(filterItem); |
| 72 | + await page.keyboard.press(KEYBOARD_KEYS.ESCAPE); |
| 73 | + await expectAutocompletePopperClosed(page); |
| 74 | + |
| 75 | + // Verify filter tag disappeared |
| 76 | + await expect(filterTag(filters, optionName)).not.toBeVisible(); |
| 77 | + } |
| 78 | + }); |
| 79 | + }); |
| 80 | + } |
74 | 81 | }); |
| 82 | + |
| 83 | +/* ——————————————————————————— helpers ——————————————————————————— */ |
| 84 | + |
| 85 | +/** |
| 86 | + * Escapes regex special characters in a string. |
| 87 | + * @param s - The string to escape. |
| 88 | + * @returns A string with all RegExp special characters escaped. |
| 89 | + */ |
| 90 | +function escapeRegExp(s: string): string { |
| 91 | + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); |
| 92 | +} |
| 93 | + |
| 94 | +/** |
| 95 | + * Waits for the autocomplete popper to be fully unmounted from the DOM. |
| 96 | + * @param page - Page. |
| 97 | + */ |
| 98 | +async function expectAutocompletePopperClosed(page: Page): Promise<void> { |
| 99 | + await expect(page.locator(MUI_CLASSES.AUTOCOMPLETE_POPPER)).toHaveCount(0); |
| 100 | +} |
| 101 | + |
| 102 | +/** |
| 103 | + * Waits for the autocomplete popper to be visible. |
| 104 | + * @param page - Page. |
| 105 | + */ |
| 106 | +async function expectAutocompletePopperOpen(page: Page): Promise<void> { |
| 107 | + await expect(page.locator(MUI_CLASSES.AUTOCOMPLETE_POPPER)).toBeVisible(); |
| 108 | +} |
| 109 | + |
| 110 | +/** |
| 111 | + * Asserts that a filter item is not selected. |
| 112 | + * @param filterItem - A filter-item locator. |
| 113 | + */ |
| 114 | +async function expectFilterItemNotSelected(filterItem: Locator): Promise<void> { |
| 115 | + await expect(filterItem).not.toHaveClass(/Mui-selected/); |
| 116 | +} |
| 117 | + |
| 118 | +/** |
| 119 | + * Asserts that a filter item is selected. |
| 120 | + * @param filterItem - A filter-item locator. |
| 121 | + */ |
| 122 | +async function expectFilterItemSelected(filterItem: Locator): Promise<void> { |
| 123 | + await expect(filterItem).toHaveClass(/Mui-selected/); |
| 124 | +} |
| 125 | + |
| 126 | +/** |
| 127 | + * Waits for all filter popovers to be fully unmounted from the DOM. |
| 128 | + * @param page - Page. |
| 129 | + */ |
| 130 | +async function expectFilterPopoverClosed(page: Page): Promise<void> { |
| 131 | + await expect(filterPopover(page)).toHaveCount(0); |
| 132 | +} |
| 133 | + |
| 134 | +/** |
| 135 | + * Waits for the filter popover to be visible. |
| 136 | + * @param page - Page. |
| 137 | + */ |
| 138 | +async function expectFilterPopoverOpen(page: Page): Promise<void> { |
| 139 | + await expect(filterPopover(page)).toBeVisible(); |
| 140 | +} |
| 141 | + |
| 142 | +/** |
| 143 | + * Extracts the display name from a filter item element. |
| 144 | + * @param filterItem - A locator for the filter-item element. |
| 145 | + * @returns The display name of the filter option. |
| 146 | + */ |
| 147 | +async function extractOptionName(filterItem: Locator): Promise<string> { |
| 148 | + return ( |
| 149 | + await filterItem.getByTestId(TEST_IDS.FILTER_TERM).innerText() |
| 150 | + ).trim(); |
| 151 | +} |
| 152 | + |
| 153 | +/** |
| 154 | + * Fills the "Search all filters" input and waits for the results to appear. |
| 155 | + * @param searchAllFilters - The search-all-filters container locator. |
| 156 | + * @param text - The text to type into the search input. |
| 157 | + */ |
| 158 | +async function fillSearchAllFilters( |
| 159 | + searchAllFilters: Locator, |
| 160 | + text: string |
| 161 | +): Promise<void> { |
| 162 | + await expectAutocompletePopperClosed(searchAllFilters.page()); |
| 163 | + const input = searchAllFilters.getByRole("combobox"); |
| 164 | + await input.fill(text); |
| 165 | + await expectAutocompletePopperOpen(searchAllFilters.page()); |
| 166 | +} |
| 167 | + |
| 168 | +/** |
| 169 | + * Returns a locator for the filter popover. |
| 170 | + * @param page - Page. |
| 171 | + * @returns A locator for the filter popover. |
| 172 | + */ |
| 173 | +function filterPopover(page: Page): Locator { |
| 174 | + return page.getByTestId(TEST_IDS.FILTER_POPOVER); |
| 175 | +} |
| 176 | + |
| 177 | +/** |
| 178 | + * Returns a regex matching a sidebar filter button, e.g. "Consent Code (5)". |
| 179 | + * @param filterName - The name of the filter. |
| 180 | + * @returns A RegExp matching the sidebar button text. |
| 181 | + */ |
| 182 | +function filterRegex(filterName: string): RegExp { |
| 183 | + return new RegExp(escapeRegExp(filterName) + "\\s+\\(\\d+\\)\\s*"); |
| 184 | +} |
| 185 | + |
| 186 | +/** |
| 187 | + * Returns a locator for a filter tag (MuiChip) within the filters container. |
| 188 | + * @param filters - The filters container locator. |
| 189 | + * @param name - The filter option name to match. |
| 190 | + * @returns A locator for the filter tag chip. |
| 191 | + */ |
| 192 | +function filterTag(filters: Locator, name: string): Locator { |
| 193 | + return filters.locator(MUI_CLASSES.CHIP, { hasText: name }); |
| 194 | +} |
| 195 | + |
| 196 | +/** |
| 197 | + * Returns a locator for the first filter item in the open popover. |
| 198 | + * @param page - Page. |
| 199 | + * @returns A locator for the first filter item. |
| 200 | + */ |
| 201 | +function firstFilterItem(page: Page): Locator { |
| 202 | + return filterPopover(page).getByTestId(TEST_IDS.FILTER_ITEM).first(); |
| 203 | +} |
| 204 | + |
| 205 | +/** |
| 206 | + * Returns the name of the first filter item in the open popover. |
| 207 | + * @param page - Page. |
| 208 | + * @returns The display name of the first option. |
| 209 | + */ |
| 210 | +async function getFirstOptionName(page: Page): Promise<string> { |
| 211 | + return extractOptionName(firstFilterItem(page)); |
| 212 | +} |
| 213 | + |
| 214 | +/** |
| 215 | + * Returns a locator for a named filter item in the autocomplete popper. |
| 216 | + * @param page - Page. |
| 217 | + * @param optionName - The display name of the filter option. |
| 218 | + * @returns A locator for the matching filter item. |
| 219 | + */ |
| 220 | +function namedFilterItem(page: Page, optionName: string): Locator { |
| 221 | + return page |
| 222 | + .locator(MUI_CLASSES.AUTOCOMPLETE_POPPER) |
| 223 | + .getByTestId(TEST_IDS.FILTER_ITEM) |
| 224 | + .filter({ hasText: RegExp(`^${escapeRegExp(optionName)}\\s*\\d+\\s*`) }) |
| 225 | + .first(); |
| 226 | +} |
| 227 | + |
| 228 | +/** |
| 229 | + * Opens a filter dropdown by clicking its sidebar button. |
| 230 | + * Uses dispatchEvent because the filter menu sometimes intercepts regular clicks. |
| 231 | + * @param filters - The filters container locator. |
| 232 | + * @param filterName - The name of the sidebar filter to open. |
| 233 | + */ |
| 234 | +async function openFilterDropdown( |
| 235 | + filters: Locator, |
| 236 | + filterName: string |
| 237 | +): Promise<void> { |
| 238 | + await expectFilterPopoverClosed(filters.page()); |
| 239 | + const button = filters.getByText(filterRegex(filterName)); |
| 240 | + await expect(button).toBeVisible(); |
| 241 | + await button.dispatchEvent("click"); |
| 242 | + await expectFilterPopoverOpen(filters.page()); |
| 243 | +} |
| 244 | + |
| 245 | +/** |
| 246 | + * Opens a sidebar filter dropdown, selects its first option, and returns the |
| 247 | + * option name. Waits for the item to be selected before returning. |
| 248 | + * @param filters - The filters container locator. |
| 249 | + * @param page - Page (needed for popover content). |
| 250 | + * @param filterName - The name of the sidebar filter to open. |
| 251 | + * @returns The display name of the selected option. |
| 252 | + */ |
| 253 | +async function selectFirstOption( |
| 254 | + filters: Locator, |
| 255 | + page: Page, |
| 256 | + filterName: string |
| 257 | +): Promise<string> { |
| 258 | + await openFilterDropdown(filters, filterName); |
| 259 | + const option = firstFilterItem(page); |
| 260 | + const name = await extractOptionName(option); |
| 261 | + await expectFilterItemNotSelected(option); |
| 262 | + await option.click(); |
| 263 | + await expectFilterItemSelected(option); |
| 264 | + await page.keyboard.press(KEYBOARD_KEYS.ESCAPE); |
| 265 | + await expectFilterPopoverClosed(page); |
| 266 | + return name; |
| 267 | +} |
0 commit comments