fix(ui): bulk edit subfields (#10035)

Fixes #10019. When bulk editing subfields, such as a field within a
group, changes are not persisted to the database. Not only this, but
nested fields with the same name as another selected field are
controlled by the same input. E.g. typing into one fields changes the
value of both.

The root problem is that field paths are incorrect.

When opening the bulk edit drawer, fields are flattened into options for
the field selector. This is so that fields in a tab, for example, aren't
hidden behind their tab when bulk editing. The problem is that
`RenderFields` is not set up to receive pre-determined field paths. It
attempts to build up its own field paths, but are never correct because
`getFieldPaths` receives the wrong arguments.

The fix is to just render the top-level fields directly, bypassing
`RenderFields` altogether.

Fields with subfields will still recurse through this function, but at
the top-level, fields can be sent directly to `RenderField` (singular)
since their paths have already been already formatted in the flattening
step.
This commit is contained in:
Jacob Fletcher
2025-03-19 17:01:59 -04:00
committed by GitHub
parent a02e4762d0
commit b5fc8c6573
10 changed files with 199 additions and 118 deletions

View File

@@ -14,7 +14,7 @@ import type { OnFieldSelect } from '../FieldSelect/index.js'
import { useForm } from '../../forms/Form/context.js'
import { Form } from '../../forms/Form/index.js'
import { RenderFields } from '../../forms/RenderFields/index.js'
import { RenderField } from '../../forms/RenderFields/RenderField.js'
import { FormSubmit } from '../../forms/Submit/index.js'
import { XIcon } from '../../icons/X/index.js'
import { useAuth } from '../../providers/Auth/index.js'
@@ -31,6 +31,7 @@ import { parseSearchParams } from '../../utilities/parseSearchParams.js'
import { FieldSelect } from '../FieldSelect/index.js'
import { baseClass, type EditManyProps } from './index.js'
import './index.scss'
import '../../forms/RenderFields/index.scss'
const Submit: React.FC<{
readonly action: string
@@ -287,14 +288,31 @@ export const EditManyDrawerContent: React.FC<
>
<FieldSelect fields={fields} onChange={onFieldSelect} />
{selectedFields.length === 0 ? null : (
<RenderFields
fields={selectedFields}
parentIndexPath=""
<div className="render-fields">
{selectedFields.map((field, i) => {
const { path } = field
return (
<RenderField
clientFieldConfig={field}
indexPath=""
key={`${path}-${i}`}
parentPath=""
parentSchemaPath={collection.slug}
permissions={collectionPermissions?.fields}
readOnly={false}
parentSchemaPath=""
path={path}
permissions={
collectionPermissions.fields === undefined ||
collectionPermissions.fields === null ||
collectionPermissions.fields === true
? true
: 'name' in field
? collectionPermissions.fields?.[field.name]
: undefined
}
/>
)
})}
</div>
)}
<div className={`${baseClass}__sidebar-wrap`}>
<div className={`${baseClass}__sidebar`}>

View File

@@ -31,11 +31,10 @@ export const EditMany: React.FC<EditManyProps> = (props) => {
const [selectedFields, setSelectedFields] = useState<FieldWithPathClient[]>([])
const collectionPermissions = permissions?.collections?.[slug]
const hasUpdatePermission = collectionPermissions?.update
const drawerSlug = `edit-${slug}`
if (selectAll === SelectAllStatus.None || !hasUpdatePermission) {
if (selectAll === SelectAllStatus.None || !collectionPermissions?.update) {
return null
}

View File

@@ -1,18 +1,16 @@
'use client'
import type { ClientField, FieldWithPathClient, FormState } from 'payload'
import { fieldAffectsData, fieldHasSubFields, fieldIsHiddenOrDisabled } from 'payload/shared'
import React, { Fragment, useState } from 'react'
import React, { useState } from 'react'
import type { FieldAction } from '../../forms/Form/types.js'
import { RenderCustomComponent } from '../../elements/RenderCustomComponent/index.js'
import { FieldLabel } from '../../fields/FieldLabel/index.js'
import { useForm } from '../../forms/Form/context.js'
import { createNestedClientFieldPath } from '../../forms/Form/createNestedClientFieldPath.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { filterOutUploadFields } from '../../utilities/filterOutUploadFields.js'
import { ReactSelect } from '../ReactSelect/index.js'
import { reduceFieldOptions } from './reduceFieldOptions.js'
import './index.scss'
const baseClass = 'field-select'
@@ -32,112 +30,14 @@ export type FieldSelectProps = {
readonly onChange: OnFieldSelect
}
export const combineLabel = ({
CustomLabel,
field,
prefix,
}: {
CustomLabel?: React.ReactNode
field?: ClientField
prefix?: React.ReactNode
}): React.ReactNode => {
return (
<Fragment>
{prefix ? (
<Fragment>
<span style={{ display: 'inline-block' }}>{prefix}</span>
{' > '}
</Fragment>
) : null}
<span style={{ display: 'inline-block' }}>
<RenderCustomComponent
CustomComponent={CustomLabel}
Fallback={<FieldLabel label={'label' in field && field.label} />}
/>
</span>
</Fragment>
)
}
export type FieldOption = { Label: React.ReactNode; value: FieldWithPathClient }
const reduceFields = ({
fields,
formState,
labelPrefix = null,
path = '',
}: {
fields: ClientField[]
formState?: FormState
labelPrefix?: React.ReactNode
path?: string
}): FieldOption[] => {
if (!fields) {
return []
}
const CustomLabel = formState?.[path]?.customComponents?.Label
return fields?.reduce((fieldsToUse, field) => {
// escape for a variety of reasons, include ui fields as they have `name`.
if (
(fieldAffectsData(field) || field.type === 'ui') &&
(field.admin?.disableBulkEdit ||
field.unique ||
fieldIsHiddenOrDisabled(field) ||
('readOnly' in field && field.readOnly))
) {
return fieldsToUse
}
if (!(field.type === 'array' || field.type === 'blocks') && fieldHasSubFields(field)) {
return [
...fieldsToUse,
...reduceFields({
fields: field.fields,
labelPrefix: combineLabel({ CustomLabel, field, prefix: labelPrefix }),
path: createNestedClientFieldPath(path, field),
}),
]
}
if (field.type === 'tabs' && 'tabs' in field) {
return [
...fieldsToUse,
...field.tabs.reduce((tabFields, tab) => {
if ('fields' in tab) {
const isNamedTab = 'name' in tab && tab.name
return [
...tabFields,
...reduceFields({
fields: tab.fields,
labelPrefix,
path: isNamedTab ? createNestedClientFieldPath(path, field) : path,
}),
]
}
}, []),
]
}
const formattedField = {
label: combineLabel({ CustomLabel, field, prefix: labelPrefix }),
value: {
...field,
path: createNestedClientFieldPath(path, field),
},
}
return [...fieldsToUse, formattedField]
}, [])
}
export const FieldSelect: React.FC<FieldSelectProps> = ({ fields, onChange }) => {
const { t } = useTranslation()
const { dispatchFields, getFields } = useForm()
const [options] = useState<FieldOption[]>(() =>
reduceFields({ fields: filterOutUploadFields(fields), formState: getFields() }),
reduceFieldOptions({ fields: filterOutUploadFields(fields), formState: getFields() }),
)
return (

View File

@@ -0,0 +1,95 @@
import type { ClientField, FieldWithPathClient, FormState } from 'payload'
import { fieldAffectsData, fieldHasSubFields, fieldIsHiddenOrDisabled } from 'payload/shared'
import { createNestedClientFieldPath } from '../../forms/Form/createNestedClientFieldPath.js'
import { combineFieldLabel } from '../../utilities/combineFieldLabel.js'
export type SelectedField = {
path: string
}
export type FieldOption = {
label: React.ReactNode
value: SelectedField
}
export const ignoreFromBulkEdit = (field: ClientField): boolean =>
Boolean(
(fieldAffectsData(field) || field.type === 'ui') &&
(field.admin.disableBulkEdit ||
field.unique ||
fieldIsHiddenOrDisabled(field) ||
('readOnly' in field && field.readOnly)),
)
export const reduceFieldOptions = ({
fields,
formState,
labelPrefix = null,
path = '',
}: {
fields: ClientField[]
formState?: FormState
labelPrefix?: React.ReactNode
path?: string
}): { Label: React.ReactNode; value: FieldWithPathClient }[] => {
if (!fields) {
return []
}
const CustomLabel = formState?.[path]?.customComponents?.Label
return fields?.reduce((fieldsToUse, field) => {
// escape for a variety of reasons, include ui fields as they have `name`.
if (
(fieldAffectsData(field) || field.type === 'ui') &&
(field.admin?.disableBulkEdit ||
field.unique ||
fieldIsHiddenOrDisabled(field) ||
('readOnly' in field && field.readOnly))
) {
return fieldsToUse
}
if (!(field.type === 'array' || field.type === 'blocks') && fieldHasSubFields(field)) {
return [
...fieldsToUse,
...reduceFieldOptions({
fields: field.fields,
labelPrefix: combineFieldLabel({ CustomLabel, field, prefix: labelPrefix }),
path: createNestedClientFieldPath(path, field),
}),
]
}
if (field.type === 'tabs' && 'tabs' in field) {
return [
...fieldsToUse,
...field.tabs.reduce((tabFields, tab) => {
if ('fields' in tab) {
const isNamedTab = 'name' in tab && tab.name
return [
...tabFields,
...reduceFieldOptions({
fields: tab.fields,
labelPrefix,
path: isNamedTab ? createNestedClientFieldPath(path, field) : path,
}),
]
}
}, []),
]
}
const formattedField = {
label: combineFieldLabel({ CustomLabel, field, prefix: labelPrefix }),
value: {
...field,
path: createNestedClientFieldPath(path, field),
},
}
return [...fieldsToUse, formattedField]
}, [])
}

View File

@@ -8,7 +8,7 @@ import { fieldIsHiddenOrDisabled, fieldIsID, tabHasName } from 'payload/shared'
import type { ReducedField } from './types.js'
import { createNestedClientFieldPath } from '../../forms/Form/createNestedClientFieldPath.js'
import { combineLabel } from '../FieldSelect/index.js'
import { combineFieldLabel } from '../../utilities/combineFieldLabel.js'
import fieldTypes from './field-types.js'
type ReduceFieldOptionsArgs = {
@@ -146,7 +146,7 @@ export const reduceFields = ({
const localizedLabel = getTranslation(field.label || '', i18n)
const formattedLabel = labelPrefix
? combineLabel({
? combineFieldLabel({
field,
prefix: labelPrefix,
})

View File

@@ -14,8 +14,8 @@ import { useFormSubmitted } from '../../forms/Form/context.js'
import { RenderFields } from '../../forms/RenderFields/index.js'
import { RowLabel } from '../../forms/RowLabel/index.js'
import { useThrottledValue } from '../../hooks/useThrottledValue.js'
import './index.scss'
import { useTranslation } from '../../providers/Translation/index.js'
import './index.scss'
const baseClass = 'array-field'

View File

@@ -33,6 +33,7 @@ export const GroupFieldComponent: GroupFieldClientComponent = (props) => {
readOnly,
schemaPath: schemaPathFromProps,
} = props
const schemaPath = schemaPathFromProps ?? name
const { i18n } = useTranslation()

View File

@@ -7,8 +7,8 @@ import type { RenderFieldsProps } from './types.js'
import { RenderIfInViewport } from '../../elements/RenderIfInViewport/index.js'
import { useOperation } from '../../providers/Operation/index.js'
import './index.scss'
import { RenderField } from './RenderField.js'
import './index.scss'
const baseClass = 'render-fields'

View File

@@ -0,0 +1,33 @@
import type { ClientField } from 'payload'
import { Fragment } from 'react'
import { RenderCustomComponent } from '../elements/RenderCustomComponent/index.js'
import { FieldLabel } from '../fields/FieldLabel/index.js'
export const combineFieldLabel = ({
CustomLabel,
field,
prefix,
}: {
CustomLabel?: React.ReactNode
field?: ClientField
prefix?: React.ReactNode
}): React.ReactNode => {
return (
<Fragment>
{prefix ? (
<Fragment>
<span style={{ display: 'inline-block' }}>{prefix}</span>
{' > '}
</Fragment>
) : null}
<span style={{ display: 'inline-block' }}>
<RenderCustomComponent
CustomComponent={CustomLabel}
Fallback={<FieldLabel label={'label' in field && field.label} />}
/>
</span>
</Fragment>
)
}

View File

@@ -423,6 +423,41 @@ test.describe('Bulk Edit', () => {
title: updatedPostTitle,
})
})
test('should bulk edit fields with subfields', async () => {
await deleteAllPosts()
const { id: docID } = await createPost()
await page.goto(postsUrl.list)
await page.locator('input#select-all').check()
await page.locator('.edit-many__toggle').click()
await page.locator('.field-select .rs__control').click()
const bulkEditModal = page.locator('#edit-posts')
const titleOption = bulkEditModal.locator('.field-select .rs__option', {
hasText: exactText('Group > Title'),
})
await titleOption.click()
const titleInput = bulkEditModal.locator('#field-group__title')
await titleInput.fill('New Group Title')
await page.locator('.form-submit button[type="submit"].edit-many__publish').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'Updated 1 Post successfully.',
)
const updatedPost = await payload
.find({
collection: 'posts',
limit: 1,
})
?.then((res) => res.docs[0])
expect(updatedPost?.group?.title).toBe('New Group Title')
})
})
async function deleteAllPosts() {