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:
@@ -14,7 +14,7 @@ import type { OnFieldSelect } from '../FieldSelect/index.js'
|
|||||||
|
|
||||||
import { useForm } from '../../forms/Form/context.js'
|
import { useForm } from '../../forms/Form/context.js'
|
||||||
import { Form } from '../../forms/Form/index.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 { FormSubmit } from '../../forms/Submit/index.js'
|
||||||
import { XIcon } from '../../icons/X/index.js'
|
import { XIcon } from '../../icons/X/index.js'
|
||||||
import { useAuth } from '../../providers/Auth/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 { FieldSelect } from '../FieldSelect/index.js'
|
||||||
import { baseClass, type EditManyProps } from './index.js'
|
import { baseClass, type EditManyProps } from './index.js'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
import '../../forms/RenderFields/index.scss'
|
||||||
|
|
||||||
const Submit: React.FC<{
|
const Submit: React.FC<{
|
||||||
readonly action: string
|
readonly action: string
|
||||||
@@ -287,14 +288,31 @@ export const EditManyDrawerContent: React.FC<
|
|||||||
>
|
>
|
||||||
<FieldSelect fields={fields} onChange={onFieldSelect} />
|
<FieldSelect fields={fields} onChange={onFieldSelect} />
|
||||||
{selectedFields.length === 0 ? null : (
|
{selectedFields.length === 0 ? null : (
|
||||||
<RenderFields
|
<div className="render-fields">
|
||||||
fields={selectedFields}
|
{selectedFields.map((field, i) => {
|
||||||
parentIndexPath=""
|
const { path } = field
|
||||||
parentPath=""
|
|
||||||
parentSchemaPath={collection.slug}
|
return (
|
||||||
permissions={collectionPermissions?.fields}
|
<RenderField
|
||||||
readOnly={false}
|
clientFieldConfig={field}
|
||||||
/>
|
indexPath=""
|
||||||
|
key={`${path}-${i}`}
|
||||||
|
parentPath=""
|
||||||
|
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-wrap`}>
|
||||||
<div className={`${baseClass}__sidebar`}>
|
<div className={`${baseClass}__sidebar`}>
|
||||||
|
|||||||
@@ -31,11 +31,10 @@ export const EditMany: React.FC<EditManyProps> = (props) => {
|
|||||||
const [selectedFields, setSelectedFields] = useState<FieldWithPathClient[]>([])
|
const [selectedFields, setSelectedFields] = useState<FieldWithPathClient[]>([])
|
||||||
|
|
||||||
const collectionPermissions = permissions?.collections?.[slug]
|
const collectionPermissions = permissions?.collections?.[slug]
|
||||||
const hasUpdatePermission = collectionPermissions?.update
|
|
||||||
|
|
||||||
const drawerSlug = `edit-${slug}`
|
const drawerSlug = `edit-${slug}`
|
||||||
|
|
||||||
if (selectAll === SelectAllStatus.None || !hasUpdatePermission) {
|
if (selectAll === SelectAllStatus.None || !collectionPermissions?.update) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import type { ClientField, FieldWithPathClient, FormState } from 'payload'
|
import type { ClientField, FieldWithPathClient, FormState } from 'payload'
|
||||||
|
|
||||||
import { fieldAffectsData, fieldHasSubFields, fieldIsHiddenOrDisabled } from 'payload/shared'
|
import React, { useState } from 'react'
|
||||||
import React, { Fragment, useState } from 'react'
|
|
||||||
|
|
||||||
import type { FieldAction } from '../../forms/Form/types.js'
|
import type { FieldAction } from '../../forms/Form/types.js'
|
||||||
|
|
||||||
import { RenderCustomComponent } from '../../elements/RenderCustomComponent/index.js'
|
|
||||||
import { FieldLabel } from '../../fields/FieldLabel/index.js'
|
import { FieldLabel } from '../../fields/FieldLabel/index.js'
|
||||||
import { useForm } from '../../forms/Form/context.js'
|
import { useForm } from '../../forms/Form/context.js'
|
||||||
import { createNestedClientFieldPath } from '../../forms/Form/createNestedClientFieldPath.js'
|
|
||||||
import { useTranslation } from '../../providers/Translation/index.js'
|
import { useTranslation } from '../../providers/Translation/index.js'
|
||||||
import { filterOutUploadFields } from '../../utilities/filterOutUploadFields.js'
|
import { filterOutUploadFields } from '../../utilities/filterOutUploadFields.js'
|
||||||
import { ReactSelect } from '../ReactSelect/index.js'
|
import { ReactSelect } from '../ReactSelect/index.js'
|
||||||
|
import { reduceFieldOptions } from './reduceFieldOptions.js'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
const baseClass = 'field-select'
|
const baseClass = 'field-select'
|
||||||
@@ -32,112 +30,14 @@ export type FieldSelectProps = {
|
|||||||
readonly onChange: OnFieldSelect
|
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 }
|
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 }) => {
|
export const FieldSelect: React.FC<FieldSelectProps> = ({ fields, onChange }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { dispatchFields, getFields } = useForm()
|
const { dispatchFields, getFields } = useForm()
|
||||||
|
|
||||||
const [options] = useState<FieldOption[]>(() =>
|
const [options] = useState<FieldOption[]>(() =>
|
||||||
reduceFields({ fields: filterOutUploadFields(fields), formState: getFields() }),
|
reduceFieldOptions({ fields: filterOutUploadFields(fields), formState: getFields() }),
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
95
packages/ui/src/elements/FieldSelect/reduceFieldOptions.ts
Normal file
95
packages/ui/src/elements/FieldSelect/reduceFieldOptions.ts
Normal 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]
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import { fieldIsHiddenOrDisabled, fieldIsID, tabHasName } from 'payload/shared'
|
|||||||
import type { ReducedField } from './types.js'
|
import type { ReducedField } from './types.js'
|
||||||
|
|
||||||
import { createNestedClientFieldPath } from '../../forms/Form/createNestedClientFieldPath.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'
|
import fieldTypes from './field-types.js'
|
||||||
|
|
||||||
type ReduceFieldOptionsArgs = {
|
type ReduceFieldOptionsArgs = {
|
||||||
@@ -146,7 +146,7 @@ export const reduceFields = ({
|
|||||||
const localizedLabel = getTranslation(field.label || '', i18n)
|
const localizedLabel = getTranslation(field.label || '', i18n)
|
||||||
|
|
||||||
const formattedLabel = labelPrefix
|
const formattedLabel = labelPrefix
|
||||||
? combineLabel({
|
? combineFieldLabel({
|
||||||
field,
|
field,
|
||||||
prefix: labelPrefix,
|
prefix: labelPrefix,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import { useFormSubmitted } from '../../forms/Form/context.js'
|
|||||||
import { RenderFields } from '../../forms/RenderFields/index.js'
|
import { RenderFields } from '../../forms/RenderFields/index.js'
|
||||||
import { RowLabel } from '../../forms/RowLabel/index.js'
|
import { RowLabel } from '../../forms/RowLabel/index.js'
|
||||||
import { useThrottledValue } from '../../hooks/useThrottledValue.js'
|
import { useThrottledValue } from '../../hooks/useThrottledValue.js'
|
||||||
import './index.scss'
|
|
||||||
import { useTranslation } from '../../providers/Translation/index.js'
|
import { useTranslation } from '../../providers/Translation/index.js'
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
const baseClass = 'array-field'
|
const baseClass = 'array-field'
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const GroupFieldComponent: GroupFieldClientComponent = (props) => {
|
|||||||
readOnly,
|
readOnly,
|
||||||
schemaPath: schemaPathFromProps,
|
schemaPath: schemaPathFromProps,
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
const schemaPath = schemaPathFromProps ?? name
|
const schemaPath = schemaPathFromProps ?? name
|
||||||
|
|
||||||
const { i18n } = useTranslation()
|
const { i18n } = useTranslation()
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import type { RenderFieldsProps } from './types.js'
|
|||||||
|
|
||||||
import { RenderIfInViewport } from '../../elements/RenderIfInViewport/index.js'
|
import { RenderIfInViewport } from '../../elements/RenderIfInViewport/index.js'
|
||||||
import { useOperation } from '../../providers/Operation/index.js'
|
import { useOperation } from '../../providers/Operation/index.js'
|
||||||
import './index.scss'
|
|
||||||
import { RenderField } from './RenderField.js'
|
import { RenderField } from './RenderField.js'
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
const baseClass = 'render-fields'
|
const baseClass = 'render-fields'
|
||||||
|
|
||||||
|
|||||||
33
packages/ui/src/utilities/combineFieldLabel.tsx
Normal file
33
packages/ui/src/utilities/combineFieldLabel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -423,6 +423,41 @@ test.describe('Bulk Edit', () => {
|
|||||||
title: updatedPostTitle,
|
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() {
|
async function deleteAllPosts() {
|
||||||
|
|||||||
Reference in New Issue
Block a user