Skip to content

Commit c31f4ef

Browse files
authored
fix(ui): bulk upload silently counts failed files as saved (#16532)
Backports #16528
1 parent 8d14915 commit c31f4ef

6 files changed

Lines changed: 156 additions & 2 deletions

File tree

packages/ui/src/elements/BulkUpload/FormsManager/index.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { useUploadHandlers } from '../../../providers/UploadHandlers/index.js'
2828
import { hasSavePermission as getHasSavePermission } from '../../../utilities/hasSavePermission.js'
2929
import { LoadingOverlay } from '../../Loading/index.js'
3030
import { useLoadingOverlay } from '../../LoadingOverlay/index.js'
31+
import { FieldErrorsToast } from '../../Toasts/fieldErrors.js'
3132
import { useBulkUpload } from '../index.js'
3233
import { createFormData } from './createFormData.js'
3334
import { formsManagementReducer } from './reducer.js'
@@ -404,7 +405,9 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
404405

405406
const json = await req.json()
406407

407-
if (req.status === 201 && json?.doc) {
408+
const wasSuccessful = req.status === 201 && json?.doc
409+
410+
if (wasSuccessful) {
408411
newDocs.push({
409412
collectionSlug,
410413
doc: json.doc,
@@ -490,14 +493,29 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
490493

491494
toast.error(nonFieldErrors[0]?.message)
492495
} else {
496+
let errorCount = fieldErrors.length
497+
498+
// Fall back to non-field errors when no field errors are present
499+
// (e.g., APIError thrown from a hook).
500+
if (!wasSuccessful && errorCount === 0) {
501+
errorCount = nonFieldErrors.length || 1
502+
}
503+
493504
currentForms[i] = {
494-
errorCount: fieldErrors.length,
505+
errorCount,
495506
formID: currentForms[i].formID,
496507
formState: fieldReducer(currentForms[i].formState, {
497508
type: 'ADD_SERVER_ERRORS',
498509
errors: fieldErrors,
499510
}),
500511
}
512+
513+
// Mimic forms/Form/index.tsx.
514+
if (!wasSuccessful) {
515+
nonFieldErrors.forEach((err) => {
516+
toast.error(<FieldErrorsToast errorMessage={err.message || t('error:unknown')} />)
517+
})
518+
}
501519
}
502520
} catch (_) {
503521
// swallow
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { CollectionConfig } from 'payload'
2+
3+
import path from 'path'
4+
import { APIError } from 'payload'
5+
import { fileURLToPath } from 'url'
6+
7+
import { bulkUploadsHookErrorSlug } from '../../shared.js'
8+
9+
const filename = fileURLToPath(import.meta.url)
10+
const dirname = path.dirname(filename)
11+
12+
export const BulkUploadsHookErrorCollection: CollectionConfig = {
13+
slug: bulkUploadsHookErrorSlug,
14+
upload: {
15+
staticDir: path.resolve(dirname, '../../media'),
16+
},
17+
admin: {
18+
useAsTitle: 'title',
19+
},
20+
fields: [
21+
{
22+
name: 'title',
23+
type: 'text',
24+
},
25+
{
26+
name: 'shouldFail',
27+
type: 'checkbox',
28+
},
29+
],
30+
hooks: {
31+
beforeChange: [
32+
({ data }) => {
33+
if (data?.shouldFail) {
34+
throw new APIError('Simulated hook error in beforeChange', 422, undefined, true)
35+
}
36+
return data
37+
},
38+
],
39+
},
40+
}

test/uploads/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { AdminThumbnailWithSearchQueries } from './collections/AdminThumbnailWit
88
import { AdminUploadControl } from './collections/AdminUploadControl/index.js'
99
import { AnyImageTypeCollection } from './collections/AnyImageType/index.js'
1010
import { BulkUploadsCollection } from './collections/BulkUploads/index.js'
11+
import { BulkUploadsHookErrorCollection } from './collections/BulkUploadsHookError/index.js'
1112
import { CustomUploadFieldCollection } from './collections/CustomUploadField/index.js'
1213
import { FileMimeType } from './collections/FileMimeType/index.js'
1314
import { NoFilesRequired } from './collections/NoFilesRequired/index.js'
@@ -1004,6 +1005,7 @@ export default buildConfigWithDefaults({
10041005
},
10051006
},
10061007
BulkUploadsCollection,
1008+
BulkUploadsHookErrorCollection,
10071009
SimpleRelationshipCollection,
10081010
FileMimeType,
10091011
{

test/uploads/e2e.spec.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
adminUploadControlSlug,
3434
animatedTypeMedia,
3535
audioSlug,
36+
bulkUploadsHookErrorSlug,
3637
bulkUploadsSlug,
3738
constructorOptionsSlug,
3839
customFileNameMediaSlug,
@@ -109,6 +110,7 @@ let consoleErrorsFromPage: string[] = []
109110
let collectErrorsFromPage: () => boolean
110111
let stopCollectingErrorsFromPage: () => boolean
111112
let bulkUploadsURL: AdminUrlUtil
113+
let bulkUploadsHookErrorURL: AdminUrlUtil
112114
let fileMimeTypeURL: AdminUrlUtil
113115
let svgOnlyURL: AdminUrlUtil
114116
let mediaWithoutDeleteAccessURL: AdminUrlUtil
@@ -153,6 +155,7 @@ describe('Uploads', () => {
153155
threeDimensionalURL = new AdminUrlUtil(serverURL, threeDimensionalSlug)
154156
constructorOptionsURL = new AdminUrlUtil(serverURL, constructorOptionsSlug)
155157
bulkUploadsURL = new AdminUrlUtil(serverURL, bulkUploadsSlug)
158+
bulkUploadsHookErrorURL = new AdminUrlUtil(serverURL, bulkUploadsHookErrorSlug)
156159
fileMimeTypeURL = new AdminUrlUtil(serverURL, fileMimeTypeSlug)
157160
svgOnlyURL = new AdminUrlUtil(serverURL, svgOnlySlug)
158161
mediaWithoutDeleteAccessURL = new AdminUrlUtil(serverURL, mediaWithoutDeleteAccessSlug)
@@ -1671,6 +1674,51 @@ describe('Uploads', () => {
16711674
const errorCount = bulkUploadModal.locator('.file-selections .error-pill__count').first()
16721675
await expect(errorCount).toHaveText('1')
16731676
})
1677+
1678+
test('should report failure when beforeChange hook throws non-field error', async () => {
1679+
await page.goto(bulkUploadsHookErrorURL.list)
1680+
1681+
await expect(page.locator('.list-header__title')).toBeVisible()
1682+
1683+
const bulkUploadButton = page.locator('.list-header__title-actions button', {
1684+
hasText: 'Bulk Upload',
1685+
})
1686+
await expect(bulkUploadButton).toBeEnabled()
1687+
1688+
const dropzoneInput = page.locator('.dropzone input[type="file"]')
1689+
await expect(async () => {
1690+
await bulkUploadButton.click()
1691+
await expect(dropzoneInput).toBeAttached({ timeout: 1500 })
1692+
}).toPass({ timeout: 5000, intervals: [500] })
1693+
1694+
await page
1695+
.locator('.dropzone input[type="file"]')
1696+
.setInputFiles([path.resolve(dirname, './image.png'), path.resolve(dirname, './small.png')])
1697+
1698+
const nextButton = page.locator('.bulk-upload--actions-bar__controls button:nth-of-type(2)')
1699+
await nextButton.click()
1700+
1701+
await page.locator('#field-shouldFail').check()
1702+
1703+
const saveButton = page.locator('.bulk-upload--actions-bar__saveButtons button')
1704+
await saveButton.click()
1705+
1706+
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
1707+
'Successfully saved 1 files',
1708+
)
1709+
await expect(
1710+
page.locator('.payload-toast-container .toast-error:has-text("Failed to save 1 files")'),
1711+
).toBeVisible()
1712+
await expect(
1713+
page.locator(
1714+
'.payload-toast-container .toast-error:has-text("Simulated hook error in beforeChange")',
1715+
),
1716+
).toBeVisible()
1717+
1718+
await expect(page.locator('.file-selections .file-selections__fileRowContainer')).toHaveCount(
1719+
1,
1720+
)
1721+
})
16741722
})
16751723

16761724
describe('remote url fetching', () => {

test/uploads/payload-types.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ export interface Config {
121121
'three-dimensional': ThreeDimensional;
122122
'constructor-options': ConstructorOption;
123123
'bulk-uploads': BulkUpload;
124+
'bulk-uploads-hook-error': BulkUploadsHookError;
124125
'simple-relationship': SimpleRelationship;
125126
'file-mime-type': FileMimeType;
126127
'svg-only': SvgOnly;
@@ -189,6 +190,7 @@ export interface Config {
189190
'three-dimensional': ThreeDimensionalSelect<false> | ThreeDimensionalSelect<true>;
190191
'constructor-options': ConstructorOptionsSelect<false> | ConstructorOptionsSelect<true>;
191192
'bulk-uploads': BulkUploadsSelect<false> | BulkUploadsSelect<true>;
193+
'bulk-uploads-hook-error': BulkUploadsHookErrorSelect<false> | BulkUploadsHookErrorSelect<true>;
192194
'simple-relationship': SimpleRelationshipSelect<false> | SimpleRelationshipSelect<true>;
193195
'file-mime-type': FileMimeTypeSelect<false> | FileMimeTypeSelect<true>;
194196
'svg-only': SvgOnlySelect<false> | SvgOnlySelect<true>;
@@ -1702,6 +1704,26 @@ export interface SimpleRelationship {
17021704
updatedAt: string;
17031705
createdAt: string;
17041706
}
1707+
/**
1708+
* This interface was referenced by `Config`'s JSON-Schema
1709+
* via the `definition` "bulk-uploads-hook-error".
1710+
*/
1711+
export interface BulkUploadsHookError {
1712+
id: string;
1713+
title?: string | null;
1714+
shouldFail?: boolean | null;
1715+
updatedAt: string;
1716+
createdAt: string;
1717+
url?: string | null;
1718+
thumbnailURL?: string | null;
1719+
filename?: string | null;
1720+
mimeType?: string | null;
1721+
filesize?: number | null;
1722+
width?: number | null;
1723+
height?: number | null;
1724+
focalX?: number | null;
1725+
focalY?: number | null;
1726+
}
17051727
/**
17061728
* This interface was referenced by `Config`'s JSON-Schema
17071729
* via the `definition` "file-mime-type".
@@ -2093,6 +2115,10 @@ export interface PayloadLockedDocument {
20932115
relationTo: 'bulk-uploads';
20942116
value: string | BulkUpload;
20952117
} | null)
2118+
| ({
2119+
relationTo: 'bulk-uploads-hook-error';
2120+
value: string | BulkUploadsHookError;
2121+
} | null)
20962122
| ({
20972123
relationTo: 'simple-relationship';
20982124
value: string | SimpleRelationship;
@@ -3691,6 +3717,25 @@ export interface BulkUploadsSelect<T extends boolean = true> {
36913717
focalX?: T;
36923718
focalY?: T;
36933719
}
3720+
/**
3721+
* This interface was referenced by `Config`'s JSON-Schema
3722+
* via the `definition` "bulk-uploads-hook-error_select".
3723+
*/
3724+
export interface BulkUploadsHookErrorSelect<T extends boolean = true> {
3725+
title?: T;
3726+
shouldFail?: T;
3727+
updatedAt?: T;
3728+
createdAt?: T;
3729+
url?: T;
3730+
thumbnailURL?: T;
3731+
filename?: T;
3732+
mimeType?: T;
3733+
filesize?: T;
3734+
width?: T;
3735+
height?: T;
3736+
focalX?: T;
3737+
focalY?: T;
3738+
}
36943739
/**
36953740
* This interface was referenced by `Config`'s JSON-Schema
36963741
* via the `definition` "simple-relationship_select".

test/uploads/shared.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export const listViewPreviewSlug = 'list-view-preview'
3636
export const threeDimensionalSlug = 'three-dimensional'
3737
export const constructorOptionsSlug = 'constructor-options'
3838
export const bulkUploadsSlug = 'bulk-uploads'
39+
export const bulkUploadsHookErrorSlug = 'bulk-uploads-hook-error'
3940
export const restrictedMimeTypesSlug = 'restricted-mime-types'
4041
export const pdfOnlySlug = 'pdf-only'
4142

0 commit comments

Comments
 (0)