fix(ui): awaits form state before rendering conditional fields (#9933)
When a condition exists on a field and it resolves to `false`, it currently "blinks" in and out when rendered within an array or block row. This is because when add rows to form state, we iterate over the _fields_ of that row and render their respective components. Then when conditions are checked for that field, we're expecting `passesCondition` to be explicitly `false`, ultimately _rendering_ the field for a brief moment before form state returns with evaluated conditions. The fix is to set these fields into local form state with a new `isLoading: true` prop, then display a loader within the row until form state returns with its proper conditions.
This commit is contained in:
@@ -14,6 +14,7 @@ export type Row = {
|
||||
blockType?: string
|
||||
collapsed?: boolean
|
||||
id: string
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export type FilterOptionsResult = {
|
||||
|
||||
@@ -9,11 +9,13 @@ import type { UseDraggableSortableReturn } from '../../elements/DraggableSortabl
|
||||
import { ArrayAction } from '../../elements/ArrayAction/index.js'
|
||||
import { Collapsible } from '../../elements/Collapsible/index.js'
|
||||
import { ErrorPill } from '../../elements/ErrorPill/index.js'
|
||||
import { ShimmerEffect } from '../../elements/ShimmerEffect/index.js'
|
||||
import { useFormSubmitted } from '../../forms/Form/context.js'
|
||||
import { RenderFields } from '../../forms/RenderFields/index.js'
|
||||
import { RowLabel } from '../../forms/RowLabel/index.js'
|
||||
import { useTranslation } from '../../providers/Translation/index.js'
|
||||
import { useThrottledValue } from '../../hooks/useThrottledValue.js'
|
||||
import './index.scss'
|
||||
import { useTranslation } from '../../providers/Translation/index.js'
|
||||
|
||||
const baseClass = 'array-field'
|
||||
|
||||
@@ -25,6 +27,7 @@ type ArrayRowProps = {
|
||||
readonly fields: ClientField[]
|
||||
readonly forceRender?: boolean
|
||||
readonly hasMaxRows?: boolean
|
||||
readonly isLoading?: boolean
|
||||
readonly isSortable?: boolean
|
||||
readonly labels: Partial<ArrayField['labels']>
|
||||
readonly moveRow: (fromIndex: number, toIndex: number) => void
|
||||
@@ -50,6 +53,7 @@ export const ArrayRow: React.FC<ArrayRowProps> = ({
|
||||
forceRender = false,
|
||||
hasMaxRows,
|
||||
isDragging,
|
||||
isLoading: isLoadingFromProps,
|
||||
isSortable,
|
||||
labels,
|
||||
listeners,
|
||||
@@ -68,6 +72,8 @@ export const ArrayRow: React.FC<ArrayRowProps> = ({
|
||||
transform,
|
||||
transition,
|
||||
}) => {
|
||||
const isLoading = useThrottledValue(isLoadingFromProps, 500)
|
||||
|
||||
const { i18n } = useTranslation()
|
||||
const hasSubmitted = useFormSubmitted()
|
||||
|
||||
@@ -136,17 +142,21 @@ export const ArrayRow: React.FC<ArrayRowProps> = ({
|
||||
isCollapsed={row.collapsed}
|
||||
onToggle={(collapsed) => setCollapse(row.id, collapsed)}
|
||||
>
|
||||
<RenderFields
|
||||
className={`${baseClass}__fields`}
|
||||
fields={fields}
|
||||
forceRender={forceRender}
|
||||
margins="small"
|
||||
parentIndexPath=""
|
||||
parentPath={path}
|
||||
parentSchemaPath={schemaPath}
|
||||
permissions={permissions === true ? permissions : permissions?.fields}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<ShimmerEffect />
|
||||
) : (
|
||||
<RenderFields
|
||||
className={`${baseClass}__fields`}
|
||||
fields={fields}
|
||||
forceRender={forceRender}
|
||||
margins="small"
|
||||
parentIndexPath=""
|
||||
parentPath={path}
|
||||
parentSchemaPath={schemaPath}
|
||||
permissions={permissions === true ? permissions : permissions?.fields}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)}
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -276,7 +276,7 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
|
||||
onDragEnd={({ moveFromIndex, moveToIndex }) => moveRow(moveFromIndex, moveToIndex)}
|
||||
>
|
||||
{rowsData.map((rowData, i) => {
|
||||
const { id: rowID } = rowData
|
||||
const { id: rowID, isLoading } = rowData
|
||||
|
||||
const rowPath = `${path}.${i}`
|
||||
|
||||
@@ -296,6 +296,7 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
|
||||
fields={fields}
|
||||
forceRender={forceRender}
|
||||
hasMaxRows={hasMaxRows}
|
||||
isLoading={isLoading}
|
||||
isSortable={isSortable}
|
||||
labels={labels}
|
||||
moveRow={moveRow}
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
'use client'
|
||||
import type {
|
||||
ClientBlock,
|
||||
ClientField,
|
||||
Labels,
|
||||
Row,
|
||||
SanitizedFieldPermissions,
|
||||
SanitizedFieldsPermissions,
|
||||
} from 'payload'
|
||||
import type { ClientBlock, ClientField, Labels, Row, SanitizedFieldPermissions } from 'payload'
|
||||
|
||||
import { getTranslation } from '@payloadcms/translations'
|
||||
import React from 'react'
|
||||
@@ -17,8 +10,10 @@ import type { RenderFieldsProps } from '../../forms/RenderFields/types.js'
|
||||
import { Collapsible } from '../../elements/Collapsible/index.js'
|
||||
import { ErrorPill } from '../../elements/ErrorPill/index.js'
|
||||
import { Pill } from '../../elements/Pill/index.js'
|
||||
import { ShimmerEffect } from '../../elements/ShimmerEffect/index.js'
|
||||
import { useFormSubmitted } from '../../forms/Form/context.js'
|
||||
import { RenderFields } from '../../forms/RenderFields/index.js'
|
||||
import { useThrottledValue } from '../../hooks/useThrottledValue.js'
|
||||
import { useTranslation } from '../../providers/Translation/index.js'
|
||||
import { RowActions } from './RowActions.js'
|
||||
import { SectionTitle } from './SectionTitle/index.js'
|
||||
@@ -33,6 +28,7 @@ type BlocksFieldProps = {
|
||||
errorCount: number
|
||||
fields: ClientField[]
|
||||
hasMaxRows?: boolean
|
||||
isLoading?: boolean
|
||||
isSortable?: boolean
|
||||
Label?: React.ReactNode
|
||||
labels: Labels
|
||||
@@ -58,6 +54,7 @@ export const BlockRow: React.FC<BlocksFieldProps> = ({
|
||||
errorCount,
|
||||
fields,
|
||||
hasMaxRows,
|
||||
isLoading: isLoadingFromProps,
|
||||
isSortable,
|
||||
Label,
|
||||
labels,
|
||||
@@ -76,6 +73,8 @@ export const BlockRow: React.FC<BlocksFieldProps> = ({
|
||||
setNodeRef,
|
||||
transform,
|
||||
}) => {
|
||||
const isLoading = useThrottledValue(isLoadingFromProps, 500)
|
||||
|
||||
const { i18n } = useTranslation()
|
||||
const hasSubmitted = useFormSubmitted()
|
||||
|
||||
@@ -161,16 +160,20 @@ export const BlockRow: React.FC<BlocksFieldProps> = ({
|
||||
key={row.id}
|
||||
onToggle={(collapsed) => setCollapse(row.id, collapsed)}
|
||||
>
|
||||
<RenderFields
|
||||
className={`${baseClass}__fields`}
|
||||
fields={fields}
|
||||
margins="small"
|
||||
parentIndexPath=""
|
||||
parentPath={path}
|
||||
parentSchemaPath={schemaPath}
|
||||
permissions={blockPermissions}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<ShimmerEffect />
|
||||
) : (
|
||||
<RenderFields
|
||||
className={`${baseClass}__fields`}
|
||||
fields={fields}
|
||||
margins="small"
|
||||
parentIndexPath=""
|
||||
parentPath={path}
|
||||
parentSchemaPath={schemaPath}
|
||||
permissions={blockPermissions}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)}
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -259,7 +259,7 @@ const BlocksFieldComponent: BlocksFieldClientComponent = (props) => {
|
||||
onDragEnd={({ moveFromIndex, moveToIndex }) => moveRow(moveFromIndex, moveToIndex)}
|
||||
>
|
||||
{rows.map((row, i) => {
|
||||
const { blockType } = row
|
||||
const { blockType, isLoading } = row
|
||||
const blockConfig = blocks.find((block) => block.slug === blockType)
|
||||
|
||||
if (blockConfig) {
|
||||
@@ -281,6 +281,7 @@ const BlocksFieldComponent: BlocksFieldClientComponent = (props) => {
|
||||
errorCount={rowErrorCount}
|
||||
fields={blockConfig.fields}
|
||||
hasMaxRows={hasMaxRows}
|
||||
isLoading={isLoading}
|
||||
isSortable={isSortable}
|
||||
Label={Label}
|
||||
labels={labels}
|
||||
|
||||
@@ -29,6 +29,7 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
|
||||
id: (subFieldState?.id?.value as string) || new ObjectId().toHexString(),
|
||||
blockType: blockType || undefined,
|
||||
collapsed: false,
|
||||
isLoading: true,
|
||||
}
|
||||
|
||||
withNewRow.splice(rowIndex, 0, newRow)
|
||||
@@ -43,6 +44,7 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
|
||||
|
||||
// add new row to array _field state_
|
||||
const { remainingFields, rows: siblingRows } = separateRows(path, state)
|
||||
|
||||
siblingRows.splice(rowIndex, 0, subFieldState)
|
||||
|
||||
const newState: FormState = {
|
||||
|
||||
@@ -142,6 +142,16 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
|
||||
|
||||
let fieldPermissions: SanitizedFieldPermissions = true
|
||||
|
||||
const fieldState: FormFieldWithoutComponents = {
|
||||
errorPaths: [],
|
||||
fieldSchema: includeSchema ? field : undefined,
|
||||
initialValue: undefined,
|
||||
isSidebar: fieldIsSidebar(field),
|
||||
passesCondition,
|
||||
valid: true,
|
||||
value: undefined,
|
||||
}
|
||||
|
||||
if (fieldAffectsData(field) && !fieldIsHiddenOrDisabled(field)) {
|
||||
fieldPermissions =
|
||||
parentPermissions === true
|
||||
@@ -163,16 +173,6 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
|
||||
|
||||
const validate = field.validate
|
||||
|
||||
const fieldState: FormFieldWithoutComponents = {
|
||||
errorPaths: [],
|
||||
fieldSchema: includeSchema ? field : undefined,
|
||||
initialValue: undefined,
|
||||
isSidebar: fieldIsSidebar(field),
|
||||
passesCondition,
|
||||
valid: true,
|
||||
value: undefined,
|
||||
}
|
||||
|
||||
let validationResult: string | true = true
|
||||
|
||||
if (typeof validate === 'function' && !skipValidation && passesCondition) {
|
||||
@@ -672,7 +672,8 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
|
||||
})
|
||||
|
||||
let childPermissions: SanitizedFieldsPermissions = undefined
|
||||
if (tabHasName(tab)) {
|
||||
|
||||
if (isNamedTab) {
|
||||
if (parentPermissions === true) {
|
||||
childPermissions = true
|
||||
} else {
|
||||
@@ -721,16 +722,8 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
|
||||
await Promise.all(promises)
|
||||
} else if (field.type === 'ui') {
|
||||
if (!filter || filter(args)) {
|
||||
state[path] = {
|
||||
disableFormData: true,
|
||||
errorPaths: [],
|
||||
fieldSchema: includeSchema ? field : undefined,
|
||||
initialValue: undefined,
|
||||
isSidebar: fieldIsSidebar(field),
|
||||
passesCondition,
|
||||
valid: true,
|
||||
value: undefined,
|
||||
}
|
||||
state[path] = fieldState
|
||||
state[path].disableFormData = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ export const iterateFields = async ({
|
||||
|
||||
fields.forEach((field, fieldIndex) => {
|
||||
let passesCondition = true
|
||||
|
||||
if (!skipConditionChecks) {
|
||||
passesCondition = Boolean(
|
||||
(field?.admin?.condition
|
||||
|
||||
@@ -118,10 +118,7 @@ export const useField = <TValue,>(options: Options): FieldType<TValue> => {
|
||||
value,
|
||||
}),
|
||||
[
|
||||
field?.errorMessage,
|
||||
field?.rows,
|
||||
field?.valid,
|
||||
field?.errorPaths,
|
||||
field,
|
||||
processing,
|
||||
setValue,
|
||||
showError,
|
||||
@@ -131,7 +128,6 @@ export const useField = <TValue,>(options: Options): FieldType<TValue> => {
|
||||
path,
|
||||
filterOptions,
|
||||
initializing,
|
||||
field?.customComponents,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import React, { Fragment } from 'react'
|
||||
import type React from 'react'
|
||||
|
||||
import { useFormFields } from '../Form/context.js'
|
||||
|
||||
@@ -19,5 +19,5 @@ export const WatchCondition: React.FC<{
|
||||
return null
|
||||
}
|
||||
|
||||
return <Fragment>{children}</Fragment>
|
||||
return children
|
||||
}
|
||||
|
||||
24
packages/ui/src/hooks/useThrottledValue.ts
Normal file
24
packages/ui/src/hooks/useThrottledValue.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* A custom React hook to throttle a value so that it updates no more than once every `delay` milliseconds.
|
||||
* @param {any} value - The value to be throttled.
|
||||
* @param {number} delay - The minimum delay (in milliseconds) between updates.
|
||||
* @returns {any} - The throttled value.
|
||||
*/
|
||||
export function useThrottledValue(value, delay) {
|
||||
const [throttledValue, setThrottledValue] = useState(value)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setThrottledValue(value)
|
||||
}, delay)
|
||||
|
||||
// Cleanup the timeout if the value changes before the delay is completed
|
||||
return () => {
|
||||
clearTimeout(handler)
|
||||
}
|
||||
}, [value, delay])
|
||||
|
||||
return throttledValue
|
||||
}
|
||||
@@ -82,12 +82,14 @@ export const Auth: React.FC<Props> = (props) => {
|
||||
if (showPasswordFields) {
|
||||
setValidateBeforeSubmit(true)
|
||||
setSchemaPathSegments([`_${collectionSlug}`, 'auth'])
|
||||
|
||||
dispatchFields({
|
||||
type: 'UPDATE',
|
||||
errorMessage: t('validation:required'),
|
||||
path: 'password',
|
||||
valid: false,
|
||||
})
|
||||
|
||||
dispatchFields({
|
||||
type: 'UPDATE',
|
||||
errorMessage: t('validation:required'),
|
||||
@@ -157,9 +159,6 @@ export const Auth: React.FC<Props> = (props) => {
|
||||
autoComplete="new-password"
|
||||
field={{
|
||||
name: 'password',
|
||||
admin: {
|
||||
disabled,
|
||||
},
|
||||
label: t('authentication:newPassword'),
|
||||
required: true,
|
||||
}}
|
||||
|
||||
@@ -92,6 +92,49 @@ const ConditionalLogic: CollectionConfig = {
|
||||
condition: ({ groupSelection }) => groupSelection === 'group2',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'enableConditionalFields',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
name: 'arrayWithConditionalField',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'textWithCondition',
|
||||
type: 'text',
|
||||
admin: {
|
||||
condition: (data) => data.enableConditionalFields,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'blocksWithConditionalField',
|
||||
type: 'blocks',
|
||||
blocks: [
|
||||
{
|
||||
slug: 'blockWithConditionalField',
|
||||
fields: [
|
||||
{
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'textWithCondition',
|
||||
type: 'text',
|
||||
admin: {
|
||||
condition: (data) => data.enableConditionalFields,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -541,6 +541,26 @@ describe('fields', () => {
|
||||
const fieldRelyingOnSiblingData = page.locator('input#field-reliesOnParentGroup')
|
||||
await expect(fieldRelyingOnSiblingData).toBeVisible()
|
||||
})
|
||||
|
||||
test('should not render conditional fields when adding array rows', async () => {
|
||||
await page.goto(url.create)
|
||||
const addRowButton = page.locator('.array-field__add-row')
|
||||
const fieldWithConditionSelector =
|
||||
'input#field-arrayWithConditionalField__0__textWithCondition'
|
||||
await addRowButton.click()
|
||||
|
||||
const wasFieldAttached = await page
|
||||
.waitForSelector(fieldWithConditionSelector, {
|
||||
state: 'attached',
|
||||
timeout: 100, // A small timeout to catch any transient rendering
|
||||
})
|
||||
.catch(() => false) // If it doesn't appear, this resolves to `false`
|
||||
|
||||
expect(wasFieldAttached).toBeFalsy()
|
||||
const fieldToToggle = page.locator('input#field-enableConditionalFields')
|
||||
await fieldToToggle.click()
|
||||
await expect(page.locator(fieldWithConditionSelector)).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
describe('tabs', () => {
|
||||
|
||||
Reference in New Issue
Block a user