diff --git a/docs/fields/relationship.mdx b/docs/fields/relationship.mdx index 10d652a2c2..96f3b1ebe2 100644 --- a/docs/fields/relationship.mdx +++ b/docs/fields/relationship.mdx @@ -94,6 +94,7 @@ The Relationship Field inherits all of the default options from the base [Field | **`allowCreate`** | Set to `false` if you'd like to disable the ability to create new documents from within the relationship field. | | **`allowEdit`** | Set to `false` if you'd like to disable the ability to edit documents from within the relationship field. | | **`sortOptions`** | Define a default sorting order for the options within a Relationship field's dropdown. [More](#sort-options) | +| **`placeholder`** | Define a custom text or function to replace the generic default placeholder | | **`appearance`** | Set to `drawer` or `select` to change the behavior of the field. Defaults to `select`. | ### Sort Options diff --git a/docs/fields/select.mdx b/docs/fields/select.mdx index c8328a8d8a..cf5ed27b54 100644 --- a/docs/fields/select.mdx +++ b/docs/fields/select.mdx @@ -89,6 +89,7 @@ The Select Field inherits all of the default options from the base [Field Admin | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | **`isClearable`** | Set to `true` if you'd like this field to be clearable within the Admin UI. | | **`isSortable`** | Set to `true` if you'd like this field to be sortable within the Admin UI using drag and drop. (Only works when `hasMany` is set to `true`) | +| **`placeholder`** | Define a custom text or function to replace the generic default placeholder | ## Example diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 3050e6250c..a8aec98c94 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -1061,6 +1061,7 @@ export type SelectField = { } & Admin['components'] isClearable?: boolean isSortable?: boolean + placeholder?: LabelFunction | string } & Admin /** * Customize the SQL table name @@ -1093,7 +1094,7 @@ export type SelectField = { Omit export type SelectFieldClient = { - admin?: AdminClient & Pick + admin?: AdminClient & Pick } & FieldBaseClient & Pick @@ -1160,10 +1161,11 @@ type RelationshipAdmin = { > } & Admin['components'] isSortable?: boolean + placeholder?: LabelFunction | string } & Admin type RelationshipAdminClient = AdminClient & - Pick + Pick export type PolymorphicRelationshipField = { admin?: { diff --git a/packages/ui/src/elements/ReactSelect/index.tsx b/packages/ui/src/elements/ReactSelect/index.tsx index 8319e77146..01c2f8f21d 100644 --- a/packages/ui/src/elements/ReactSelect/index.tsx +++ b/packages/ui/src/elements/ReactSelect/index.tsx @@ -84,7 +84,6 @@ const SelectAdapter: React.FC = (props) => { captureMenuScroll customProps={customProps} isLoading={isLoading} - placeholder={getTranslation(placeholder, i18n)} {...props} className={classes} classNamePrefix="rs" @@ -113,6 +112,7 @@ const SelectAdapter: React.FC = (props) => { onMenuClose={onMenuClose} onMenuOpen={onMenuOpen} options={options} + placeholder={getTranslation(placeholder, i18n)} styles={styles} unstyled={true} value={value} @@ -160,7 +160,6 @@ const SelectAdapter: React.FC = (props) => { = (props) => { onMenuClose={onMenuClose} onMenuOpen={onMenuOpen} options={options} + placeholder={getTranslation(placeholder, i18n)} styles={styles} unstyled={true} value={value} diff --git a/packages/ui/src/elements/ReactSelect/types.ts b/packages/ui/src/elements/ReactSelect/types.ts index 4520327b08..2f4b8592d6 100644 --- a/packages/ui/src/elements/ReactSelect/types.ts +++ b/packages/ui/src/elements/ReactSelect/types.ts @@ -1,3 +1,4 @@ +import type { LabelFunction } from 'payload' import type { CommonProps, GroupBase, Props as ReactSelectStateManagerProps } from 'react-select' import type { DocumentDrawerProps, UseDocumentDrawer } from '../DocumentDrawer/types.js' @@ -101,7 +102,7 @@ export type ReactSelectAdapterProps = { onMenuOpen?: () => void onMenuScrollToBottom?: () => void options: Option[] | OptionGroup[] - placeholder?: string + placeholder?: LabelFunction | string showError?: boolean value?: Option | Option[] } diff --git a/packages/ui/src/elements/WhereBuilder/Condition/Relationship/index.tsx b/packages/ui/src/elements/WhereBuilder/Condition/Relationship/index.tsx index cd5e1ea22d..825e0144db 100644 --- a/packages/ui/src/elements/WhereBuilder/Condition/Relationship/index.tsx +++ b/packages/ui/src/elements/WhereBuilder/Condition/Relationship/index.tsx @@ -23,7 +23,7 @@ const maxResultsPerRequest = 10 export const RelationshipFilter: React.FC = (props) => { const { disabled, - field: { admin: { isSortable } = {}, hasMany, relationTo }, + field: { admin: { isSortable, placeholder } = {}, hasMany, relationTo }, filterOptions, onChange, value, @@ -412,7 +412,7 @@ export const RelationshipFilter: React.FC = (props) => { onInputChange={handleInputChange} onMenuScrollToBottom={handleScrollToBottom} options={options} - placeholder={t('general:selectValue')} + placeholder={placeholder} value={valueToRender} /> )} diff --git a/packages/ui/src/elements/WhereBuilder/Condition/Select/index.tsx b/packages/ui/src/elements/WhereBuilder/Condition/Select/index.tsx index c9b1ebf0bc..43f7b509a0 100644 --- a/packages/ui/src/elements/WhereBuilder/Condition/Select/index.tsx +++ b/packages/ui/src/elements/WhereBuilder/Condition/Select/index.tsx @@ -11,6 +11,9 @@ import { formatOptions } from './formatOptions.js' export const Select: React.FC = ({ disabled, + field: { + admin: { placeholder }, + }, isClearable, onChange, operator, @@ -77,6 +80,7 @@ export const Select: React.FC = ({ isMulti={isMulti} onChange={onSelect} options={options.map((option) => ({ ...option, label: getTranslation(option.label, i18n) }))} + placeholder={placeholder} value={valueToRender} /> ) diff --git a/packages/ui/src/elements/WhereBuilder/Condition/Select/types.ts b/packages/ui/src/elements/WhereBuilder/Condition/Select/types.ts index 27c34b3f59..186f42228e 100644 --- a/packages/ui/src/elements/WhereBuilder/Condition/Select/types.ts +++ b/packages/ui/src/elements/WhereBuilder/Condition/Select/types.ts @@ -1,4 +1,4 @@ -import type { Option, SelectFieldClient } from 'payload' +import type { LabelFunction, Option, SelectFieldClient } from 'payload' import type { DefaultFilterProps } from '../types.js' @@ -7,5 +7,6 @@ export type SelectFilterProps = { readonly isClearable?: boolean readonly onChange: (val: string) => void readonly options: Option[] + readonly placeholder?: LabelFunction | string readonly value: string } & DefaultFilterProps diff --git a/packages/ui/src/fields/Relationship/index.tsx b/packages/ui/src/fields/Relationship/index.tsx index a1fd1a80ed..3d093c0c66 100644 --- a/packages/ui/src/fields/Relationship/index.tsx +++ b/packages/ui/src/fields/Relationship/index.tsx @@ -9,7 +9,7 @@ import type { import { dequal } from 'dequal/lite' import { wordBoundariesRegex } from 'payload/shared' import * as qs from 'qs-esm' -import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' import type { DocumentDrawerProps } from '../../elements/DocumentDrawer/types.js' import type { ListDrawerProps } from '../../elements/ListDrawer/types.js' @@ -56,6 +56,7 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => className, description, isSortable = true, + placeholder, sortOptions, } = {}, hasMany, @@ -779,6 +780,7 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => }) }} options={options} + placeholder={placeholder} showError={showError} value={valueToRender ?? null} /> diff --git a/packages/ui/src/fields/Select/Input.tsx b/packages/ui/src/fields/Select/Input.tsx index d51d0148e9..da6d007a9a 100644 --- a/packages/ui/src/fields/Select/Input.tsx +++ b/packages/ui/src/fields/Select/Input.tsx @@ -1,5 +1,5 @@ 'use client' -import type { OptionObject, StaticDescription, StaticLabel } from 'payload' +import type { LabelFunction, OptionObject, StaticDescription, StaticLabel } from 'payload' import { getTranslation } from '@payloadcms/translations' import React from 'react' @@ -33,6 +33,7 @@ export type SelectInputProps = { readonly onInputChange?: ReactSelectAdapterProps['onInputChange'] readonly options?: OptionObject[] readonly path: string + readonly placeholder?: LabelFunction | string readonly readOnly?: boolean readonly required?: boolean readonly showError?: boolean @@ -58,6 +59,7 @@ export const SelectInput: React.FC = (props) => { onInputChange, options, path, + placeholder, readOnly, required, showError, @@ -125,6 +127,7 @@ export const SelectInput: React.FC = (props) => { ...option, label: getTranslation(option.label, i18n), }))} + placeholder={placeholder} showError={showError} value={valueToRender as OptionObject} /> diff --git a/packages/ui/src/fields/Select/index.tsx b/packages/ui/src/fields/Select/index.tsx index b8b67af5f9..d9b4f65084 100644 --- a/packages/ui/src/fields/Select/index.tsx +++ b/packages/ui/src/fields/Select/index.tsx @@ -38,6 +38,7 @@ const SelectFieldComponent: SelectFieldClientComponent = (props) => { description, isClearable = true, isSortable = true, + placeholder, } = {} as SelectFieldClientProps['field']['admin'], hasMany = false, label, @@ -118,6 +119,7 @@ const SelectFieldComponent: SelectFieldClientComponent = (props) => { onChange={onChange} options={options} path={path} + placeholder={placeholder} readOnly={readOnly || disabled} required={required} showError={showError} diff --git a/test/admin/collections/Placeholder.ts b/test/admin/collections/Placeholder.ts new file mode 100644 index 0000000000..5e47103c0d --- /dev/null +++ b/test/admin/collections/Placeholder.ts @@ -0,0 +1,41 @@ +import type { CollectionConfig } from 'payload' + +import { placeholderCollectionSlug } from '../slugs.js' + +export const Placeholder: CollectionConfig = { + slug: placeholderCollectionSlug, + fields: [ + { + name: 'defaultSelect', + type: 'select', + options: [ + { + label: 'Option 1', + value: 'option1', + }, + ], + }, + { + name: 'placeholderSelect', + type: 'select', + options: [{ label: 'Option 1', value: 'option1' }], + admin: { + placeholder: 'Custom placeholder', + }, + }, + { + name: 'defaultRelationship', + type: 'relationship', + relationTo: 'posts', + }, + { + name: 'placeholderRelationship', + type: 'relationship', + relationTo: 'posts', + admin: { + placeholder: 'Custom placeholder', + }, + }, + ], + versions: true, +} diff --git a/test/admin/config.ts b/test/admin/config.ts index 10a5c6c8d5..80f0de525f 100644 --- a/test/admin/config.ts +++ b/test/admin/config.ts @@ -1,7 +1,6 @@ import { fileURLToPath } from 'node:url' import path from 'path' -const filename = fileURLToPath(import.meta.url) -const dirname = path.dirname(filename) + import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' import { Array } from './collections/Array.js' import { BaseListFilter } from './collections/BaseListFilter.js' @@ -19,6 +18,7 @@ import { CollectionHidden } from './collections/Hidden.js' import { ListDrawer } from './collections/ListDrawer.js' import { CollectionNoApiView } from './collections/NoApiView.js' import { CollectionNotInView } from './collections/NotInView.js' +import { Placeholder } from './collections/Placeholder.js' import { Posts } from './collections/Posts.js' import { UploadCollection } from './collections/Upload.js' import { UploadTwoCollection } from './collections/UploadTwo.js' @@ -43,7 +43,8 @@ import { protectedCustomNestedViewPath, publicCustomViewPath, } from './shared.js' - +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) export default buildConfigWithDefaults({ admin: { importMap: { @@ -165,6 +166,7 @@ export default buildConfigWithDefaults({ BaseListFilter, with300Documents, ListDrawer, + Placeholder, ], globals: [ GlobalHidden, diff --git a/test/admin/e2e/list-view/e2e.spec.ts b/test/admin/e2e/list-view/e2e.spec.ts index 7e155f8492..d9bf0c149e 100644 --- a/test/admin/e2e/list-view/e2e.spec.ts +++ b/test/admin/e2e/list-view/e2e.spec.ts @@ -1,11 +1,10 @@ import type { Page } from '@playwright/test' -import type { User as PayloadUser } from 'payload' import { expect, test } from '@playwright/test' import { mapAsync } from 'payload' import * as qs from 'qs-esm' -import type { Config, Geo, Post, User } from '../../payload-types.js' +import type { Config, Geo, Post } from '../../payload-types.js' import { ensureCompilationIsDone, @@ -21,6 +20,7 @@ import { customViews1CollectionSlug, geoCollectionSlug, listDrawerSlug, + placeholderCollectionSlug, postsCollectionSlug, with300DocumentsSlug, } from '../../slugs.js' @@ -64,6 +64,7 @@ describe('List View', () => { let customViewsUrl: AdminUrlUtil let with300DocumentsUrl: AdminUrlUtil let withListViewUrl: AdminUrlUtil + let placeholderUrl: AdminUrlUtil let user: any let serverURL: string @@ -87,7 +88,7 @@ describe('List View', () => { baseListFiltersUrl = new AdminUrlUtil(serverURL, 'base-list-filters') customViewsUrl = new AdminUrlUtil(serverURL, customViews1CollectionSlug) withListViewUrl = new AdminUrlUtil(serverURL, listDrawerSlug) - + placeholderUrl = new AdminUrlUtil(serverURL, placeholderCollectionSlug) const context = await browser.newContext() page = await context.newPage() initPageConsoleErrorCatch(page) @@ -1408,6 +1409,66 @@ describe('List View', () => { ).toHaveText('Title') }) }) + + describe('placeholder', () => { + test('should display placeholder in filter options', async () => { + await page.goto( + `${placeholderUrl.list}${qs.stringify( + { + where: { + or: [ + { + and: [ + { + defaultSelect: { + equals: '', + }, + }, + { + placeholderSelect: { + equals: '', + }, + }, + { + defaultRelationship: { + equals: '', + }, + }, + { + placeholderRelationship: { + equals: '', + }, + }, + ], + }, + ], + }, + }, + { addQueryPrefix: true }, + )}`, + ) + + const conditionValueSelects = page.locator('#list-controls-where .condition__value') + await expect(conditionValueSelects.nth(0)).toHaveText('Select a value') + await expect(conditionValueSelects.nth(1)).toHaveText('Custom placeholder') + await expect(conditionValueSelects.nth(2)).toHaveText('Select a value') + await expect(conditionValueSelects.nth(3)).toHaveText('Custom placeholder') + }) + }) + test('should display placeholder in edit view', async () => { + await page.goto(placeholderUrl.create) + + await expect(page.locator('#field-defaultSelect .rs__placeholder')).toHaveText('Select a value') + await expect(page.locator('#field-placeholderSelect .rs__placeholder')).toHaveText( + 'Custom placeholder', + ) + await expect(page.locator('#field-defaultRelationship .rs__placeholder')).toHaveText( + 'Select a value', + ) + await expect(page.locator('#field-placeholderRelationship .rs__placeholder')).toHaveText( + 'Custom placeholder', + ) + }) }) async function createPost(overrides?: Partial): Promise { diff --git a/test/admin/slugs.ts b/test/admin/slugs.ts index a64f1085fa..93f0739321 100644 --- a/test/admin/slugs.ts +++ b/test/admin/slugs.ts @@ -14,6 +14,7 @@ export const noApiViewCollectionSlug = 'collection-no-api-view' export const disableDuplicateSlug = 'disable-duplicate' export const disableCopyToLocale = 'disable-copy-to-locale' export const uploadCollectionSlug = 'uploads' +export const placeholderCollectionSlug = 'placeholder' export const uploadTwoCollectionSlug = 'uploads-two' export const customFieldsSlug = 'custom-fields'