fix(ui): cannot filter by virtual relationship fields in WhereBuilder (#13686)

### What?
- Fixed an issue where virtual relationship fields (`virtual: string`,
e.g. `post.title`) could not be used in the WhereBuilder filter
dropdown.
- Ensured regular virtual fields (`virtual: true`) are still excluded
since they are not backed by database fields.

### Why?
Previously, attempting to filter by a virtual relationship caused
runtime errors because the field was treated as a plain text field. At
the same time, non-queryable virtuals needed to remain excluded to avoid
invalid queries.

### How?
- Updated `reduceFieldsToOptions` to recognize `virtual: string` fields
and map them to their underlying path while keeping their declared type
and operators.
- Continued to filter out `virtual: true` fields in the same guard used
for hidden/disabled fields.
This commit is contained in:
Patrik
2025-09-04 11:17:10 -04:00
committed by GitHub
parent d109b44856
commit 7e98fbf78e
6 changed files with 133 additions and 2 deletions

View File

@@ -30,7 +30,27 @@ export const reduceFieldsToOptions = ({
}: ReduceFieldOptionsArgs): ReducedField[] => { }: ReduceFieldOptionsArgs): ReducedField[] => {
return fields.reduce((reduced, field) => { return fields.reduce((reduced, field) => {
// Do not filter out `field.admin.disableListFilter` fields here, as these should still render as disabled if they appear in the URL query // Do not filter out `field.admin.disableListFilter` fields here, as these should still render as disabled if they appear in the URL query
if (fieldIsHiddenOrDisabled(field) && !fieldIsID(field)) { // Filter out `virtual: true` fields since they are regular virtuals and not backed by a DB field
if (
(fieldIsHiddenOrDisabled(field) && !fieldIsID(field)) ||
('virtual' in field && field.virtual === true)
) {
return reduced
}
// Handle virtual:string fields (virtual relationships, e.g. "post.title")
if ('virtual' in field && typeof field.virtual === 'string') {
const baseLabel = ('label' in field && field.label) || ('name' in field && field.name) || ''
const localizedLabel = getTranslation(baseLabel, i18n)
reduced.push({
label: localizedLabel,
plainTextLabel: localizedLabel,
value: field.virtual, // e.g. "post.title"
...fieldTypes[field.type],
field,
operators: fieldTypes[field.type].operators,
})
return reduced return reduced
} }

View File

@@ -0,0 +1,27 @@
import type { CollectionConfig } from 'payload'
import { virtualsSlug } from '../slugs.js'
export const Virtuals: CollectionConfig = {
slug: virtualsSlug,
admin: {
useAsTitle: 'virtualTitleFromPost',
},
fields: [
{
name: 'virtualTitleFromPost',
type: 'text',
virtual: 'post.title',
},
{
name: 'virtualText',
type: 'text',
virtual: true,
},
{
name: 'post',
type: 'relationship',
relationTo: 'posts',
},
],
}

View File

@@ -28,6 +28,7 @@ import { UploadCollection } from './collections/Upload.js'
import { UploadTwoCollection } from './collections/UploadTwo.js' import { UploadTwoCollection } from './collections/UploadTwo.js'
import { UseAsTitleGroupField } from './collections/UseAsTitleGroupField.js' import { UseAsTitleGroupField } from './collections/UseAsTitleGroupField.js'
import { Users } from './collections/Users.js' import { Users } from './collections/Users.js'
import { Virtuals } from './collections/Virtuals.js'
import { with300Documents } from './collections/With300Documents.js' import { with300Documents } from './collections/With300Documents.js'
import { CustomGlobalViews1 } from './globals/CustomViews1.js' import { CustomGlobalViews1 } from './globals/CustomViews1.js'
import { CustomGlobalViews2 } from './globals/CustomViews2.js' import { CustomGlobalViews2 } from './globals/CustomViews2.js'
@@ -187,6 +188,7 @@ export default buildConfigWithDefaults({
UseAsTitleGroupField, UseAsTitleGroupField,
DisableBulkEdit, DisableBulkEdit,
CustomListDrawer, CustomListDrawer,
Virtuals,
], ],
globals: [ globals: [
GlobalHidden, GlobalHidden,

View File

@@ -4,7 +4,7 @@ import { expect, test } from '@playwright/test'
import { mapAsync } from 'payload' import { mapAsync } from 'payload'
import * as qs from 'qs-esm' import * as qs from 'qs-esm'
import type { Config, Geo, Post } from '../../payload-types.js' import type { Config, Geo, Post, Virtual } from '../../payload-types.js'
import { import {
ensureCompilationIsDone, ensureCompilationIsDone,
@@ -23,6 +23,7 @@ import {
listDrawerSlug, listDrawerSlug,
placeholderCollectionSlug, placeholderCollectionSlug,
postsCollectionSlug, postsCollectionSlug,
virtualsSlug,
with300DocumentsSlug, with300DocumentsSlug,
} from '../../slugs.js' } from '../../slugs.js'
@@ -70,6 +71,7 @@ describe('List View', () => {
let placeholderUrl: AdminUrlUtil let placeholderUrl: AdminUrlUtil
let disableBulkEditUrl: AdminUrlUtil let disableBulkEditUrl: AdminUrlUtil
let user: any let user: any
let virtualsUrl: AdminUrlUtil
let serverURL: string let serverURL: string
let adminRoutes: ReturnType<typeof getRoutes> let adminRoutes: ReturnType<typeof getRoutes>
@@ -94,6 +96,7 @@ describe('List View', () => {
withListViewUrl = new AdminUrlUtil(serverURL, listDrawerSlug) withListViewUrl = new AdminUrlUtil(serverURL, listDrawerSlug)
placeholderUrl = new AdminUrlUtil(serverURL, placeholderCollectionSlug) placeholderUrl = new AdminUrlUtil(serverURL, placeholderCollectionSlug)
disableBulkEditUrl = new AdminUrlUtil(serverURL, 'disable-bulk-edit') disableBulkEditUrl = new AdminUrlUtil(serverURL, 'disable-bulk-edit')
virtualsUrl = new AdminUrlUtil(serverURL, virtualsSlug)
const context = await browser.newContext() const context = await browser.newContext()
page = await context.newPage() page = await context.newPage()
initPageConsoleErrorCatch(page) initPageConsoleErrorCatch(page)
@@ -416,6 +419,44 @@ describe('List View', () => {
).toBeVisible() ).toBeVisible()
}) })
test('should not allow search by virtual: true field in field dropdown', async () => {
await page.goto(virtualsUrl.list)
await openListFilters(page, {})
const whereBuilder = page.locator('.where-builder')
await whereBuilder.locator('.where-builder__add-first-filter').click()
const conditionField = whereBuilder.locator('.condition__field')
await conditionField.click()
const menuList = conditionField.locator('.rs__menu-list')
// ensure the virtual field is not present
await expect(menuList.locator('div', { hasText: exactText('Virtual Text') })).toHaveCount(0)
})
test('should allow to filter by virtual relationship field', async () => {
const post1 = await createPost({ title: 'somePost' })
const post2 = await createPost({ title: 'otherPost' })
await createVirtualDoc({ post: post1.id })
await createVirtualDoc({ post: post2.id })
await page.goto(virtualsUrl.list)
await expect(page.locator(tableRowLocator)).toHaveCount(2)
await addListFilter({
page,
fieldLabel: 'Virtual Title From Post',
operatorLabel: 'equals',
value: 'somePost',
})
await expect(page.locator(tableRowLocator)).toHaveCount(1)
})
test('should allow to filter in array field', async () => { test('should allow to filter in array field', async () => {
await createArray() await createArray()
@@ -1786,3 +1827,13 @@ async function createArray() {
}, },
}) })
} }
async function createVirtualDoc(overrides?: Partial<Virtual>): Promise<Virtual> {
return payload.create({
collection: virtualsSlug,
data: {
post: overrides?.post,
...overrides,
},
}) as unknown as Promise<Virtual>
}

View File

@@ -94,6 +94,7 @@ export interface Config {
'use-as-title-group-field': UseAsTitleGroupField; 'use-as-title-group-field': UseAsTitleGroupField;
'disable-bulk-edit': DisableBulkEdit; 'disable-bulk-edit': DisableBulkEdit;
'custom-list-drawer': CustomListDrawer; 'custom-list-drawer': CustomListDrawer;
virtuals: Virtual;
'payload-locked-documents': PayloadLockedDocument; 'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference; 'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration; 'payload-migrations': PayloadMigration;
@@ -127,6 +128,7 @@ export interface Config {
'use-as-title-group-field': UseAsTitleGroupFieldSelect<false> | UseAsTitleGroupFieldSelect<true>; 'use-as-title-group-field': UseAsTitleGroupFieldSelect<false> | UseAsTitleGroupFieldSelect<true>;
'disable-bulk-edit': DisableBulkEditSelect<false> | DisableBulkEditSelect<true>; 'disable-bulk-edit': DisableBulkEditSelect<false> | DisableBulkEditSelect<true>;
'custom-list-drawer': CustomListDrawerSelect<false> | CustomListDrawerSelect<true>; 'custom-list-drawer': CustomListDrawerSelect<false> | CustomListDrawerSelect<true>;
virtuals: VirtualsSelect<false> | VirtualsSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>; 'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>; 'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>; 'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
@@ -582,6 +584,18 @@ export interface CustomListDrawer {
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "virtuals".
*/
export interface Virtual {
id: string;
virtualTitleFromPost?: string | null;
virtualText?: string | null;
post?: (string | null) | Post;
updatedAt: string;
createdAt: string;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents". * via the `definition` "payload-locked-documents".
@@ -696,6 +710,10 @@ export interface PayloadLockedDocument {
| ({ | ({
relationTo: 'custom-list-drawer'; relationTo: 'custom-list-drawer';
value: string | CustomListDrawer; value: string | CustomListDrawer;
} | null)
| ({
relationTo: 'virtuals';
value: string | Virtual;
} | null); } | null);
globalSlug?: string | null; globalSlug?: string | null;
user: { user: {
@@ -1111,6 +1129,17 @@ export interface CustomListDrawerSelect<T extends boolean = true> {
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "virtuals_select".
*/
export interface VirtualsSelect<T extends boolean = true> {
virtualTitleFromPost?: T;
virtualText?: T;
post?: T;
updatedAt?: T;
createdAt?: T;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select". * via the `definition` "payload-locked-documents_select".

View File

@@ -23,6 +23,7 @@ export const uploadTwoCollectionSlug = 'uploads-two'
export const customFieldsSlug = 'custom-fields' export const customFieldsSlug = 'custom-fields'
export const listDrawerSlug = 'with-list-drawer' export const listDrawerSlug = 'with-list-drawer'
export const virtualsSlug = 'virtuals'
export const collectionSlugs = [ export const collectionSlugs = [
usersCollectionSlug, usersCollectionSlug,
customViews1CollectionSlug, customViews1CollectionSlug,
@@ -39,6 +40,7 @@ export const collectionSlugs = [
customFieldsSlug, customFieldsSlug,
disableDuplicateSlug, disableDuplicateSlug,
listDrawerSlug, listDrawerSlug,
virtualsSlug,
] ]
export const customGlobalViews1GlobalSlug = 'custom-global-views-one' export const customGlobalViews1GlobalSlug = 'custom-global-views-one'