diff --git a/packages/payload/src/admin/forms/Form.ts b/packages/payload/src/admin/forms/Form.ts index 6cfe65184..822176bd6 100644 --- a/packages/payload/src/admin/forms/Form.ts +++ b/packages/payload/src/admin/forms/Form.ts @@ -14,6 +14,7 @@ export type Row = { blockType?: string collapsed?: boolean id: string + isLoading?: boolean } export type FilterOptionsResult = { diff --git a/packages/ui/src/fields/Array/ArrayRow.tsx b/packages/ui/src/fields/Array/ArrayRow.tsx index 834f68ce8..2dfd4eabf 100644 --- a/packages/ui/src/fields/Array/ArrayRow.tsx +++ b/packages/ui/src/fields/Array/ArrayRow.tsx @@ -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 readonly moveRow: (fromIndex: number, toIndex: number) => void @@ -50,6 +53,7 @@ export const ArrayRow: React.FC = ({ forceRender = false, hasMaxRows, isDragging, + isLoading: isLoadingFromProps, isSortable, labels, listeners, @@ -68,6 +72,8 @@ export const ArrayRow: React.FC = ({ transform, transition, }) => { + const isLoading = useThrottledValue(isLoadingFromProps, 500) + const { i18n } = useTranslation() const hasSubmitted = useFormSubmitted() @@ -136,17 +142,21 @@ export const ArrayRow: React.FC = ({ isCollapsed={row.collapsed} onToggle={(collapsed) => setCollapse(row.id, collapsed)} > - + {isLoading ? ( + + ) : ( + + )} ) diff --git a/packages/ui/src/fields/Array/index.tsx b/packages/ui/src/fields/Array/index.tsx index 2a6c894f7..f4c0eaa22 100644 --- a/packages/ui/src/fields/Array/index.tsx +++ b/packages/ui/src/fields/Array/index.tsx @@ -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} diff --git a/packages/ui/src/fields/Blocks/BlockRow.tsx b/packages/ui/src/fields/Blocks/BlockRow.tsx index eedee9603..84f4107fb 100644 --- a/packages/ui/src/fields/Blocks/BlockRow.tsx +++ b/packages/ui/src/fields/Blocks/BlockRow.tsx @@ -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 = ({ errorCount, fields, hasMaxRows, + isLoading: isLoadingFromProps, isSortable, Label, labels, @@ -76,6 +73,8 @@ export const BlockRow: React.FC = ({ setNodeRef, transform, }) => { + const isLoading = useThrottledValue(isLoadingFromProps, 500) + const { i18n } = useTranslation() const hasSubmitted = useFormSubmitted() @@ -161,16 +160,20 @@ export const BlockRow: React.FC = ({ key={row.id} onToggle={(collapsed) => setCollapse(row.id, collapsed)} > - + {isLoading ? ( + + ) : ( + + )} ) diff --git a/packages/ui/src/fields/Blocks/index.tsx b/packages/ui/src/fields/Blocks/index.tsx index 2049fde90..c99700964 100644 --- a/packages/ui/src/fields/Blocks/index.tsx +++ b/packages/ui/src/fields/Blocks/index.tsx @@ -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} diff --git a/packages/ui/src/forms/Form/fieldReducer.ts b/packages/ui/src/forms/Form/fieldReducer.ts index cb655226d..bea0d4ef1 100644 --- a/packages/ui/src/forms/Form/fieldReducer.ts +++ b/packages/ui/src/forms/Form/fieldReducer.ts @@ -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 = { diff --git a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts index 8da6b4985..039dcf28c 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts +++ b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts @@ -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 } } diff --git a/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts b/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts index 7a5e715b9..d25621d75 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts +++ b/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts @@ -102,6 +102,7 @@ export const iterateFields = async ({ fields.forEach((field, fieldIndex) => { let passesCondition = true + if (!skipConditionChecks) { passesCondition = Boolean( (field?.admin?.condition diff --git a/packages/ui/src/forms/useField/index.tsx b/packages/ui/src/forms/useField/index.tsx index cdf5fb1e4..5f79710fd 100644 --- a/packages/ui/src/forms/useField/index.tsx +++ b/packages/ui/src/forms/useField/index.tsx @@ -118,10 +118,7 @@ export const useField = (options: Options): FieldType => { value, }), [ - field?.errorMessage, - field?.rows, - field?.valid, - field?.errorPaths, + field, processing, setValue, showError, @@ -131,7 +128,6 @@ export const useField = (options: Options): FieldType => { path, filterOptions, initializing, - field?.customComponents, ], ) diff --git a/packages/ui/src/forms/withCondition/WatchCondition.tsx b/packages/ui/src/forms/withCondition/WatchCondition.tsx index 38efb9b7d..7aa50bee3 100644 --- a/packages/ui/src/forms/withCondition/WatchCondition.tsx +++ b/packages/ui/src/forms/withCondition/WatchCondition.tsx @@ -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 {children} + return children } diff --git a/packages/ui/src/hooks/useThrottledValue.ts b/packages/ui/src/hooks/useThrottledValue.ts new file mode 100644 index 000000000..62e640e19 --- /dev/null +++ b/packages/ui/src/hooks/useThrottledValue.ts @@ -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 +} diff --git a/packages/ui/src/views/Edit/Auth/index.tsx b/packages/ui/src/views/Edit/Auth/index.tsx index dd32abc54..409b39ba8 100644 --- a/packages/ui/src/views/Edit/Auth/index.tsx +++ b/packages/ui/src/views/Edit/Auth/index.tsx @@ -82,12 +82,14 @@ export const Auth: React.FC = (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) => { autoComplete="new-password" field={{ name: 'password', - admin: { - disabled, - }, label: t('authentication:newPassword'), required: true, }} diff --git a/test/fields/collections/ConditionalLogic/index.ts b/test/fields/collections/ConditionalLogic/index.ts index a310aa5e5..0a412c9d4 100644 --- a/test/fields/collections/ConditionalLogic/index.ts +++ b/test/fields/collections/ConditionalLogic/index.ts @@ -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, + }, + }, + ], + }, + ], + }, ], } diff --git a/test/fields/e2e.spec.ts b/test/fields/e2e.spec.ts index 0a12e58a6..c6ec5303a 100644 --- a/test/fields/e2e.spec.ts +++ b/test/fields/e2e.spec.ts @@ -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', () => {