Skip to content

Commit d62d9b4

Browse files
fix(ui): bulk upload losing state when adding additional files (#12946)
Fixes an issue where adding additional upload files would clear the state of the originally uploaded files.
1 parent 67fa5a0 commit d62d9b4

File tree

8 files changed

+122
-72
lines changed

8 files changed

+122
-72
lines changed

packages/ui/src/elements/BulkUpload/FileSidebar/index.scss

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,13 @@
8383
background-color: var(--theme-elevation-100);
8484
}
8585

86-
.thumbnail {
87-
width: base(1.2);
88-
height: base(1.2);
86+
.file-selections__thumbnail,
87+
.file-selections__thumbnail-shimmer {
88+
width: calc(var(--base) * 1.2);
89+
height: calc(var(--base) * 1.2);
90+
border-radius: var(--style-radius-s);
8991
flex-shrink: 0;
9092
object-fit: cover;
91-
border-radius: var(--style-radius-s);
9293
}
9394

9495
p {

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

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,13 @@ import { Drawer } from '../../Drawer/index.js'
1414
import { ErrorPill } from '../../ErrorPill/index.js'
1515
import { Pill } from '../../Pill/index.js'
1616
import { ShimmerEffect } from '../../ShimmerEffect/index.js'
17+
import { createThumbnail } from '../../Thumbnail/createThumbnail.js'
1718
import { Thumbnail } from '../../Thumbnail/index.js'
1819
import { Actions } from '../ActionsBar/index.js'
19-
import './index.scss'
2020
import { AddFilesView } from '../AddFilesView/index.js'
2121
import { useFormsManager } from '../FormsManager/index.js'
2222
import { useBulkUpload } from '../index.js'
23+
import './index.scss'
2324

2425
const addMoreFilesDrawerSlug = 'bulk-upload-drawer--add-more-files'
2526

@@ -33,7 +34,6 @@ export function FileSidebar() {
3334
isInitializing,
3435
removeFile,
3536
setActiveIndex,
36-
thumbnailUrls,
3737
totalErrorCount,
3838
} = useFormsManager()
3939
const { initialFiles, maxFiles } = useBulkUpload()
@@ -139,7 +139,7 @@ export function FileSidebar() {
139139
/>
140140
))
141141
: null}
142-
{forms.map(({ errorCount, formState }, index) => {
142+
{forms.map(({ errorCount, formID, formState }, index) => {
143143
const currentFile = (formState?.file?.value as File) || ({} as File)
144144

145145
return (
@@ -151,17 +151,14 @@ export function FileSidebar() {
151151
]
152152
.filter(Boolean)
153153
.join(' ')}
154-
key={index}
154+
key={formID}
155155
>
156156
<button
157157
className={`${baseClass}__fileRow`}
158158
onClick={() => setActiveIndex(index)}
159159
type="button"
160160
>
161-
<Thumbnail
162-
className={`${baseClass}__thumbnail`}
163-
fileSrc={isImage(currentFile.type) ? thumbnailUrls[index] : null}
164-
/>
161+
<SidebarThumbnail file={currentFile} formID={formID} />
165162
<div className={`${baseClass}__fileDetails`}>
166163
<p className={`${baseClass}__fileName`} title={currentFile.name}>
167164
{currentFile.name || t('upload:noFile')}
@@ -200,3 +197,54 @@ export function FileSidebar() {
200197
</div>
201198
)
202199
}
200+
201+
function SidebarThumbnail({ file, formID }: { file: File; formID: string }) {
202+
const [thumbnailURL, setThumbnailURL] = React.useState<null | string>(null)
203+
const [isLoading, setIsLoading] = React.useState(true)
204+
205+
React.useEffect(() => {
206+
let isCancelled = false
207+
208+
async function generateThumbnail() {
209+
setIsLoading(true)
210+
setThumbnailURL(null)
211+
212+
try {
213+
if (isImage(file.type)) {
214+
const url = await createThumbnail(file)
215+
if (!isCancelled) {
216+
setThumbnailURL(url)
217+
}
218+
} else {
219+
setThumbnailURL(null)
220+
}
221+
} catch (_) {
222+
if (!isCancelled) {
223+
setThumbnailURL(null)
224+
}
225+
} finally {
226+
if (!isCancelled) {
227+
setIsLoading(false)
228+
}
229+
}
230+
}
231+
232+
void generateThumbnail()
233+
234+
return () => {
235+
isCancelled = true
236+
}
237+
}, [file])
238+
239+
if (isLoading) {
240+
return <ShimmerEffect className={`${baseClass}__thumbnail-shimmer`} disableInlineStyles />
241+
}
242+
243+
return (
244+
<Thumbnail
245+
className={`${baseClass}__thumbnail`}
246+
fileSrc={thumbnailURL}
247+
key={`${formID}-${thumbnailURL || 'placeholder'}`}
248+
/>
249+
)
250+
}

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

Lines changed: 19 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import type {
99
} from 'payload'
1010

1111
import { useModal } from '@faceless-ui/modal'
12-
import { isImage } from 'payload/shared'
1312
import * as qs from 'qs-esm'
1413
import React from 'react'
1514
import { toast } from 'sonner'
@@ -25,7 +24,6 @@ import { useUploadHandlers } from '../../../providers/UploadHandlers/index.js'
2524
import { hasSavePermission as getHasSavePermission } from '../../../utilities/hasSavePermission.js'
2625
import { LoadingOverlay } from '../../Loading/index.js'
2726
import { useLoadingOverlay } from '../../LoadingOverlay/index.js'
28-
import { createThumbnail } from '../../Thumbnail/createThumbnail.js'
2927
import { useBulkUpload } from '../index.js'
3028
import { createFormData } from './createFormData.js'
3129
import { formsManagementReducer } from './reducer.js'
@@ -57,7 +55,6 @@ type FormsManagerContext = {
5755
errorCount: number
5856
index: number
5957
}) => void
60-
readonly thumbnailUrls: string[]
6158
readonly totalErrorCount?: number
6259
readonly updateUploadEdits: (args: UploadEdits) => void
6360
}
@@ -79,7 +76,6 @@ const Context = React.createContext<FormsManagerContext>({
7976
saveAllDocs: () => Promise.resolve(),
8077
setActiveIndex: () => 0,
8178
setFormTotalErrorCount: () => {},
82-
thumbnailUrls: [],
8379
totalErrorCount: 0,
8480
updateUploadEdits: () => {},
8581
})
@@ -119,37 +115,6 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
119115

120116
const formsRef = React.useRef(forms)
121117
formsRef.current = forms
122-
const formsCount = forms.length
123-
124-
const thumbnailUrlsRef = React.useRef<string[]>([])
125-
const processedFiles = React.useRef(new Set()) // Track already-processed files
126-
const [renderedThumbnails, setRenderedThumbnails] = React.useState<string[]>([])
127-
128-
React.useEffect(() => {
129-
// eslint-disable-next-line @typescript-eslint/no-floating-promises
130-
;(async () => {
131-
const newThumbnails = [...thumbnailUrlsRef.current]
132-
133-
for (let i = 0; i < formsCount; i++) {
134-
const file = formsRef.current[i].formState.file.value as File
135-
136-
// Skip if already processed
137-
if (processedFiles.current.has(file) || !file || !isImage(file.type)) {
138-
continue
139-
}
140-
processedFiles.current.add(file)
141-
142-
// Generate thumbnail and update ref
143-
const thumbnailUrl = await createThumbnail(file)
144-
newThumbnails[i] = thumbnailUrl
145-
thumbnailUrlsRef.current = newThumbnails
146-
147-
// Trigger re-render in batches
148-
setRenderedThumbnails([...newThumbnails])
149-
await new Promise((resolve) => setTimeout(resolve, 100))
150-
}
151-
})()
152-
}, [formsCount])
153118

154119
const { toggleLoadingOverlay } = useLoadingOverlay()
155120
const { closeModal } = useModal()
@@ -250,6 +215,7 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
250215
if (i === activeIndex) {
251216
return {
252217
errorCount: form.errorCount,
218+
formID: form.formID,
253219
formState: currentFormsData,
254220
uploadEdits: form.uploadEdits,
255221
}
@@ -264,29 +230,30 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
264230

265231
const addFiles = React.useCallback(
266232
async (files: FileList) => {
233+
if (forms.length) {
234+
// save the state of the current form before adding new files
235+
dispatch({
236+
type: 'UPDATE_FORM',
237+
errorCount: forms[activeIndex].errorCount,
238+
formState: getFormDataRef.current(),
239+
index: activeIndex,
240+
})
241+
}
242+
267243
toggleLoadingOverlay({ isLoading: true, key: 'addingDocs' })
268244
if (!hasInitializedState) {
269245
await initializeSharedFormState()
270246
}
271247
dispatch({ type: 'ADD_FORMS', files, initialState: initialStateRef.current })
272248
toggleLoadingOverlay({ isLoading: false, key: 'addingDocs' })
273249
},
274-
[initializeSharedFormState, hasInitializedState, toggleLoadingOverlay],
250+
[initializeSharedFormState, hasInitializedState, toggleLoadingOverlay, activeIndex, forms],
275251
)
276252

277-
const removeThumbnails = React.useCallback((indexes: number[]) => {
278-
thumbnailUrlsRef.current = thumbnailUrlsRef.current.filter((_, i) => !indexes.includes(i))
279-
setRenderedThumbnails([...thumbnailUrlsRef.current])
253+
const removeFile: FormsManagerContext['removeFile'] = React.useCallback((index) => {
254+
dispatch({ type: 'REMOVE_FORM', index })
280255
}, [])
281256

282-
const removeFile: FormsManagerContext['removeFile'] = React.useCallback(
283-
(index) => {
284-
dispatch({ type: 'REMOVE_FORM', index })
285-
removeThumbnails([index])
286-
},
287-
[removeThumbnails],
288-
)
289-
290257
const setFormTotalErrorCount: FormsManagerContext['setFormTotalErrorCount'] = React.useCallback(
291258
({ errorCount, index }) => {
292259
dispatch({
@@ -304,6 +271,7 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
304271
const currentForms = [...forms]
305272
currentForms[activeIndex] = {
306273
errorCount: currentForms[activeIndex].errorCount,
274+
formID: currentForms[activeIndex].formID,
307275
formState: currentFormsData,
308276
uploadEdits: currentForms[activeIndex].uploadEdits,
309277
}
@@ -372,6 +340,7 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
372340

373341
currentForms[i] = {
374342
errorCount: fieldErrors.length,
343+
formID: currentForms[i].formID,
375344
formState: fieldReducer(currentForms[i].formState, {
376345
type: 'ADD_SERVER_ERRORS',
377346
errors: fieldErrors,
@@ -416,10 +385,6 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
416385
if (typeof onSuccess === 'function') {
417386
onSuccess(newDocs, errorCount)
418387
}
419-
420-
if (remainingForms.length && thumbnailIndexesToRemove.length) {
421-
removeThumbnails(thumbnailIndexesToRemove)
422-
}
423388
}
424389

425390
if (errorCount) {
@@ -439,15 +404,14 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
439404
},
440405
[
441406
actionURL,
442-
activeIndex,
443-
forms,
444-
removeThumbnails,
445-
onSuccess,
446407
collectionSlug,
447408
getUploadHandler,
448409
t,
410+
forms,
411+
activeIndex,
449412
closeModal,
450413
drawerSlug,
414+
onSuccess,
451415
],
452416
)
453417

@@ -578,7 +542,6 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
578542
saveAllDocs,
579543
setActiveIndex,
580544
setFormTotalErrorCount,
581-
thumbnailUrls: renderedThumbnails,
582545
totalErrorCount,
583546
updateUploadEdits,
584547
}}

packages/ui/src/elements/BulkUpload/FormsManager/reducer.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export type State = {
44
activeIndex: number
55
forms: {
66
errorCount: number
7+
formID: string
78
formState: FormState
89
uploadEdits?: UploadEdits
910
}[]
@@ -49,6 +50,7 @@ export function formsManagementReducer(state: State, action: Action): State {
4950
for (let i = 0; i < action.files.length; i++) {
5051
newForms[i] = {
5152
errorCount: 0,
53+
formID: crypto.randomUUID(),
5254
formState: {
5355
...(action.initialState || {}),
5456
file: {

packages/ui/src/elements/BulkUpload/index.scss

Whitespace-only changes.

packages/ui/src/elements/ShimmerEffect/index.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,25 @@ import './index.scss'
66

77
export type ShimmerEffectProps = {
88
readonly animationDelay?: string
9+
readonly className?: string
10+
readonly disableInlineStyles?: boolean
911
readonly height?: number | string
1012
readonly width?: number | string
1113
}
1214

1315
export const ShimmerEffect: React.FC<ShimmerEffectProps> = ({
1416
animationDelay = '0ms',
17+
className,
18+
disableInlineStyles = false,
1519
height = '60px',
1620
width = '100%',
1721
}) => {
1822
return (
1923
<div
20-
className="shimmer-effect"
24+
className={['shimmer-effect', className].filter(Boolean).join(' ')}
2125
style={{
22-
height: typeof height === 'number' ? `${height}px` : height,
23-
width: typeof width === 'number' ? `${width}px` : width,
26+
height: !disableInlineStyles && (typeof height === 'number' ? `${height}px` : height),
27+
width: !disableInlineStyles && (typeof width === 'number' ? `${width}px` : width),
2428
}}
2529
>
2630
<div

packages/ui/src/elements/Thumbnail/createThumbnail.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,16 @@ export const createThumbnail = (file: File): Promise<string> => {
2727
const canvas = new OffscreenCanvas(drawWidth, drawHeight) // Create an OffscreenCanvas
2828
const ctx = canvas.getContext('2d')
2929

30+
// Determine output format based on input file type
31+
const outputFormat = file.type === 'image/png' ? 'image/png' : 'image/jpeg'
32+
const quality = file.type === 'image/png' ? undefined : 0.8 // PNG doesn't use quality, use higher quality for JPEG
33+
3034
// Draw the image onto the OffscreenCanvas with calculated dimensions
3135
ctx.drawImage(img, 0, 0, drawWidth, drawHeight)
3236

3337
// Convert the OffscreenCanvas to a Blob and free up memory
3438
canvas
35-
.convertToBlob({ type: 'image/jpeg', quality: 0.25 })
39+
.convertToBlob({ type: outputFormat, ...(quality && { quality }) })
3640
.then((blob) => {
3741
URL.revokeObjectURL(img.src) // Release the Object URL
3842
const reader = new FileReader()

test/uploads/e2e.spec.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1145,6 +1145,34 @@ describe('Uploads', () => {
11451145
.first()
11461146
await expect(errorCount).toHaveText('1')
11471147
})
1148+
1149+
test('should preserve state when adding additional files to an existing bulk upload', async () => {
1150+
await page.goto(uploadsTwo.list)
1151+
await page.locator('.list-header__title-actions button', { hasText: 'Bulk Upload' }).click()
1152+
1153+
await page.setInputFiles('.dropzone input[type="file"]', path.resolve(dirname, './image.png'))
1154+
1155+
await page.locator('#field-prefix').fill('should-preserve')
1156+
1157+
// add another file
1158+
await page
1159+
.locator('.file-selections__header__actions button', { hasText: 'Add File' })
1160+
.click()
1161+
await page.setInputFiles('.dropzone input[type="file"]', path.resolve(dirname, './small.png'))
1162+
1163+
const originalFileRow = page
1164+
.locator('.file-selections__filesContainer .file-selections__fileRowContainer')
1165+
.nth(1)
1166+
1167+
// ensure the original file thumbnail is visible (not using default placeholder svg)
1168+
await expect(originalFileRow.locator('.thumbnail img')).toBeVisible()
1169+
1170+
// navigate to the first file added
1171+
await originalFileRow.locator('button.file-selections__fileRow').click()
1172+
1173+
// ensure the prefix field is still filled with the original value
1174+
await expect(page.locator('#field-prefix')).toHaveValue('should-preserve')
1175+
})
11481176
})
11491177

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

0 commit comments

Comments
 (0)