diff --git a/packages/ui/src/elements/EditMany/index.tsx b/packages/ui/src/elements/EditMany/index.tsx index 8f4b64ab423..2522c34f926 100644 --- a/packages/ui/src/elements/EditMany/index.tsx +++ b/packages/ui/src/elements/EditMany/index.tsx @@ -1,5 +1,5 @@ 'use client' -import type { ClientCollectionConfig, FormState } from 'payload' +import type { ClientCollectionConfig, FieldWithPathClient, FormState } from 'payload' import { useModal } from '@faceless-ui/modal' import { getTranslation } from '@payloadcms/translations' @@ -36,10 +36,25 @@ export type EditManyProps = { readonly collection: ClientCollectionConfig } +const sanitizeUnselectedFields = (formState: FormState, selected: FieldWithPathClient[]) => { + const filteredData = selected.reduce((acc, field) => { + const foundState = formState?.[field.path] + + if (foundState) { + acc[field.path] = formState?.[field.path]?.value + } + + return acc + }, {} as FormData) + + return filteredData +} + const Submit: React.FC<{ readonly action: string readonly disabled: boolean -}> = ({ action, disabled }) => { + readonly selected?: FieldWithPathClient[] +}> = ({ action, disabled, selected }) => { const { submit } = useForm() const { t } = useTranslation() @@ -47,9 +62,10 @@ const Submit: React.FC<{ void submit({ action, method: 'PATCH', + overrides: (formState) => sanitizeUnselectedFields(formState, selected), skipValidation: true, }) - }, [action, submit]) + }, [action, submit, selected]) return ( @@ -58,7 +74,11 @@ const Submit: React.FC<{ ) } -const PublishButton: React.FC<{ action: string; disabled: boolean }> = ({ action, disabled }) => { +const PublishButton: React.FC<{ + action: string + disabled: boolean + selected?: FieldWithPathClient[] +}> = ({ action, disabled, selected }) => { const { submit } = useForm() const { t } = useTranslation() @@ -66,12 +86,13 @@ const PublishButton: React.FC<{ action: string; disabled: boolean }> = ({ action void submit({ action, method: 'PATCH', - overrides: { + overrides: (formState) => ({ + ...sanitizeUnselectedFields(formState, selected), _status: 'published', - }, + }), skipValidation: true, }) - }, [action, submit]) + }, [action, submit, selected]) return ( @@ -80,7 +101,11 @@ const PublishButton: React.FC<{ action: string; disabled: boolean }> = ({ action ) } -const SaveDraftButton: React.FC<{ action: string; disabled: boolean }> = ({ action, disabled }) => { +const SaveDraftButton: React.FC<{ + action: string + disabled: boolean + selected?: FieldWithPathClient[] +}> = ({ action, disabled, selected }) => { const { submit } = useForm() const { t } = useTranslation() @@ -88,12 +113,13 @@ const SaveDraftButton: React.FC<{ action: string; disabled: boolean }> = ({ acti void submit({ action, method: 'PATCH', - overrides: { + overrides: (formState) => ({ + ...sanitizeUnselectedFields(formState, selected), _status: 'draft', - }, + }), skipValidation: true, }) - }, [action, submit]) + }, [action, submit, selected]) return ( = (props) => { const { count, getQueryParams, selectAll } = useSelection() const { i18n, t } = useTranslation() - const [selected, setSelected] = useState([]) + const [selected, setSelected] = useState([]) const searchParams = useSearchParams() const router = useRouter() const [initialState, setInitialState] = useState() @@ -184,7 +210,7 @@ export const EditMany: React.FC = (props) => { return state }, - [slug, getFormState, collectionPermissions], + [getFormState, slug, collectionPermissions], ) useEffect(() => { @@ -289,16 +315,19 @@ export const EditMany: React.FC = (props) => { ) : ( )} diff --git a/packages/ui/src/elements/FieldSelect/index.tsx b/packages/ui/src/elements/FieldSelect/index.tsx index 5bf5b3dd4d6..2ebfe60cec0 100644 --- a/packages/ui/src/elements/FieldSelect/index.tsx +++ b/packages/ui/src/elements/FieldSelect/index.tsx @@ -1,5 +1,5 @@ 'use client' -import type { ClientField, FieldWithPath, FormState } from 'payload' +import type { ClientField, FieldWithPathClient, FormState } from 'payload' import { fieldAffectsData, fieldHasSubFields, fieldIsHiddenOrDisabled } from 'payload/shared' import React, { Fragment, useState } from 'react' @@ -16,7 +16,7 @@ const baseClass = 'field-select' export type FieldSelectProps = { readonly fields: ClientField[] - readonly setSelected: (fields: FieldWithPath[]) => void + readonly setSelected: (fields: FieldWithPathClient[]) => void } export const combineLabel = ({ @@ -56,7 +56,7 @@ const reduceFields = ({ formState?: FormState labelPrefix?: React.ReactNode path?: string -}): { Label: React.ReactNode; value: FieldWithPath }[] => { +}): { Label: React.ReactNode; value: FieldWithPathClient }[] => { if (!fields) { return [] } diff --git a/packages/ui/src/forms/Form/index.tsx b/packages/ui/src/forms/Form/index.tsx index 237cec9dfa7..857dfc12024 100644 --- a/packages/ui/src/forms/Form/index.tsx +++ b/packages/ui/src/forms/Form/index.tsx @@ -14,6 +14,7 @@ import React, { useCallback, useEffect, useReducer, useRef, useState } from 'rea import { toast } from 'sonner' import type { + CreateFormData, Context as FormContextType, FormProps, GetDataByPath, @@ -174,7 +175,7 @@ export const Form: React.FC = (props) => { const { action: actionArg = action, method: methodToUse = method, - overrides = {}, + overrides: overridesFromArgs = {}, skipValidation, } = options @@ -263,6 +264,14 @@ export const Form: React.FC = (props) => { return } + let overrides = {} + + if (typeof overridesFromArgs === 'function') { + overrides = overridesFromArgs(contextRef.current.fields) + } else if (typeof overridesFromArgs === 'object') { + overrides = overridesFromArgs + } + // If submit handler comes through via props, run that if (onSubmit) { const serializableFields = deepCopyObjectSimpleWithoutReactComponents( @@ -270,10 +279,8 @@ export const Form: React.FC = (props) => { ) const data = reduceFieldsToValues(serializableFields, true) - if (overrides) { - for (const [key, value] of Object.entries(overrides)) { - data[key] = value - } + for (const [key, value] of Object.entries(overrides)) { + data[key] = value } onSubmit(serializableFields, data) @@ -288,7 +295,9 @@ export const Form: React.FC = (props) => { return } - const formData = contextRef.current.createFormData(overrides) + const formData = contextRef.current.createFormData(overrides, { + mergeOverrideData: Boolean(typeof overridesFromArgs !== 'function'), + }) try { let res @@ -443,9 +452,8 @@ export const Form: React.FC = (props) => { [], ) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const createFormData = useCallback((overrides: any = {}) => { - const data = reduceFieldsToValues(contextRef.current.fields, true) + const createFormData = useCallback((overrides, { mergeOverrideData = true }) => { + let data = reduceFieldsToValues(contextRef.current.fields, true) const file = data?.file @@ -453,13 +461,17 @@ export const Form: React.FC = (props) => { delete data.file } - const dataWithOverrides = { - ...data, - ...overrides, + if (mergeOverrideData) { + data = { + ...data, + ...overrides, + } + } else { + data = overrides } const dataToSerialize = { - _payload: JSON.stringify(dataWithOverrides), + _payload: JSON.stringify(data), file, } diff --git a/packages/ui/src/forms/Form/types.ts b/packages/ui/src/forms/Form/types.ts index 00a0637751f..896dbdcd6a6 100644 --- a/packages/ui/src/forms/Form/types.ts +++ b/packages/ui/src/forms/Form/types.ts @@ -60,7 +60,7 @@ export type FormProps = { export type SubmitOptions = { action?: string method?: string - overrides?: Record + overrides?: ((formState) => FormData) | Record skipValidation?: boolean } @@ -70,7 +70,14 @@ export type Submit = ( e?: React.FormEvent, ) => Promise export type ValidateForm = () => Promise -export type CreateFormData = (overrides?: any) => FormData +export type CreateFormData = ( + overrides?: Record, + /** + * If mergeOverrideData true, the data will be merged with the existing data in the form state. + * @default true + */ + options?: { mergeOverrideData?: boolean }, +) => FormData export type GetFields = () => FormState export type GetField = (path: string) => FormField export type GetData = () => Data diff --git a/test/admin/collections/Posts.ts b/test/admin/collections/Posts.ts index 2bf07ac8910..55f7fe7c3e4 100644 --- a/test/admin/collections/Posts.ts +++ b/test/admin/collections/Posts.ts @@ -103,16 +103,64 @@ export const Posts: CollectionConfig = { }, ], }, + { + name: 'arrayOfFields', + type: 'array', + admin: { + initCollapsed: true, + }, + fields: [ + { + name: 'optional', + type: 'text', + }, + { + name: 'innerArrayOfFields', + type: 'array', + fields: [ + { + name: 'innerOptional', + type: 'text', + }, + ], + }, + ], + }, { name: 'group', type: 'group', fields: [ + { + name: 'defaultValueField', + type: 'text', + defaultValue: 'testing', + }, { name: 'title', type: 'text', }, ], }, + { + name: 'someBlock', + type: 'blocks', + blocks: [ + { + slug: 'textBlock', + fields: [ + { + name: 'textFieldForBlock', + type: 'text', + }, + ], + }, + ], + }, + { + name: 'defaultValueField', + type: 'text', + defaultValue: 'testing', + }, { name: 'relationship', type: 'relationship', diff --git a/test/admin/e2e/3/e2e.spec.ts b/test/admin/e2e/3/e2e.spec.ts index edbc062e7b2..a647ae69b0b 100644 --- a/test/admin/e2e/3/e2e.spec.ts +++ b/test/admin/e2e/3/e2e.spec.ts @@ -516,6 +516,68 @@ describe('admin3', () => { await expect(page.locator('.row-3 .cell-title')).toContainText(updatedPostTitle) }) + test('should not override un-edited values in bulk edit if it has a defaultValue', async () => { + await deleteAllPosts() + const post1Title = 'Post' + const postData = { + title: 'Post', + arrayOfFields: [ + { + optional: 'some optional array field', + innerArrayOfFields: [ + { + innerOptional: 'some inner optional array field', + }, + ], + }, + ], + group: { + defaultValueField: 'not the group default value', + title: 'some title', + }, + someBlock: [ + { + textFieldForBlock: 'some text for block text', + blockType: 'textBlock', + }, + ], + defaultValueField: 'not the default value', + } + const updatedPostTitle = `${post1Title} (Updated)` + await Promise.all([createPost(postData)]) + 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 titleOption = page.locator('.field-select .rs__option', { + hasText: exactText('Title'), + }) + + await expect(titleOption).toBeVisible() + await titleOption.click() + const titleInput = page.locator('#field-title') + await expect(titleInput).toBeVisible() + await titleInput.fill(updatedPostTitle) + 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, + }) + + expect(updatedPost.docs[0].title).toBe(updatedPostTitle) + expect(updatedPost.docs[0].arrayOfFields.length).toBe(1) + expect(updatedPost.docs[0].arrayOfFields[0].optional).toBe('some optional array field') + expect(updatedPost.docs[0].arrayOfFields[0].innerArrayOfFields.length).toBe(1) + expect(updatedPost.docs[0].someBlock[0].textFieldForBlock).toBe('some text for block text') + expect(updatedPost.docs[0].defaultValueField).toBe('not the default value') + }) + test('should bulk update with filters and across pages', async () => { // First, delete all posts created by the seed await deleteAllPosts() diff --git a/test/admin/payload-types.ts b/test/admin/payload-types.ts index 98067ca3772..e7d85c26037 100644 --- a/test/admin/payload-types.ts +++ b/test/admin/payload-types.ts @@ -138,9 +138,31 @@ export interface Post { [k: string]: unknown; }[] | null; + arrayOfFields?: + | { + optional?: string | null; + innerArrayOfFields?: + | { + innerOptional?: string | null; + id?: string | null; + }[] + | null; + id?: string | null; + }[] + | null; group?: { + defaultValueField?: string | null; title?: string | null; }; + someBlock?: + | { + textFieldForBlock?: string | null; + id?: string | null; + blockName?: string | null; + blockType: 'textBlock'; + }[] + | null; + defaultValueField?: string | null; relationship?: (string | null) | Post; customCell?: string | null; sidebarField?: string | null; @@ -484,11 +506,36 @@ export interface PostsSelect { description?: T; number?: T; richText?: T; + arrayOfFields?: + | T + | { + optional?: T; + innerArrayOfFields?: + | T + | { + innerOptional?: T; + id?: T; + }; + id?: T; + }; group?: | T | { + defaultValueField?: T; title?: T; }; + someBlock?: + | T + | { + textBlock?: + | T + | { + textFieldForBlock?: T; + id?: T; + blockName?: T; + }; + }; + defaultValueField?: T; relationship?: T; customCell?: T; sidebarField?: T;