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:
@@ -83,12 +83,13 @@
|
||||
background-color: var(--theme-elevation-100);
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: base(1.2);
|
||||
height: base(1.2);
|
||||
.file-selections__thumbnail,
|
||||
.file-selections__thumbnail-shimmer {
|
||||
width: calc(var(--base) * 1.2);
|
||||
height: calc(var(--base) * 1.2);
|
||||
border-radius: var(--style-radius-s);
|
||||
flex-shrink: 0;
|
||||
object-fit: cover;
|
||||
border-radius: var(--style-radius-s);
|
||||
}
|
||||
|
||||
p {
|
||||
|
||||
@@ -14,12 +14,13 @@ import { Drawer } from '../../Drawer/index.js'
|
||||
import { ErrorPill } from '../../ErrorPill/index.js'
|
||||
import { Pill } from '../../Pill/index.js'
|
||||
import { ShimmerEffect } from '../../ShimmerEffect/index.js'
|
||||
import { createThumbnail } from '../../Thumbnail/createThumbnail.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 { useBulkUpload } from '../index.js'
|
||||
import './index.scss'
|
||||
|
||||
const addMoreFilesDrawerSlug = 'bulk-upload-drawer--add-more-files'
|
||||
|
||||
@@ -33,7 +34,6 @@ export function FileSidebar() {
|
||||
isInitializing,
|
||||
removeFile,
|
||||
setActiveIndex,
|
||||
thumbnailUrls,
|
||||
totalErrorCount,
|
||||
} = useFormsManager()
|
||||
const { initialFiles, maxFiles } = useBulkUpload()
|
||||
@@ -139,7 +139,7 @@ export function FileSidebar() {
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
{forms.map(({ errorCount, formState }, index) => {
|
||||
{forms.map(({ errorCount, formID, formState }, index) => {
|
||||
const currentFile = (formState?.file?.value as File) || ({} as File)
|
||||
|
||||
return (
|
||||
@@ -151,17 +151,14 @@ export function FileSidebar() {
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
key={index}
|
||||
key={formID}
|
||||
>
|
||||
<button
|
||||
className={`${baseClass}__fileRow`}
|
||||
onClick={() => setActiveIndex(index)}
|
||||
type="button"
|
||||
>
|
||||
<Thumbnail
|
||||
className={`${baseClass}__thumbnail`}
|
||||
fileSrc={isImage(currentFile.type) ? thumbnailUrls[index] : null}
|
||||
/>
|
||||
<SidebarThumbnail file={currentFile} formID={formID} />
|
||||
<div className={`${baseClass}__fileDetails`}>
|
||||
<p className={`${baseClass}__fileName`} title={currentFile.name}>
|
||||
{currentFile.name || t('upload:noFile')}
|
||||
@@ -200,3 +197,54 @@ export function FileSidebar() {
|
||||
</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'}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
} from 'payload'
|
||||
|
||||
import { useModal } from '@faceless-ui/modal'
|
||||
import { isImage } from 'payload/shared'
|
||||
import * as qs from 'qs-esm'
|
||||
import React from 'react'
|
||||
import { toast } from 'sonner'
|
||||
@@ -25,7 +24,6 @@ import { useUploadHandlers } from '../../../providers/UploadHandlers/index.js'
|
||||
import { hasSavePermission as getHasSavePermission } from '../../../utilities/hasSavePermission.js'
|
||||
import { LoadingOverlay } from '../../Loading/index.js'
|
||||
import { useLoadingOverlay } from '../../LoadingOverlay/index.js'
|
||||
import { createThumbnail } from '../../Thumbnail/createThumbnail.js'
|
||||
import { useBulkUpload } from '../index.js'
|
||||
import { createFormData } from './createFormData.js'
|
||||
import { formsManagementReducer } from './reducer.js'
|
||||
@@ -57,7 +55,6 @@ type FormsManagerContext = {
|
||||
errorCount: number
|
||||
index: number
|
||||
}) => void
|
||||
readonly thumbnailUrls: string[]
|
||||
readonly totalErrorCount?: number
|
||||
readonly updateUploadEdits: (args: UploadEdits) => void
|
||||
}
|
||||
@@ -79,7 +76,6 @@ const Context = React.createContext<FormsManagerContext>({
|
||||
saveAllDocs: () => Promise.resolve(),
|
||||
setActiveIndex: () => 0,
|
||||
setFormTotalErrorCount: () => {},
|
||||
thumbnailUrls: [],
|
||||
totalErrorCount: 0,
|
||||
updateUploadEdits: () => {},
|
||||
})
|
||||
@@ -119,37 +115,6 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
|
||||
|
||||
const formsRef = React.useRef(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 { closeModal } = useModal()
|
||||
@@ -250,6 +215,7 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
|
||||
if (i === activeIndex) {
|
||||
return {
|
||||
errorCount: form.errorCount,
|
||||
formID: form.formID,
|
||||
formState: currentFormsData,
|
||||
uploadEdits: form.uploadEdits,
|
||||
}
|
||||
@@ -264,6 +230,16 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
|
||||
|
||||
const addFiles = React.useCallback(
|
||||
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' })
|
||||
if (!hasInitializedState) {
|
||||
await initializeSharedFormState()
|
||||
@@ -271,21 +247,12 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
|
||||
dispatch({ type: 'ADD_FORMS', files, initialState: initialStateRef.current })
|
||||
toggleLoadingOverlay({ isLoading: false, key: 'addingDocs' })
|
||||
},
|
||||
[initializeSharedFormState, hasInitializedState, toggleLoadingOverlay],
|
||||
[initializeSharedFormState, hasInitializedState, toggleLoadingOverlay, activeIndex, forms],
|
||||
)
|
||||
|
||||
const removeThumbnails = React.useCallback((indexes: number[]) => {
|
||||
thumbnailUrlsRef.current = thumbnailUrlsRef.current.filter((_, i) => !indexes.includes(i))
|
||||
setRenderedThumbnails([...thumbnailUrlsRef.current])
|
||||
}, [])
|
||||
|
||||
const removeFile: FormsManagerContext['removeFile'] = React.useCallback(
|
||||
(index) => {
|
||||
const removeFile: FormsManagerContext['removeFile'] = React.useCallback((index) => {
|
||||
dispatch({ type: 'REMOVE_FORM', index })
|
||||
removeThumbnails([index])
|
||||
},
|
||||
[removeThumbnails],
|
||||
)
|
||||
}, [])
|
||||
|
||||
const setFormTotalErrorCount: FormsManagerContext['setFormTotalErrorCount'] = React.useCallback(
|
||||
({ errorCount, index }) => {
|
||||
@@ -304,6 +271,7 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
|
||||
const currentForms = [...forms]
|
||||
currentForms[activeIndex] = {
|
||||
errorCount: currentForms[activeIndex].errorCount,
|
||||
formID: currentForms[activeIndex].formID,
|
||||
formState: currentFormsData,
|
||||
uploadEdits: currentForms[activeIndex].uploadEdits,
|
||||
}
|
||||
@@ -372,6 +340,7 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
|
||||
|
||||
currentForms[i] = {
|
||||
errorCount: fieldErrors.length,
|
||||
formID: currentForms[i].formID,
|
||||
formState: fieldReducer(currentForms[i].formState, {
|
||||
type: 'ADD_SERVER_ERRORS',
|
||||
errors: fieldErrors,
|
||||
@@ -416,10 +385,6 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
|
||||
if (typeof onSuccess === 'function') {
|
||||
onSuccess(newDocs, errorCount)
|
||||
}
|
||||
|
||||
if (remainingForms.length && thumbnailIndexesToRemove.length) {
|
||||
removeThumbnails(thumbnailIndexesToRemove)
|
||||
}
|
||||
}
|
||||
|
||||
if (errorCount) {
|
||||
@@ -439,15 +404,14 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
|
||||
},
|
||||
[
|
||||
actionURL,
|
||||
activeIndex,
|
||||
forms,
|
||||
removeThumbnails,
|
||||
onSuccess,
|
||||
collectionSlug,
|
||||
getUploadHandler,
|
||||
t,
|
||||
forms,
|
||||
activeIndex,
|
||||
closeModal,
|
||||
drawerSlug,
|
||||
onSuccess,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -578,7 +542,6 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
|
||||
saveAllDocs,
|
||||
setActiveIndex,
|
||||
setFormTotalErrorCount,
|
||||
thumbnailUrls: renderedThumbnails,
|
||||
totalErrorCount,
|
||||
updateUploadEdits,
|
||||
}}
|
||||
|
||||
@@ -4,6 +4,7 @@ export type State = {
|
||||
activeIndex: number
|
||||
forms: {
|
||||
errorCount: number
|
||||
formID: string
|
||||
formState: FormState
|
||||
uploadEdits?: UploadEdits
|
||||
}[]
|
||||
@@ -49,6 +50,7 @@ export function formsManagementReducer(state: State, action: Action): State {
|
||||
for (let i = 0; i < action.files.length; i++) {
|
||||
newForms[i] = {
|
||||
errorCount: 0,
|
||||
formID: crypto.randomUUID(),
|
||||
formState: {
|
||||
...(action.initialState || {}),
|
||||
file: {
|
||||
|
||||
@@ -6,21 +6,25 @@ import './index.scss'
|
||||
|
||||
export type ShimmerEffectProps = {
|
||||
readonly animationDelay?: string
|
||||
readonly className?: string
|
||||
readonly disableInlineStyles?: boolean
|
||||
readonly height?: number | string
|
||||
readonly width?: number | string
|
||||
}
|
||||
|
||||
export const ShimmerEffect: React.FC<ShimmerEffectProps> = ({
|
||||
animationDelay = '0ms',
|
||||
className,
|
||||
disableInlineStyles = false,
|
||||
height = '60px',
|
||||
width = '100%',
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className="shimmer-effect"
|
||||
className={['shimmer-effect', className].filter(Boolean).join(' ')}
|
||||
style={{
|
||||
height: typeof height === 'number' ? `${height}px` : height,
|
||||
width: typeof width === 'number' ? `${width}px` : width,
|
||||
height: !disableInlineStyles && (typeof height === 'number' ? `${height}px` : height),
|
||||
width: !disableInlineStyles && (typeof width === 'number' ? `${width}px` : width),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -27,12 +27,16 @@ export const createThumbnail = (file: File): Promise<string> => {
|
||||
const canvas = new OffscreenCanvas(drawWidth, drawHeight) // Create an OffscreenCanvas
|
||||
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
|
||||
ctx.drawImage(img, 0, 0, drawWidth, drawHeight)
|
||||
|
||||
// Convert the OffscreenCanvas to a Blob and free up memory
|
||||
canvas
|
||||
.convertToBlob({ type: 'image/jpeg', quality: 0.25 })
|
||||
.convertToBlob({ type: outputFormat, ...(quality && { quality }) })
|
||||
.then((blob) => {
|
||||
URL.revokeObjectURL(img.src) // Release the Object URL
|
||||
const reader = new FileReader()
|
||||
|
||||
@@ -1145,6 +1145,34 @@ describe('Uploads', () => {
|
||||
.first()
|
||||
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', () => {
|
||||
|
||||
Reference in New Issue
Block a user