fix(next): nested relationship filter options (#11375)

Continuation of #11008. When `filterOptions` are set on a relationship
field that is _nested within another field_, those filter options are
not applied to `Filter` component in the list view. This is because we
were only shallowly resolving filter options on top-level fields, as
opposed to recursively traversing fields to resolve them even when
deeply nested.
This commit is contained in:
Jacob Fletcher
2025-02-24 15:24:25 -05:00
committed by GitHub
parent 09ca5143eb
commit 0a1af45549
6 changed files with 93 additions and 8 deletions

View File

@@ -153,7 +153,7 @@ export const renderListView = async (
const renderedFilters = renderFilters(collectionConfig.fields, req.payload.importMap)
const resolvedFilterOptions = await resolveAllFilterOptions({
collectionConfig,
fields: collectionConfig.fields,
req,
})

View File

@@ -1,19 +1,21 @@
import type { CollectionConfig, PayloadRequest, ResolvedFilterOptions } from 'payload'
import type { Field, PayloadRequest, ResolvedFilterOptions } from 'payload'
import { resolveFilterOptions } from '@payloadcms/ui/rsc'
import { fieldIsHiddenOrDisabled } from 'payload/shared'
import { fieldHasSubFields, fieldIsHiddenOrDisabled } from 'payload/shared'
export const resolveAllFilterOptions = async ({
collectionConfig,
fields,
req,
result,
}: {
collectionConfig: CollectionConfig
fields: Field[]
req: PayloadRequest
result?: Map<string, ResolvedFilterOptions>
}): Promise<Map<string, ResolvedFilterOptions>> => {
const resolvedFilterOptions = new Map<string, ResolvedFilterOptions>()
const resolvedFilterOptions = !result ? new Map<string, ResolvedFilterOptions>() : result
await Promise.all(
collectionConfig.fields.map(async (field) => {
fields.map(async (field) => {
if (fieldIsHiddenOrDisabled(field)) {
return
}
@@ -28,8 +30,29 @@ export const resolveAllFilterOptions = async ({
siblingData: {}, // use empty object to prevent breaking queries when accessing properties of data
user: req.user,
})
resolvedFilterOptions.set(field.name, options)
}
if (fieldHasSubFields(field)) {
await resolveAllFilterOptions({
fields: field.fields,
req,
result: resolvedFilterOptions,
})
}
if (field.type === 'tabs') {
await Promise.all(
field.tabs.map((tab) =>
resolveAllFilterOptions({
fields: tab.fields,
req,
result: resolvedFilterOptions,
}),
),
)
}
}),
)

View File

@@ -88,6 +88,28 @@ export const Relationship: CollectionConfig = {
relationTo: slug,
type: 'relationship',
},
{
type: 'collapsible',
label: 'Collapsible',
fields: [
{
name: 'nestedRelationshipFilteredByField',
filterOptions: () => {
return {
filter: {
equals: 'Include me',
},
}
},
admin: {
description:
'This will filter the relationship options if the filter field in this document is set to "Include me"',
},
relationTo: slug,
type: 'relationship',
},
],
},
{
name: 'relationshipFilteredAsync',
filterOptions: (args: FilterOptionsProps<FieldsRelationship>) => {

View File

@@ -351,6 +351,41 @@ describe('Relationship Field', () => {
await expect(valueOptions.locator(`text=${idToInclude}`)).toBeVisible()
})
test('should apply filter options of nested fields to list view filter controls', async () => {
const { id: idToInclude } = await payload.create({
collection: slug,
data: {
filter: 'Include me',
},
})
// first ensure that filter options are applied in the edit view
await page.goto(url.edit(idToInclude))
const field = page.locator('#field-nestedRelationshipFilteredByField')
await field.click({ delay: 100 })
const options = field.locator('.rs__option')
await expect(options).toHaveCount(1)
await expect(options).toContainText(idToInclude)
// now ensure that the same filter options are applied in the list view
await page.goto(url.list)
const whereBuilder = await addListFilter({
page,
fieldLabel: 'Collapsible > Nested Relationship Filtered By Field',
operatorLabel: 'equals',
skipValueInput: true,
})
const valueInput = page.locator('.condition__value input')
await valueInput.click()
const valueOptions = whereBuilder.locator('.condition__value .rs__option')
await expect(valueOptions).toHaveCount(2)
await expect(valueOptions.locator(`text=None`)).toBeVisible()
await expect(valueOptions.locator(`text=${idToInclude}`)).toBeVisible()
})
test('should allow usage of relationTo in filterOptions', async () => {
const { id: include } = (await payload.create({
collection: relationOneSlug,

View File

@@ -177,6 +177,10 @@ export interface FieldsRelationship {
* This will filter the relationship options if the filter field in this document is set to "Include me"
*/
relationshipFilteredByField?: (string | null) | FieldsRelationship;
/**
* This will filter the relationship options if the filter field in this document is set to "Include me"
*/
nestedRelationshipFilteredByField?: (string | null) | FieldsRelationship;
relationshipFilteredAsync?: (string | null) | RelationOne;
relationshipManyFiltered?:
| (
@@ -506,6 +510,7 @@ export interface FieldsRelationshipSelect<T extends boolean = true> {
relationshipWithTitle?: T;
relationshipFilteredByID?: T;
relationshipFilteredByField?: T;
nestedRelationshipFilteredByField?: T;
relationshipFilteredAsync?: T;
relationshipManyFiltered?: T;
filter?: T;

View File

@@ -31,7 +31,7 @@
}
],
"paths": {
"@payload-config": ["./test/admin/config.ts"],
"@payload-config": ["./test/fields-relationship/config.ts"],
"@payloadcms/live-preview": ["./packages/live-preview/src"],
"@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],
"@payloadcms/live-preview-vue": ["./packages/live-preview-vue/src/index.ts"],