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.
This commit is contained in:
Jarrod Flesch
2025-06-26 15:23:38 -04:00
committed by GitHub
parent 67fa5a0b3b
commit d62d9b4b8e
8 changed files with 122 additions and 72 deletions

View File

@@ -83,12 +83,13 @@
background-color: var(--theme-elevation-100); background-color: var(--theme-elevation-100);
} }
.thumbnail { .file-selections__thumbnail,
width: base(1.2); .file-selections__thumbnail-shimmer {
height: base(1.2); width: calc(var(--base) * 1.2);
height: calc(var(--base) * 1.2);
border-radius: var(--style-radius-s);
flex-shrink: 0; flex-shrink: 0;
object-fit: cover; object-fit: cover;
border-radius: var(--style-radius-s);
} }
p { p {

View File

@@ -14,12 +14,13 @@ import { Drawer } from '../../Drawer/index.js'
import { ErrorPill } from '../../ErrorPill/index.js' import { ErrorPill } from '../../ErrorPill/index.js'
import { Pill } from '../../Pill/index.js' import { Pill } from '../../Pill/index.js'
import { ShimmerEffect } from '../../ShimmerEffect/index.js' import { ShimmerEffect } from '../../ShimmerEffect/index.js'
import { createThumbnail } from '../../Thumbnail/createThumbnail.js'
import { Thumbnail } from '../../Thumbnail/index.js' import { Thumbnail } from '../../Thumbnail/index.js'
import { Actions } from '../ActionsBar/index.js' import { Actions } from '../ActionsBar/index.js'
import './index.scss'
import { AddFilesView } from '../AddFilesView/index.js' import { AddFilesView } from '../AddFilesView/index.js'
import { useFormsManager } from '../FormsManager/index.js' import { useFormsManager } from '../FormsManager/index.js'
import { useBulkUpload } from '../index.js' import { useBulkUpload } from '../index.js'
import './index.scss'
const addMoreFilesDrawerSlug = 'bulk-upload-drawer--add-more-files' const addMoreFilesDrawerSlug = 'bulk-upload-drawer--add-more-files'
@@ -33,7 +34,6 @@ export function FileSidebar() {
isInitializing, isInitializing,
removeFile, removeFile,
setActiveIndex, setActiveIndex,
thumbnailUrls,
totalErrorCount, totalErrorCount,
} = useFormsManager() } = useFormsManager()
const { initialFiles, maxFiles } = useBulkUpload() const { initialFiles, maxFiles } = useBulkUpload()
@@ -139,7 +139,7 @@ export function FileSidebar() {
/> />
)) ))
: null} : null}
{forms.map(({ errorCount, formState }, index) => { {forms.map(({ errorCount, formID, formState }, index) => {
const currentFile = (formState?.file?.value as File) || ({} as File) const currentFile = (formState?.file?.value as File) || ({} as File)
return ( return (
@@ -151,17 +151,14 @@ export function FileSidebar() {
] ]
.filter(Boolean) .filter(Boolean)
.join(' ')} .join(' ')}
key={index} key={formID}
> >
<button <button
className={`${baseClass}__fileRow`} className={`${baseClass}__fileRow`}
onClick={() => setActiveIndex(index)} onClick={() => setActiveIndex(index)}
type="button" type="button"
> >
<Thumbnail <SidebarThumbnail file={currentFile} formID={formID} />
className={`${baseClass}__thumbnail`}
fileSrc={isImage(currentFile.type) ? thumbnailUrls[index] : null}
/>
<div className={`${baseClass}__fileDetails`}> <div className={`${baseClass}__fileDetails`}>
<p className={`${baseClass}__fileName`} title={currentFile.name}> <p className={`${baseClass}__fileName`} title={currentFile.name}>
{currentFile.name || t('upload:noFile')} {currentFile.name || t('upload:noFile')}
@@ -200,3 +197,54 @@ export function FileSidebar() {
</div> </div>
) )
} }
function SidebarThumbnail({ file, formID }: { file: File; formID: string }) {
const [thumbnailURL, setThumbnailURL] = React.useState<null | string>(null)
const [isLoading, setIsLoading] = React.useState(true)
React.useEffect(() => {
let isCancelled = false
async function generateThumbnail() {
setIsLoading(true)
setThumbnailURL(null)
try {
if (isImage(file.type)) {
const url = await createThumbnail(file)
if (!isCancelled) {
setThumbnailURL(url)
}
} else {
setThumbnailURL(null)
}
} catch (_) {
if (!isCancelled) {
setThumbnailURL(null)
}
} finally {
if (!isCancelled) {
setIsLoading(false)
}
}
}
void generateThumbnail()
return () => {
isCancelled = true
}
}, [file])
if (isLoading) {
return <ShimmerEffect className={`${baseClass}__thumbnail-shimmer`} disableInlineStyles />
}
return (
<Thumbnail
className={`${baseClass}__thumbnail`}
fileSrc={thumbnailURL}
key={`${formID}-${thumbnailURL || 'placeholder'}`}
/>
)
}

View File

@@ -9,7 +9,6 @@ import type {
} from 'payload' } from 'payload'
import { useModal } from '@faceless-ui/modal' import { useModal } from '@faceless-ui/modal'
import { isImage } from 'payload/shared'
import * as qs from 'qs-esm' import * as qs from 'qs-esm'
import React from 'react' import React from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -25,7 +24,6 @@ import { useUploadHandlers } from '../../../providers/UploadHandlers/index.js'
import { hasSavePermission as getHasSavePermission } from '../../../utilities/hasSavePermission.js' import { hasSavePermission as getHasSavePermission } from '../../../utilities/hasSavePermission.js'
import { LoadingOverlay } from '../../Loading/index.js' import { LoadingOverlay } from '../../Loading/index.js'
import { useLoadingOverlay } from '../../LoadingOverlay/index.js' import { useLoadingOverlay } from '../../LoadingOverlay/index.js'
import { createThumbnail } from '../../Thumbnail/createThumbnail.js'
import { useBulkUpload } from '../index.js' import { useBulkUpload } from '../index.js'
import { createFormData } from './createFormData.js' import { createFormData } from './createFormData.js'
import { formsManagementReducer } from './reducer.js' import { formsManagementReducer } from './reducer.js'
@@ -57,7 +55,6 @@ type FormsManagerContext = {
errorCount: number errorCount: number
index: number index: number
}) => void }) => void
readonly thumbnailUrls: string[]
readonly totalErrorCount?: number readonly totalErrorCount?: number
readonly updateUploadEdits: (args: UploadEdits) => void readonly updateUploadEdits: (args: UploadEdits) => void
} }
@@ -79,7 +76,6 @@ const Context = React.createContext<FormsManagerContext>({
saveAllDocs: () => Promise.resolve(), saveAllDocs: () => Promise.resolve(),
setActiveIndex: () => 0, setActiveIndex: () => 0,
setFormTotalErrorCount: () => {}, setFormTotalErrorCount: () => {},
thumbnailUrls: [],
totalErrorCount: 0, totalErrorCount: 0,
updateUploadEdits: () => {}, updateUploadEdits: () => {},
}) })
@@ -119,37 +115,6 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
const formsRef = React.useRef(forms) const formsRef = React.useRef(forms)
formsRef.current = forms formsRef.current = forms
const formsCount = forms.length
const thumbnailUrlsRef = React.useRef<string[]>([])
const processedFiles = React.useRef(new Set()) // Track already-processed files
const [renderedThumbnails, setRenderedThumbnails] = React.useState<string[]>([])
React.useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
;(async () => {
const newThumbnails = [...thumbnailUrlsRef.current]
for (let i = 0; i < formsCount; i++) {
const file = formsRef.current[i].formState.file.value as File
// Skip if already processed
if (processedFiles.current.has(file) || !file || !isImage(file.type)) {
continue
}
processedFiles.current.add(file)
// Generate thumbnail and update ref
const thumbnailUrl = await createThumbnail(file)
newThumbnails[i] = thumbnailUrl
thumbnailUrlsRef.current = newThumbnails
// Trigger re-render in batches
setRenderedThumbnails([...newThumbnails])
await new Promise((resolve) => setTimeout(resolve, 100))
}
})()
}, [formsCount])
const { toggleLoadingOverlay } = useLoadingOverlay() const { toggleLoadingOverlay } = useLoadingOverlay()
const { closeModal } = useModal() const { closeModal } = useModal()
@@ -250,6 +215,7 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
if (i === activeIndex) { if (i === activeIndex) {
return { return {
errorCount: form.errorCount, errorCount: form.errorCount,
formID: form.formID,
formState: currentFormsData, formState: currentFormsData,
uploadEdits: form.uploadEdits, uploadEdits: form.uploadEdits,
} }
@@ -264,6 +230,16 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
const addFiles = React.useCallback( const addFiles = React.useCallback(
async (files: FileList) => { async (files: FileList) => {
if (forms.length) {
// save the state of the current form before adding new files
dispatch({
type: 'UPDATE_FORM',
errorCount: forms[activeIndex].errorCount,
formState: getFormDataRef.current(),
index: activeIndex,
})
}
toggleLoadingOverlay({ isLoading: true, key: 'addingDocs' }) toggleLoadingOverlay({ isLoading: true, key: 'addingDocs' })
if (!hasInitializedState) { if (!hasInitializedState) {
await initializeSharedFormState() await initializeSharedFormState()
@@ -271,22 +247,13 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
dispatch({ type: 'ADD_FORMS', files, initialState: initialStateRef.current }) dispatch({ type: 'ADD_FORMS', files, initialState: initialStateRef.current })
toggleLoadingOverlay({ isLoading: false, key: 'addingDocs' }) toggleLoadingOverlay({ isLoading: false, key: 'addingDocs' })
}, },
[initializeSharedFormState, hasInitializedState, toggleLoadingOverlay], [initializeSharedFormState, hasInitializedState, toggleLoadingOverlay, activeIndex, forms],
) )
const removeThumbnails = React.useCallback((indexes: number[]) => { const removeFile: FormsManagerContext['removeFile'] = React.useCallback((index) => {
thumbnailUrlsRef.current = thumbnailUrlsRef.current.filter((_, i) => !indexes.includes(i)) dispatch({ type: 'REMOVE_FORM', index })
setRenderedThumbnails([...thumbnailUrlsRef.current])
}, []) }, [])
const removeFile: FormsManagerContext['removeFile'] = React.useCallback(
(index) => {
dispatch({ type: 'REMOVE_FORM', index })
removeThumbnails([index])
},
[removeThumbnails],
)
const setFormTotalErrorCount: FormsManagerContext['setFormTotalErrorCount'] = React.useCallback( const setFormTotalErrorCount: FormsManagerContext['setFormTotalErrorCount'] = React.useCallback(
({ errorCount, index }) => { ({ errorCount, index }) => {
dispatch({ dispatch({
@@ -304,6 +271,7 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
const currentForms = [...forms] const currentForms = [...forms]
currentForms[activeIndex] = { currentForms[activeIndex] = {
errorCount: currentForms[activeIndex].errorCount, errorCount: currentForms[activeIndex].errorCount,
formID: currentForms[activeIndex].formID,
formState: currentFormsData, formState: currentFormsData,
uploadEdits: currentForms[activeIndex].uploadEdits, uploadEdits: currentForms[activeIndex].uploadEdits,
} }
@@ -372,6 +340,7 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
currentForms[i] = { currentForms[i] = {
errorCount: fieldErrors.length, errorCount: fieldErrors.length,
formID: currentForms[i].formID,
formState: fieldReducer(currentForms[i].formState, { formState: fieldReducer(currentForms[i].formState, {
type: 'ADD_SERVER_ERRORS', type: 'ADD_SERVER_ERRORS',
errors: fieldErrors, errors: fieldErrors,
@@ -416,10 +385,6 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
if (typeof onSuccess === 'function') { if (typeof onSuccess === 'function') {
onSuccess(newDocs, errorCount) onSuccess(newDocs, errorCount)
} }
if (remainingForms.length && thumbnailIndexesToRemove.length) {
removeThumbnails(thumbnailIndexesToRemove)
}
} }
if (errorCount) { if (errorCount) {
@@ -439,15 +404,14 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
}, },
[ [
actionURL, actionURL,
activeIndex,
forms,
removeThumbnails,
onSuccess,
collectionSlug, collectionSlug,
getUploadHandler, getUploadHandler,
t, t,
forms,
activeIndex,
closeModal, closeModal,
drawerSlug, drawerSlug,
onSuccess,
], ],
) )
@@ -578,7 +542,6 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
saveAllDocs, saveAllDocs,
setActiveIndex, setActiveIndex,
setFormTotalErrorCount, setFormTotalErrorCount,
thumbnailUrls: renderedThumbnails,
totalErrorCount, totalErrorCount,
updateUploadEdits, updateUploadEdits,
}} }}

View File

@@ -4,6 +4,7 @@ export type State = {
activeIndex: number activeIndex: number
forms: { forms: {
errorCount: number errorCount: number
formID: string
formState: FormState formState: FormState
uploadEdits?: UploadEdits uploadEdits?: UploadEdits
}[] }[]
@@ -49,6 +50,7 @@ export function formsManagementReducer(state: State, action: Action): State {
for (let i = 0; i < action.files.length; i++) { for (let i = 0; i < action.files.length; i++) {
newForms[i] = { newForms[i] = {
errorCount: 0, errorCount: 0,
formID: crypto.randomUUID(),
formState: { formState: {
...(action.initialState || {}), ...(action.initialState || {}),
file: { file: {

View File

@@ -6,21 +6,25 @@ import './index.scss'
export type ShimmerEffectProps = { export type ShimmerEffectProps = {
readonly animationDelay?: string readonly animationDelay?: string
readonly className?: string
readonly disableInlineStyles?: boolean
readonly height?: number | string readonly height?: number | string
readonly width?: number | string readonly width?: number | string
} }
export const ShimmerEffect: React.FC<ShimmerEffectProps> = ({ export const ShimmerEffect: React.FC<ShimmerEffectProps> = ({
animationDelay = '0ms', animationDelay = '0ms',
className,
disableInlineStyles = false,
height = '60px', height = '60px',
width = '100%', width = '100%',
}) => { }) => {
return ( return (
<div <div
className="shimmer-effect" className={['shimmer-effect', className].filter(Boolean).join(' ')}
style={{ style={{
height: typeof height === 'number' ? `${height}px` : height, height: !disableInlineStyles && (typeof height === 'number' ? `${height}px` : height),
width: typeof width === 'number' ? `${width}px` : width, width: !disableInlineStyles && (typeof width === 'number' ? `${width}px` : width),
}} }}
> >
<div <div

View File

@@ -27,12 +27,16 @@ export const createThumbnail = (file: File): Promise<string> => {
const canvas = new OffscreenCanvas(drawWidth, drawHeight) // Create an OffscreenCanvas const canvas = new OffscreenCanvas(drawWidth, drawHeight) // Create an OffscreenCanvas
const ctx = canvas.getContext('2d') const ctx = canvas.getContext('2d')
// Determine output format based on input file type
const outputFormat = file.type === 'image/png' ? 'image/png' : 'image/jpeg'
const quality = file.type === 'image/png' ? undefined : 0.8 // PNG doesn't use quality, use higher quality for JPEG
// Draw the image onto the OffscreenCanvas with calculated dimensions // Draw the image onto the OffscreenCanvas with calculated dimensions
ctx.drawImage(img, 0, 0, drawWidth, drawHeight) ctx.drawImage(img, 0, 0, drawWidth, drawHeight)
// Convert the OffscreenCanvas to a Blob and free up memory // Convert the OffscreenCanvas to a Blob and free up memory
canvas canvas
.convertToBlob({ type: 'image/jpeg', quality: 0.25 }) .convertToBlob({ type: outputFormat, ...(quality && { quality }) })
.then((blob) => { .then((blob) => {
URL.revokeObjectURL(img.src) // Release the Object URL URL.revokeObjectURL(img.src) // Release the Object URL
const reader = new FileReader() const reader = new FileReader()

View File

@@ -1145,6 +1145,34 @@ describe('Uploads', () => {
.first() .first()
await expect(errorCount).toHaveText('1') await expect(errorCount).toHaveText('1')
}) })
test('should preserve state when adding additional files to an existing bulk upload', async () => {
await page.goto(uploadsTwo.list)
await page.locator('.list-header__title-actions button', { hasText: 'Bulk Upload' }).click()
await page.setInputFiles('.dropzone input[type="file"]', path.resolve(dirname, './image.png'))
await page.locator('#field-prefix').fill('should-preserve')
// add another file
await page
.locator('.file-selections__header__actions button', { hasText: 'Add File' })
.click()
await page.setInputFiles('.dropzone input[type="file"]', path.resolve(dirname, './small.png'))
const originalFileRow = page
.locator('.file-selections__filesContainer .file-selections__fileRowContainer')
.nth(1)
// ensure the original file thumbnail is visible (not using default placeholder svg)
await expect(originalFileRow.locator('.thumbnail img')).toBeVisible()
// navigate to the first file added
await originalFileRow.locator('button.file-selections__fileRow').click()
// ensure the prefix field is still filled with the original value
await expect(page.locator('#field-prefix')).toHaveValue('should-preserve')
})
}) })
describe('remote url fetching', () => { describe('remote url fetching', () => {