fix(ui): automatically subscribes custom fields to conditional logic (#9928)

Currently, custom components do not respect `admin.condition` unless
manually wrapped with the `withCondition` HOC, like all default fields
currently do. This should not be a requirement of component authors.
Instead, we can automatically detect custom client and server fields and
wrap them with the underlying `WatchCondition` component which will
subscribe to the `passesCondition` property within client-side form
state.

For my future self: there are potentially multiple instances where
fields subscribe to conditions duplicately, such as when rendering a
default Payload field within a custom field component. This was always a
problem and it is non-breaking, but needs to be reevaluated and removed
in the future for performance. Only the default fields that Payload
renders client-side need to subscribe to field conditions in this way.
When importing a Payload field into your custom field component, for
example, it should not include the HOC, because custom components now
watch conditions themselves.
This commit is contained in:
Jacob Fletcher
2024-12-13 14:12:37 -05:00
committed by GitHub
parent 33d5482e9d
commit 1502e09581
32 changed files with 186 additions and 49 deletions

View File

@@ -22,18 +22,18 @@ export const getJobsLocalAPI = (payload: Payload) => ({
req?: PayloadRequest
// TTaskOrWorkflowlug with keyof TypedJobs['workflows'] removed:
task: TTaskOrWorkflowSlug extends keyof TypedJobs['tasks'] ? TTaskOrWorkflowSlug : never
workflow?: never
waitUntil?: Date
workflow?: never
}
| {
input: TypedJobs['workflows'][TTaskOrWorkflowSlug]['input']
queue?: string
req?: PayloadRequest
task?: never
waitUntil?: Date
workflow: TTaskOrWorkflowSlug extends keyof TypedJobs['workflows']
? TTaskOrWorkflowSlug
: never
waitUntil?: Date
},
): Promise<
TTaskOrWorkflowSlug extends keyof TypedJobs['workflows']
@@ -60,8 +60,8 @@ export const getJobsLocalAPI = (payload: Payload) => ({
input: args.input,
queue,
taskSlug: 'task' in args ? args.task : undefined,
workflowSlug: 'workflow' in args ? args.workflow : undefined,
waitUntil: args.waitUntil?.toISOString() ?? undefined,
workflowSlug: 'workflow' in args ? args.workflow : undefined,
} as BaseJob,
req: args.req,
})) as TTaskOrWorkflowSlug extends keyof TypedJobs['workflows']

View File

@@ -9,7 +9,6 @@ import {
RenderCustomComponent,
useEditDepth,
useField,
withCondition,
} from '@payloadcms/ui'
import { mergeFieldStyles } from '@payloadcms/ui/shared'
import React, { useCallback, useMemo } from 'react'
@@ -143,4 +142,4 @@ function fallbackRender({ error }: { error: Error }) {
)
}
export const RichText: typeof RichTextComponent = withCondition(RichTextComponent)
export const RichText: typeof RichTextComponent = RichTextComponent

View File

@@ -14,7 +14,6 @@ import {
useEditDepth,
useField,
useTranslation,
withCondition,
} from '@payloadcms/ui'
import { mergeFieldStyles } from '@payloadcms/ui/shared'
import { isHotkey } from 'is-hotkey'
@@ -459,4 +458,4 @@ const RichTextField: React.FC<LoadedSlateFieldProps> = (props) => {
)
}
export const RichText = withCondition(RichTextField)
export const RichText = RichTextField

View File

@@ -196,6 +196,7 @@ export { useField } from '../../forms/useField/index.js'
export type { FieldType, Options } from '../../forms/useField/types.js'
export { withCondition } from '../../forms/withCondition/index.js'
export { WatchCondition } from '../../forms/withCondition/WatchCondition.js'
// graphics
export { Account } from '../../graphics/Account/index.js'

View File

@@ -51,10 +51,10 @@ export function RenderField({
readOnly,
schemaPath,
}: RenderFieldProps) {
const Field = useFormFields(([fields]) => fields && fields?.[path]?.customComponents?.Field)
const CustomField = useFormFields(([fields]) => fields && fields?.[path]?.customComponents?.Field)
if (Field !== undefined) {
return Field || null
if (CustomField !== undefined) {
return CustomField || null
}
const baseFieldProps: Pick<ClientComponentProps, 'forceRender' | 'readOnly' | 'schemaPath'> = {

View File

@@ -9,7 +9,7 @@ import type { RenderFieldMethod } from './types.js'
import { RenderServerComponent } from '../../elements/RenderServerComponent/index.js'
// eslint-disable-next-line payload/no-imports-from-exports-dir -- need this to reference already existing bundle. Otherwise, bundle size increases., payload/no-imports-from-exports-dir
import { FieldDescription } from '../../exports/client/index.js'
import { FieldDescription, WatchCondition } from '../../exports/client/index.js'
const defaultUIFieldComponentKeys: Array<'Cell' | 'Description' | 'Field' | 'Filter'> = [
'Cell',
@@ -135,12 +135,16 @@ export const renderField: RenderFieldMethod = ({
fieldConfig.admin.components = {}
}
fieldState.customComponents.Field = RenderServerComponent({
fieldState.customComponents.Field = (
<WatchCondition path={path}>
{RenderServerComponent({
clientProps,
Component: fieldConfig.editor.FieldComponent,
importMap: req.payload.importMap,
serverProps,
})
})}
</WatchCondition>
)
break
}
@@ -236,13 +240,17 @@ export const renderField: RenderFieldMethod = ({
}
if ('Field' in fieldConfig.admin.components) {
fieldState.customComponents.Field = RenderServerComponent({
fieldState.customComponents.Field = (
<WatchCondition path={path}>
{RenderServerComponent({
clientProps,
Component: fieldConfig.admin.components.Field,
importMap: req.payload.importMap,
key: 'field.admin.components.Field',
serverProps,
})
})}
</WatchCondition>
)
}
}
}

View File

@@ -6,7 +6,6 @@ import { useFormFields } from '../Form/context.js'
export const WatchCondition: React.FC<{
children: React.ReactNode
indexPath: string
path: string
}> = (props) => {
const { children, path } = props

View File

@@ -10,10 +10,10 @@ export const withCondition = <P extends MarkOptional<FieldPaths, 'indexPath' | '
Field: React.ComponentType<P>,
): React.FC<P> => {
const CheckForCondition: React.FC<P> = (props) => {
const { indexPath, path } = props
const { path } = props
return (
<WatchCondition indexPath={indexPath} path={path}>
<WatchCondition path={path}>
<Field {...props} />
</WatchCondition>
)

View File

@@ -36,5 +36,5 @@
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
},
}
}

View File

@@ -28,12 +28,12 @@
"sharp": "0.32.6"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@types/node": "^22.5.4",
"@types/react": "19.0.1",
"@types/react-dom": "19.0.1",
"eslint": "^9.16.0",
"eslint-config-next": "15.1.0",
"@eslint/eslintrc": "^3.2.0",
"prettier": "^3.4.2",
"typescript": "5.7.2"
},

View File

@@ -36,5 +36,5 @@
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
},
}
}

View File

@@ -28,12 +28,12 @@
"sharp": "0.32.6"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@types/node": "^22.5.4",
"@types/react": "19.0.1",
"@types/react-dom": "19.0.1",
"eslint": "^9.16.0",
"eslint-config-next": "15.1.0",
"@eslint/eslintrc": "^3.2.0",
"prettier": "^3.4.2",
"typescript": "5.7.2"
},

View File

@@ -36,5 +36,5 @@
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
},
}
}

View File

@@ -54,6 +54,7 @@
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@tailwindcss/typography": "^0.5.13",
"@types/escape-html": "^1.0.2",
"@types/jsonwebtoken": "^9.0.6",
@@ -64,9 +65,8 @@
"copyfiles": "^2.4.1",
"eslint": "^9.16.0",
"eslint-config-next": "15.1.0",
"@eslint/eslintrc": "^3.2.0",
"prettier": "^3.4.2",
"postcss": "^8.4.38",
"prettier": "^3.4.2",
"tailwindcss": "^3.4.3",
"typescript": "5.7.2"
},

View File

@@ -36,5 +36,5 @@
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
},
}
}

View File

@@ -28,12 +28,12 @@
"sharp": "0.32.6"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@types/node": "^22.5.4",
"@types/react": "19.0.1",
"@types/react-dom": "19.0.1",
"eslint": "^9.16.0",
"eslint-config-next": "15.1.0",
"@eslint/eslintrc": "^3.2.0",
"prettier": "^3.4.2",
"typescript": "5.7.2"
},

View File

@@ -36,5 +36,5 @@
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
},
}
}

View File

@@ -29,12 +29,12 @@
"sharp": "0.32.6"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@types/node": "^22.5.4",
"@types/react": "19.0.1",
"@types/react-dom": "19.0.1",
"eslint": "^9.16.0",
"eslint-config-next": "15.1.0",
"@eslint/eslintrc": "^3.2.0",
"prettier": "^3.4.2",
"typescript": "5.7.2"
},

View File

@@ -36,5 +36,5 @@
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
},
}
}

View File

@@ -28,12 +28,12 @@
"react-dom": "19.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@types/node": "^22.5.4",
"@types/react": "19.0.1",
"@types/react-dom": "19.0.1",
"eslint": "^9.16.0",
"eslint-config-next": "15.1.0",
"@eslint/eslintrc": "^3.2.0",
"prettier": "^3.4.2",
"typescript": "5.7.2"
},

View File

@@ -36,5 +36,5 @@
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
},
}
}

View File

@@ -29,12 +29,12 @@
"react-dom": "19.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@types/node": "^22.5.4",
"@types/react": "19.0.1",
"@types/react-dom": "19.0.1",
"eslint": "^9.16.0",
"eslint-config-next": "15.1.0",
"@eslint/eslintrc": "^3.2.0",
"prettier": "^3.4.2",
"typescript": "5.7.2"
},

View File

@@ -36,5 +36,5 @@
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
},
}
}

View File

@@ -56,6 +56,7 @@
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@tailwindcss/typography": "^0.5.13",
"@types/escape-html": "^1.0.2",
"@types/jsonwebtoken": "^9.0.6",
@@ -66,9 +67,8 @@
"copyfiles": "^2.4.1",
"eslint": "^9.16.0",
"eslint-config-next": "^15.1.0",
"@eslint/eslintrc": "^3.2.0",
"prettier": "^3.4.2",
"postcss": "^8.4.38",
"prettier": "^3.4.2",
"tailwindcss": "^3.4.3",
"typescript": "5.7.2"
},

View File

@@ -0,0 +1,11 @@
'use client'
import type { TextFieldClientComponent } from 'payload'
import React from 'react'
const CustomClientField: TextFieldClientComponent = () => {
return <div id="custom-client-field">Custom Client Field</div>
}
export default CustomClientField

View File

@@ -0,0 +1,11 @@
'use client'
import type { TextFieldClientComponent } from 'payload'
import { TextField } from '@payloadcms/ui'
import React from 'react'
const CustomFieldWithField: TextFieldClientComponent = (props) => {
return <TextField {...props} />
}
export default CustomFieldWithField

View File

@@ -0,0 +1,11 @@
'use client'
import type { TextFieldClientComponent } from 'payload'
import { TextField, withCondition } from '@payloadcms/ui'
import React from 'react'
const MyField: TextFieldClientComponent = (props) => {
return <TextField {...props} />
}
export default withCondition(MyField)

View File

@@ -0,0 +1,9 @@
import type { TextFieldServerComponent } from 'payload'
import React from 'react'
const CustomServerField: TextFieldServerComponent = () => {
return <div id="custom-server-field">Custom Server Field</div>
}
export default CustomServerField

View File

@@ -84,6 +84,49 @@ describe('Conditional Logic', () => {
expect(true).toBe(true)
})
test('should conditionally render custom field that renders a Payload field', async () => {
await page.goto(url.create)
await toggleConditionAndCheckField(
'label[for=field-toggleField]',
'label[for=field-customFieldWithField]',
)
expect(true).toBe(true)
})
test('should conditionally render custom field that wraps itself with the withCondition HOC (legacy)', async () => {
await page.goto(url.create)
await toggleConditionAndCheckField(
'label[for=field-toggleField]',
'label[for=field-customFieldWithHOC]',
)
expect(true).toBe(true)
})
test('should toggle conditional custom client field', async () => {
await page.goto(url.create)
await toggleConditionAndCheckField('label[for=field-toggleField]', '#custom-client-field')
expect(true).toBe(true)
})
test('should conditionally render custom server field', async () => {
await page.goto(url.create)
await toggleConditionAndCheckField('label[for=field-toggleField]', '#custom-server-field')
expect(true).toBe(true)
})
test('should conditionally render rich text fields', async () => {
await page.goto(url.create)
await toggleConditionAndCheckField(
'label[for=field-toggleField]',
'.field-type.rich-text-lexical',
)
expect(true).toBe(true)
})
test('should show conditional field based on user data', async () => {
await page.goto(url.create)
const userConditional = page.locator('input#field-userConditional')

View File

@@ -20,7 +20,54 @@ const ConditionalLogic: CollectionConfig = {
{
name: 'fieldWithCondition',
type: 'text',
required: true,
admin: {
condition: ({ toggleField }) => Boolean(toggleField),
},
},
{
name: 'customFieldWithField',
type: 'text',
admin: {
components: {
Field: '/collections/ConditionalLogic/CustomFieldWithField',
},
condition: ({ toggleField }) => Boolean(toggleField),
},
},
{
name: 'customFieldWithHOC',
label: 'Custom Field With HOC (legacy)',
type: 'text',
admin: {
components: {
Field: '/collections/ConditionalLogic/CustomFieldWithHOC',
},
condition: ({ toggleField }) => Boolean(toggleField),
},
},
{
name: 'customClientFieldWithCondition',
type: 'text',
admin: {
components: {
Field: '/collections/ConditionalLogic/CustomClientField',
},
condition: ({ toggleField }) => Boolean(toggleField),
},
},
{
name: 'customServerFieldWithCondition',
type: 'text',
admin: {
components: {
Field: '/collections/ConditionalLogic/CustomServerField',
},
condition: ({ toggleField }) => Boolean(toggleField),
},
},
{
name: 'conditionalRichText',
type: 'richText',
admin: {
condition: ({ toggleField }) => Boolean(toggleField),
},

View File

@@ -5,5 +5,4 @@ import type { ConditionalLogic } from '../../payload-types.js'
export const conditionalLogicDoc: RequiredDataFromCollection<ConditionalLogic> = {
text: 'Seeded conditional logic document',
toggleField: true,
fieldWithCondition: 'spiderman',
}

View File

@@ -1,9 +1,9 @@
'use client'
import type { TextFieldServerComponent } from 'payload'
import type { TextFieldClientComponent } from 'payload'
import React from 'react'
export const CustomField: TextFieldServerComponent = ({ schemaPath }) => {
export const CustomField: TextFieldClientComponent = ({ schemaPath }) => {
return <div id="custom-field-schema-path">{schemaPath}</div>
}