From b5fc8c6573a8e24ef7bacb4d9783e6017d857ab3 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Wed, 19 Mar 2025 17:01:59 -0400 Subject: [PATCH] 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. --- .../src/elements/EditMany/DrawerContent.tsx | 36 ++++-- packages/ui/src/elements/EditMany/index.tsx | 3 +- .../ui/src/elements/FieldSelect/index.tsx | 106 +----------------- .../FieldSelect/reduceFieldOptions.ts | 95 ++++++++++++++++ .../elements/WhereBuilder/reduceFields.tsx | 4 +- packages/ui/src/fields/Array/ArrayRow.tsx | 2 +- packages/ui/src/fields/Group/index.tsx | 1 + packages/ui/src/forms/RenderFields/index.tsx | 2 +- .../ui/src/utilities/combineFieldLabel.tsx | 33 ++++++ test/bulk-edit/e2e.spec.ts | 35 ++++++ 10 files changed, 199 insertions(+), 118 deletions(-) create mode 100644 packages/ui/src/elements/FieldSelect/reduceFieldOptions.ts create mode 100644 packages/ui/src/utilities/combineFieldLabel.tsx diff --git a/packages/ui/src/elements/EditMany/DrawerContent.tsx b/packages/ui/src/elements/EditMany/DrawerContent.tsx index aa9e35ea13..a152aaec55 100644 --- a/packages/ui/src/elements/EditMany/DrawerContent.tsx +++ b/packages/ui/src/elements/EditMany/DrawerContent.tsx @@ -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< > {selectedFields.length === 0 ? null : ( - +
+ {selectedFields.map((field, i) => { + const { path } = field + + return ( + + ) + })} +
)}
diff --git a/packages/ui/src/elements/EditMany/index.tsx b/packages/ui/src/elements/EditMany/index.tsx index e394ee5be6..7042d20f9a 100644 --- a/packages/ui/src/elements/EditMany/index.tsx +++ b/packages/ui/src/elements/EditMany/index.tsx @@ -31,11 +31,10 @@ export const EditMany: React.FC = (props) => { const [selectedFields, setSelectedFields] = useState([]) 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 } diff --git a/packages/ui/src/elements/FieldSelect/index.tsx b/packages/ui/src/elements/FieldSelect/index.tsx index 3e74442954..23c4e809b9 100644 --- a/packages/ui/src/elements/FieldSelect/index.tsx +++ b/packages/ui/src/elements/FieldSelect/index.tsx @@ -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 ( - - {prefix ? ( - - {prefix} - {' > '} - - ) : null} - - } - /> - - - ) -} - 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 = ({ fields, onChange }) => { const { t } = useTranslation() const { dispatchFields, getFields } = useForm() const [options] = useState(() => - reduceFields({ fields: filterOutUploadFields(fields), formState: getFields() }), + reduceFieldOptions({ fields: filterOutUploadFields(fields), formState: getFields() }), ) return ( diff --git a/packages/ui/src/elements/FieldSelect/reduceFieldOptions.ts b/packages/ui/src/elements/FieldSelect/reduceFieldOptions.ts new file mode 100644 index 0000000000..d3d337e146 --- /dev/null +++ b/packages/ui/src/elements/FieldSelect/reduceFieldOptions.ts @@ -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] + }, []) +} diff --git a/packages/ui/src/elements/WhereBuilder/reduceFields.tsx b/packages/ui/src/elements/WhereBuilder/reduceFields.tsx index 571ae4b3b3..4822f5ce77 100644 --- a/packages/ui/src/elements/WhereBuilder/reduceFields.tsx +++ b/packages/ui/src/elements/WhereBuilder/reduceFields.tsx @@ -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, }) diff --git a/packages/ui/src/fields/Array/ArrayRow.tsx b/packages/ui/src/fields/Array/ArrayRow.tsx index 395bc35a3f..d3c495d167 100644 --- a/packages/ui/src/fields/Array/ArrayRow.tsx +++ b/packages/ui/src/fields/Array/ArrayRow.tsx @@ -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' diff --git a/packages/ui/src/fields/Group/index.tsx b/packages/ui/src/fields/Group/index.tsx index 6190304ac6..d46c53d876 100644 --- a/packages/ui/src/fields/Group/index.tsx +++ b/packages/ui/src/fields/Group/index.tsx @@ -33,6 +33,7 @@ export const GroupFieldComponent: GroupFieldClientComponent = (props) => { readOnly, schemaPath: schemaPathFromProps, } = props + const schemaPath = schemaPathFromProps ?? name const { i18n } = useTranslation() diff --git a/packages/ui/src/forms/RenderFields/index.tsx b/packages/ui/src/forms/RenderFields/index.tsx index ad3f280ba7..c81e5ccfdc 100644 --- a/packages/ui/src/forms/RenderFields/index.tsx +++ b/packages/ui/src/forms/RenderFields/index.tsx @@ -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' diff --git a/packages/ui/src/utilities/combineFieldLabel.tsx b/packages/ui/src/utilities/combineFieldLabel.tsx new file mode 100644 index 0000000000..08ecc285fa --- /dev/null +++ b/packages/ui/src/utilities/combineFieldLabel.tsx @@ -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 ( + + {prefix ? ( + + {prefix} + {' > '} + + ) : null} + + } + /> + + + ) +} diff --git a/test/bulk-edit/e2e.spec.ts b/test/bulk-edit/e2e.spec.ts index be1904236d..0056523fe9 100644 --- a/test/bulk-edit/e2e.spec.ts +++ b/test/bulk-edit/e2e.spec.ts @@ -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() {