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:
@@ -30,7 +30,27 @@ export const reduceFieldsToOptions = ({
|
||||
}: ReduceFieldOptionsArgs): ReducedField[] => {
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
27
test/admin/collections/Virtuals.ts
Normal file
27
test/admin/collections/Virtuals.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user