Skip to content

Commit

Permalink
fix(ui): awaits form state before rendering conditional fields (#9933)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
jacobsfletch authored Dec 13, 2024
1 parent 9c8cdea commit 796df37
Show file tree
Hide file tree
Showing 14 changed files with 157 additions and 63 deletions.
1 change: 1 addition & 0 deletions packages/payload/src/admin/forms/Form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type Row = {
blockType?: string
collapsed?: boolean
id: string
isLoading?: boolean
}

export type FilterOptionsResult = {
Expand Down
34 changes: 22 additions & 12 deletions packages/ui/src/fields/Array/ArrayRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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
Expand All @@ -50,6 +53,7 @@ export const ArrayRow: React.FC<ArrayRowProps> = ({
forceRender = false,
hasMaxRows,
isDragging,
isLoading: isLoadingFromProps,
isSortable,
labels,
listeners,
Expand All @@ -68,6 +72,8 @@ export const ArrayRow: React.FC<ArrayRowProps> = ({
transform,
transition,
}) => {
const isLoading = useThrottledValue(isLoadingFromProps, 500)

const { i18n } = useTranslation()
const hasSubmitted = useFormSubmitted()

Expand Down Expand Up @@ -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>
)
Expand Down
3 changes: 2 additions & 1 deletion packages/ui/src/fields/Array/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`

Expand All @@ -296,6 +296,7 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
fields={fields}
forceRender={forceRender}
hasMaxRows={hasMaxRows}
isLoading={isLoading}
isSortable={isSortable}
labels={labels}
moveRow={moveRow}
Expand Down
39 changes: 21 additions & 18 deletions packages/ui/src/fields/Blocks/BlockRow.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'
Expand All @@ -33,6 +28,7 @@ type BlocksFieldProps = {
errorCount: number
fields: ClientField[]
hasMaxRows?: boolean
isLoading?: boolean
isSortable?: boolean
Label?: React.ReactNode
labels: Labels
Expand All @@ -58,6 +54,7 @@ export const BlockRow: React.FC<BlocksFieldProps> = ({
errorCount,
fields,
hasMaxRows,
isLoading: isLoadingFromProps,
isSortable,
Label,
labels,
Expand All @@ -76,6 +73,8 @@ export const BlockRow: React.FC<BlocksFieldProps> = ({
setNodeRef,
transform,
}) => {
const isLoading = useThrottledValue(isLoadingFromProps, 500)

const { i18n } = useTranslation()
const hasSubmitted = useFormSubmitted()

Expand Down Expand Up @@ -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>
)
Expand Down
3 changes: 2 additions & 1 deletion packages/ui/src/fields/Blocks/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -281,6 +281,7 @@ const BlocksFieldComponent: BlocksFieldClientComponent = (props) => {
errorCount={rowErrorCount}
fields={blockConfig.fields}
hasMaxRows={hasMaxRows}
isLoading={isLoading}
isSortable={isSortable}
Label={Label}
labels={labels}
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/forms/Form/fieldReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export const iterateFields = async ({

fields.forEach((field, fieldIndex) => {
let passesCondition = true

if (!skipConditionChecks) {
passesCondition = Boolean(
(field?.admin?.condition
Expand Down
6 changes: 1 addition & 5 deletions packages/ui/src/forms/useField/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -131,7 +128,6 @@ export const useField = <TValue,>(options: Options): FieldType<TValue> => {
path,
filterOptions,
initializing,
field?.customComponents,
],
)

Expand Down
4 changes: 2 additions & 2 deletions packages/ui/src/forms/withCondition/WatchCondition.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import React, { Fragment } from 'react'
import type React from 'react'

import { useFormFields } from '../Form/context.js'

Expand All @@ -19,5 +19,5 @@ export const WatchCondition: React.FC<{
return null
}

return <Fragment>{children}</Fragment>
return children
}
24 changes: 24 additions & 0 deletions packages/ui/src/hooks/useThrottledValue.ts
Original file line number Diff line number Diff line change
@@ -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
}
5 changes: 2 additions & 3 deletions packages/ui/src/views/Edit/Auth/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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,
}}
Expand Down
Loading

0 comments on commit 796df37

Please sign in to comment.