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:
@@ -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']
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'> = {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
2
templates/_template/.vscode/settings.json
vendored
2
templates/_template/.vscode/settings.json
vendored
@@ -36,5 +36,5 @@
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
2
templates/blank/.vscode/settings.json
vendored
2
templates/blank/.vscode/settings.json
vendored
@@ -36,5 +36,5 @@
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
2
templates/website/.vscode/settings.json
vendored
2
templates/website/.vscode/settings.json
vendored
@@ -36,5 +36,5 @@
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -36,5 +36,5 @@
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -36,5 +36,5 @@
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -36,5 +36,5 @@
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -36,5 +36,5 @@
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -36,5 +36,5 @@
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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')
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user