Files
payloadcms/packages/ui/src/fields/Array/index.tsx
Jacob Fletcher a6f13f7330 fix(ui): properly extracts label from field in FieldLabel component (#8190)
Although the `<FieldLabel />` component receives a `field` prop, it does
not use this prop to extract the `label` from the field. This is
currently only an issue when rendering this component directly, such as
within `admin.components.Label`. The label simply won't appear unless
explicitly provided, despite it being passed as `field.label`. This is
not an issue when rendering field components themselves, because they
properly thread this value through as a top-level prop.

Here's an example of the issue:

```tsx
import type { TextFieldLabelServerComponent } from 'payload'

import { FieldLabel } from '@payloadcms/ui'
import React from 'react'

export const MyCustomLabelComponent: TextFieldLabelServerComponent = ({ clientField }) => {
  return (
    <FieldLabel
      field={clientField}
      label={clientField.label} // this should not be needed!
    />
  )
}
```

Here is the end result:

```tsx
import type { TextFieldLabelServerComponent } from 'payload'

import { FieldLabel } from '@payloadcms/ui'
import React from 'react'

export const MyCustomLabelComponent: TextFieldLabelServerComponent = ({ clientField }) => {
  return <FieldLabel field={clientField} />
}
```
2024-09-12 14:45:17 -04:00

361 lines
11 KiB
TypeScript

'use client'
import type {
ArrayFieldClientComponent,
ArrayFieldClientProps,
ArrayField as ArrayFieldType,
} from 'payload'
import { getTranslation } from '@payloadcms/translations'
import React, { useCallback } from 'react'
import { Banner } from '../../elements/Banner/index.js'
import { Button } from '../../elements/Button/index.js'
import { DraggableSortableItem } from '../../elements/DraggableSortable/DraggableSortableItem/index.js'
import { DraggableSortable } from '../../elements/DraggableSortable/index.js'
import { ErrorPill } from '../../elements/ErrorPill/index.js'
import { useFieldProps } from '../../forms/FieldPropsProvider/index.js'
import { useForm, useFormSubmitted } from '../../forms/Form/context.js'
import { extractRowsAndCollapsedIDs, toggleAllRows } from '../../forms/Form/rowHelpers.js'
import { NullifyLocaleField } from '../../forms/NullifyField/index.js'
import { useField } from '../../forms/useField/index.js'
import { withCondition } from '../../forms/withCondition/index.js'
import { useConfig } from '../../providers/Config/index.js'
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
import { useLocale } from '../../providers/Locale/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { scrollToID } from '../../utilities/scrollToID.js'
import { FieldDescription } from '../FieldDescription/index.js'
import { FieldError } from '../FieldError/index.js'
import { FieldLabel } from '../FieldLabel/index.js'
import { fieldBaseClass } from '../shared/index.js'
import { ArrayRow } from './ArrayRow.js'
import './index.scss'
const baseClass = 'array-field'
export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
const {
descriptionProps,
errorProps,
field,
field: {
name,
_path: pathFromProps,
admin: {
className,
components: { RowLabel },
description,
isSortable = true,
readOnly: readOnlyFromAdmin,
} = {},
fields,
label,
localized,
maxRows,
minRows: minRowsProp,
required,
},
forceRender = false,
labelProps,
readOnly: readOnlyFromTopLevelProps,
validate,
} = props
const readOnlyFromProps = readOnlyFromTopLevelProps || readOnlyFromAdmin
const {
indexPath,
path: pathFromContext,
permissions,
readOnly: readOnlyFromContext,
} = useFieldProps()
const minRows = (minRowsProp ?? required) ? 1 : 0
const { setDocFieldPreferences } = useDocumentInfo()
const { addFieldRow, dispatchFields, setModified } = useForm()
const submitted = useFormSubmitted()
const { code: locale } = useLocale()
const { i18n, t } = useTranslation()
const {
config: { localization },
} = useConfig()
const editingDefaultLocale = (() => {
if (localization && localization.fallback) {
const defaultLocale = localization.defaultLocale || 'en'
return locale === defaultLocale
}
return true
})()
// Handle labeling for Arrays, Global Arrays, and Blocks
const getLabels = (p: ArrayFieldClientProps): Partial<ArrayFieldType['labels']> => {
if ('labels' in p && p?.labels) {
return p.labels
}
if ('labels' in p.field && p.field.labels) {
return { plural: p.field.labels?.plural, singular: p.field.labels?.singular }
}
if ('label' in p.field && p.field.label) {
return { plural: undefined, singular: p.field.label }
}
return { plural: t('general:rows'), singular: t('general:row') }
}
const labels = getLabels(props)
const memoizedValidate = useCallback(
(value, options) => {
// alternative locales can be null
if (!editingDefaultLocale && value === null) {
return true
}
if (typeof validate === 'function') {
return validate(value, { ...options, maxRows, minRows, required })
}
},
[maxRows, minRows, required, validate, editingDefaultLocale],
)
const {
errorPaths,
formInitializing,
formProcessing,
path,
rows = [],
schemaPath,
showError,
valid,
value,
} = useField<number>({
hasRows: true,
path: pathFromContext ?? pathFromProps ?? name,
validate: memoizedValidate,
})
const disabled = readOnlyFromProps || readOnlyFromContext || formProcessing || formInitializing
const addRow = useCallback(
async (rowIndex: number): Promise<void> => {
await addFieldRow({ path, rowIndex, schemaPath })
setModified(true)
setTimeout(() => {
scrollToID(`${path}-row-${rowIndex + 1}`)
}, 0)
},
[addFieldRow, path, setModified, schemaPath],
)
const duplicateRow = useCallback(
(rowIndex: number) => {
dispatchFields({ type: 'DUPLICATE_ROW', path, rowIndex })
setModified(true)
setTimeout(() => {
scrollToID(`${path}-row-${rowIndex}`)
}, 0)
},
[dispatchFields, path, setModified],
)
const removeRow = useCallback(
(rowIndex: number) => {
dispatchFields({ type: 'REMOVE_ROW', path, rowIndex })
setModified(true)
},
[dispatchFields, path, setModified],
)
const moveRow = useCallback(
(moveFromIndex: number, moveToIndex: number) => {
dispatchFields({ type: 'MOVE_ROW', moveFromIndex, moveToIndex, path })
setModified(true)
},
[dispatchFields, path, setModified],
)
const toggleCollapseAll = useCallback(
(collapsed: boolean) => {
const { collapsedIDs, updatedRows } = toggleAllRows({
collapsed,
rows,
})
dispatchFields({ type: 'SET_ALL_ROWS_COLLAPSED', path, updatedRows })
setDocFieldPreferences(path, { collapsed: collapsedIDs })
},
[dispatchFields, path, rows, setDocFieldPreferences],
)
const setCollapse = useCallback(
(rowID: string, collapsed: boolean) => {
const { collapsedIDs, updatedRows } = extractRowsAndCollapsedIDs({
collapsed,
rowID,
rows,
})
dispatchFields({ type: 'SET_ROW_COLLAPSED', path, updatedRows })
setDocFieldPreferences(path, { collapsed: collapsedIDs })
},
[dispatchFields, path, rows, setDocFieldPreferences],
)
const hasMaxRows = maxRows && rows.length >= maxRows
const fieldErrorCount = errorPaths.length
const fieldHasErrors = submitted && errorPaths.length > 0
const showRequired = disabled && rows.length === 0
const showMinRows = rows.length < minRows || (required && rows.length === 0)
return (
<div
className={[
fieldBaseClass,
baseClass,
className,
fieldHasErrors ? `${baseClass}--has-error` : `${baseClass}--has-no-error`,
]
.filter(Boolean)
.join(' ')}
id={`field-${path.replace(/\./g, '__')}`}
>
{showError && (
<FieldError
CustomError={field?.admin?.components?.Error}
field={field}
path={path}
{...(errorProps || {})}
/>
)}
<header className={`${baseClass}__header`}>
<div className={`${baseClass}__header-wrap`}>
<div className={`${baseClass}__header-content`}>
<h3 className={`${baseClass}__title`}>
<FieldLabel
as="span"
field={field}
Label={field?.admin?.components?.Label}
unstyled
{...(labelProps || {})}
/>
</h3>
{fieldHasErrors && fieldErrorCount > 0 && (
<ErrorPill count={fieldErrorCount} i18n={i18n} withMessage />
)}
</div>
{rows.length > 0 && (
<ul className={`${baseClass}__header-actions`}>
<li>
<button
className={`${baseClass}__header-action`}
onClick={() => toggleCollapseAll(true)}
type="button"
>
{t('fields:collapseAll')}
</button>
</li>
<li>
<button
className={`${baseClass}__header-action`}
onClick={() => toggleCollapseAll(false)}
type="button"
>
{t('fields:showAll')}
</button>
</li>
</ul>
)}
</div>
<FieldDescription
Description={field?.admin?.components?.Description}
description={description}
field={field}
{...(descriptionProps || {})}
/>
</header>
<NullifyLocaleField fieldValue={value} localized={localized} path={path} />
{(rows.length > 0 || (!valid && (showRequired || showMinRows))) && (
<DraggableSortable
className={`${baseClass}__draggable-rows`}
ids={rows.map((row) => row.id)}
onDragEnd={({ moveFromIndex, moveToIndex }) => moveRow(moveFromIndex, moveToIndex)}
>
{rows.map((row, i) => {
const rowErrorCount = errorPaths?.filter((errorPath) =>
errorPath.startsWith(`${path}.${i}.`),
).length
return (
<DraggableSortableItem disabled={disabled || !isSortable} id={row.id} key={row.id}>
{(draggableSortableItemProps) => (
<ArrayRow
{...draggableSortableItemProps}
addRow={addRow}
duplicateRow={duplicateRow}
errorCount={rowErrorCount}
fields={fields}
forceRender={forceRender}
hasMaxRows={hasMaxRows}
indexPath={indexPath}
isSortable={isSortable}
labels={labels}
moveRow={moveRow}
path={path}
permissions={permissions}
readOnly={disabled}
removeRow={removeRow}
row={row}
rowCount={rows.length}
rowIndex={i}
RowLabel={RowLabel}
schemaPath={schemaPath}
setCollapse={setCollapse}
/>
)}
</DraggableSortableItem>
)
})}
{!valid && (
<React.Fragment>
{showRequired && (
<Banner>
{t('validation:fieldHasNo', { label: getTranslation(labels.plural, i18n) })}
</Banner>
)}
{showMinRows && (
<Banner type="error">
{t('validation:requiresAtLeast', {
count: minRows,
label:
getTranslation(minRows > 1 ? labels.plural : labels.singular, i18n) ||
t(minRows > 1 ? 'general:rows' : 'general:row'),
})}
</Banner>
)}
</React.Fragment>
)}
</DraggableSortable>
)}
{!disabled && !hasMaxRows && (
<Button
buttonStyle="icon-label"
className={`${baseClass}__add-row`}
icon="plus"
iconPosition="left"
iconStyle="with-border"
onClick={() => {
void addRow(value || 0)
}}
>
{t('fields:addLabel', { label: getTranslation(labels.singular, i18n) })}
</Button>
)}
</div>
)
}
export const ArrayField = withCondition(ArrayFieldComponent)