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

@@ -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 { UseAsTitleGroupField } from './collections/UseAsTitleGroupField.js'
import { Users } from './collections/Users.js'
import { Virtuals } from './collections/Virtuals.js'
import { with300Documents } from './collections/With300Documents.js'
import { CustomGlobalViews1 } from './globals/CustomViews1.js'
import { CustomGlobalViews2 } from './globals/CustomViews2.js'
@@ -187,6 +188,7 @@ export default buildConfigWithDefaults({
UseAsTitleGroupField,
DisableBulkEdit,
CustomListDrawer,
Virtuals,
],
globals: [
GlobalHidden,

View File

@@ -4,7 +4,7 @@ import { expect, test } from '@playwright/test'
import { mapAsync } from 'payload'
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 {
ensureCompilationIsDone,
@@ -23,6 +23,7 @@ import {
listDrawerSlug,
placeholderCollectionSlug,
postsCollectionSlug,
virtualsSlug,
with300DocumentsSlug,
} from '../../slugs.js'
@@ -70,6 +71,7 @@ describe('List View', () => {
let placeholderUrl: AdminUrlUtil
let disableBulkEditUrl: AdminUrlUtil
let user: any
let virtualsUrl: AdminUrlUtil
let serverURL: string
let adminRoutes: ReturnType<typeof getRoutes>
@@ -94,6 +96,7 @@ describe('List View', () => {
withListViewUrl = new AdminUrlUtil(serverURL, listDrawerSlug)
placeholderUrl = new AdminUrlUtil(serverURL, placeholderCollectionSlug)
disableBulkEditUrl = new AdminUrlUtil(serverURL, 'disable-bulk-edit')
virtualsUrl = new AdminUrlUtil(serverURL, virtualsSlug)
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
@@ -416,6 +419,44 @@ describe('List View', () => {
).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 () => {
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;
'disable-bulk-edit': DisableBulkEdit;
'custom-list-drawer': CustomListDrawer;
virtuals: Virtual;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
@@ -127,6 +128,7 @@ export interface Config {
'use-as-title-group-field': UseAsTitleGroupFieldSelect<false> | UseAsTitleGroupFieldSelect<true>;
'disable-bulk-edit': DisableBulkEditSelect<false> | DisableBulkEditSelect<true>;
'custom-list-drawer': CustomListDrawerSelect<false> | CustomListDrawerSelect<true>;
virtuals: VirtualsSelect<false> | VirtualsSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
@@ -582,6 +584,18 @@ export interface CustomListDrawer {
updatedAt: 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
* via the `definition` "payload-locked-documents".
@@ -696,6 +710,10 @@ export interface PayloadLockedDocument {
| ({
relationTo: 'custom-list-drawer';
value: string | CustomListDrawer;
} | null)
| ({
relationTo: 'virtuals';
value: string | Virtual;
} | null);
globalSlug?: string | null;
user: {
@@ -1111,6 +1129,17 @@ export interface CustomListDrawerSelect<T extends boolean = true> {
updatedAt?: 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
* 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 listDrawerSlug = 'with-list-drawer'
export const virtualsSlug = 'virtuals'
export const collectionSlugs = [
usersCollectionSlug,
customViews1CollectionSlug,
@@ -39,6 +40,7 @@ export const collectionSlugs = [
customFieldsSlug,
disableDuplicateSlug,
listDrawerSlug,
virtualsSlug,
]
export const customGlobalViews1GlobalSlug = 'custom-global-views-one'