fix(next): resolve filterOptions by path (#13779)

Follow up to #11375.

When setting `filterOptions` on relationship or upload fields _that are
nested within a named field_, those options won't be applied to the
`Filter` component in the list view.

This is because of how we key the results when resolving `filterOptions`
on the server. Instead of using the field path as expected, we were
using the field name, causing a failed lookup on the front-end. This
also solves an issue where two fields with the same name would override
each other's `filterOptions`, since field names alone are not unique.

Unrelated: this PR also does some general housekeeping to e2e test
helpers.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211332845301583
This commit is contained in:
Jacob Fletcher
2025-09-11 16:24:16 -04:00
committed by GitHub
parent 82820312e8
commit 8a7124a15e
36 changed files with 313 additions and 207 deletions

View File

@@ -0,0 +1,5 @@
export { openListColumns } from './openListColumns.js'
export { reorderColumns } from './reorderColumns.js'
export { sortColumn } from './sortColumn.js'
export { toggleColumn } from './toggleColumn.js'
export { waitForColumnInURL } from './waitForColumnsInURL.js'

View File

@@ -3,7 +3,7 @@ import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { wait } from 'payload/shared'
import { exactText } from '../../helpers.js'
import { exactText } from '../../../helpers.js'
export const reorderColumns = async (
page: Page,

View File

@@ -2,8 +2,9 @@ import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { exactText } from '../../helpers.js'
import { exactText } from '../../../helpers.js'
import { openListColumns } from './openListColumns.js'
import { waitForColumnInURL } from './waitForColumnsInURL.js'
export const toggleColumn = async (
page: Page,
@@ -64,24 +65,3 @@ export const toggleColumn = async (
return { columnContainer }
}
export const waitForColumnInURL = async ({
page,
columnName,
state,
}: {
columnName: string
page: Page
state: 'off' | 'on'
}): Promise<void> => {
await page.waitForURL(/.*\?.*/)
const identifier = `${state === 'off' ? '-' : ''}${columnName}`
// Test that the identifier is in the URL
// It must appear in the `columns` query parameter, i.e. after `columns=...` and before the next `&`
// It must also appear in it entirety to prevent partially matching other values, i.e. between quotation marks
const regex = new RegExp(`columns=([^&]*${encodeURIComponent(`"${identifier}"`)}[^&]*)`)
await page.waitForURL(regex)
}

View File

@@ -0,0 +1,22 @@
import type { Page } from 'playwright'
export const waitForColumnInURL = async ({
page,
columnName,
state,
}: {
columnName: string
page: Page
state: 'off' | 'on'
}): Promise<void> => {
await page.waitForURL(/.*\?.*/)
const identifier = `${state === 'off' ? '-' : ''}${columnName}`
// Test that the identifier is in the URL
// It must appear in the `columns` query parameter, i.e. after `columns=...` and before the next `&`
// It must also appear in it entirety to prevent partially matching other values, i.e. between quotation marks
const regex = new RegExp(`columns=([^&]*${encodeURIComponent(`"${identifier}"`)}[^&]*)`)
await page.waitForURL(regex)
}

View File

@@ -2,55 +2,77 @@ import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { selectInput } from '../selectInput.js'
import { openListFilters } from './openListFilters.js'
import { selectInput } from './selectInput.js'
export const addListFilter = async ({
page,
fieldLabel = 'ID',
operatorLabel = 'equals',
value = '',
skipValueInput,
value,
}: {
fieldLabel: string
operatorLabel: string
page: Page
replaceExisting?: boolean
skipValueInput?: boolean
value?: string
}): Promise<{
/**
* A Locator pointing to the condition that was just added.
*/
condition: Locator
/**
* A Locator pointing to the WhereBuilder node.
*/
whereBuilder: Locator
}> => {
await openListFilters(page, {})
const whereBuilder = page.locator('.where-builder')
await whereBuilder.locator('.where-builder__add-first-filter').click()
const addFirst = whereBuilder.locator('.where-builder__add-first-filter')
const initializedEmpty = await addFirst.isVisible()
if (initializedEmpty) {
await addFirst.click()
}
const filters = whereBuilder.locator('.where-builder__or-filters > li')
expect(await filters.count()).toBeGreaterThan(0)
// If there were already filter(s), need to add another and manipulate _that_ instead of the existing one
if (!initializedEmpty) {
const addFilterButtons = whereBuilder.locator('.where-builder__add-or')
await addFilterButtons.last().click()
await expect(filters).toHaveCount(2)
}
const condition = filters.last()
await selectInput({
selectLocator: whereBuilder.locator('.condition__field'),
selectLocator: condition.locator('.condition__field'),
multiSelect: false,
option: fieldLabel,
})
await selectInput({
selectLocator: whereBuilder.locator('.condition__operator'),
selectLocator: condition.locator('.condition__operator'),
multiSelect: false,
option: operatorLabel,
})
if (!skipValueInput) {
if (value !== undefined) {
const networkPromise = page.waitForResponse(
(response) =>
response.url().includes(encodeURIComponent('where[or')) && response.status() === 200,
)
const valueLocator = whereBuilder.locator('.condition__value')
const valueLocator = condition.locator('.condition__value')
const valueInput = valueLocator.locator('input')
await valueInput.fill(value)
await expect(valueInput).toHaveValue(value)
if ((await valueLocator.locator('input.rs__input').count()) > 0) {
const valueOptions = whereBuilder.locator('.condition__value .rs__option')
const valueOptions = condition.locator('.condition__value .rs__option')
const createValue = valueOptions.locator(`text=Create "${value}"`)
if ((await createValue.count()) > 0) {
await createValue.click()
@@ -65,5 +87,5 @@ export const addListFilter = async ({
await networkPromise
}
return { whereBuilder }
return { whereBuilder, condition }
}

View File

@@ -0,0 +1,2 @@
export { addListFilter } from './addListFilter.js'
export { openListFilters } from './openListFilters.js'

View File

@@ -1,90 +0,0 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { exactText } from 'helpers.js'
type ToggleOptions = {
groupByContainerSelector: string
targetState: 'closed' | 'open'
togglerSelector: string
}
/**
* Toggles the group-by drawer in the list view based on the targetState option.
*/
export const toggleGroupBy = async (
page: Page,
{
targetState = 'open',
togglerSelector = '#toggle-group-by',
groupByContainerSelector = '#list-controls-group-by',
}: ToggleOptions,
) => {
const groupByContainer = page.locator(groupByContainerSelector).first()
const isAlreadyOpen = await groupByContainer.isVisible()
if (!isAlreadyOpen && targetState === 'open') {
await page.locator(togglerSelector).first().click()
await expect(page.locator(`${groupByContainerSelector}.rah-static--height-auto`)).toBeVisible()
}
if (isAlreadyOpen && targetState === 'closed') {
await page.locator(togglerSelector).first().click()
await expect(page.locator(`${groupByContainerSelector}.rah-static--height-auto`)).toBeHidden()
}
return { groupByContainer }
}
/**
* Closes the group-by drawer in the list view. If it's already closed, does nothing.
*/
export const closeGroupBy = async (
page: Page,
options?: Omit<ToggleOptions, 'targetState'>,
): Promise<{
groupByContainer: Locator
}> => toggleGroupBy(page, { ...(options || ({} as ToggleOptions)), targetState: 'closed' })
/**
* Opens the group-by drawer in the list view. If it's already open, does nothing.
*/
export const openGroupBy = async (
page: Page,
options?: Omit<ToggleOptions, 'targetState'>,
): Promise<{
groupByContainer: Locator
}> => toggleGroupBy(page, { ...(options || ({} as ToggleOptions)), targetState: 'open' })
export const addGroupBy = async (
page: Page,
{ fieldLabel, fieldPath }: { fieldLabel: string; fieldPath: string },
): Promise<{ field: Locator; groupByContainer: Locator }> => {
const { groupByContainer } = await openGroupBy(page)
const field = groupByContainer.locator('#group-by--field-select')
await field.click()
await field.locator('.rs__option', { hasText: exactText(fieldLabel) })?.click()
await expect(field.locator('.react-select--single-value')).toHaveText(fieldLabel)
await expect(page).toHaveURL(new RegExp(`&groupBy=${fieldPath}`))
return { groupByContainer, field }
}
export const clearGroupBy = async (page: Page): Promise<{ groupByContainer: Locator }> => {
const { groupByContainer } = await openGroupBy(page)
await groupByContainer.locator('#group-by--reset').click()
const field = groupByContainer.locator('#group-by--field-select')
await expect(field.locator('.react-select--single-value')).toHaveText('Select a value')
await expect(groupByContainer.locator('#group-by--reset')).toBeHidden()
await expect(page).not.toHaveURL(/&groupBy=/)
await expect(groupByContainer.locator('#field-direction input')).toBeDisabled()
await expect(page.locator('.table-wrap')).toHaveCount(1)
await expect(page.locator('.group-by-header')).toHaveCount(0)
return { groupByContainer }
}

View File

@@ -0,0 +1,22 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { exactText } from 'helpers.js'
import { openGroupBy } from './openGroupBy.js'
export const addGroupBy = async (
page: Page,
{ fieldLabel, fieldPath }: { fieldLabel: string; fieldPath: string },
): Promise<{ field: Locator; groupByContainer: Locator }> => {
const { groupByContainer } = await openGroupBy(page)
const field = groupByContainer.locator('#group-by--field-select')
await field.click()
await field.locator('.rs__option', { hasText: exactText(fieldLabel) })?.click()
await expect(field.locator('.react-select--single-value')).toHaveText(fieldLabel)
await expect(page).toHaveURL(new RegExp(`&groupBy=${fieldPath}`))
return { groupByContainer, field }
}

View File

@@ -0,0 +1,21 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { openGroupBy } from './openGroupBy.js'
export const clearGroupBy = async (page: Page): Promise<{ groupByContainer: Locator }> => {
const { groupByContainer } = await openGroupBy(page)
await groupByContainer.locator('#group-by--reset').click()
const field = groupByContainer.locator('#group-by--field-select')
await expect(field.locator('.react-select--single-value')).toHaveText('Select a value')
await expect(groupByContainer.locator('#group-by--reset')).toBeHidden()
await expect(page).not.toHaveURL(/&groupBy=/)
await expect(groupByContainer.locator('#field-direction input')).toBeDisabled()
await expect(page.locator('.table-wrap')).toHaveCount(1)
await expect(page.locator('.group-by-header')).toHaveCount(0)
return { groupByContainer }
}

View File

@@ -0,0 +1,15 @@
import type { Locator, Page } from '@playwright/test'
import type { ToggleOptions } from './toggleGroupBy.js';
import { toggleGroupBy } from './toggleGroupBy.js'
/**
* Closes the group-by drawer in the list view. If it's already closed, does nothing.
*/
export const closeGroupBy = async (
page: Page,
options?: Omit<ToggleOptions, 'targetState'>,
): Promise<{
groupByContainer: Locator
}> => toggleGroupBy(page, { ...(options || ({} as ToggleOptions)), targetState: 'closed' })

View File

@@ -0,0 +1,5 @@
export { addGroupBy } from './addGroupBy.js'
export { clearGroupBy } from './clearGroupBy.js'
export { closeGroupBy } from './closeGroupBy.js'
export { openGroupBy } from './openGroupBy.js'
export { toggleGroupBy } from './toggleGroupBy.js'

View File

@@ -0,0 +1,15 @@
import type { Locator, Page } from '@playwright/test'
import type { ToggleOptions } from './toggleGroupBy.js'
import { toggleGroupBy } from './toggleGroupBy.js'
/**
* Opens the group-by drawer in the list view. If it's already open, does nothing.
*/
export const openGroupBy = async (
page: Page,
options?: Omit<ToggleOptions, 'targetState'>,
): Promise<{
groupByContainer: Locator
}> => toggleGroupBy(page, { ...(options || ({} as ToggleOptions)), targetState: 'open' })

View File

@@ -0,0 +1,37 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
export type ToggleOptions = {
groupByContainerSelector: string
targetState: 'closed' | 'open'
togglerSelector: string
}
/**
* Toggles the group-by drawer in the list view based on the targetState option.
*/
export const toggleGroupBy = async (
page: Page,
{
targetState = 'open',
togglerSelector = '#toggle-group-by',
groupByContainerSelector = '#list-controls-group-by',
}: ToggleOptions,
) => {
const groupByContainer = page.locator(groupByContainerSelector).first()
const isAlreadyOpen = await groupByContainer.isVisible()
if (!isAlreadyOpen && targetState === 'open') {
await page.locator(togglerSelector).first().click()
await expect(page.locator(`${groupByContainerSelector}.rah-static--height-auto`)).toBeVisible()
}
if (isAlreadyOpen && targetState === 'closed') {
await page.locator(togglerSelector).first().click()
await expect(page.locator(`${groupByContainerSelector}.rah-static--height-auto`)).toBeHidden()
}
return { groupByContainer }
}