feat(ui): make select and relationship field placeholder configurable (#12253)

### What?
Allows to overwrite the default placeholder text of select and
relationship fields.

### Why?
The default placeholder text is generic. In some scenarios a custom
placeholder can guide the user better.

### How?
Adds a new property `admin.placeholder` to relationship and select field
which allows to define an alternative text or translation function for
the placeholder. The placeholder is used in the form fields as well as
in the filter options.

![Screenshot 2025-04-29 at 15 28
54](https://github.com/user-attachments/assets/d83d60c8-d4f6-41b7-951c-9f21c238afd8)
![Screenshot 2025-04-29 at 15 28
19](https://github.com/user-attachments/assets/d2263cf1-6042-4072-b5a9-e10af5f380bb)

---------

Co-authored-by: Dan Ribbens <DanRibbens@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Dan Ribbens <dan.ribbens@gmail.com>
This commit is contained in:
Tobias Odendahl
2025-05-01 21:17:47 +02:00
committed by GitHub
parent 78d3af7dc9
commit e5683913b4
15 changed files with 138 additions and 16 deletions

View File

@@ -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. | | **`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. | | **`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) | | **`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`. | | **`appearance`** | Set to `drawer` or `select` to change the behavior of the field. Defaults to `select`. |
### Sort Options ### Sort Options

View File

@@ -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. | | **`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`) | | **`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 ## Example

View File

@@ -1061,6 +1061,7 @@ export type SelectField = {
} & Admin['components'] } & Admin['components']
isClearable?: boolean isClearable?: boolean
isSortable?: boolean isSortable?: boolean
placeholder?: LabelFunction | string
} & Admin } & Admin
/** /**
* Customize the SQL table name * Customize the SQL table name
@@ -1093,7 +1094,7 @@ export type SelectField = {
Omit<FieldBase, 'validate'> Omit<FieldBase, 'validate'>
export type SelectFieldClient = { export type SelectFieldClient = {
admin?: AdminClient & Pick<SelectField['admin'], 'isClearable' | 'isSortable'> admin?: AdminClient & Pick<SelectField['admin'], 'isClearable' | 'isSortable' | 'placeholder'>
} & FieldBaseClient & } & FieldBaseClient &
Pick<SelectField, 'hasMany' | 'interfaceName' | 'options' | 'type'> Pick<SelectField, 'hasMany' | 'interfaceName' | 'options' | 'type'>
@@ -1160,10 +1161,11 @@ type RelationshipAdmin = {
> >
} & Admin['components'] } & Admin['components']
isSortable?: boolean isSortable?: boolean
placeholder?: LabelFunction | string
} & Admin } & Admin
type RelationshipAdminClient = AdminClient & type RelationshipAdminClient = AdminClient &
Pick<RelationshipAdmin, 'allowCreate' | 'allowEdit' | 'appearance' | 'isSortable'> Pick<RelationshipAdmin, 'allowCreate' | 'allowEdit' | 'appearance' | 'isSortable' | 'placeholder'>
export type PolymorphicRelationshipField = { export type PolymorphicRelationshipField = {
admin?: { admin?: {

View File

@@ -84,7 +84,6 @@ const SelectAdapter: React.FC<ReactSelectAdapterProps> = (props) => {
captureMenuScroll captureMenuScroll
customProps={customProps} customProps={customProps}
isLoading={isLoading} isLoading={isLoading}
placeholder={getTranslation(placeholder, i18n)}
{...props} {...props}
className={classes} className={classes}
classNamePrefix="rs" classNamePrefix="rs"
@@ -113,6 +112,7 @@ const SelectAdapter: React.FC<ReactSelectAdapterProps> = (props) => {
onMenuClose={onMenuClose} onMenuClose={onMenuClose}
onMenuOpen={onMenuOpen} onMenuOpen={onMenuOpen}
options={options} options={options}
placeholder={getTranslation(placeholder, i18n)}
styles={styles} styles={styles}
unstyled={true} unstyled={true}
value={value} value={value}
@@ -160,7 +160,6 @@ const SelectAdapter: React.FC<ReactSelectAdapterProps> = (props) => {
<CreatableSelect <CreatableSelect
captureMenuScroll captureMenuScroll
isLoading={isLoading} isLoading={isLoading}
placeholder={getTranslation(placeholder, i18n)}
{...props} {...props}
className={classes} className={classes}
classNamePrefix="rs" classNamePrefix="rs"
@@ -191,6 +190,7 @@ const SelectAdapter: React.FC<ReactSelectAdapterProps> = (props) => {
onMenuClose={onMenuClose} onMenuClose={onMenuClose}
onMenuOpen={onMenuOpen} onMenuOpen={onMenuOpen}
options={options} options={options}
placeholder={getTranslation(placeholder, i18n)}
styles={styles} styles={styles}
unstyled={true} unstyled={true}
value={value} value={value}

View File

@@ -1,3 +1,4 @@
import type { LabelFunction } from 'payload'
import type { CommonProps, GroupBase, Props as ReactSelectStateManagerProps } from 'react-select' import type { CommonProps, GroupBase, Props as ReactSelectStateManagerProps } from 'react-select'
import type { DocumentDrawerProps, UseDocumentDrawer } from '../DocumentDrawer/types.js' import type { DocumentDrawerProps, UseDocumentDrawer } from '../DocumentDrawer/types.js'
@@ -101,7 +102,7 @@ export type ReactSelectAdapterProps = {
onMenuOpen?: () => void onMenuOpen?: () => void
onMenuScrollToBottom?: () => void onMenuScrollToBottom?: () => void
options: Option[] | OptionGroup[] options: Option[] | OptionGroup[]
placeholder?: string placeholder?: LabelFunction | string
showError?: boolean showError?: boolean
value?: Option | Option[] value?: Option | Option[]
} }

View File

@@ -23,7 +23,7 @@ const maxResultsPerRequest = 10
export const RelationshipFilter: React.FC<Props> = (props) => { export const RelationshipFilter: React.FC<Props> = (props) => {
const { const {
disabled, disabled,
field: { admin: { isSortable } = {}, hasMany, relationTo }, field: { admin: { isSortable, placeholder } = {}, hasMany, relationTo },
filterOptions, filterOptions,
onChange, onChange,
value, value,
@@ -412,7 +412,7 @@ export const RelationshipFilter: React.FC<Props> = (props) => {
onInputChange={handleInputChange} onInputChange={handleInputChange}
onMenuScrollToBottom={handleScrollToBottom} onMenuScrollToBottom={handleScrollToBottom}
options={options} options={options}
placeholder={t('general:selectValue')} placeholder={placeholder}
value={valueToRender} value={valueToRender}
/> />
)} )}

View File

@@ -11,6 +11,9 @@ import { formatOptions } from './formatOptions.js'
export const Select: React.FC<Props> = ({ export const Select: React.FC<Props> = ({
disabled, disabled,
field: {
admin: { placeholder },
},
isClearable, isClearable,
onChange, onChange,
operator, operator,
@@ -77,6 +80,7 @@ export const Select: React.FC<Props> = ({
isMulti={isMulti} isMulti={isMulti}
onChange={onSelect} onChange={onSelect}
options={options.map((option) => ({ ...option, label: getTranslation(option.label, i18n) }))} options={options.map((option) => ({ ...option, label: getTranslation(option.label, i18n) }))}
placeholder={placeholder}
value={valueToRender} value={valueToRender}
/> />
) )

View File

@@ -1,4 +1,4 @@
import type { Option, SelectFieldClient } from 'payload' import type { LabelFunction, Option, SelectFieldClient } from 'payload'
import type { DefaultFilterProps } from '../types.js' import type { DefaultFilterProps } from '../types.js'
@@ -7,5 +7,6 @@ export type SelectFilterProps = {
readonly isClearable?: boolean readonly isClearable?: boolean
readonly onChange: (val: string) => void readonly onChange: (val: string) => void
readonly options: Option[] readonly options: Option[]
readonly placeholder?: LabelFunction | string
readonly value: string readonly value: string
} & DefaultFilterProps } & DefaultFilterProps

View File

@@ -9,7 +9,7 @@ import type {
import { dequal } from 'dequal/lite' import { dequal } from 'dequal/lite'
import { wordBoundariesRegex } from 'payload/shared' import { wordBoundariesRegex } from 'payload/shared'
import * as qs from 'qs-esm' 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 { DocumentDrawerProps } from '../../elements/DocumentDrawer/types.js'
import type { ListDrawerProps } from '../../elements/ListDrawer/types.js' import type { ListDrawerProps } from '../../elements/ListDrawer/types.js'
@@ -56,6 +56,7 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
className, className,
description, description,
isSortable = true, isSortable = true,
placeholder,
sortOptions, sortOptions,
} = {}, } = {},
hasMany, hasMany,
@@ -779,6 +780,7 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
}) })
}} }}
options={options} options={options}
placeholder={placeholder}
showError={showError} showError={showError}
value={valueToRender ?? null} value={valueToRender ?? null}
/> />

View File

@@ -1,5 +1,5 @@
'use client' 'use client'
import type { OptionObject, StaticDescription, StaticLabel } from 'payload' import type { LabelFunction, OptionObject, StaticDescription, StaticLabel } from 'payload'
import { getTranslation } from '@payloadcms/translations' import { getTranslation } from '@payloadcms/translations'
import React from 'react' import React from 'react'
@@ -33,6 +33,7 @@ export type SelectInputProps = {
readonly onInputChange?: ReactSelectAdapterProps['onInputChange'] readonly onInputChange?: ReactSelectAdapterProps['onInputChange']
readonly options?: OptionObject[] readonly options?: OptionObject[]
readonly path: string readonly path: string
readonly placeholder?: LabelFunction | string
readonly readOnly?: boolean readonly readOnly?: boolean
readonly required?: boolean readonly required?: boolean
readonly showError?: boolean readonly showError?: boolean
@@ -58,6 +59,7 @@ export const SelectInput: React.FC<SelectInputProps> = (props) => {
onInputChange, onInputChange,
options, options,
path, path,
placeholder,
readOnly, readOnly,
required, required,
showError, showError,
@@ -125,6 +127,7 @@ export const SelectInput: React.FC<SelectInputProps> = (props) => {
...option, ...option,
label: getTranslation(option.label, i18n), label: getTranslation(option.label, i18n),
}))} }))}
placeholder={placeholder}
showError={showError} showError={showError}
value={valueToRender as OptionObject} value={valueToRender as OptionObject}
/> />

View File

@@ -38,6 +38,7 @@ const SelectFieldComponent: SelectFieldClientComponent = (props) => {
description, description,
isClearable = true, isClearable = true,
isSortable = true, isSortable = true,
placeholder,
} = {} as SelectFieldClientProps['field']['admin'], } = {} as SelectFieldClientProps['field']['admin'],
hasMany = false, hasMany = false,
label, label,
@@ -118,6 +119,7 @@ const SelectFieldComponent: SelectFieldClientComponent = (props) => {
onChange={onChange} onChange={onChange}
options={options} options={options}
path={path} path={path}
placeholder={placeholder}
readOnly={readOnly || disabled} readOnly={readOnly || disabled}
required={required} required={required}
showError={showError} showError={showError}

View File

@@ -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,
}

View File

@@ -1,7 +1,6 @@
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import path from 'path' import path from 'path'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { Array } from './collections/Array.js' import { Array } from './collections/Array.js'
import { BaseListFilter } from './collections/BaseListFilter.js' import { BaseListFilter } from './collections/BaseListFilter.js'
@@ -19,6 +18,7 @@ import { CollectionHidden } from './collections/Hidden.js'
import { ListDrawer } from './collections/ListDrawer.js' import { ListDrawer } from './collections/ListDrawer.js'
import { CollectionNoApiView } from './collections/NoApiView.js' import { CollectionNoApiView } from './collections/NoApiView.js'
import { CollectionNotInView } from './collections/NotInView.js' import { CollectionNotInView } from './collections/NotInView.js'
import { Placeholder } from './collections/Placeholder.js'
import { Posts } from './collections/Posts.js' import { Posts } from './collections/Posts.js'
import { UploadCollection } from './collections/Upload.js' import { UploadCollection } from './collections/Upload.js'
import { UploadTwoCollection } from './collections/UploadTwo.js' import { UploadTwoCollection } from './collections/UploadTwo.js'
@@ -43,7 +43,8 @@ import {
protectedCustomNestedViewPath, protectedCustomNestedViewPath,
publicCustomViewPath, publicCustomViewPath,
} from './shared.js' } from './shared.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfigWithDefaults({ export default buildConfigWithDefaults({
admin: { admin: {
importMap: { importMap: {
@@ -165,6 +166,7 @@ export default buildConfigWithDefaults({
BaseListFilter, BaseListFilter,
with300Documents, with300Documents,
ListDrawer, ListDrawer,
Placeholder,
], ],
globals: [ globals: [
GlobalHidden, GlobalHidden,

View File

@@ -1,11 +1,10 @@
import type { Page } from '@playwright/test' import type { Page } from '@playwright/test'
import type { User as PayloadUser } from 'payload'
import { expect, test } from '@playwright/test' 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, User } from '../../payload-types.js' import type { Config, Geo, Post } from '../../payload-types.js'
import { import {
ensureCompilationIsDone, ensureCompilationIsDone,
@@ -21,6 +20,7 @@ import {
customViews1CollectionSlug, customViews1CollectionSlug,
geoCollectionSlug, geoCollectionSlug,
listDrawerSlug, listDrawerSlug,
placeholderCollectionSlug,
postsCollectionSlug, postsCollectionSlug,
with300DocumentsSlug, with300DocumentsSlug,
} from '../../slugs.js' } from '../../slugs.js'
@@ -64,6 +64,7 @@ describe('List View', () => {
let customViewsUrl: AdminUrlUtil let customViewsUrl: AdminUrlUtil
let with300DocumentsUrl: AdminUrlUtil let with300DocumentsUrl: AdminUrlUtil
let withListViewUrl: AdminUrlUtil let withListViewUrl: AdminUrlUtil
let placeholderUrl: AdminUrlUtil
let user: any let user: any
let serverURL: string let serverURL: string
@@ -87,7 +88,7 @@ describe('List View', () => {
baseListFiltersUrl = new AdminUrlUtil(serverURL, 'base-list-filters') baseListFiltersUrl = new AdminUrlUtil(serverURL, 'base-list-filters')
customViewsUrl = new AdminUrlUtil(serverURL, customViews1CollectionSlug) customViewsUrl = new AdminUrlUtil(serverURL, customViews1CollectionSlug)
withListViewUrl = new AdminUrlUtil(serverURL, listDrawerSlug) withListViewUrl = new AdminUrlUtil(serverURL, listDrawerSlug)
placeholderUrl = new AdminUrlUtil(serverURL, placeholderCollectionSlug)
const context = await browser.newContext() const context = await browser.newContext()
page = await context.newPage() page = await context.newPage()
initPageConsoleErrorCatch(page) initPageConsoleErrorCatch(page)
@@ -1408,6 +1409,66 @@ describe('List View', () => {
).toHaveText('Title') ).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<Post>): Promise<Post> { async function createPost(overrides?: Partial<Post>): Promise<Post> {

View File

@@ -14,6 +14,7 @@ export const noApiViewCollectionSlug = 'collection-no-api-view'
export const disableDuplicateSlug = 'disable-duplicate' export const disableDuplicateSlug = 'disable-duplicate'
export const disableCopyToLocale = 'disable-copy-to-locale' export const disableCopyToLocale = 'disable-copy-to-locale'
export const uploadCollectionSlug = 'uploads' export const uploadCollectionSlug = 'uploads'
export const placeholderCollectionSlug = 'placeholder'
export const uploadTwoCollectionSlug = 'uploads-two' export const uploadTwoCollectionSlug = 'uploads-two'
export const customFieldsSlug = 'custom-fields' export const customFieldsSlug = 'custom-fields'