Files
payloadcms/packages/ui/src/fields/Number/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

218 lines
6.8 KiB
TypeScript

'use client'
import type { NumberFieldClientComponent, NumberFieldClientProps } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import { isNumber } from 'payload/shared'
import React, { useCallback, useEffect, useState } from 'react'
import type { Option } from '../../elements/ReactSelect/types.js'
import { ReactSelect } from '../../elements/ReactSelect/index.js'
import { useFieldProps } from '../../forms/FieldPropsProvider/index.js'
import { useField } from '../../forms/useField/index.js'
import { withCondition } from '../../forms/withCondition/index.js'
import { RenderComponent } from '../../providers/Config/RenderComponent.js'
import { useTranslation } from '../../providers/Translation/index.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 './index.scss'
const NumberFieldComponent: NumberFieldClientComponent = (props) => {
const {
descriptionProps,
errorProps,
field,
field: {
name,
_path: pathFromProps,
admin: {
className,
description,
placeholder,
readOnly: readOnlyFromAdmin,
step = 1,
style,
width,
} = {} as NumberFieldClientProps['field']['admin'],
hasMany = false,
label,
max = Infinity,
maxRows = Infinity,
min = -Infinity,
required,
},
labelProps,
onChange: onChangeFromProps,
readOnly: readOnlyFromTopLevelProps,
validate,
} = props
const readOnlyFromProps = readOnlyFromTopLevelProps || readOnlyFromAdmin
const { i18n, t } = useTranslation()
const memoizedValidate = useCallback(
(value, options) => {
if (typeof validate === 'function') {
return validate(value, { ...options, max, min, required })
}
},
[validate, min, max, required],
)
const { path: pathFromContext, readOnly: readOnlyFromContext } = useFieldProps()
const { formInitializing, formProcessing, path, setValue, showError, value } = useField<
number | number[]
>({
path: pathFromContext ?? pathFromProps ?? name,
validate: memoizedValidate,
})
const disabled = readOnlyFromProps || readOnlyFromContext || formProcessing || formInitializing
const handleChange = useCallback(
(e) => {
const val = parseFloat(e.target.value)
let newVal = val
if (Number.isNaN(val)) {
newVal = undefined
}
if (typeof onChangeFromProps === 'function') {
onChangeFromProps(newVal)
}
setValue(newVal)
},
[onChangeFromProps, setValue],
)
const [valueToRender, setValueToRender] = useState<
{ id: string; label: string; value: { value: number } }[]
>([]) // Only for hasMany
const handleHasManyChange = useCallback(
(selectedOption) => {
if (!disabled) {
let newValue
if (!selectedOption) {
newValue = []
} else if (Array.isArray(selectedOption)) {
newValue = selectedOption.map((option) => Number(option.value?.value || option.value))
} else {
newValue = [Number(selectedOption.value?.value || selectedOption.value)]
}
setValue(newValue)
}
},
[disabled, setValue],
)
// useEffect update valueToRender:
useEffect(() => {
if (hasMany && Array.isArray(value)) {
setValueToRender(
value.map((val, index) => {
return {
id: `${val}${index}`, // append index to avoid duplicate keys but allow duplicate numbers
label: `${val}`,
value: {
toString: () => `${val}${index}`,
value: (val as unknown as Record<string, number>)?.value || val,
}, // You're probably wondering, why the hell is this done that way? Well, React-select automatically uses "label-value" as a key, so we will get that react duplicate key warning if we just pass in the value as multiple values can be the same. So we need to append the index to the toString() of the value to avoid that warning, as it uses that as the key.
}
}),
)
}
}, [value, hasMany])
return (
<div
className={[
fieldBaseClass,
'number',
className,
showError && 'error',
disabled && 'read-only',
hasMany && 'has-many',
]
.filter(Boolean)
.join(' ')}
style={{
...style,
width,
}}
>
<FieldLabel field={field} Label={field?.admin?.components?.Label} {...(labelProps || {})} />
<div className={`${fieldBaseClass}__wrap`}>
<FieldError
CustomError={field?.admin?.components?.Error}
field={field}
path={path}
{...(errorProps || {})}
/>
{hasMany ? (
<ReactSelect
className={`field-${path.replace(/\./g, '__')}`}
disabled={disabled}
filterOption={(_, rawInput) => {
const isOverHasMany = Array.isArray(value) && value.length >= maxRows
return isNumber(rawInput) && !isOverHasMany
}}
isClearable
isCreatable
isMulti
isSortable
noOptionsMessage={() => {
const isOverHasMany = Array.isArray(value) && value.length >= maxRows
if (isOverHasMany) {
return t('validation:limitReached', { max: maxRows, value: value.length + 1 })
}
return null
}}
// numberOnly
onChange={handleHasManyChange}
options={[]}
placeholder={t('general:enterAValue')}
showError={showError}
value={valueToRender as Option[]}
/>
) : (
<div>
<RenderComponent mappedComponent={field?.admin?.components?.beforeInput} />
<input
disabled={disabled}
id={`field-${path.replace(/\./g, '__')}`}
max={max}
min={min}
name={path}
onChange={handleChange}
onWheel={(e) => {
// @ts-expect-error
e.target.blur()
}}
placeholder={getTranslation(placeholder, i18n)}
step={step}
type="number"
value={typeof value === 'number' ? value : ''}
/>
<RenderComponent mappedComponent={field?.admin?.components?.afterInput} />
</div>
)}
<FieldDescription
Description={field?.admin?.components?.Description}
description={description}
field={field}
{...(descriptionProps || {})}
/>
</div>
</div>
)
}
export const NumberField = withCondition(NumberFieldComponent)