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[] => {
|
}: 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 { 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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
|
|||||||
@@ -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".
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user