fix: ensures select & radio field option labels accept JSX elements (#11658)
### What This PR ensures that `select` and `radio` field option labels properly accept and render JSX elements. ### Why Previously, JSX elements could be passed as option labels, but the type definition for options only allowed `LabelFunction` or `StaticLabel`, resulting in type errors. Additionally: - JSX labels did not render correctly in the list view but now do. - In the versions diff view, JSX labels were not supported since it only accepts strings. To address this, we now fallback to the option `value` when the label is a JSX element.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
import type { I18nClient } from '@payloadcms/translations'
|
||||
import type { OptionObject, SelectField, SelectFieldDiffClientComponent } from 'payload'
|
||||
import type { Option, SelectField, SelectFieldDiffClientComponent } from 'payload'
|
||||
|
||||
import { getTranslation } from '@payloadcms/translations'
|
||||
import { useTranslation } from '@payloadcms/ui'
|
||||
@@ -17,7 +17,7 @@ const getOptionsToRender = (
|
||||
value: string,
|
||||
options: SelectField['options'],
|
||||
hasMany: boolean,
|
||||
): (OptionObject | string)[] | OptionObject | string => {
|
||||
): Option | Option[] => {
|
||||
if (hasMany && Array.isArray(value)) {
|
||||
return value.map(
|
||||
(val) =>
|
||||
@@ -31,17 +31,33 @@ const getOptionsToRender = (
|
||||
)
|
||||
}
|
||||
|
||||
const getTranslatedOptions = (
|
||||
options: (OptionObject | string)[] | OptionObject | string,
|
||||
i18n: I18nClient,
|
||||
): string => {
|
||||
/**
|
||||
* Translates option labels while ensuring they are strings.
|
||||
* If `options.label` is a JSX element, it falls back to `options.value` because `DiffViewer`
|
||||
* expects all values to be strings.
|
||||
*/
|
||||
const getTranslatedOptions = (options: Option | Option[], i18n: I18nClient): string => {
|
||||
if (Array.isArray(options)) {
|
||||
return options
|
||||
.map((option) => (typeof option === 'string' ? option : getTranslation(option.label, i18n)))
|
||||
.map((option) => {
|
||||
if (typeof option === 'string') {
|
||||
return option
|
||||
}
|
||||
const translatedLabel = getTranslation(option.label, i18n)
|
||||
|
||||
// Ensure the result is a string, otherwise use option.value
|
||||
return typeof translatedLabel === 'string' ? translatedLabel : option.value
|
||||
})
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
return typeof options === 'string' ? options : getTranslation(options.label, i18n)
|
||||
if (typeof options === 'string') {
|
||||
return options
|
||||
}
|
||||
|
||||
const translatedLabel = getTranslation(options.label, i18n)
|
||||
|
||||
return typeof translatedLabel === 'string' ? translatedLabel : options.value
|
||||
}
|
||||
|
||||
export const Select: SelectFieldDiffClientComponent = ({
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import type { EditorProps } from '@monaco-editor/react'
|
||||
import type { JSONSchema4 } from 'json-schema'
|
||||
import type { CSSProperties } from 'react'
|
||||
import type React from 'react'
|
||||
import type { DeepUndefinable, MarkRequired } from 'ts-essentials'
|
||||
|
||||
import type {
|
||||
@@ -432,11 +433,16 @@ export type Validate<
|
||||
options: ValidateOptions<TData, TSiblingData, TFieldConfig, TValue>,
|
||||
) => Promise<string | true> | string | true
|
||||
|
||||
export type OptionLabel =
|
||||
| (() => React.JSX.Element)
|
||||
| LabelFunction
|
||||
| React.JSX.Element
|
||||
| StaticLabel
|
||||
|
||||
export type OptionObject = {
|
||||
label: LabelFunction | StaticLabel
|
||||
label: OptionLabel
|
||||
value: string
|
||||
}
|
||||
|
||||
export type Option = OptionObject | string
|
||||
|
||||
export type FieldGraphQLType = {
|
||||
|
||||
@@ -1274,6 +1274,7 @@ export type {
|
||||
NumberField,
|
||||
NumberFieldClient,
|
||||
Option,
|
||||
OptionLabel,
|
||||
OptionObject,
|
||||
PointField,
|
||||
PointFieldClient,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { JSX } from 'react'
|
||||
import type { I18n, TFunction } from '../types.js'
|
||||
|
||||
type LabelType =
|
||||
| (() => JSX.Element)
|
||||
| (({ t }: { t: TFunction }) => string)
|
||||
| JSX.Element
|
||||
| Record<string, string>
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
import type { DefaultCellComponentProps, UploadFieldClient } from 'payload'
|
||||
|
||||
import { getTranslation } from '@payloadcms/translations'
|
||||
import { fieldAffectsData, fieldIsID, formatAdminURL } from 'payload/shared'
|
||||
import { fieldAffectsData, fieldIsID } from 'payload/shared'
|
||||
import React from 'react' // TODO: abstract this out to support all routers
|
||||
|
||||
import { useConfig } from '../../../providers/Config/index.js'
|
||||
import { useTranslation } from '../../../providers/Translation/index.js'
|
||||
import { formatAdminURL } from '../../../utilities/formatAdminURL.js'
|
||||
import { getDisplayedFieldValue } from '../../../utilities/getDisplayedFieldValue.js'
|
||||
import { Link } from '../../Link/index.js'
|
||||
import { CodeCell } from './fields/Code/index.js'
|
||||
import { cellComponents } from './fields/index.js'
|
||||
@@ -96,12 +98,17 @@ export const DefaultCell: React.FC<DefaultCellComponentProps> = (props) => {
|
||||
)
|
||||
}
|
||||
|
||||
const displayedValue = getDisplayedFieldValue(cellData, field, i18n)
|
||||
|
||||
const DefaultCellComponent: React.FC<DefaultCellComponentProps> =
|
||||
typeof cellData !== 'undefined' && cellComponents[field.type]
|
||||
|
||||
let CellComponent: React.ReactNode = null
|
||||
|
||||
if (DefaultCellComponent) {
|
||||
// Handle JSX labels before using DefaultCellComponent
|
||||
if (React.isValidElement(displayedValue)) {
|
||||
CellComponent = displayedValue
|
||||
} else if (DefaultCellComponent) {
|
||||
CellComponent = <DefaultCellComponent cellData={cellData} rowData={rowData} {...props} />
|
||||
} else if (!DefaultCellComponent) {
|
||||
// DefaultCellComponent does not exist for certain field types like `text`
|
||||
@@ -125,13 +132,17 @@ export const DefaultCell: React.FC<DefaultCellComponentProps> = (props) => {
|
||||
} else {
|
||||
return (
|
||||
<WrapElement {...wrapElementProps}>
|
||||
{(cellData === '' || typeof cellData === 'undefined' || cellData === null) &&
|
||||
{(displayedValue === '' ||
|
||||
typeof displayedValue === 'undefined' ||
|
||||
displayedValue === null) &&
|
||||
i18n.t('general:noLabel', {
|
||||
label: getTranslation(('label' in field ? field.label : null) || 'data', i18n),
|
||||
})}
|
||||
{typeof cellData === 'string' && cellData}
|
||||
{typeof cellData === 'number' && cellData}
|
||||
{typeof cellData === 'object' && cellData !== null && JSON.stringify(cellData)}
|
||||
{typeof displayedValue === 'string' && displayedValue}
|
||||
{typeof displayedValue === 'number' && displayedValue}
|
||||
{typeof displayedValue === 'object' &&
|
||||
displayedValue !== null &&
|
||||
JSON.stringify(displayedValue)}
|
||||
</WrapElement>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,11 +27,13 @@ import React from 'react'
|
||||
import type { SortColumnProps } from '../SortColumn/index.js'
|
||||
|
||||
import {
|
||||
DefaultCell,
|
||||
RenderCustomComponent,
|
||||
RenderDefaultCell,
|
||||
SortColumn,
|
||||
// eslint-disable-next-line payload/no-imports-from-exports-dir
|
||||
} from '../../exports/client/index.js'
|
||||
import { hasOptionLabelJSXElement } from '../../utilities/hasOptionLabelJSXElement.js'
|
||||
import { RenderServerComponent } from '../RenderServerComponent/index.js'
|
||||
import { filterFields } from './filterFields.js'
|
||||
|
||||
@@ -270,6 +272,16 @@ export const buildColumnState = (args: Args): Column[] => {
|
||||
importMap: payload.importMap,
|
||||
serverProps: cellServerProps,
|
||||
})
|
||||
} else if (
|
||||
cellClientProps.cellData &&
|
||||
cellClientProps.field &&
|
||||
hasOptionLabelJSXElement(cellClientProps)
|
||||
) {
|
||||
CustomCell = RenderServerComponent({
|
||||
clientProps: cellClientProps,
|
||||
Component: DefaultCell,
|
||||
importMap: payload.importMap,
|
||||
})
|
||||
} else {
|
||||
const CustomCellComponent = _field?.admin?.components?.Cell
|
||||
|
||||
|
||||
32
packages/ui/src/utilities/getDisplayedFieldValue.ts
Normal file
32
packages/ui/src/utilities/getDisplayedFieldValue.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { I18nClient } from '@payloadcms/translations'
|
||||
import type { ClientField } from 'payload'
|
||||
|
||||
import { getTranslation } from '@payloadcms/translations'
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* Returns the appropriate display value for a field.
|
||||
* - For select and radio fields:
|
||||
* - Returns JSX elements as-is.
|
||||
* - Translates localized label objects based on the current language.
|
||||
* - Returns string labels directly.
|
||||
* - Falls back to the option value if no valid label is found.
|
||||
* - For all other field types, returns `cellData` unchanged.
|
||||
*/
|
||||
export const getDisplayedFieldValue = (cellData: any, field: ClientField, i18n: I18nClient) => {
|
||||
if ((field?.type === 'select' || field?.type === 'radio') && Array.isArray(field.options)) {
|
||||
const selectedOption = field.options.find((opt) =>
|
||||
typeof opt === 'object' ? opt.value === cellData : opt === cellData,
|
||||
)
|
||||
|
||||
if (selectedOption) {
|
||||
if (typeof selectedOption === 'object' && 'label' in selectedOption) {
|
||||
return React.isValidElement(selectedOption.label)
|
||||
? selectedOption.label // Return JSX directly
|
||||
: getTranslation(selectedOption.label, i18n) || selectedOption.value // Use translation or fallback to value
|
||||
}
|
||||
return selectedOption // If option is a string, return it directly
|
||||
}
|
||||
}
|
||||
return cellData // Default fallback if no match found
|
||||
}
|
||||
23
packages/ui/src/utilities/hasOptionLabelJSXElement.ts
Normal file
23
packages/ui/src/utilities/hasOptionLabelJSXElement.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { DefaultCellComponentProps } from 'payload'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
export const hasOptionLabelJSXElement = (cellClientProps: DefaultCellComponentProps) => {
|
||||
const { cellData, field } = cellClientProps
|
||||
|
||||
if ((field?.type === 'select' || field?.type == 'radio') && Array.isArray(field?.options)) {
|
||||
const matchingOption = field.options.find(
|
||||
(option) => typeof option === 'object' && option.value === cellData,
|
||||
)
|
||||
|
||||
if (
|
||||
matchingOption &&
|
||||
typeof matchingOption === 'object' &&
|
||||
React.isValidElement(matchingOption.label)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
21
test/fields/collections/Radio/CustomJSXLabel.tsx
Normal file
21
test/fields/collections/Radio/CustomJSXLabel.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
export const CustomJSXLabel = () => {
|
||||
return (
|
||||
<svg
|
||||
className="graphic-icon"
|
||||
height="20px"
|
||||
id="payload-logo"
|
||||
viewBox="0 0 25 25"
|
||||
width="20px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M11.8673 21.2336L4.40922 16.9845C4.31871 16.9309 4.25837 16.8355 4.25837 16.7282V10.1609C4.25837 10.0477 4.38508 9.97616 4.48162 10.0298L13.1404 14.9642C13.2611 15.0358 13.412 14.9464 13.412 14.8093V11.6091C13.412 11.4839 13.3456 11.3647 13.2309 11.2992L2.81624 5.36353C2.72573 5.30989 2.60505 5.30989 2.51454 5.36353L1.15085 6.14422C1.06034 6.19786 1 6.29321 1 6.40048V18.5995C1 18.7068 1.06034 18.8021 1.15085 18.8558L11.8491 24.9583C11.9397 25.0119 12.0603 25.0119 12.1509 24.9583L21.1355 19.8331C21.2562 19.7616 21.2562 19.5948 21.1355 19.5232L18.3357 17.9261C18.2211 17.8605 18.0883 17.8605 17.9737 17.9261L12.175 21.2336C12.0845 21.2872 11.9638 21.2872 11.8733 21.2336H11.8673Z"
|
||||
fill="var(--theme-elevation-1000)"
|
||||
/>
|
||||
<path
|
||||
d="M22.8491 6.13827L12.1508 0.0417218C12.0603 -0.0119135 11.9397 -0.0119135 11.8491 0.0417218L6.19528 3.2658C6.0746 3.33731 6.0746 3.50418 6.19528 3.57569L8.97092 5.16091C9.08557 5.22647 9.21832 5.22647 9.33296 5.16091L11.8672 3.71872C11.9578 3.66508 12.0784 3.66508 12.1689 3.71872L19.627 7.96782C19.7175 8.02146 19.7778 8.11681 19.7778 8.22408V14.8212C19.7778 14.9464 19.8442 15.0656 19.9589 15.1311L22.7345 16.7104C22.8552 16.7819 23.006 16.6925 23.006 16.5554V6.40048C23.006 6.29321 22.9457 6.19786 22.8552 6.14423L22.8491 6.13827Z"
|
||||
fill="var(--theme-elevation-1000)"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -75,4 +75,16 @@ describe('Radio', () => {
|
||||
'Value One',
|
||||
)
|
||||
})
|
||||
|
||||
test('should show custom JSX label in list', async () => {
|
||||
await page.goto(url.list)
|
||||
await expect(page.locator('.cell-radioWithJsxLabelOption svg#payload-logo')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show custom JSX label while editing', async () => {
|
||||
await page.goto(url.create)
|
||||
await expect(
|
||||
page.locator('label[for="field-radioWithJsxLabelOption-three"] svg#payload-logo'),
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { radioFieldsSlug } from '../../slugs.js'
|
||||
import { CustomJSXLabel } from './CustomJSXLabel.js'
|
||||
|
||||
const RadioFields: CollectionConfig = {
|
||||
slug: radioFieldsSlug,
|
||||
@@ -27,6 +28,26 @@ const RadioFields: CollectionConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'radioWithJsxLabelOption',
|
||||
label: 'Radio with JSX label option',
|
||||
type: 'radio',
|
||||
defaultValue: 'three',
|
||||
options: [
|
||||
{
|
||||
label: 'Value One',
|
||||
value: 'one',
|
||||
},
|
||||
{
|
||||
label: 'Value Two',
|
||||
value: 'two',
|
||||
},
|
||||
{
|
||||
label: CustomJSXLabel,
|
||||
value: 'three',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
21
test/fields/collections/Select/CustomJSXLabel.tsx
Normal file
21
test/fields/collections/Select/CustomJSXLabel.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
export const CustomJSXLabel = () => {
|
||||
return (
|
||||
<svg
|
||||
className="graphic-icon"
|
||||
height="20px"
|
||||
id="payload-logo"
|
||||
viewBox="0 0 25 25"
|
||||
width="20px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M11.8673 21.2336L4.40922 16.9845C4.31871 16.9309 4.25837 16.8355 4.25837 16.7282V10.1609C4.25837 10.0477 4.38508 9.97616 4.48162 10.0298L13.1404 14.9642C13.2611 15.0358 13.412 14.9464 13.412 14.8093V11.6091C13.412 11.4839 13.3456 11.3647 13.2309 11.2992L2.81624 5.36353C2.72573 5.30989 2.60505 5.30989 2.51454 5.36353L1.15085 6.14422C1.06034 6.19786 1 6.29321 1 6.40048V18.5995C1 18.7068 1.06034 18.8021 1.15085 18.8558L11.8491 24.9583C11.9397 25.0119 12.0603 25.0119 12.1509 24.9583L21.1355 19.8331C21.2562 19.7616 21.2562 19.5948 21.1355 19.5232L18.3357 17.9261C18.2211 17.8605 18.0883 17.8605 17.9737 17.9261L12.175 21.2336C12.0845 21.2872 11.9638 21.2872 11.8733 21.2336H11.8673Z"
|
||||
fill="var(--theme-elevation-1000)"
|
||||
/>
|
||||
<path
|
||||
d="M22.8491 6.13827L12.1508 0.0417218C12.0603 -0.0119135 11.9397 -0.0119135 11.8491 0.0417218L6.19528 3.2658C6.0746 3.33731 6.0746 3.50418 6.19528 3.57569L8.97092 5.16091C9.08557 5.22647 9.21832 5.22647 9.33296 5.16091L11.8672 3.71872C11.9578 3.66508 12.0784 3.66508 12.1689 3.71872L19.627 7.96782C19.7175 8.02146 19.7778 8.11681 19.7778 8.22408V14.8212C19.7778 14.9464 19.8442 15.0656 19.9589 15.1311L22.7345 16.7104C22.8552 16.7819 23.006 16.6925 23.006 16.5554V6.40048C23.006 6.29321 22.9457 6.19786 22.8552 6.14423L22.8491 6.13827Z"
|
||||
fill="var(--theme-elevation-1000)"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -31,7 +31,7 @@ let page: Page
|
||||
let serverURL: string
|
||||
let url: AdminUrlUtil
|
||||
|
||||
describe('Radio', () => {
|
||||
describe('Select', () => {
|
||||
beforeAll(async ({ browser }, testInfo) => {
|
||||
testInfo.setTimeout(TEST_TIMEOUT_LONG)
|
||||
process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
|
||||
@@ -75,4 +75,24 @@ describe('Radio', () => {
|
||||
await saveDocAndAssert(page)
|
||||
await expect(field.locator('.rs__value-container')).toContainText('One')
|
||||
})
|
||||
|
||||
test('should show custom JSX option label in edit', async () => {
|
||||
await page.goto(url.create)
|
||||
|
||||
const svgLocator = page.locator('#field-selectWithJsxLabelOption svg#payload-logo')
|
||||
|
||||
await expect(svgLocator).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show custom JSX option label in list', async () => {
|
||||
await page.goto(url.list)
|
||||
|
||||
const columnsButton = page.locator('button:has-text("Columns")')
|
||||
|
||||
await columnsButton.click()
|
||||
|
||||
await page.locator('text=Select with JSX label option').click()
|
||||
|
||||
await expect(page.locator('.cell-selectWithJsxLabelOption svg#payload-logo')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { selectFieldsSlug } from '../../slugs.js'
|
||||
import { CustomJSXLabel } from './CustomJSXLabel.js'
|
||||
|
||||
const SelectFields: CollectionConfig = {
|
||||
slug: selectFieldsSlug,
|
||||
versions: true,
|
||||
fields: [
|
||||
{
|
||||
name: 'select',
|
||||
@@ -221,6 +223,26 @@ const SelectFields: CollectionConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'selectWithJsxLabelOption',
|
||||
label: 'Select with JSX label option',
|
||||
type: 'select',
|
||||
defaultValue: 'three',
|
||||
options: [
|
||||
{
|
||||
label: 'Value One',
|
||||
value: 'one',
|
||||
},
|
||||
{
|
||||
label: 'Value Two',
|
||||
value: 'two',
|
||||
},
|
||||
{
|
||||
label: CustomJSXLabel,
|
||||
value: 'three',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -1305,6 +1305,7 @@ export interface EmailField {
|
||||
export interface RadioField {
|
||||
id: string;
|
||||
radio?: ('one' | 'two' | 'three') | null;
|
||||
radioWithJsxLabelOption?: ('one' | 'two' | 'three') | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -1800,6 +1801,7 @@ export interface SelectField {
|
||||
settings?: {
|
||||
category?: ('a' | 'b')[] | null;
|
||||
};
|
||||
selectWithJsxLabelOption?: ('one' | 'two' | 'three') | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -1841,6 +1843,10 @@ export interface TabsField {
|
||||
* When active, the nested conditional tab should be visible. When inactive, it should be hidden.
|
||||
*/
|
||||
nestedConditionalTabVisible?: boolean | null;
|
||||
conditionalTabGroup?: {
|
||||
conditionalTabGroupTitle?: string | null;
|
||||
conditionalTab?: {};
|
||||
};
|
||||
nestedUnconditionalTabInput?: string | null;
|
||||
nestedConditionalTabInput?: string | null;
|
||||
};
|
||||
@@ -3075,6 +3081,7 @@ export interface EmailFieldsSelect<T extends boolean = true> {
|
||||
*/
|
||||
export interface RadioFieldsSelect<T extends boolean = true> {
|
||||
radio?: T;
|
||||
radioWithJsxLabelOption?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
@@ -3437,6 +3444,7 @@ export interface SelectFieldsSelect<T extends boolean = true> {
|
||||
| {
|
||||
category?: T;
|
||||
};
|
||||
selectWithJsxLabelOption?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
@@ -3471,6 +3479,12 @@ export interface TabsFieldsSelect<T extends boolean = true> {
|
||||
| {
|
||||
conditionalTabField?: T;
|
||||
nestedConditionalTabVisible?: T;
|
||||
conditionalTabGroup?:
|
||||
| T
|
||||
| {
|
||||
conditionalTabGroupTitle?: T;
|
||||
conditionalTab?: T | {};
|
||||
};
|
||||
nestedUnconditionalTabInput?: T;
|
||||
nestedConditionalTabInput?: T;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user