feat: pass path to FieldDescription (#4364)

fix: DescriptionFunction type

Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
This commit is contained in:
Timothy Choi
2023-12-04 19:59:18 +01:00
committed by GitHub
parent 65adfd21ed
commit 3b8a27d199
27 changed files with 130 additions and 46 deletions

View File

@@ -251,11 +251,16 @@ const field = {
### Description
A description can be configured three ways.
A description can be configured in three ways.
- As a string
- As a function that accepts an object containing the field's value, which returns a string
- As a React component that accepts value as a prop
- As a function which returns a string
- As a React component
Functions are called with an optional argument object with the following shape, and React components are rendered with the following props:
- `path` - the path of the field
- `value` - the current value of the field
As shown above, you can simply provide a string that will show by the field, but there are use cases where you may want to create some dynamic feedback. By using a function or a component for the `description` property you can provide realtime feedback as the user interacts with the form.
@@ -269,8 +274,8 @@ As shown above, you can simply provide a string that will show by the field, but
type: 'text',
maxLength: 20,
admin: {
description: ({ value }) =>
`${typeof value === 'string' ? 20 - value.length : '20'} characters left`,
description: ({ path, value }) =>
`${typeof value === 'string' ? 20 - value.length : '20'} characters left (field: ${path})`,
},
},
]
@@ -290,11 +295,12 @@ This example will display the number of characters allowed as the user types.
maxLength: 20,
admin: {
description:
({ value }) => (
({ path, value }) => (
<div>
Character count:
{' '}
{ value?.length || 0 }
(field: {path})
</div>
)
}
@@ -303,7 +309,7 @@ This example will display the number of characters allowed as the user types.
}
```
This component will count the number of characters entered.
This component will count the number of characters entered, as well as display the path of the field.
### TypeScript

View File

@@ -10,13 +10,13 @@ import { isComponent } from './types'
const baseClass = 'field-description'
const FieldDescription: React.FC<Props> = (props) => {
const { className, description, value, marginPlacement } = props
const { className, description, marginPlacement, path, value } = props
const { i18n } = useTranslation()
if (isComponent(description)) {
const Description = description
return <Description value={value} />
return <Description path={path} value={value} />
}
if (description) {
@@ -31,7 +31,7 @@ const FieldDescription: React.FC<Props> = (props) => {
.join(' ')}
>
{typeof description === 'function'
? description({ value })
? description({ path, value })
: getTranslation(description, i18n)}
</div>
)

View File

@@ -1,8 +1,12 @@
import React from 'react'
export type DescriptionFunction = (value?: unknown) => string
type Args<T = unknown> = {
path: string
value?: T
}
export type DescriptionFunction<T = unknown> = (args: Args<T>) => string
export type DescriptionComponent = React.ComponentType<{ value: unknown }>
export type DescriptionComponent<T = unknown> = React.ComponentType<Args<T>>
export type Description =
| DescriptionComponent
@@ -13,8 +17,9 @@ export type Description =
export type Props = {
className?: string
description?: Description
marginPlacement?: 'bottom' | 'top'
path?: string
value?: unknown
marginPlacement?: 'top' | 'bottom'
}
export function isComponent(description: Description): description is DescriptionComponent {

View File

@@ -214,6 +214,7 @@ const ArrayFieldType: React.FC<Props> = (props) => {
<FieldDescription
className={`field-description-${path.replace(/\./g, '__')}`}
description={description}
path={path}
value={value}
/>
</header>

View File

@@ -215,7 +215,7 @@ const BlocksField: React.FC<Props> = (props) => {
</ul>
)}
</div>
<FieldDescription description={description} value={value} />
<FieldDescription description={description} path={path} value={value} />
</header>
<NullifyLocaleField fieldValue={value} localized={localized} path={path} />
{(rows.length > 0 || (!valid && (showRequired || showMinRows))) && (

View File

@@ -96,7 +96,7 @@ const Checkbox: React.FC<Props> = (props) => {
readOnly={readOnly}
required={required}
/>
<FieldDescription description={description} value={value} />
<FieldDescription description={description} path={path} value={value} />
</div>
)
}

View File

@@ -82,7 +82,7 @@ const Code: React.FC<Props> = (props) => {
readOnly={readOnly}
value={(value as string) || ''}
/>
<FieldDescription description={description} value={value} />
<FieldDescription description={description} path={path} value={value} />
</div>
)
}

View File

@@ -14,8 +14,8 @@ import RenderFields from '../../RenderFields'
import { RowLabel } from '../../RowLabel'
import { WatchChildErrors } from '../../WatchChildErrors'
import withCondition from '../../withCondition'
import './index.scss'
import { fieldBaseClass } from '../shared'
import './index.scss'
const baseClass = 'collapsible-field'
@@ -89,7 +89,6 @@ const CollapsibleField: React.FC<Props> = (props) => {
return (
<div
id={`field-${fieldPreferencesKey}${path ? `-${path.replace(/\./g, '__')}` : ''}`}
className={[
fieldBaseClass,
baseClass,
@@ -98,6 +97,7 @@ const CollapsibleField: React.FC<Props> = (props) => {
]
.filter(Boolean)
.join(' ')}
id={`field-${fieldPreferencesKey}${path ? `-${path.replace(/\./g, '__')}` : ''}`}
>
<WatchChildErrors fieldSchema={fields} path={path} setErrorCount={setErrorCount} />
<Collapsible
@@ -125,7 +125,7 @@ const CollapsibleField: React.FC<Props> = (props) => {
readOnly={readOnly}
/>
</Collapsible>
<FieldDescription description={description} />
<FieldDescription description={description} path={path} />
</div>
)
}

View File

@@ -91,7 +91,7 @@ export const DateTimeInput: React.FC<DateTimeInputProps> = (props) => {
/>
{Array.isArray(afterInput) && afterInput.map((Component, i) => <Component key={i} />)}
</div>
<FieldDescription description={description} value={value} />
<FieldDescription description={description} path={path} value={value} />
</div>
)
}

View File

@@ -81,7 +81,7 @@ const Email: React.FC<Props> = (props) => {
/>
{Array.isArray(afterInput) && afterInput.map((Component, i) => <Component key={i} />)}
</div>
<FieldDescription description={description} value={value} />
<FieldDescription description={description} path={path} value={value} />
</div>
)
}

View File

@@ -77,6 +77,7 @@ const Group: React.FC<Props> = (props) => {
<FieldDescription
className={`field-description-${path.replace(/\./g, '__')}`}
description={description}
path={path}
value={null}
/>
</header>

View File

@@ -97,7 +97,7 @@ const JSONField: React.FC<Props> = (props) => {
readOnly={readOnly}
value={stringValue}
/>
<FieldDescription description={description} value={value} />
<FieldDescription description={description} path={path} value={value} />
</div>
)
}

View File

@@ -134,7 +134,7 @@ const PointField: React.FC<Props> = (props) => {
</div>
</li>
</ul>
<FieldDescription description={description} value={value} />
<FieldDescription description={description} path={path} value={value} />
</div>
)
}

View File

@@ -8,13 +8,15 @@ import { optionIsObject } from '../../../../../fields/config/types'
import DefaultError from '../../Error'
import FieldDescription from '../../FieldDescription'
import DefaultLabel from '../../Label'
import { fieldBaseClass } from '../shared'
import RadioInput from './RadioInput'
import './index.scss'
import { fieldBaseClass } from '../shared'
const baseClass = 'radio-group'
export type RadioGroupInputProps = Omit<RadioField, 'type'> & {
Error?: React.ComponentType<any>
Label?: React.ComponentType<any>
className?: string
description?: Description
errorMessage?: string
@@ -28,13 +30,13 @@ export type RadioGroupInputProps = Omit<RadioField, 'type'> & {
style?: React.CSSProperties
value?: string
width?: string
Error?: React.ComponentType<any>
Label?: React.ComponentType<any>
}
const RadioGroupInput: React.FC<RadioGroupInputProps> = (props) => {
const {
name,
Error,
Label,
className,
description,
errorMessage,
@@ -49,8 +51,6 @@ const RadioGroupInput: React.FC<RadioGroupInputProps> = (props) => {
style,
value,
width,
Error,
Label,
} = props
const ErrorComp = Error || DefaultError
@@ -103,7 +103,7 @@ const RadioGroupInput: React.FC<RadioGroupInputProps> = (props) => {
)
})}
</ul>
<FieldDescription description={description} value={value} />
<FieldDescription description={description} path={path} value={value} />
</div>
)
}

View File

@@ -12,13 +12,13 @@ const RadioGroup: React.FC<Props> = (props) => {
name,
admin: {
className,
components: { Error, Label } = {},
condition,
description,
layout = 'horizontal',
readOnly,
style,
width,
components: { Error, Label } = {},
} = {},
label,
options,
@@ -44,6 +44,8 @@ const RadioGroup: React.FC<Props> = (props) => {
return (
<RadioGroupInput
Error={Error}
Label={Label}
className={className}
description={description}
errorMessage={errorMessage}
@@ -58,8 +60,6 @@ const RadioGroup: React.FC<Props> = (props) => {
style={style}
value={value}
width={width}
Error={Error}
Label={Label}
/>
)
}

View File

@@ -527,7 +527,7 @@ const Relationship: React.FC<Props> = (props) => {
</div>
)}
{errorLoading && <div className={`${baseClass}__error-loading`}>{errorLoading}</div>}
<FieldDescription description={description} value={value} />
<FieldDescription description={description} path={path} value={value} />
</div>
)
}

View File

@@ -14,6 +14,8 @@ import { fieldBaseClass } from '../shared'
import './index.scss'
export type SelectInputProps = Omit<SelectField, 'options' | 'type' | 'value'> & {
Error?: React.ComponentType<any>
Label?: React.ComponentType<any>
className?: string
description?: Description
errorMessage?: string
@@ -29,12 +31,12 @@ export type SelectInputProps = Omit<SelectField, 'options' | 'type' | 'value'> &
style?: React.CSSProperties
value?: string | string[]
width?: string
Error?: React.ComponentType<any>
Label?: React.ComponentType<any>
}
const SelectInput: React.FC<SelectInputProps> = (props) => {
const {
Error,
Label,
className,
defaultValue,
description,
@@ -52,8 +54,6 @@ const SelectInput: React.FC<SelectInputProps> = (props) => {
style,
value,
width,
Error,
Label,
} = props
const { i18n } = useTranslation()
@@ -111,7 +111,7 @@ const SelectInput: React.FC<SelectInputProps> = (props) => {
showError={showError}
value={valueToRender as Option}
/>
<FieldDescription description={description} value={value} />
<FieldDescription description={description} path={path} value={value} />
</div>
)
}

View File

@@ -177,6 +177,7 @@ const TabsField: React.FC<Props> = (props) => {
className={`${baseClass}__description`}
description={activeTabConfig.description}
marginPlacement="bottom"
path={path}
/>
<RenderFields
fieldSchema={activeTabConfig.fields.map((field) => {

View File

@@ -95,6 +95,7 @@ const TextInput: React.FC<TextInputProps> = (props) => {
<FieldDescription
className={`field-description-${path.replace(/\./g, '__')}`}
description={description}
path={path}
value={value}
/>
</div>

View File

@@ -98,7 +98,7 @@ const TextareaInput: React.FC<TextAreaInputProps> = (props) => {
{Array.isArray(afterInput) && afterInput.map((Component, i) => <Component key={i} />)}
</div>
</label>
<FieldDescription description={description} value={value} />
<FieldDescription description={description} path={path} value={value} />
</div>
)
}

View File

@@ -24,6 +24,8 @@ import './index.scss'
const baseClass = 'upload'
export type UploadInputProps = Omit<UploadField, 'type'> & {
Error?: React.ComponentType<any>
Label?: React.ComponentType<any>
api?: string
className?: string
collection?: SanitizedCollectionConfig
@@ -41,12 +43,12 @@ export type UploadInputProps = Omit<UploadField, 'type'> & {
style?: React.CSSProperties
value?: string
width?: string
Error?: React.ComponentType<any>
Label?: React.ComponentType<any>
}
const UploadInput: React.FC<UploadInputProps> = (props) => {
const {
Error,
Label,
api = '/api',
className,
collection,
@@ -64,8 +66,6 @@ const UploadInput: React.FC<UploadInputProps> = (props) => {
style,
value,
width,
Error,
Label,
} = props
const { i18n, t } = useTranslation('fields')
@@ -191,7 +191,7 @@ const UploadInput: React.FC<UploadInputProps> = (props) => {
</div>
</div>
)}
<FieldDescription description={description} value={file} />
<FieldDescription description={description} path={path} value={file} />
</React.Fragment>
)}
{!readOnly && <DocumentDrawer onSave={onSave} />}

View File

@@ -22,3 +22,9 @@ export {
formatListDrawerSlug,
useListDrawer,
} from '../../admin/components/elements/ListDrawer'
export {
Description,
DescriptionComponent,
DescriptionFunction,
} from '../../admin/components/forms/FieldDescription/types'

View File

@@ -94,7 +94,7 @@ const RichText: React.FC<FieldProps> = (props) => {
value={value}
/>
</ErrorBoundary>
<FieldDescription description={description} value={value} />
<FieldDescription description={description} path={path} value={value} />
</div>
</div>
)

View File

@@ -445,7 +445,7 @@ const RichText: React.FC<FieldProps> = (props) => {
</div>
</div>
</Slate>
<FieldDescription description={description} value={value} />
<FieldDescription description={description} path={path} value={value} />
</div>
</div>
)

View File

@@ -3,6 +3,7 @@ import type { CollectionConfig } from '../../../packages/payload/src/collections
import { slateEditor } from '../../../packages/richtext-slate/src'
import DemoUIFieldCell from '../components/DemoUIField/Cell'
import DemoUIFieldField from '../components/DemoUIField/Field'
import { FieldDescriptionComponent, FieldDescriptionFunction } from '../components/FieldDescription'
import { slugPluralLabel, slugSingularLabel } from '../shared'
import { postsCollectionSlug } from '../slugs'
@@ -93,5 +94,26 @@ export const Posts: CollectionConfig = {
'This is a very long description that takes many characters to complete and hopefully will wrap instead of push the sidebar open, lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum voluptates. Quisquam, voluptatum voluptates.',
},
},
{
name: 'descriptionAsString',
type: 'text',
admin: {
description: 'Static field description.',
},
},
{
name: 'descriptionAsFunction',
type: 'text',
admin: {
description: FieldDescriptionFunction,
},
},
{
name: 'descriptionAsComponent',
type: 'text',
admin: {
description: FieldDescriptionComponent,
},
},
],
}

View File

@@ -0,0 +1,18 @@
import React from 'react'
import type {
DescriptionComponent,
DescriptionFunction,
} from '../../../../packages/payload/src/admin/components/forms/FieldDescription/types'
export const FieldDescriptionComponent: DescriptionComponent<string> = ({ path, value }) => {
return (
<div>
Component description: {path} - {value}
</div>
)
}
export const FieldDescriptionFunction: DescriptionFunction<string> = ({ path, value }) => {
return `Function description: ${path} - ${value}`
}

View File

@@ -1157,6 +1157,29 @@ describe('admin', () => {
})
})
})
describe('Field descriptions', () => {
test('should render static field description', async () => {
await page.goto(url.create)
await expect(page.locator('.field-description-descriptionAsString')).toContainText(
'Static field description.',
)
})
test('should render functional field description', async () => {
await page.goto(url.create)
await page.locator('#field-descriptionAsFunction').fill('functional')
await expect(page.locator('.field-description-descriptionAsFunction')).toContainText(
'Function description: descriptionAsFunction - functional',
)
})
test('should render component field description', async () => {
await page.goto(url.create)
await page.locator('#field-descriptionAsComponent').fill('component')
await expect(page.locator('.field-description-descriptionAsComponent')).toContainText(
'Component description: descriptionAsComponent - component',
)
})
})
})
async function createPost(overrides?: Partial<Post>): Promise<Post> {