diff --git a/packages/ui/src/utilities/reduceFieldsToOptions.tsx b/packages/ui/src/utilities/reduceFieldsToOptions.tsx index fafee2484..a5064c8a7 100644 --- a/packages/ui/src/utilities/reduceFieldsToOptions.tsx +++ b/packages/ui/src/utilities/reduceFieldsToOptions.tsx @@ -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 } diff --git a/test/admin/collections/Virtuals.ts b/test/admin/collections/Virtuals.ts new file mode 100644 index 000000000..edcabfc6f --- /dev/null +++ b/test/admin/collections/Virtuals.ts @@ -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', + }, + ], +} diff --git a/test/admin/config.ts b/test/admin/config.ts index 69ba64555..8ce03dc68 100644 --- a/test/admin/config.ts +++ b/test/admin/config.ts @@ -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, diff --git a/test/admin/e2e/list-view/e2e.spec.ts b/test/admin/e2e/list-view/e2e.spec.ts index 2634455a2..a1ecb49f6 100644 --- a/test/admin/e2e/list-view/e2e.spec.ts +++ b/test/admin/e2e/list-view/e2e.spec.ts @@ -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 @@ -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): Promise { + return payload.create({ + collection: virtualsSlug, + data: { + post: overrides?.post, + ...overrides, + }, + }) as unknown as Promise +} diff --git a/test/admin/payload-types.ts b/test/admin/payload-types.ts index b7fab557c..eced683e8 100644 --- a/test/admin/payload-types.ts +++ b/test/admin/payload-types.ts @@ -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 | UseAsTitleGroupFieldSelect; 'disable-bulk-edit': DisableBulkEditSelect | DisableBulkEditSelect; 'custom-list-drawer': CustomListDrawerSelect | CustomListDrawerSelect; + virtuals: VirtualsSelect | VirtualsSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; @@ -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 { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "virtuals_select". + */ +export interface VirtualsSelect { + 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". diff --git a/test/admin/slugs.ts b/test/admin/slugs.ts index 9b47ca79f..9126ecabb 100644 --- a/test/admin/slugs.ts +++ b/test/admin/slugs.ts @@ -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'