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:
Jacob Fletcher
2024-12-13 11:42:52 -05:00
committed by GitHub
parent 9c8cdea4b3
commit 796df37461
14 changed files with 157 additions and 63 deletions

View File

@@ -14,6 +14,7 @@ export type Row = {
blockType?: string
collapsed?: boolean
id: string
isLoading?: boolean
}
export type FilterOptionsResult = {

View File

@@ -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>
)

View File

@@ -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}

View File

@@ -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>
)

View File

@@ -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}

View File

@@ -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 = {

View File

@@ -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
}
}

View File

@@ -102,6 +102,7 @@ export const iterateFields = async ({
fields.forEach((field, fieldIndex) => {
let passesCondition = true
if (!skipConditionChecks) {
passesCondition = Boolean(
(field?.admin?.condition

View File

@@ -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,
],
)

View File

@@ -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
}

View 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
}

View File

@@ -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,
}}

View File

@@ -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,
},
},
],
},
],
},
],
}

View File

@@ -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', () => {