Files
payloadcms/packages/ui/src/providers/ComponentMap/buildComponentMap/fields.tsx

806 lines
28 KiB
TypeScript

import type { I18nClient } from '@payloadcms/translations'
import type { CustomComponent } from 'payload/config'
import type {
CellComponentProps,
Field,
FieldDescriptionProps,
FieldWithPath,
LabelProps,
Option,
SanitizedConfig,
} from 'payload/types'
import { fieldAffectsData, fieldIsPresentationalOnly } from 'payload/types'
import React, { Fragment } from 'react'
import type { ArrayFieldProps } from '../../../fields/Array/index.js'
import type { BlocksFieldProps } from '../../../fields/Blocks/index.js'
import type { CheckboxFieldProps } from '../../../fields/Checkbox/index.js'
import type { CodeFieldProps } from '../../../fields/Code/index.js'
import type { CollapsibleFieldProps } from '../../../fields/Collapsible/index.js'
import type { DateFieldProps } from '../../../fields/DateTime/index.js'
import type { EmailFieldProps } from '../../../fields/Email/index.js'
import type { GroupFieldProps } from '../../../fields/Group/index.js'
import type { JSONFieldProps } from '../../../fields/JSON/index.js'
import type { NumberFieldProps } from '../../../fields/Number/index.js'
import type { PointFieldProps } from '../../../fields/Point/index.js'
import type { RadioFieldProps } from '../../../fields/RadioGroup/index.js'
import type { RelationshipFieldProps } from '../../../fields/Relationship/types.js'
import type { RichTextFieldProps } from '../../../fields/RichText/index.js'
import type { RowFieldProps } from '../../../fields/Row/types.js'
import type { SelectFieldProps } from '../../../fields/Select/index.js'
import type { TabsFieldProps } from '../../../fields/Tabs/index.js'
import type { TextFieldProps } from '../../../fields/Text/types.js'
import type { TextareaFieldProps } from '../../../fields/Textarea/types.js'
import type { UploadFieldProps } from '../../../fields/Upload/types.js'
import type { FormFieldBase } from '../../../fields/shared/index.js'
import type { WithServerSidePropsPrePopulated } from './index.js'
import type {
FieldComponentProps,
FieldMap,
MappedField,
MappedTab,
ReducedBlock,
} from './types.js'
import { HiddenInput } from '../../../fields/HiddenInput/index.js'
import { FieldDescription } from '../../../forms/FieldDescription/index.js'
export const mapFields = (args: {
WithServerSideProps: WithServerSidePropsPrePopulated
config: SanitizedConfig
/**
* If mapFields is used outside of collections, you might not want it to add an id field
*/
disableAddingID?: boolean
fieldSchema: FieldWithPath[]
filter?: (field: Field) => boolean
i18n: I18nClient
parentPath?: string
readOnly?: boolean
}): FieldMap => {
const {
WithServerSideProps,
config,
disableAddingID,
fieldSchema,
filter,
i18n,
i18n: { t },
parentPath,
readOnly: readOnlyOverride,
} = args
const result: FieldMap = fieldSchema.reduce((acc, field): FieldMap => {
const fieldIsPresentational = fieldIsPresentationalOnly(field)
let CustomFieldComponent: CustomComponent<FieldComponentProps> = field.admin?.components?.Field
const CustomCellComponent = field.admin?.components?.Cell
const isHidden = field?.admin && 'hidden' in field.admin && field.admin.hidden
if (fieldIsPresentational || (!field?.hidden && field?.admin?.disabled !== true)) {
if ((filter && typeof filter === 'function' && filter(field)) || !filter) {
if (isHidden) {
if (CustomFieldComponent) {
CustomFieldComponent = HiddenInput
}
}
const isFieldAffectingData = fieldAffectsData(field)
const path = `${parentPath ? `${parentPath}.` : ''}${
field.path || (isFieldAffectingData && 'name' in field ? field.name : '')
}`
const AfterInput =
('admin' in field &&
'components' in field.admin &&
'afterInput' in field.admin.components &&
Array.isArray(field.admin?.components?.afterInput) && (
<Fragment>
{field.admin.components.afterInput.map((Component, i) => (
<WithServerSideProps Component={Component} key={i} />
))}
</Fragment>
)) ||
null
const BeforeInput =
('admin' in field &&
field.admin?.components &&
'beforeInput' in field.admin.components &&
Array.isArray(field.admin.components.beforeInput) && (
<Fragment>
{field.admin.components.beforeInput.map((Component, i) => (
<WithServerSideProps Component={Component} key={i} />
))}
</Fragment>
)) ||
null
let label = undefined
if ('label' in field) {
if (typeof field.label === 'string' || typeof field.label === 'object') {
label = field.label
} else if (typeof field.label === 'function') {
label = field.label({ t })
}
}
const labelProps: LabelProps = {
label,
required: 'required' in field ? field.required : undefined,
}
const CustomLabelComponent =
('admin' in field &&
field.admin?.components &&
'Label' in field.admin.components &&
field.admin.components?.Label) ||
undefined
// If we return undefined here (so if no CUSTOM label component is set), the field client component is responsible for falling back to the default label
const CustomLabel =
CustomLabelComponent !== undefined ? (
<WithServerSideProps Component={CustomLabelComponent} {...(labelProps || {})} />
) : undefined
let description = undefined
if (field.admin && 'description' in field.admin) {
if (
typeof field.admin?.description === 'string' ||
typeof field.admin?.description === 'object'
) {
description = field.admin.description
} else if (typeof field.admin?.description === 'function') {
description = field.admin?.description({ t })
}
}
const descriptionProps: FieldDescriptionProps = {
description,
}
let CustomDescriptionComponent = undefined
if (
field.admin?.components &&
'Description' in field.admin.components &&
field.admin.components?.Description
) {
CustomDescriptionComponent = field.admin.components.Description
} else if (description) {
CustomDescriptionComponent = FieldDescription
}
const CustomDescription =
CustomDescriptionComponent !== undefined ? (
<WithServerSideProps
Component={CustomDescriptionComponent}
{...(descriptionProps || {})}
/>
) : undefined
const errorProps = {
path,
}
const CustomErrorComponent =
('admin' in field &&
field.admin?.components &&
'Error' in field.admin.components &&
field.admin?.components?.Error) ||
undefined
const CustomError =
CustomErrorComponent !== undefined ? (
<WithServerSideProps Component={CustomErrorComponent} {...(errorProps || {})} />
) : undefined
// These fields are shared across all field types even if they are not used in the default field, as the custom field component can use them
const baseFieldProps: FormFieldBase = {
AfterInput,
BeforeInput,
CustomDescription,
CustomError,
CustomLabel,
custom: 'admin' in field && 'custom' in field.admin ? field.admin?.custom : undefined,
descriptionProps,
disabled: 'admin' in field && 'disabled' in field.admin ? field.admin?.disabled : false,
errorProps,
label: labelProps?.label,
path,
required: 'required' in field ? field.required : undefined,
}
let fieldComponentProps: FieldComponentProps
let fieldOptions: Option[]
if ('options' in field) {
fieldOptions = field.options.map((option) => {
if (typeof option === 'object' && typeof option.label === 'function') {
return {
label: option.label({ t }),
value: option.value,
}
}
return option
})
}
const cellComponentProps: CellComponentProps = {
name: 'name' in field ? field.name : undefined,
fieldType: field.type,
isFieldAffectingData,
label: labelProps?.label || undefined,
labels: 'labels' in field ? field.labels : undefined,
options: 'options' in field ? fieldOptions : undefined,
relationTo: 'relationTo' in field ? field.relationTo : undefined,
}
switch (field.type) {
case 'array': {
let CustomRowLabel: React.ReactNode
if (
'admin' in field &&
field.admin.components &&
'RowLabel' in field.admin.components &&
field.admin.components.RowLabel
) {
const CustomRowLabelComponent = field.admin.components.RowLabel
CustomRowLabel = (
<WithServerSideProps Component={CustomRowLabelComponent} {...(labelProps || {})} />
)
}
const arrayFieldProps: Omit<ArrayFieldProps, 'indexPath' | 'permissions'> = {
...baseFieldProps,
name: field.name,
CustomRowLabel,
className: field.admin?.className,
disabled: field.admin?.disabled,
fieldMap: mapFields({
WithServerSideProps,
config,
fieldSchema: field.fields,
filter,
i18n,
parentPath: path,
readOnly: readOnlyOverride,
}),
isSortable: field.admin?.isSortable,
labels: field.labels,
maxRows: field.maxRows,
minRows: field.minRows,
readOnly: field.admin?.readOnly,
required: field.required,
style: field.admin?.style,
width: field.admin?.width,
}
fieldComponentProps = arrayFieldProps
break
}
case 'blocks': {
const blocks = field.blocks.map((block) => {
const blockFieldMap = mapFields({
WithServerSideProps,
config,
fieldSchema: block.fields,
filter,
i18n,
parentPath: `${path}.${block.slug}`,
readOnly: readOnlyOverride,
})
const reducedBlock: ReducedBlock = {
slug: block.slug,
custom: block.admin?.custom,
fieldMap: blockFieldMap,
imageAltText: block.imageAltText,
imageURL: block.imageURL,
labels: block.labels,
}
return reducedBlock
})
const blocksField: Omit<BlocksFieldProps, 'indexPath' | 'permissions'> = {
...baseFieldProps,
name: field.name,
blocks,
className: field.admin?.className,
disabled: field.admin?.disabled,
isSortable: field.admin?.isSortable,
labels: field.labels,
maxRows: field.maxRows,
minRows: field.minRows,
readOnly: field.admin?.readOnly,
required: field.required,
style: field.admin?.style,
width: field.admin?.width,
}
fieldComponentProps = blocksField
cellComponentProps.blocks = field.blocks.map((b) => ({
slug: b.slug,
labels: b.labels,
}))
break
}
case 'checkbox': {
const checkboxField: CheckboxFieldProps = {
...baseFieldProps,
name: field.name,
className: field.admin?.className,
disabled: field.admin?.disabled,
readOnly: field.admin?.readOnly,
required: field.required,
style: field.admin?.style,
width: field.admin?.width,
}
fieldComponentProps = checkboxField
break
}
case 'code': {
const codeField: CodeFieldProps = {
...baseFieldProps,
name: field.name,
className: field.admin?.className,
disabled: field.admin?.disabled,
editorOptions: field.admin?.editorOptions,
language: field.admin?.language,
readOnly: field.admin?.readOnly,
required: field.required,
style: field.admin?.style,
width: field.admin?.width,
}
fieldComponentProps = codeField
break
}
case 'collapsible': {
let CustomCollapsibleLabel: React.ReactNode
if (
field?.admin?.components &&
'RowLabel' in field.admin.components &&
field?.admin?.components?.RowLabel
) {
const CustomCollapsibleLabelComponent = field.admin.components.RowLabel
CustomCollapsibleLabel = (
<WithServerSideProps
Component={CustomCollapsibleLabelComponent}
{...(labelProps || {})}
/>
)
}
const collapsibleField: Omit<CollapsibleFieldProps, 'indexPath' | 'permissions'> = {
...baseFieldProps,
CustomLabel: CustomCollapsibleLabel,
className: field.admin?.className,
disabled: field.admin?.disabled,
fieldMap: mapFields({
WithServerSideProps,
config,
disableAddingID: true,
fieldSchema: field.fields,
filter,
i18n,
parentPath: path,
readOnly: readOnlyOverride,
}),
initCollapsed: field.admin?.initCollapsed,
readOnly: field.admin?.readOnly,
required: field.required,
style: field.admin?.style,
width: field.admin?.width,
}
fieldComponentProps = collapsibleField as CollapsibleFieldProps // TODO: dunno why this is needed
break
}
case 'date': {
const dateField: DateFieldProps = {
...baseFieldProps,
name: field.name,
className: field.admin?.className,
date: field.admin?.date,
disabled: field.admin?.disabled,
placeholder: field.admin?.placeholder,
readOnly: field.admin?.readOnly,
required: field.required,
style: field.admin?.style,
width: field.admin?.width,
}
fieldComponentProps = dateField
cellComponentProps.dateDisplayFormat = field.admin?.date?.displayFormat
break
}
case 'email': {
const emailField: EmailFieldProps = {
...baseFieldProps,
name: field.name,
className: field.admin?.className,
disabled: field.admin?.disabled,
placeholder: field.admin?.placeholder,
readOnly: field.admin?.readOnly,
required: field.required,
style: field.admin?.style,
width: field.admin?.width,
}
fieldComponentProps = emailField
break
}
case 'group': {
const groupField: Omit<GroupFieldProps, 'indexPath' | 'permissions'> = {
...baseFieldProps,
name: field.name,
className: field.admin?.className,
disabled: field.admin?.disabled,
fieldMap: mapFields({
WithServerSideProps,
config,
disableAddingID: true,
fieldSchema: field.fields,
filter,
i18n,
parentPath: path,
readOnly: readOnlyOverride,
}),
readOnly: field.admin?.readOnly,
style: field.admin?.style,
width: field.admin?.width,
}
fieldComponentProps = groupField
break
}
case 'json': {
const jsonField: JSONFieldProps = {
...baseFieldProps,
name: field.name,
className: field.admin?.className,
disabled: field.admin?.disabled,
editorOptions: field.admin?.editorOptions,
jsonSchema: field.jsonSchema,
readOnly: field.admin?.readOnly,
required: field.required,
style: field.admin?.style,
width: field.admin?.width,
}
fieldComponentProps = jsonField
break
}
case 'number': {
const numberField: NumberFieldProps = {
...baseFieldProps,
name: field.name,
className: field.admin?.className,
disabled: field.admin?.disabled,
hasMany: field.hasMany,
max: field.max,
maxRows: field.maxRows,
min: field.min,
readOnly: field.admin?.readOnly,
required: field.required,
step: field.admin?.step,
style: field.admin?.style,
width: field.admin?.width,
}
fieldComponentProps = numberField
break
}
case 'point': {
const pointField: PointFieldProps = {
...baseFieldProps,
name: field.name,
className: field.admin?.className,
disabled: field.admin?.disabled,
readOnly: field.admin?.readOnly,
required: field.required,
style: field.admin?.style,
width: field.admin?.width,
}
fieldComponentProps = pointField
break
}
case 'relationship': {
const relationshipField: RelationshipFieldProps = {
...baseFieldProps,
name: field.name,
allowCreate: field.admin.allowCreate,
className: field.admin?.className,
disabled: field.admin?.disabled,
hasMany: field.hasMany,
isSortable: field.admin?.isSortable,
readOnly: field.admin?.readOnly,
relationTo: field.relationTo,
required: field.required,
sortOptions: field.admin.sortOptions,
style: field.admin?.style,
width: field.admin?.width,
}
cellComponentProps.relationTo = field.relationTo
fieldComponentProps = relationshipField
break
}
case 'radio': {
const radioField: RadioFieldProps = {
...baseFieldProps,
name: field.name,
className: field.admin?.className,
disabled: field.admin?.disabled,
options: fieldOptions,
readOnly: field.admin?.readOnly,
required: field.required,
style: field.admin?.style,
width: field.admin?.width,
}
cellComponentProps.options = fieldOptions
fieldComponentProps = radioField
break
}
case 'richText': {
const richTextField: RichTextFieldProps = {
...baseFieldProps,
name: field.name,
className: field.admin?.className,
disabled: field.admin?.disabled,
readOnly: field.admin?.readOnly,
required: field.required,
style: field.admin?.style,
width: field.admin?.width,
}
if (typeof field?.editor === 'function') {
throw new Error('Attempted to access unsanitized rich text editor.')
}
const RichTextFieldComponent = field.editor.FieldComponent
const RichTextCellComponent = field.editor.CellComponent
if (typeof field.editor.generateComponentMap === 'function') {
const result = field.editor.generateComponentMap({
WithServerSideProps,
config,
i18n,
schemaPath: path,
})
richTextField.richTextComponentMap = result
cellComponentProps.richTextComponentMap = result
}
if (RichTextFieldComponent) {
CustomFieldComponent = RichTextFieldComponent
}
if (RichTextCellComponent) {
cellComponentProps.CellComponentOverride = (
<WithServerSideProps Component={RichTextCellComponent} />
)
}
fieldComponentProps = richTextField
break
}
case 'row': {
const rowField: Omit<RowFieldProps, 'indexPath' | 'permissions'> = {
...baseFieldProps,
className: field.admin?.className,
disabled: field.admin?.disabled,
fieldMap: mapFields({
WithServerSideProps,
config,
disableAddingID: true,
fieldSchema: field.fields,
filter,
i18n,
parentPath: path,
readOnly: readOnlyOverride,
}),
readOnly: field.admin?.readOnly,
required: field.required,
style: field.admin?.style,
width: field.admin?.width,
}
fieldComponentProps = rowField
break
}
case 'tabs': {
// `tabs` fields require a field map of each of its tab's nested fields
const tabs = field.tabs.map((tab) => {
const tabFieldMap = mapFields({
WithServerSideProps,
config,
disableAddingID: true,
fieldSchema: tab.fields,
filter,
i18n,
parentPath: path,
readOnly: readOnlyOverride,
})
const reducedTab: MappedTab = {
name: 'name' in tab ? tab.name : undefined,
fieldMap: tabFieldMap,
label: tab.label,
}
return reducedTab
})
const tabsField: Omit<TabsFieldProps, 'indexPath' | 'permissions'> = {
...baseFieldProps,
name: 'name' in field ? (field.name as string) : undefined,
className: field.admin?.className,
disabled: field.admin?.disabled,
readOnly: field.admin?.readOnly,
required: field.required,
style: field.admin?.style,
tabs,
width: field.admin?.width,
}
fieldComponentProps = tabsField
break
}
case 'text': {
const textField: TextFieldProps = {
...baseFieldProps,
name: field.name,
className: field.admin?.className,
disabled: field.admin?.disabled,
hasMany: field.hasMany,
maxLength: field.maxLength,
minLength: field.minLength,
placeholder: field.admin?.placeholder,
readOnly: field.admin?.readOnly,
required: field.required,
style: field.admin?.style,
width: field.admin?.width,
}
fieldComponentProps = textField
break
}
case 'textarea': {
const textareaField: TextareaFieldProps = {
...baseFieldProps,
name: field.name,
className: field.admin?.className,
disabled: field.admin?.disabled,
maxLength: field.maxLength,
minLength: field.minLength,
placeholder: field.admin?.placeholder,
readOnly: field.admin?.readOnly,
required: field.required,
rows: field.admin?.rows,
style: field.admin?.style,
width: field.admin?.width,
}
fieldComponentProps = textareaField
break
}
case 'ui': {
fieldComponentProps = baseFieldProps
break
}
case 'upload': {
const uploadField: UploadFieldProps = {
...baseFieldProps,
name: field.name,
className: field.admin?.className,
disabled: field.admin?.disabled,
filterOptions: field.filterOptions,
readOnly: field.admin?.readOnly,
relationTo: field.relationTo,
required: field.required,
style: field.admin?.style,
width: field.admin?.width,
}
cellComponentProps.relationTo = field.relationTo
fieldComponentProps = uploadField
break
}
case 'select': {
const selectField: SelectFieldProps = {
...baseFieldProps,
name: field.name,
className: field.admin?.className,
disabled: field.admin?.disabled,
hasMany: field.hasMany,
isClearable: field.admin?.isClearable,
options: fieldOptions,
readOnly: field.admin?.readOnly,
required: field.required,
style: field.admin?.style,
width: field.admin?.width,
}
cellComponentProps.options = fieldOptions
fieldComponentProps = selectField
break
}
default: {
break
}
}
const reducedField: MappedField = {
name: 'name' in field ? field.name : undefined,
type: field.type,
CustomCell: CustomCellComponent ? (
<WithServerSideProps Component={CustomCellComponent} {...cellComponentProps} />
) : undefined,
CustomField: CustomFieldComponent ? (
<WithServerSideProps Component={CustomFieldComponent} {...fieldComponentProps} />
) : undefined,
cellComponentProps,
custom: field?.admin?.custom,
disableBulkEdit:
'admin' in field && 'disableBulkEdit' in field.admin && field.admin.disableBulkEdit,
disableListColumn:
'admin' in field && 'disableListColumn' in field.admin && field.admin.disableListColumn,
disableListFilter:
'admin' in field && 'disableListFilter' in field.admin && field.admin.disableListFilter,
fieldComponentProps,
fieldIsPresentational,
isFieldAffectingData,
isHidden,
isSidebar:
'admin' in field && 'position' in field.admin && field.admin.position === 'sidebar',
localized: 'localized' in field ? field.localized : false,
unique: 'unique' in field ? field.unique : false,
}
acc.push(reducedField)
}
}
return acc
}, [])
const hasID =
result.findIndex((f) => 'name' in f && f.isFieldAffectingData && f.name === 'id') > -1
if (!disableAddingID && !hasID) {
// TODO: For all fields (not just this one) we need to add the name to both .fieldComponentProps.name AND .name. This can probably be improved
result.push({
name: 'id',
type: 'text',
CustomField: null,
cellComponentProps: {
name: 'id',
},
disableBulkEdit: true,
fieldComponentProps: {
name: 'id',
label: 'ID',
},
fieldIsPresentational: false,
isFieldAffectingData: true,
isHidden: true,
localized: undefined,
})
}
return result
}