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

@@ -182,6 +182,7 @@ export const clientTranslationKeys = createClientTranslationKeys([
'general:duplicate',
'general:duplicateWithoutSaving',
'general:edit',
'general:editAll',
'general:editing',
'general:editingLabel',
'general:editingTakenOver',

View File

@@ -235,6 +235,7 @@ export const arTranslations: DefaultTranslationsObject = {
duplicate: 'استنساخ',
duplicateWithoutSaving: 'استنساخ بدون حفظ التغييرات',
edit: 'تعديل',
editAll: 'تحرير الكل',
editedSince: 'تم التحرير منذ',
editing: 'جاري التعديل',
editingLabel_many: 'تعديل {{count}} {{label}}',

View File

@@ -239,6 +239,7 @@ export const azTranslations: DefaultTranslationsObject = {
duplicate: 'Dublikat',
duplicateWithoutSaving: 'Dəyişiklikləri saxlamadan dublikatla',
edit: 'Redaktə et',
editAll: 'Hamısını redaktə et',
editedSince: 'Redaktə edilib',
editing: 'Redaktə olunur',
editingLabel_many: '{{count}} {{label}} redaktə olunur',

View File

@@ -238,6 +238,7 @@ export const bgTranslations: DefaultTranslationsObject = {
duplicate: 'Дупликирай',
duplicateWithoutSaving: 'Дупликирай без да запазваш промените',
edit: 'Редактирай',
editAll: 'Редактирай всички',
editedSince: 'Редактирано от',
editing: 'Редактиране',
editingLabel_many: 'Редактиране на {{count}} {{label}}',

View File

@@ -239,6 +239,7 @@ export const caTranslations: DefaultTranslationsObject = {
duplicate: 'Duplicar',
duplicateWithoutSaving: 'Duplica sense desar',
edit: 'Edita',
editAll: 'Edita-ho tot',
editedSince: 'Editat des de',
editing: 'Editant',
editingLabel_many: 'Editent {{count}} {{label}}',

View File

@@ -237,6 +237,7 @@ export const csTranslations: DefaultTranslationsObject = {
duplicate: 'Duplikovat',
duplicateWithoutSaving: 'Duplikovat bez uložení změn',
edit: 'Upravit',
editAll: 'Upravit vše',
editedSince: 'Upraveno od',
editing: 'Úprava',
editingLabel_many: 'Úprava {{count}} {{label}}',

View File

@@ -237,6 +237,7 @@ export const daTranslations: DefaultTranslationsObject = {
duplicate: 'Duplikér',
duplicateWithoutSaving: 'Dupliker uden at gemme ændringer',
edit: 'Redigere',
editAll: 'Rediger alle',
editedSince: 'Dette dokument er blevet redigeret siden du startede',
editing: 'Rediger',
editingLabel_many: 'Rediger {{count}} {{label}}',

View File

@@ -243,6 +243,7 @@ export const deTranslations: DefaultTranslationsObject = {
duplicate: 'Duplizieren',
duplicateWithoutSaving: 'Dupliziere ohne Änderungen zu speichern',
edit: 'Bearbeiten',
editAll: 'Bearbeite alle',
editedSince: 'Bearbeitet seit',
editing: 'Bearbeite',
editingLabel_many: 'Bearbeiten von {{count}} {{label}}',

View File

@@ -239,6 +239,7 @@ export const enTranslations = {
duplicate: 'Duplicate',
duplicateWithoutSaving: 'Duplicate without saving changes',
edit: 'Edit',
editAll: 'Edit all',
editedSince: 'Edited since',
editing: 'Editing',
editingLabel_many: 'Editing {{count}} {{label}}',
@@ -375,8 +376,8 @@ export const enTranslations = {
within: 'within',
},
upload: {
addFile: 'Add File',
addFiles: 'Add Files',
addFile: 'Add file',
addFiles: 'Add files',
bulkUpload: 'Bulk Upload',
crop: 'Crop',
cropToolDescription:

View File

@@ -243,6 +243,7 @@ export const esTranslations: DefaultTranslationsObject = {
duplicate: 'Duplicar',
duplicateWithoutSaving: 'Duplicar sin guardar cambios',
edit: 'Editar',
editAll: 'Editar Todo',
editedSince: 'Editado desde',
editing: 'Editando',
editingLabel_many: 'Edición de {{count}} {{label}}',

View File

@@ -236,6 +236,7 @@ export const etTranslations: DefaultTranslationsObject = {
duplicate: 'Dubleeri',
duplicateWithoutSaving: 'Dubleeri ilma muudatusi salvestamata',
edit: 'Muuda',
editAll: 'Muuda kõiki',
editedSince: 'Muudetud alates',
editing: 'Muutmine',
editingLabel_many: 'Muudan {{count}} {{label}}',

View File

@@ -237,6 +237,7 @@ export const faTranslations: DefaultTranslationsObject = {
duplicate: 'تکراری',
duplicateWithoutSaving: 'رونوشت بدون ذخیره کردن تغییرات',
edit: 'نگارش',
editAll: 'ویرایش همه',
editedSince: 'ویرایش شده از',
editing: 'در حال نگارش',
editingLabel_many: 'در حال نگارش {{count}} از {{label}}',

View File

@@ -246,6 +246,7 @@ export const frTranslations: DefaultTranslationsObject = {
duplicate: 'Dupliquer',
duplicateWithoutSaving: 'Dupliquer sans enregistrer les modifications',
edit: 'Éditer',
editAll: 'Modifier tout',
editedSince: 'Modifié depuis',
editing: 'Modification en cours',
editingLabel_many: 'Modification des {{count}} {{label}}',

View File

@@ -233,6 +233,7 @@ export const heTranslations: DefaultTranslationsObject = {
duplicate: 'שכפול',
duplicateWithoutSaving: 'שכפול ללא שמירת שינויים',
edit: 'עריכה',
editAll: 'עריכה הכל',
editedSince: 'נערך מאז',
editing: 'עריכה',
editingLabel_many: 'עריכת {{count}} {{label}}',

View File

@@ -239,6 +239,7 @@ export const hrTranslations: DefaultTranslationsObject = {
duplicate: 'Duplikat',
duplicateWithoutSaving: 'Dupliciraj bez spremanja promjena',
edit: 'Uredi',
editAll: 'Uredi sve',
editedSince: 'Uređeno od',
editing: 'Uređivanje',
editingLabel_many: 'Uređivanje {{count}} {{label}}',

View File

@@ -241,6 +241,7 @@ export const huTranslations: DefaultTranslationsObject = {
duplicate: 'Duplikálás',
duplicateWithoutSaving: 'Duplikálás a módosítások mentése nélkül',
edit: 'Szerkesztés',
editAll: 'Összes szerkesztése',
editedSince: 'Szerkesztve',
editing: 'Szerkesztés',
editingLabel_many: '{{count}} {{label}} szerkesztése',

View File

@@ -242,6 +242,7 @@ export const itTranslations: DefaultTranslationsObject = {
duplicate: 'Duplica',
duplicateWithoutSaving: 'Duplica senza salvare le modifiche',
edit: 'Modificare',
editAll: 'Modifica Tutto',
editedSince: 'Modificato da',
editing: 'Modifica',
editingLabel_many: 'Modificare {{count}} {{label}}',

View File

@@ -239,6 +239,7 @@ export const jaTranslations: DefaultTranslationsObject = {
duplicate: '複製',
duplicateWithoutSaving: '変更を保存せずに複製',
edit: '編集',
editAll: 'すべてを編集',
editedSince: 'から編集',
editing: '編集',
editingLabel_many: '{{count}}つの{{label}}を編集しています',

View File

@@ -237,6 +237,7 @@ export const koTranslations: DefaultTranslationsObject = {
duplicate: '복제',
duplicateWithoutSaving: '변경 사항 저장 없이 복제',
edit: '수정',
editAll: '모두 수정',
editedSince: '편집됨',
editing: '수정 중',
editingLabel_many: '{{count}}개의 {{label}} 수정 중',

View File

@@ -241,6 +241,7 @@ export const myTranslations: DefaultTranslationsObject = {
duplicate: 'ပုံတူပွားမည်။',
duplicateWithoutSaving: 'သေချာပါပြီ။',
edit: 'တည်းဖြတ်ပါ။',
editAll: 'အားလုံးကို တည်းဖြတ်ပါ။',
editedSince: 'ကစပြီးတည်းဖြတ်ခဲ့သည်',
editing: 'ပြင်ဆင်နေသည်။',
editingLabel_many: 'တည်းဖြတ်ခြင်း {{count}} {{label}}',

View File

@@ -239,6 +239,7 @@ export const nbTranslations: DefaultTranslationsObject = {
duplicate: 'Dupliser',
duplicateWithoutSaving: 'Dupliser uten å lagre endringer',
edit: 'Redigere',
editAll: 'Rediger alle',
editedSince: 'Redigert siden',
editing: 'Redigerer',
editingLabel_many: 'Redigerer {{count}} {{label}}',

View File

@@ -242,6 +242,7 @@ export const nlTranslations: DefaultTranslationsObject = {
duplicate: 'Dupliceren',
duplicateWithoutSaving: 'Dupliceren zonder wijzigingen te bewaren',
edit: 'Bewerk',
editAll: 'Bewerk alles',
editedSince: 'Bewerkt sinds',
editing: 'Bewerken',
editingLabel_many: 'Bewerken {{count}} {{label}}',

View File

@@ -239,6 +239,7 @@ export const plTranslations: DefaultTranslationsObject = {
duplicate: 'Zduplikuj',
duplicateWithoutSaving: 'Zduplikuj bez zapisywania zmian',
edit: 'Edytuj',
editAll: 'Edytuj wszystko',
editedSince: 'Edytowano od',
editing: 'Edycja',
editingLabel_many: 'Edytowanie {{count}} {{label}}',

View File

@@ -239,6 +239,7 @@ export const ptTranslations: DefaultTranslationsObject = {
duplicate: 'Duplicar',
duplicateWithoutSaving: 'Duplicar sem salvar alterações',
edit: 'Editar',
editAll: 'Editar todos',
editedSince: 'Editado desde',
editing: 'Editando',
editingLabel_many: 'Editando {{count}} {{label}}',

View File

@@ -243,6 +243,7 @@ export const roTranslations: DefaultTranslationsObject = {
duplicate: 'Duplicați',
duplicateWithoutSaving: 'Duplicați fără salvarea modificărilor',
edit: 'Editează',
editAll: 'Editează toate',
editedSince: 'Editat din',
editing: 'Editare',
editingLabel_many: 'Editare {{count}} {{label}}',

View File

@@ -239,6 +239,7 @@ export const rsTranslations: DefaultTranslationsObject = {
duplicate: 'Дупликат',
duplicateWithoutSaving: 'Понови без чувања промена',
edit: 'Уреди',
editAll: 'Уреди све',
editedSince: 'Измењено од',
editing: 'Уређивање',
editingLabel_many: 'Уређивање {{count}} {{label}}',

View File

@@ -239,6 +239,7 @@ export const rsLatinTranslations: DefaultTranslationsObject = {
duplicate: 'Duplikat',
duplicateWithoutSaving: 'Ponovi bez čuvanja promena',
edit: 'Uredi',
editAll: 'Uredi sve',
editedSince: 'Izmenjeno od',
editing: 'Uređivanje',
editingLabel_many: 'Uređivanje {{count}} {{label}}',

View File

@@ -241,6 +241,7 @@ export const ruTranslations: DefaultTranslationsObject = {
duplicate: 'Дублировать',
duplicateWithoutSaving: 'Дублирование без сохранения изменений',
edit: 'Редактировать',
editAll: 'Редактировать все',
editedSince: 'Отредактировано с',
editing: 'Редактирование',
editingLabel_many: 'Редактирование {{count}} {{label}}',

View File

@@ -240,6 +240,7 @@ export const skTranslations: DefaultTranslationsObject = {
duplicate: 'Duplikovať',
duplicateWithoutSaving: 'Duplikovať bez uloženia zmien',
edit: 'Upraviť',
editAll: 'Upraviť všetko',
editedSince: 'Upravené od',
editing: 'Úpravy',
editingLabel_many: 'Úprava {{count}} {{label}}',

View File

@@ -238,6 +238,7 @@ export const slTranslations: DefaultTranslationsObject = {
duplicate: 'Podvoji',
duplicateWithoutSaving: 'Podvoji brez shranjevanja sprememb',
edit: 'Uredi',
editAll: 'Uredi vse',
editedSince: 'Urejeno od',
editing: 'Urejanje',
editingLabel_many: 'Urejanje {{count}} {{label}}',

View File

@@ -239,6 +239,7 @@ export const svTranslations: DefaultTranslationsObject = {
duplicate: 'Duplicera',
duplicateWithoutSaving: 'Duplicera utan att spara ändringar',
edit: 'Redigera',
editAll: 'Redigera alla',
editedSince: 'Redigerad sedan',
editing: 'Redigerar',
editingLabel_many: 'Redigerar {{count}} {{label}}',

View File

@@ -235,6 +235,7 @@ export const thTranslations: DefaultTranslationsObject = {
duplicate: 'สำเนา',
duplicateWithoutSaving: 'สำเนาโดยไม่บันทึกการแก้ไข',
edit: 'แก้ไข',
editAll: 'แก้ไขทั้งหมด',
editedSince: 'แก้ไขตั้งแต่',
editing: 'แก้ไข',
editingLabel_many: 'กำลังแก้ไข {{count}} {{label}}',

View File

@@ -242,6 +242,7 @@ export const trTranslations: DefaultTranslationsObject = {
duplicate: 'Çoğalt',
duplicateWithoutSaving: 'Ayarları kaydetmeden çoğalt',
edit: 'Düzenle',
editAll: 'Hepsini düzenle',
editedSince: 'O tarihten itibaren düzenlendi',
editing: 'Düzenleniyor',
editingLabel_many: '{{count}} {{label}} düzenleniyor',

View File

@@ -238,6 +238,7 @@ export const ukTranslations: DefaultTranslationsObject = {
duplicate: 'Дублювати',
duplicateWithoutSaving: 'Дублювання без збереження змін',
edit: 'Редагувати',
editAll: 'Редагувати все',
editedSince: 'Відредаговано з',
editing: 'Редагування',
editingLabel_many: 'Редагування {{count}} {{label}}',

View File

@@ -238,6 +238,7 @@ export const viTranslations: DefaultTranslationsObject = {
duplicate: 'Tạo bản sao',
duplicateWithoutSaving: 'Không lưu dữ liệu và tạo bản sao',
edit: 'Chỉnh sửa',
editAll: 'Chỉnh sửa tất cả',
editedSince: 'Được chỉnh sửa từ',
editing: 'Đang chỉnh sửa',
editingLabel_many: 'Đang chỉnh sửa {{count}} {{label}}',

View File

@@ -229,6 +229,7 @@ export const zhTranslations: DefaultTranslationsObject = {
duplicate: '重复',
duplicateWithoutSaving: '重复而不保存更改。',
edit: '编辑',
editAll: '编辑全部',
editedSince: '自...以来编辑',
editing: '编辑中',
editingLabel_many: '编辑 {{count}} {{label}}',

View File

@@ -229,6 +229,7 @@ export const zhTwTranslations: DefaultTranslationsObject = {
duplicate: '複製',
duplicateWithoutSaving: '複製而不儲存變更。',
edit: '編輯',
editAll: '編輯全部',
editedSince: '自...以來編輯',
editing: '編輯中',
editingLabel_many: '編輯 {{count}} 個 {{label}}',

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'

View File

@@ -36,7 +36,7 @@ import { navigateToDoc } from 'helpers/e2e/navigateToDoc.js'
import { upsertPrefs } from 'helpers/e2e/upsertPrefs.js'
import { RESTClient } from 'helpers/rest.js'
import { GeneratedTypes } from 'helpers/sdk/types.js'
import { wait } from 'payload/shared'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -49,7 +49,7 @@ const dirname = path.dirname(filename)
* Repeat above for Globals
*/
const { beforeAll, beforeEach, describe, afterEach } = test
const { beforeAll, beforeEach, describe } = test
let url: AdminUrlUtil
let urlWithRequiredLocalizedFields: AdminUrlUtil
let urlRelationshipLocalized: AdminUrlUtil

View File

@@ -25,6 +25,11 @@ export const Uploads2: CollectionConfig = {
enableRichTextRelationship: false,
},
fields: [
{
name: 'prefix',
type: 'text',
required: true,
},
{
type: 'text',
name: 'title',

View File

@@ -413,7 +413,7 @@ describe('Uploads', () => {
// save the document and expect an error
await page.locator('button#action-save').click()
await expect(page.locator('.payload-toast-container .toast-error')).toContainText(
'The following field is invalid: audio',
'The following field is invalid: Audio',
)
})
@@ -689,6 +689,171 @@ describe('Uploads', () => {
await expect(clientText).toHaveText('This text was rendered on the client')
})
describe('bulk uploads', () => {
test('should bulk upload multiple files', async () => {
// Navigate to the upload creation page
await page.goto(uploadsOne.create)
await page.waitForURL(uploadsOne.create)
// Upload single file
await page.setInputFiles(
'.file-field input[type="file"]',
path.resolve(dirname, './image.png'),
)
const filename = page.locator('.file-field__filename')
await expect(filename).toHaveValue('image.png')
const bulkUploadButton = page.locator('#field-hasManyUpload button', {
hasText: exactText('Create New'),
})
await bulkUploadButton.click()
const bulkUploadModal = page.locator('#bulk-upload-drawer-slug-1')
await expect(bulkUploadModal).toBeVisible()
// Bulk upload multiple files at once
await page.setInputFiles('#bulk-upload-drawer-slug-1 .dropzone input[type="file"]', [
path.resolve(dirname, './image.png'),
path.resolve(dirname, './test-image.png'),
])
await page
.locator('.bulk-upload--file-manager .render-fields #field-prefix')
.fill('prefix-one')
const nextImageChevronButton = page.locator(
'.bulk-upload--actions-bar__controls button:nth-of-type(2)',
)
await nextImageChevronButton.click()
await page
.locator('.bulk-upload--file-manager .render-fields #field-prefix')
.fill('prefix-two')
const saveButton = page.locator('.bulk-upload--actions-bar__saveButtons button')
await saveButton.click()
await page.waitForSelector('#field-hasManyUpload .upload--has-many__dragItem')
const itemCount = await page
.locator('#field-hasManyUpload .upload--has-many__dragItem')
.count()
expect(itemCount).toEqual(2)
await saveDocAndAssert(page)
})
test('should apply field value to all bulk upload files after edit many', async () => {
// Navigate to the upload creation page
await page.goto(uploadsOne.create)
await page.waitForURL(uploadsOne.create)
// Upload single file
await page.setInputFiles(
'.file-field input[type="file"]',
path.resolve(dirname, './image.png'),
)
const filename = page.locator('.file-field__filename')
await expect(filename).toHaveValue('image.png')
const bulkUploadButton = page.locator('#field-hasManyUpload button', {
hasText: exactText('Create New'),
})
await bulkUploadButton.click()
const bulkUploadModal = page.locator('#bulk-upload-drawer-slug-1')
await expect(bulkUploadModal).toBeVisible()
// Bulk upload multiple files at once
await page.setInputFiles('#bulk-upload-drawer-slug-1 .dropzone input[type="file"]', [
path.resolve(dirname, './image.png'),
path.resolve(dirname, './test-image.png'),
])
await page.locator('#bulk-upload-drawer-slug-1 .edit-many-bulk-uploads__toggle').click()
const editManyBulkUploadModal = page.locator('#edit-uploads-2-bulk-uploads')
await expect(editManyBulkUploadModal).toBeVisible()
const fieldSelector = page.locator('.edit-many-bulk-uploads__form .react-select')
await fieldSelector.click({ delay: 100 })
const options = page.locator('.rs__option')
// Select an option
await options.locator('text=Prefix').click()
await page.locator('#edit-uploads-2-bulk-uploads #field-prefix').fill('some prefix')
await page.locator('.edit-many-bulk-uploads__sidebar-wrap button').click()
const saveButton = page.locator('.bulk-upload--actions-bar__saveButtons button')
await saveButton.click()
await page.waitForSelector('#field-hasManyUpload .upload--has-many__dragItem')
const itemCount = await page
.locator('#field-hasManyUpload .upload--has-many__dragItem')
.count()
expect(itemCount).toEqual(2)
await saveDocAndAssert(page)
})
test('should remove validation errors from bulk upload files after correction in edit many drawer', async () => {
// Navigate to the upload creation page
await page.goto(uploadsOne.create)
await page.waitForURL(uploadsOne.create)
// Upload single file
await page.setInputFiles(
'.file-field input[type="file"]',
path.resolve(dirname, './image.png'),
)
const filename = page.locator('.file-field__filename')
await expect(filename).toHaveValue('image.png')
const bulkUploadButton = page.locator('#field-hasManyUpload button', {
hasText: exactText('Create New'),
})
await bulkUploadButton.click()
const bulkUploadModal = page.locator('#bulk-upload-drawer-slug-1')
await expect(bulkUploadModal).toBeVisible()
// Bulk upload multiple files at once
await page.setInputFiles('#bulk-upload-drawer-slug-1 .dropzone input[type="file"]', [
path.resolve(dirname, './image.png'),
path.resolve(dirname, './test-image.png'),
])
const saveButton = page.locator('.bulk-upload--actions-bar__saveButtons button')
await saveButton.click()
await expect(page.locator('.payload-toast-container')).toContainText('Failed to save 2 files')
const errorCount = page
.locator('#bulk-upload-drawer-slug-1 .file-selections .error-pill__count')
.first()
await expect(errorCount).toHaveText('2')
await page.locator('#bulk-upload-drawer-slug-1 .edit-many-bulk-uploads__toggle').click()
const editManyBulkUploadModal = page.locator('#edit-uploads-2-bulk-uploads')
await expect(editManyBulkUploadModal).toBeVisible()
const fieldSelector = page.locator('.edit-many-bulk-uploads__form .react-select')
await fieldSelector.click({ delay: 100 })
const options = page.locator('.rs__option')
// Select an option
await options.locator('text=Prefix').click()
await page.locator('#edit-uploads-2-bulk-uploads #field-prefix').fill('some prefix')
await page.locator('.edit-many-bulk-uploads__sidebar-wrap button').click()
await saveButton.click()
await expect(page.locator('.payload-toast-container')).toContainText(
'Successfully saved 2 files',
)
await saveDocAndAssert(page)
})
})
describe('remote url fetching', () => {
beforeAll(async () => {
mockCorsServer = startMockCorsServer()

View File

@@ -959,6 +959,7 @@ export interface Uploads1 {
*/
export interface Uploads2 {
id: string;
prefix: string;
title?: string | null;
updatedAt: string;
createdAt: string;
@@ -2280,6 +2281,7 @@ export interface Uploads1Select<T extends boolean = true> {
* via the `definition` "uploads-2_select".
*/
export interface Uploads2Select<T extends boolean = true> {
prefix?: T;
title?: T;
updatedAt?: T;
createdAt?: T;

BIN
test/uploads/test-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB