diff --git a/packages/payload/src/collections/config/listSearchableFields.spec.ts b/packages/payload/src/collections/config/listSearchableFields.spec.ts new file mode 100644 index 00000000000..a10439f8591 --- /dev/null +++ b/packages/payload/src/collections/config/listSearchableFields.spec.ts @@ -0,0 +1,102 @@ +import type { Config } from '../../config/types.js' +import type { CollectionConfig } from '../../index.js' + +import { InvalidConfiguration } from '../../errors/InvalidConfiguration.js' +import { sanitizeCollection } from './sanitize.js' +import { describe, it, expect } from 'vitest' + +describe('sanitize - collections -', () => { + const config = { + collections: [], + globals: [], + } as Partial + + describe('validate listSearchableFields -', () => { + const defaultCollection: CollectionConfig = { + slug: 'collection-with-defaults', + fields: [ + { + name: 'title', + type: 'text', + }, + { + name: 'slug', + type: 'text', + }, + ], + } + + it('should throw on non-existent field', () => { + const collectionConfig: CollectionConfig = { + ...defaultCollection, + admin: { + listSearchableFields: ['title', 'nonExistentField'], + }, + } + expect(() => { + sanitizeCollection( + // @ts-expect-error + { + ...config, + collections: [collectionConfig], + }, + collectionConfig, + ) + }).toThrow(InvalidConfiguration) + }) + + it('should not throw when all fields exist', () => { + const collectionConfig: CollectionConfig = { + ...defaultCollection, + admin: { + listSearchableFields: ['title', 'slug'], + }, + } + expect(() => { + sanitizeCollection( + // @ts-expect-error + { + ...config, + collections: [collectionConfig], + }, + collectionConfig, + ) + }).not.toThrow() + }) + + it('should not throw on default field: id', () => { + const collectionConfig: CollectionConfig = { + ...defaultCollection, + admin: { + listSearchableFields: ['id'], + }, + } + expect(() => { + sanitizeCollection( + // @ts-expect-error + { + ...config, + collections: [collectionConfig], + }, + collectionConfig, + ) + }).not.toThrow() + }) + + it('should not throw when listSearchableFields is undefined', () => { + const collectionConfig: CollectionConfig = { + ...defaultCollection, + } + expect(() => { + sanitizeCollection( + // @ts-expect-error + { + ...config, + collections: [collectionConfig], + }, + collectionConfig, + ) + }).not.toThrow() + }) + }) +}) diff --git a/packages/payload/src/collections/config/listSearchableFields.ts b/packages/payload/src/collections/config/listSearchableFields.ts new file mode 100644 index 00000000000..1c8828ecbe1 --- /dev/null +++ b/packages/payload/src/collections/config/listSearchableFields.ts @@ -0,0 +1,47 @@ +import type { CollectionConfig } from '../../index.js' + +import { InvalidConfiguration } from '../../errors/InvalidConfiguration.js' +import { fieldAffectsData } from '../../fields/config/types.js' +import { flattenTopLevelFields } from '../../utilities/flattenTopLevelFields.js' + +/** + * Validate listSearchableFields for collections. + * + * Previously, specifying a field name in `admin.listSearchableFields` that does not + * exist on the collection would not throw until the field was actually queried + * (e.g. when searching in the List View), and the resulting error was only + * ever surfaced in production builds - in development it silently failed to + * error at all. Validating eagerly here, at config sanitization time, ensures a + * clear, actionable error is thrown consistently in every environment. + */ +export const validateListSearchableFields = (config: CollectionConfig) => { + if (!config.admin?.listSearchableFields?.length) { + return + } + + const fields = flattenTopLevelFields(config.fields) + + for (const fieldName of config.admin.listSearchableFields) { + if (fieldName === 'id') { + continue + } + + // Only validate the top-level segment of the path - relationship/join + // paths such as `category.title` are resolved deeper in the query and + // are not flattened top-level fields of this collection. + const topLevelFieldName = fieldName.split('.')[0] + + const searchableField = fields.find((field) => { + if (fieldAffectsData(field)) { + return field.name === topLevelFieldName + } + return false + }) + + if (!searchableField) { + throw new InvalidConfiguration( + `The field "${fieldName}" specified in "admin.listSearchableFields" does not exist in the collection "${config.slug}"`, + ) + } + } +} diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index 708ba0f89ea..b977d2cc016 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -26,6 +26,7 @@ import { baseVersionFields } from '../../versions/baseFields.js' import { versionDefaults } from '../../versions/defaults.js' import { defaultCollectionEndpoints } from '../endpoints/index.js' import { addDefaultsToAuthConfig, addDefaultsToCollectionConfig } from './defaults.js' +import { validateListSearchableFields } from './listSearchableFields.js' import { sanitizeCompoundIndexes } from './sanitizeCompoundIndexes.js' import { validateUseAsTitle } from './useAsTitle.js' @@ -338,6 +339,7 @@ export const sanitizeCollection = ( } validateUseAsTitle(sanitized) + validateListSearchableFields(sanitized) const sanitizedConfig = sanitized as SanitizedCollectionConfig