Skip to content

Commit

Permalink
fix(ui): prevents unwanted data overrides when bulk editing (#9842)
Browse files Browse the repository at this point in the history
### What?

It became possible for fields to reset to a defined `defaultValue` when
bulk editing from the `edit-many` drawer.

### Why?

The form-state of all fields were being considered during a bulk edit -
this also meant using their initial states - this meant any fields with
default values or nested fields (`arrays`) would be overwritten with
their initial states

I.e. empty values or default values.

### How?

Now - we only send through the form data of the fields specifically
being edited in the edit-many drawer and ignore all other fields.

Leaving all other fields stay their current values.

Fixes #9590

---------

Co-authored-by: Dan Ribbens <dan.ribbens@gmail.com>
  • Loading branch information
PatrikKozak and DanRibbens authored Dec 10, 2024
1 parent fee1744 commit 563694d
Show file tree
Hide file tree
Showing 7 changed files with 236 additions and 31 deletions.
55 changes: 42 additions & 13 deletions packages/ui/src/elements/EditMany/index.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -36,20 +36,36 @@ 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()

const save = useCallback(() => {
void submit({
action,
method: 'PATCH',
overrides: (formState) => sanitizeUnselectedFields(formState, selected),
skipValidation: true,
})
}, [action, submit])
}, [action, submit, selected])

return (
<FormSubmit className={`${baseClass}__save`} disabled={disabled} onClick={save}>
Expand All @@ -58,20 +74,25 @@ 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()

const save = useCallback(() => {
void submit({
action,
method: 'PATCH',
overrides: {
overrides: (formState) => ({
...sanitizeUnselectedFields(formState, selected),
_status: 'published',
},
}),
skipValidation: true,
})
}, [action, submit])
}, [action, submit, selected])

return (
<FormSubmit className={`${baseClass}__publish`} disabled={disabled} onClick={save}>
Expand All @@ -80,20 +101,25 @@ 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()

const save = useCallback(() => {
void submit({
action,
method: 'PATCH',
overrides: {
overrides: (formState) => ({
...sanitizeUnselectedFields(formState, selected),
_status: 'draft',
},
}),
skipValidation: true,
})
}, [action, submit])
}, [action, submit, selected])

return (
<FormSubmit
Expand Down Expand Up @@ -125,7 +151,7 @@ export const EditMany: React.FC<EditManyProps> = (props) => {

const { count, getQueryParams, selectAll } = useSelection()
const { i18n, t } = useTranslation()
const [selected, setSelected] = useState([])
const [selected, setSelected] = useState<FieldWithPathClient[]>([])
const searchParams = useSearchParams()
const router = useRouter()
const [initialState, setInitialState] = useState<FormState>()
Expand Down Expand Up @@ -184,7 +210,7 @@ export const EditMany: React.FC<EditManyProps> = (props) => {

return state
},
[slug, getFormState, collectionPermissions],
[getFormState, slug, collectionPermissions],
)

useEffect(() => {
Expand Down Expand Up @@ -289,16 +315,19 @@ export const EditMany: React.FC<EditManyProps> = (props) => {
<SaveDraftButton
action={`${serverURL}${apiRoute}/${slug}${queryString}&draft=true`}
disabled={selected.length === 0}
selected={selected}
/>
<PublishButton
action={`${serverURL}${apiRoute}/${slug}${queryString}&draft=true`}
disabled={selected.length === 0}
selected={selected}
/>
</React.Fragment>
) : (
<Submit
action={`${serverURL}${apiRoute}/${slug}${queryString}`}
disabled={selected.length === 0}
selected={selected}
/>
)}
</div>
Expand Down
6 changes: 3 additions & 3 deletions packages/ui/src/elements/FieldSelect/index.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 = ({
Expand Down Expand Up @@ -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 []
}
Expand Down
38 changes: 25 additions & 13 deletions packages/ui/src/forms/Form/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -174,7 +175,7 @@ export const Form: React.FC<FormProps> = (props) => {
const {
action: actionArg = action,
method: methodToUse = method,
overrides = {},
overrides: overridesFromArgs = {},
skipValidation,
} = options

Expand Down Expand Up @@ -263,17 +264,23 @@ export const Form: React.FC<FormProps> = (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(
contextRef.current.fields,
)
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)
Expand All @@ -288,7 +295,9 @@ export const Form: React.FC<FormProps> = (props) => {
return
}

const formData = contextRef.current.createFormData(overrides)
const formData = contextRef.current.createFormData(overrides, {
mergeOverrideData: Boolean(typeof overridesFromArgs !== 'function'),
})

try {
let res
Expand Down Expand Up @@ -443,23 +452,26 @@ export const Form: React.FC<FormProps> = (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<CreateFormData>((overrides, { mergeOverrideData = true }) => {
let data = reduceFieldsToValues(contextRef.current.fields, true)

const file = data?.file

if (file) {
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,
}

Expand Down
11 changes: 9 additions & 2 deletions packages/ui/src/forms/Form/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export type FormProps = {
export type SubmitOptions = {
action?: string
method?: string
overrides?: Record<string, unknown>
overrides?: ((formState) => FormData) | Record<string, unknown>
skipValidation?: boolean
}

Expand All @@ -70,7 +70,14 @@ export type Submit = (
e?: React.FormEvent<HTMLFormElement>,
) => Promise<void>
export type ValidateForm = () => Promise<boolean>
export type CreateFormData = (overrides?: any) => FormData
export type CreateFormData = (
overrides?: Record<string, unknown>,
/**
* 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
Expand Down
48 changes: 48 additions & 0 deletions test/admin/collections/Posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading

0 comments on commit 563694d

Please sign in to comment.