Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(ui): prevents unwanted data overrides when bulk editing #9842

Merged
merged 9 commits into from
Dec 10, 2024
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 = {
},
],
},
{
PatrikKozak marked this conversation as resolved.
Show resolved Hide resolved
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