Skip to content

Commit 3c92fbd

Browse files
authored
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.
1 parent d66cdbd commit 3c92fbd

File tree

15 files changed

+250
-17
lines changed

15 files changed

+250
-17
lines changed

packages/next/src/views/Version/RenderFieldsToDiff/fields/Select/index.tsx

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22
import type { I18nClient } from '@payloadcms/translations'
3-
import type { OptionObject, SelectField, SelectFieldDiffClientComponent } from 'payload'
3+
import type { Option, SelectField, SelectFieldDiffClientComponent } from 'payload'
44

55
import { getTranslation } from '@payloadcms/translations'
66
import { useTranslation } from '@payloadcms/ui'
@@ -17,7 +17,7 @@ const getOptionsToRender = (
1717
value: string,
1818
options: SelectField['options'],
1919
hasMany: boolean,
20-
): (OptionObject | string)[] | OptionObject | string => {
20+
): Option | Option[] => {
2121
if (hasMany && Array.isArray(value)) {
2222
return value.map(
2323
(val) =>
@@ -31,17 +31,33 @@ const getOptionsToRender = (
3131
)
3232
}
3333

34-
const getTranslatedOptions = (
35-
options: (OptionObject | string)[] | OptionObject | string,
36-
i18n: I18nClient,
37-
): string => {
34+
/**
35+
* Translates option labels while ensuring they are strings.
36+
* If `options.label` is a JSX element, it falls back to `options.value` because `DiffViewer`
37+
* expects all values to be strings.
38+
*/
39+
const getTranslatedOptions = (options: Option | Option[], i18n: I18nClient): string => {
3840
if (Array.isArray(options)) {
3941
return options
40-
.map((option) => (typeof option === 'string' ? option : getTranslation(option.label, i18n)))
42+
.map((option) => {
43+
if (typeof option === 'string') {
44+
return option
45+
}
46+
const translatedLabel = getTranslation(option.label, i18n)
47+
48+
// Ensure the result is a string, otherwise use option.value
49+
return typeof translatedLabel === 'string' ? translatedLabel : option.value
50+
})
4151
.join(', ')
4252
}
4353

44-
return typeof options === 'string' ? options : getTranslation(options.label, i18n)
54+
if (typeof options === 'string') {
55+
return options
56+
}
57+
58+
const translatedLabel = getTranslation(options.label, i18n)
59+
60+
return typeof translatedLabel === 'string' ? translatedLabel : options.value
4561
}
4662

4763
export const Select: SelectFieldDiffClientComponent = ({

packages/payload/src/fields/config/types.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import type { EditorProps } from '@monaco-editor/react'
55
import type { JSONSchema4 } from 'json-schema'
66
import type { CSSProperties } from 'react'
7+
import type React from 'react'
78
import type { DeepUndefinable, MarkRequired } from 'ts-essentials'
89

910
import type {
@@ -432,11 +433,16 @@ export type Validate<
432433
options: ValidateOptions<TData, TSiblingData, TFieldConfig, TValue>,
433434
) => Promise<string | true> | string | true
434435

436+
export type OptionLabel =
437+
| (() => React.JSX.Element)
438+
| LabelFunction
439+
| React.JSX.Element
440+
| StaticLabel
441+
435442
export type OptionObject = {
436-
label: LabelFunction | StaticLabel
443+
label: OptionLabel
437444
value: string
438445
}
439-
440446
export type Option = OptionObject | string
441447

442448
export type FieldGraphQLType = {

packages/payload/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1274,6 +1274,7 @@ export type {
12741274
NumberField,
12751275
NumberFieldClient,
12761276
Option,
1277+
OptionLabel,
12771278
OptionObject,
12781279
PointField,
12791280
PointFieldClient,

packages/translations/src/utilities/getTranslation.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { JSX } from 'react'
33
import type { I18n, TFunction } from '../types.js'
44

55
type LabelType =
6+
| (() => JSX.Element)
67
| (({ t }: { t: TFunction }) => string)
78
| JSX.Element
89
| Record<string, string>

packages/ui/src/elements/Table/DefaultCell/index.tsx

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22
import type { DefaultCellComponentProps, UploadFieldClient } from 'payload'
33

44
import { getTranslation } from '@payloadcms/translations'
5-
import { fieldAffectsData, fieldIsID, formatAdminURL } from 'payload/shared'
5+
import { fieldAffectsData, fieldIsID } from 'payload/shared'
66
import React from 'react' // TODO: abstract this out to support all routers
77

88
import { useConfig } from '../../../providers/Config/index.js'
99
import { useTranslation } from '../../../providers/Translation/index.js'
10+
import { formatAdminURL } from '../../../utilities/formatAdminURL.js'
11+
import { getDisplayedFieldValue } from '../../../utilities/getDisplayedFieldValue.js'
1012
import { Link } from '../../Link/index.js'
1113
import { CodeCell } from './fields/Code/index.js'
1214
import { cellComponents } from './fields/index.js'
@@ -96,12 +98,17 @@ export const DefaultCell: React.FC<DefaultCellComponentProps> = (props) => {
9698
)
9799
}
98100

101+
const displayedValue = getDisplayedFieldValue(cellData, field, i18n)
102+
99103
const DefaultCellComponent: React.FC<DefaultCellComponentProps> =
100104
typeof cellData !== 'undefined' && cellComponents[field.type]
101105

102106
let CellComponent: React.ReactNode = null
103107

104-
if (DefaultCellComponent) {
108+
// Handle JSX labels before using DefaultCellComponent
109+
if (React.isValidElement(displayedValue)) {
110+
CellComponent = displayedValue
111+
} else if (DefaultCellComponent) {
105112
CellComponent = <DefaultCellComponent cellData={cellData} rowData={rowData} {...props} />
106113
} else if (!DefaultCellComponent) {
107114
// DefaultCellComponent does not exist for certain field types like `text`
@@ -125,13 +132,17 @@ export const DefaultCell: React.FC<DefaultCellComponentProps> = (props) => {
125132
} else {
126133
return (
127134
<WrapElement {...wrapElementProps}>
128-
{(cellData === '' || typeof cellData === 'undefined' || cellData === null) &&
135+
{(displayedValue === '' ||
136+
typeof displayedValue === 'undefined' ||
137+
displayedValue === null) &&
129138
i18n.t('general:noLabel', {
130139
label: getTranslation(('label' in field ? field.label : null) || 'data', i18n),
131140
})}
132-
{typeof cellData === 'string' && cellData}
133-
{typeof cellData === 'number' && cellData}
134-
{typeof cellData === 'object' && cellData !== null && JSON.stringify(cellData)}
141+
{typeof displayedValue === 'string' && displayedValue}
142+
{typeof displayedValue === 'number' && displayedValue}
143+
{typeof displayedValue === 'object' &&
144+
displayedValue !== null &&
145+
JSON.stringify(displayedValue)}
135146
</WrapElement>
136147
)
137148
}

packages/ui/src/elements/TableColumns/buildColumnState.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,13 @@ import React from 'react'
2727
import type { SortColumnProps } from '../SortColumn/index.js'
2828

2929
import {
30+
DefaultCell,
3031
RenderCustomComponent,
3132
RenderDefaultCell,
3233
SortColumn,
3334
// eslint-disable-next-line payload/no-imports-from-exports-dir
3435
} from '../../exports/client/index.js'
36+
import { hasOptionLabelJSXElement } from '../../utilities/hasOptionLabelJSXElement.js'
3537
import { RenderServerComponent } from '../RenderServerComponent/index.js'
3638
import { filterFields } from './filterFields.js'
3739

@@ -270,6 +272,16 @@ export const buildColumnState = (args: Args): Column[] => {
270272
importMap: payload.importMap,
271273
serverProps: cellServerProps,
272274
})
275+
} else if (
276+
cellClientProps.cellData &&
277+
cellClientProps.field &&
278+
hasOptionLabelJSXElement(cellClientProps)
279+
) {
280+
CustomCell = RenderServerComponent({
281+
clientProps: cellClientProps,
282+
Component: DefaultCell,
283+
importMap: payload.importMap,
284+
})
273285
} else {
274286
const CustomCellComponent = _field?.admin?.components?.Cell
275287

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { I18nClient } from '@payloadcms/translations'
2+
import type { ClientField } from 'payload'
3+
4+
import { getTranslation } from '@payloadcms/translations'
5+
import React from 'react'
6+
7+
/**
8+
* Returns the appropriate display value for a field.
9+
* - For select and radio fields:
10+
* - Returns JSX elements as-is.
11+
* - Translates localized label objects based on the current language.
12+
* - Returns string labels directly.
13+
* - Falls back to the option value if no valid label is found.
14+
* - For all other field types, returns `cellData` unchanged.
15+
*/
16+
export const getDisplayedFieldValue = (cellData: any, field: ClientField, i18n: I18nClient) => {
17+
if ((field?.type === 'select' || field?.type === 'radio') && Array.isArray(field.options)) {
18+
const selectedOption = field.options.find((opt) =>
19+
typeof opt === 'object' ? opt.value === cellData : opt === cellData,
20+
)
21+
22+
if (selectedOption) {
23+
if (typeof selectedOption === 'object' && 'label' in selectedOption) {
24+
return React.isValidElement(selectedOption.label)
25+
? selectedOption.label // Return JSX directly
26+
: getTranslation(selectedOption.label, i18n) || selectedOption.value // Use translation or fallback to value
27+
}
28+
return selectedOption // If option is a string, return it directly
29+
}
30+
}
31+
return cellData // Default fallback if no match found
32+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { DefaultCellComponentProps } from 'payload'
2+
3+
import React from 'react'
4+
5+
export const hasOptionLabelJSXElement = (cellClientProps: DefaultCellComponentProps) => {
6+
const { cellData, field } = cellClientProps
7+
8+
if ((field?.type === 'select' || field?.type == 'radio') && Array.isArray(field?.options)) {
9+
const matchingOption = field.options.find(
10+
(option) => typeof option === 'object' && option.value === cellData,
11+
)
12+
13+
if (
14+
matchingOption &&
15+
typeof matchingOption === 'object' &&
16+
React.isValidElement(matchingOption.label)
17+
) {
18+
return true
19+
}
20+
}
21+
22+
return false
23+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export const CustomJSXLabel = () => {
2+
return (
3+
<svg
4+
className="graphic-icon"
5+
height="20px"
6+
id="payload-logo"
7+
viewBox="0 0 25 25"
8+
width="20px"
9+
xmlns="http://www.w3.org/2000/svg"
10+
>
11+
<path
12+
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"
13+
fill="var(--theme-elevation-1000)"
14+
/>
15+
<path
16+
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"
17+
fill="var(--theme-elevation-1000)"
18+
/>
19+
</svg>
20+
)
21+
}

test/fields/collections/Radio/e2e.spec.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,16 @@ describe('Radio', () => {
7575
'Value One',
7676
)
7777
})
78+
79+
test('should show custom JSX label in list', async () => {
80+
await page.goto(url.list)
81+
await expect(page.locator('.cell-radioWithJsxLabelOption svg#payload-logo')).toBeVisible()
82+
})
83+
84+
test('should show custom JSX label while editing', async () => {
85+
await page.goto(url.create)
86+
await expect(
87+
page.locator('label[for="field-radioWithJsxLabelOption-three"] svg#payload-logo'),
88+
).toBeVisible()
89+
})
7890
})

0 commit comments

Comments
 (0)