feat(ui): adds edit many option for bulk uploads (#10646)

### What?

This PR introduces the ability to bulk edit multiple uploads
simultaneously within the `Edit all` option for bulk uploads. Users can
now select fields to update across all selected uploads in a single
operation.

### Why?

Managing multiple uploads individually can be time-consuming and
inefficient, especially when updating common fields. This feature
streamlines the process, improving user experience and productivity when
handling bulk uploads.

### How?

* Added an `Edit Many` drawer component specific to bulk uploads that
allows users to select fields for bulk editing.
* Enhanced the FormsManager and related logic to ensure updates are
applied consistently across all selected uploads.

![Screenshot 2025-01-21 at 3 16
49 PM](https://github.com/user-attachments/assets/ef1f4a12-95a6-4b21-8efa-5280df0917fc)
This commit is contained in:
Patrik
2025-01-22 16:20:13 -05:00
committed by GitHub
parent 67f7c9513f
commit be2c482054
54 changed files with 719 additions and 56 deletions

View File

@@ -1,17 +1,24 @@
'use client'
import type { ClientCollectionConfig } from 'payload'
import React from 'react'
import { ChevronIcon } from '../../../icons/Chevron/index.js'
import { useConfig } from '../../../providers/Config/index.js'
import { useTranslation } from '../../../providers/Translation/index.js'
import { Button } from '../../Button/index.js'
import { EditManyBulkUploads } from '../EditMany/index.js'
import { useFormsManager } from '../FormsManager/index.js'
import './index.scss'
const baseClass = 'bulk-upload--actions-bar'
export function ActionsBar() {
type Props = {
readonly collectionConfig: ClientCollectionConfig
}
export function ActionsBar({ collectionConfig }: Props) {
const { activeIndex, forms, setActiveIndex } = useFormsManager()
const { t } = useTranslation()
@@ -56,6 +63,7 @@ export function ActionsBar() {
<ChevronIcon direction="right" />
</Button>
</div>
<EditManyBulkUploads collection={collectionConfig} />
</div>
<Actions className={`${baseClass}__saveButtons`} />

View File

@@ -36,7 +36,7 @@ export function AddingFilesView() {
const { user } = useAuth()
const { openModal } = useModal()
const collection = getEntityConfig({ collectionSlug })
const collectionConfig = getEntityConfig({ collectionSlug })
return (
<div className={baseClass}>
@@ -45,7 +45,7 @@ export function AddingFilesView() {
<div className={`${baseClass}__editView`}>
<DrawerHeader
onClose={() => openModal(discardBulkUploadModalSlug)}
title={getTranslation(collection.labels.singular, i18n)}
title={getTranslation(collectionConfig.labels.singular, i18n)}
/>
{activeForm ? (
<DocumentInfoProvider
@@ -66,7 +66,7 @@ export function AddingFilesView() {
Upload={documentSlots.Upload}
versionCount={0}
>
<ActionsBar />
<ActionsBar collectionConfig={collectionConfig} />
<EditForm submitted={hasSubmitted} />
</DocumentInfoProvider>
) : null}

View File

@@ -1,7 +1,5 @@
'use client'
import type { ClientCollectionConfig } from 'payload'
import { useRouter, useSearchParams } from 'next/navigation.js'
import React, { useCallback, useEffect } from 'react'

View File

@@ -0,0 +1,98 @@
'use client'
import type { ClientCollectionConfig, FieldWithPathClient } from 'payload'
import { useModal } from '@faceless-ui/modal'
import { getTranslation } from '@payloadcms/translations'
import React, { useCallback, useState } from 'react'
import type { FormProps } from '../../../forms/Form/index.js'
import type { State } from '../FormsManager/reducer.js'
import { Button } from '../../../elements/Button/index.js'
import { Form } from '../../../forms/Form/index.js'
import { RenderFields } from '../../../forms/RenderFields/index.js'
import { XIcon } from '../../../icons/X/index.js'
import { useAuth } from '../../../providers/Auth/index.js'
import { useTranslation } from '../../../providers/Translation/index.js'
import { FieldSelect } from '../../FieldSelect/index.js'
import { useFormsManager } from '../FormsManager/index.js'
import { baseClass, type EditManyBulkUploadsProps } from './index.js'
import './index.scss'
export const EditManyBulkUploadsDrawerContent: React.FC<
{
collection: ClientCollectionConfig
drawerSlug: string
forms: State['forms']
} & EditManyBulkUploadsProps
> = (props) => {
const { collection: { slug, fields, labels: { plural } } = {}, drawerSlug, forms } = props
const { permissions } = useAuth()
const { i18n, t } = useTranslation()
const { closeModal } = useModal()
const { bulkUpdateForm } = useFormsManager()
const [selectedFields, setSelectedFields] = useState<FieldWithPathClient[]>([])
const collectionPermissions = permissions?.collections?.[slug]
const handleSubmit: FormProps['onSubmit'] = useCallback(
(formState) => {
const pairedData = selectedFields.reduce((acc, field) => {
const { path } = field
if (formState[path]) {
acc[path] = formState[path].value
}
return acc
}, {})
void bulkUpdateForm(pairedData, () => closeModal(drawerSlug))
},
[closeModal, drawerSlug, bulkUpdateForm, selectedFields],
)
return (
<div className={`${baseClass}__main`}>
<div className={`${baseClass}__header`}>
<h2 className={`${baseClass}__header__title`}>
{t('general:editingLabel', {
count: forms.length,
label: getTranslation(plural, i18n),
})}
</h2>
<button
aria-label={t('general:close')}
className={`${baseClass}__header__close`}
id={`close-drawer__${drawerSlug}`}
onClick={() => closeModal(drawerSlug)}
type="button"
>
<XIcon />
</button>
</div>
<Form className={`${baseClass}__form`} initialState={{}} onSubmit={handleSubmit}>
<FieldSelect fields={fields} setSelected={setSelectedFields} />
{selectedFields.length === 0 ? null : (
<RenderFields
fields={selectedFields}
parentIndexPath=""
parentPath=""
parentSchemaPath={slug}
permissions={collectionPermissions?.fields}
readOnly={false}
/>
)}
<div className={`${baseClass}__sidebar-wrap`}>
<div className={`${baseClass}__sidebar`}>
<div className={`${baseClass}__sidebar-sticky-wrap`}>
<div className={`${baseClass}__document-actions`}>
<Button type="submit">{t('general:applyChanges')}</Button>
</div>
</div>
</div>
</div>
</Form>
</div>
)
}

View File

@@ -0,0 +1,201 @@
@import '../../../scss/styles.scss';
@layer payload-default {
.edit-many-bulk-uploads {
&__toggle {
font-size: 1rem;
line-height: base(1.2);
display: inline-flex;
background: var(--theme-elevation-150);
color: var(--theme-elevation-800);
border-radius: $style-radius-s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border: 0;
padding: 0 base(0.4);
align-items: center;
cursor: pointer;
text-decoration: none;
&:active,
&:focus {
outline: none;
}
&:hover {
background: var(--theme-elevation-100);
}
&:active {
background: var(--theme-elevation-100);
}
}
&__form {
height: 100%;
}
&__main {
width: calc(100% - #{base(15)});
display: flex;
flex-direction: column;
min-height: 100%;
}
&__header {
display: flex;
margin-top: base(2.5);
margin-bottom: base(1);
width: 100%;
&__title {
margin: 0;
flex-grow: 1;
}
&__close {
border: 0;
background-color: transparent;
padding: 0;
cursor: pointer;
overflow: hidden;
width: base(1);
height: base(1);
svg {
width: base(2);
height: base(2);
position: relative;
inset-inline-start: base(-0.5);
top: base(-0.5);
.stroke {
stroke-width: 2px;
vector-effect: non-scaling-stroke;
}
}
}
}
&__edit {
padding-top: base(1);
padding-bottom: base(2);
flex-grow: 1;
}
[dir='rtl'] &__sidebar-wrap {
left: 0;
border-right: 1px solid var(--theme-elevation-100);
right: auto;
}
&__sidebar-wrap {
position: fixed;
width: base(15);
height: 100%;
top: 0;
right: 0;
overflow: visible;
border-left: 1px solid var(--theme-elevation-100);
}
&__sidebar {
width: 100%;
height: 100%;
overflow-y: auto;
}
&__sidebar-sticky-wrap {
display: flex;
flex-direction: column;
min-height: 100%;
}
&__collection-actions,
&__meta,
&__sidebar-fields {
[dir='ltr'] & {
padding-left: base(1.5);
}
[dir='rtl'] & {
padding-right: base(1.5);
}
}
&__document-actions {
padding-right: $baseline;
position: sticky;
top: 0;
z-index: var(--z-nav);
> * {
position: relative;
z-index: 1;
}
@include mid-break {
@include blur-bg;
}
}
&__document-actions {
display: flex;
flex-wrap: wrap;
padding: base(1);
gap: base(0.5);
.form-submit {
width: calc(50% - #{base(1)});
@include mid-break {
width: auto;
flex-grow: 1;
}
.btn {
width: 100%;
padding-left: base(0.5);
padding-right: base(0.5);
margin-bottom: 0;
}
}
}
@include mid-break {
&__main {
width: 100%;
min-height: initial;
}
&__sidebar-wrap {
position: static;
width: 100%;
height: initial;
}
&__form {
display: block;
}
&__edit {
padding-top: 0;
padding-bottom: 0;
}
&__document-actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
top: auto;
z-index: var(--z-nav);
}
&__document-actions,
&__sidebar-fields {
padding-left: var(--gutter-h);
padding-right: var(--gutter-h);
}
}
}
}

View File

@@ -0,0 +1,58 @@
'use client'
import type { ClientCollectionConfig } from 'payload'
import React from 'react'
import { useAuth } from '../../../providers/Auth/index.js'
import { EditDepthProvider } from '../../../providers/EditDepth/index.js'
import { useTranslation } from '../../../providers/Translation/index.js'
import { Drawer, DrawerToggler } from '../../Drawer/index.js'
import { useFormsManager } from '../FormsManager/index.js'
import './index.scss'
import { EditManyBulkUploadsDrawerContent } from './DrawerContent.js'
export const baseClass = 'edit-many-bulk-uploads'
export type EditManyBulkUploadsProps = {
readonly collection: ClientCollectionConfig
}
export const EditManyBulkUploads: React.FC<EditManyBulkUploadsProps> = (props) => {
const { collection: { slug } = {}, collection } = props
const { permissions } = useAuth()
const { t } = useTranslation()
const { forms } = useFormsManager() // Access forms managed in bulk uploads
const collectionPermissions = permissions?.collections?.[slug]
const hasUpdatePermission = collectionPermissions?.update
const drawerSlug = `edit-${slug}-bulk-uploads`
if (!hasUpdatePermission) {
return null
}
return (
<div className={baseClass}>
<DrawerToggler
aria-label={t('general:editAll')}
className={`${baseClass}__toggle`}
slug={drawerSlug}
>
{t('general:editAll')}
</DrawerToggler>
<EditDepthProvider>
<Drawer Header={null} slug={drawerSlug}>
<EditManyBulkUploadsDrawerContent
collection={collection}
drawerSlug={drawerSlug}
forms={forms}
/>
</Drawer>
</EditDepthProvider>
</div>
)
}

View File

@@ -185,9 +185,14 @@
&__header__actions {
display: flex;
align-items: center;
gap: var(--base);
}
&__header__addFile {
height: fit-content;
}
&__toggler {
display: none;
margin: 0;

View File

@@ -16,9 +16,9 @@ import { Pill } from '../../Pill/index.js'
import { ShimmerEffect } from '../../ShimmerEffect/index.js'
import { Thumbnail } from '../../Thumbnail/index.js'
import { Actions } from '../ActionsBar/index.js'
import './index.scss'
import { AddFilesView } from '../AddFilesView/index.js'
import { useFormsManager } from '../FormsManager/index.js'
import './index.scss'
import { useBulkUpload } from '../index.js'
const addMoreFilesDrawerSlug = 'bulk-upload-drawer--add-more-files'
@@ -88,8 +88,13 @@ export function FileSidebar() {
</div>
<div className={`${baseClass}__header__actions`}>
{typeof maxFiles === 'number' && totalFileCount < maxFiles ? (
<Pill onClick={() => openModal(addMoreFilesDrawerSlug)}>{t('upload:addFile')}</Pill>
{(typeof maxFiles === 'number' ? totalFileCount < maxFiles : true) ? (
<Pill
className={`${baseClass}__header__addFile`}
onClick={() => openModal(addMoreFilesDrawerSlug)}
>
{t('upload:addFile')}
</Pill>
) : null}
<Button
buttonStyle="transparent"

View File

@@ -25,6 +25,10 @@ import { formsManagementReducer } from './reducer.js'
type FormsManagerContext = {
readonly activeIndex: State['activeIndex']
readonly addFiles: (filelist: FileList) => Promise<void>
readonly bulkUpdateForm: (
updatedFields: Record<string, unknown>,
afterStateUpdate?: () => void,
) => Promise<void>
readonly collectionSlug: string
readonly docPermissions?: SanitizedDocumentPermissions
readonly documentSlots: DocumentSlots
@@ -51,6 +55,7 @@ type FormsManagerContext = {
const Context = React.createContext<FormsManagerContext>({
activeIndex: 0,
addFiles: () => Promise.resolve(),
bulkUpdateForm: () => null,
collectionSlug: '',
docPermissions: undefined,
documentSlots: {},
@@ -300,51 +305,49 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
}
// should expose some sort of helper for this
if (json?.errors?.length) {
const [fieldErrors, nonFieldErrors] = json.errors.reduce(
([fieldErrs, nonFieldErrs], err) => {
const newFieldErrs: any[] = []
const newNonFieldErrs: any[] = []
const [fieldErrors, nonFieldErrors] = (json?.errors || []).reduce(
([fieldErrs, nonFieldErrs], err) => {
const newFieldErrs: any[] = []
const newNonFieldErrs: any[] = []
if (err?.message) {
newNonFieldErrs.push(err)
}
if (Array.isArray(err?.data?.errors)) {
err.data?.errors.forEach((dataError) => {
if (dataError?.path) {
newFieldErrs.push(dataError)
} else {
newNonFieldErrs.push(dataError)
}
})
}
return [
[...fieldErrs, ...newFieldErrs],
[...nonFieldErrs, ...newNonFieldErrs],
]
},
[[], []],
)
currentForms[i] = {
errorCount: fieldErrors.length,
formState: fieldReducer(currentForms[i].formState, {
type: 'ADD_SERVER_ERRORS',
errors: fieldErrors,
}),
}
if (req.status === 413) {
// file too large
currentForms[i] = {
...currentForms[i],
errorCount: currentForms[i].errorCount + 1,
if (err?.message) {
newNonFieldErrs.push(err)
}
toast.error(nonFieldErrors[0]?.message)
if (Array.isArray(err?.data?.errors)) {
err.data?.errors.forEach((dataError) => {
if (dataError?.path) {
newFieldErrs.push(dataError)
} else {
newNonFieldErrs.push(dataError)
}
})
}
return [
[...fieldErrs, ...newFieldErrs],
[...nonFieldErrs, ...newNonFieldErrs],
]
},
[[], []],
)
currentForms[i] = {
errorCount: fieldErrors.length,
formState: fieldReducer(currentForms[i].formState, {
type: 'ADD_SERVER_ERRORS',
errors: fieldErrors,
}),
}
if (req.status === 413) {
// file too large
currentForms[i] = {
...currentForms[i],
errorCount: currentForms[i].errorCount + 1,
}
toast.error(nonFieldErrors[0]?.message)
}
} catch (_) {
// swallow
@@ -385,6 +388,53 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
[actionURL, activeIndex, forms, onSuccess, t, closeModal, drawerSlug],
)
const bulkUpdateForm = React.useCallback(
async (updatedFields: Record<string, unknown>, afterStateUpdate?: () => void) => {
for (let i = 0; i < forms.length; i++) {
Object.entries(updatedFields).forEach(([path, value]) => {
if (forms[i].formState[path]) {
forms[i].formState[path].value = value
dispatch({
type: 'UPDATE_FORM',
errorCount: forms[i].errorCount,
formState: forms[i].formState,
index: i,
})
}
})
if (typeof afterStateUpdate === 'function') {
afterStateUpdate()
}
if (hasSubmitted) {
const { state } = await getFormState({
collectionSlug,
docPermissions,
docPreferences: null,
formState: forms[i].formState,
operation: 'create',
schemaPath: collectionSlug,
})
const newFormErrorCount = Object.values(state).reduce(
(acc, value) => (value?.valid === false ? acc + 1 : acc),
0,
)
dispatch({
type: 'UPDATE_FORM',
errorCount: newFormErrorCount,
formState: state,
index: i,
})
}
}
},
[collectionSlug, docPermissions, forms, getFormState, hasSubmitted],
)
React.useEffect(() => {
if (!collectionSlug) {
return
@@ -425,6 +475,7 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
value={{
activeIndex: state.activeIndex,
addFiles,
bulkUpdateForm,
collectionSlug,
docPermissions,
documentSlots,

View File

@@ -1,4 +1,4 @@
import type { FormFieldWithoutComponents, FormState } from 'payload'
import type { FormState } from 'payload'
export type State = {
activeIndex: number
@@ -15,6 +15,13 @@ type Action =
index: number
type: 'UPDATE_ERROR_COUNT'
}
| {
errorCount: number
formState: FormState
index: number
type: 'UPDATE_FORM'
updatedFields?: Record<string, unknown>
}
| {
files: FileList
initialState: FormState | null
@@ -99,6 +106,25 @@ export function formsManagementReducer(state: State, action: Action): State {
totalErrorCount: state.forms.reduce((acc, form) => acc + form.errorCount, 0),
}
}
case 'UPDATE_FORM': {
const updatedForms = [...state.forms]
updatedForms[action.index].errorCount = action.errorCount
// Merge the existing formState with the new formState
updatedForms[action.index] = {
...updatedForms[action.index],
formState: {
...updatedForms[action.index].formState,
...action.formState,
},
}
return {
...state,
forms: updatedForms,
totalErrorCount: state.forms.reduce((acc, form) => acc + form.errorCount, 0),
}
}
default: {
return state
}

View File

@@ -1,3 +1,5 @@
@import '../../../scss/styles.scss';
@layer payload-default {
.bulk-upload--drawer-header {
display: flex;

View File

@@ -1,3 +1,5 @@
'use client'
import React from 'react'
import { DrawerCloseButton } from '../DrawerCloseButton/index.js'