diff --git a/src/components/inputs/upload-input-v3/__tests__/upload-input-v3.test.js b/src/components/inputs/upload-input-v3/__tests__/upload-input-v3.test.js
index af4e87aa..ee86e21a 100644
--- a/src/components/inputs/upload-input-v3/__tests__/upload-input-v3.test.js
+++ b/src/components/inputs/upload-input-v3/__tests__/upload-input-v3.test.js
@@ -346,6 +346,143 @@ describe('UploadInputV3', () => {
});
});
+ describe('Image Preview', () => {
+ test('shows thumbnail preview image during upload when onThumbnail fires', () => {
+ render();
+ act(() => {
+ dropzoneCallbacks.onAddedFile({ name: 'photo.jpg', size: 136000 });
+ });
+ act(() => {
+ dropzoneCallbacks.onThumbnail({ name: 'photo.jpg', size: 136000 }, 'data:image/jpeg;base64,abc123');
+ });
+ const img = screen.getByRole('img', { name: 'photo.jpg' });
+ expect(img).toHaveAttribute('src', 'data:image/jpeg;base64,abc123');
+ });
+
+ test('shows no preview image before onThumbnail fires (non-image or slow thumbnail)', () => {
+ render();
+ act(() => {
+ dropzoneCallbacks.onAddedFile({ name: 'document.pdf', size: 50000 });
+ });
+ expect(screen.queryByRole('img', { name: 'document.pdf' })).not.toBeInTheDocument();
+ });
+
+ test('preserves dataURL preview after value updates with server-renamed filename', () => {
+ const dataURL = 'data:image/jpeg;base64,abc123';
+ const { rerender } = render();
+
+ act(() => {
+ dropzoneCallbacks.onAddedFile({ name: 'photo.jpg', size: 136000 });
+ dropzoneCallbacks.onThumbnail({ name: 'photo.jpg', size: 136000 }, dataURL);
+ });
+ act(() => {
+ dropzoneCallbacks.onFileCompleted({ name: 'photo.jpg', size: 136000 });
+ });
+
+ rerender();
+
+ const img = screen.getByRole('img', { name: 'server_246_abc123.jpg' });
+ expect(img).toHaveAttribute('src', dataURL);
+ });
+
+ test('does not assign cancelled file preview to the next upload', () => {
+ const dataURL_A = 'data:image/jpeg;base64,aaaa';
+ const dataURL_B = 'data:image/jpeg;base64,bbbb';
+ const { rerender } = render();
+
+ // Upload file A and generate its thumbnail
+ act(() => {
+ dropzoneCallbacks.onAddedFile({ name: 'photo-a.jpg', size: 10000 });
+ });
+ act(() => {
+ dropzoneCallbacks.onThumbnail({ name: 'photo-a.jpg', size: 10000 }, dataURL_A);
+ });
+
+ // Cancel file A via the delete button
+ act(() => {
+ fireEvent.click(screen.getByRole('button'));
+ });
+
+ // Upload file B and generate its thumbnail
+ act(() => {
+ dropzoneCallbacks.onAddedFile({ name: 'photo-b.jpg', size: 20000 });
+ });
+ act(() => {
+ dropzoneCallbacks.onThumbnail({ name: 'photo-b.jpg', size: 20000 }, dataURL_B);
+ });
+ act(() => {
+ dropzoneCallbacks.onFileCompleted({ name: 'photo-b.jpg', size: 20000 });
+ });
+
+ rerender();
+
+ const img = screen.getByRole('img', { name: 'server_photo_b.jpg' });
+ expect(img).toHaveAttribute('src', dataURL_B);
+ expect(img).not.toHaveAttribute('src', dataURL_A);
+ });
+
+ test('matches preview by filename stem when server reorders parallel uploads', () => {
+ const dataURL_A = 'data:image/jpeg;base64,aaaa';
+ const dataURL_B = 'data:image/jpeg;base64,bbbb';
+ const { rerender } = render();
+
+ // Both files added and thumbnails generated
+ act(() => {
+ dropzoneCallbacks.onAddedFile({ name: 'sunset.jpg', size: 10000 });
+ dropzoneCallbacks.onThumbnail({ name: 'sunset.jpg', size: 10000 }, dataURL_A);
+ dropzoneCallbacks.onAddedFile({ name: 'portrait.jpg', size: 20000 });
+ dropzoneCallbacks.onThumbnail({ name: 'portrait.jpg', size: 20000 }, dataURL_B);
+ });
+ act(() => {
+ dropzoneCallbacks.onFileCompleted({ name: 'sunset.jpg', size: 10000 });
+ dropzoneCallbacks.onFileCompleted({ name: 'portrait.jpg', size: 20000 });
+ });
+
+ // Server returns them in REVERSE order — portrait before sunset
+ // Server filenames contain the original stem (common pattern: prefix_stem_hash.ext)
+ rerender();
+
+ expect(screen.getByRole('img', { name: '246_portrait_abc123.jpg' })).toHaveAttribute('src', dataURL_B);
+ expect(screen.getByRole('img', { name: '246_sunset_def456.jpg' })).toHaveAttribute('src', dataURL_A);
+ });
+
+ test('does not assign errored file preview to the next upload', () => {
+ const dataURL_A = 'data:image/jpeg;base64,aaaa';
+ const dataURL_B = 'data:image/jpeg;base64,bbbb';
+ const { rerender } = render();
+
+ // Upload file A, thumbnail fires, then it errors
+ act(() => {
+ dropzoneCallbacks.onAddedFile({ name: 'photo-a.jpg', size: 10000 });
+ dropzoneCallbacks.onThumbnail({ name: 'photo-a.jpg', size: 10000 }, dataURL_A);
+ });
+ act(() => {
+ dropzoneCallbacks.onFileError({ name: 'photo-a.jpg', size: 10000 }, 'Upload failed');
+ });
+
+ // Dismiss the error, then upload file B
+ act(() => {
+ fireEvent.click(screen.getByRole('button'));
+ });
+ act(() => {
+ dropzoneCallbacks.onAddedFile({ name: 'photo-b.jpg', size: 20000 });
+ dropzoneCallbacks.onThumbnail({ name: 'photo-b.jpg', size: 20000 }, dataURL_B);
+ });
+ act(() => {
+ dropzoneCallbacks.onFileCompleted({ name: 'photo-b.jpg', size: 20000 });
+ });
+
+ rerender();
+
+ const img = screen.getByRole('img', { name: 'server_photo_b.jpg' });
+ expect(img).toHaveAttribute('src', dataURL_B);
+ expect(img).not.toHaveAttribute('src', dataURL_A);
+ });
+ });
+
describe('Edge Cases', () => {
test('handles empty value array', () => {
const { container } = render();
diff --git a/src/components/inputs/upload-input-v3/dropzone-v3.js b/src/components/inputs/upload-input-v3/dropzone-v3.js
index e9e99aeb..b2bfb1fe 100644
--- a/src/components/inputs/upload-input-v3/dropzone-v3.js
+++ b/src/components/inputs/upload-input-v3/dropzone-v3.js
@@ -24,6 +24,7 @@ export const DropzoneV3 = ({
onFileRemoved,
onFileCompleted,
onFileError,
+ onThumbnail,
onDropzoneReady,
eventHandlers = {},
children,
@@ -39,6 +40,10 @@ export const DropzoneV3 = ({
if (onAddedFile) onAddedFile(file);
if (eventHandlers.addedfile) eventHandlers.addedfile(file);
},
+ thumbnail: (file, dataURL) => {
+ if (onThumbnail) onThumbnail(file, dataURL);
+ if (eventHandlers.thumbnail) eventHandlers.thumbnail(file, dataURL);
+ },
removedfile: (file) => {
if (onFileRemoved) onFileRemoved(file);
if (eventHandlers.removedfile) eventHandlers.removedfile(file);
diff --git a/src/components/inputs/upload-input-v3/index.js b/src/components/inputs/upload-input-v3/index.js
index 134c8855..d98e790c 100644
--- a/src/components/inputs/upload-input-v3/index.js
+++ b/src/components/inputs/upload-input-v3/index.js
@@ -11,7 +11,7 @@
* limitations under the License.
**/
-import React, { useState, useRef, useMemo, useCallback, useEffect } from 'react';
+import React, { useState, useRef, useMemo, useCallback, useLayoutEffect } from 'react';
import T from "i18n-react/dist/i18n-react";
import {
Box,
@@ -54,6 +54,9 @@ const UploadInputV3 = ({
const dropzoneInstanceRef = useRef(null);
const [uploadingFiles, setUploadingFiles] = useState([]);
const [errorFiles, setErrorFiles] = useState([]);
+ const [filePreviews, setFilePreviews] = useState({});
+ const prevValueRef = useRef(value);
+ const pendingPreviewsRef = useRef([]);
const getDefaultAllowedExtensions = useCallback(() => {
return mediaType && mediaType.type
@@ -139,7 +142,14 @@ const UploadInputV3 = ({
}, []);
const handleAddedFile = useCallback((file) => {
- setUploadingFiles(prev => [...prev, { name: file.name, size: file.size, progress: 0, complete: false }]);
+ setUploadingFiles(prev => [...prev, { name: file.name, size: file.size, progress: 0, complete: false, previewUrl: null }]);
+ }, []);
+
+ const handleThumbnail = useCallback((file, dataURL) => {
+ pendingPreviewsRef.current.push({ name: file.name, size: file.size, dataURL });
+ setUploadingFiles(prev => prev.map(f =>
+ f.name === file.name && f.size === file.size ? { ...f, previewUrl: dataURL } : f
+ ));
}, []);
const handleUploadProgress = useCallback((file, progress) => {
@@ -162,13 +172,39 @@ const UploadInputV3 = ({
));
}, []);
- // Once the parent updates value, remove all completed files from uploadingFiles
- useEffect(() => {
+ // Once the parent updates value, remove completed uploading files and assign local previews to new files
+ useLayoutEffect(() => {
+ const prevValue = prevValueRef.current;
+ prevValueRef.current = value;
+
if (uploadingFiles.length === 0 || value.length === 0) return;
+
+ // Detect files newly added to value and assign queued dataURL previews (keyed by server filename)
+ const prevFilenames = new Set(prevValue.map(f => f.filename));
+ const newFiles = value.filter(f => !prevFilenames.has(f.filename));
+ if (newFiles.length > 0 && pendingPreviewsRef.current.length > 0) {
+ const matchedNames = new Set();
+ const avail = (pred) => pendingPreviewsRef.current.find(e => !matchedNames.has(e.name) && pred(e));
+ const updates = Object.fromEntries(newFiles.flatMap(f => {
+ const entry =
+ avail(e => e.name === f.filename) ??
+ avail(e => { const s = e.name.replace(/\.[^.]+$/, ''); return s && f.filename.includes(s); }) ??
+ avail(() => true);
+ if (!entry?.dataURL) return [];
+ matchedNames.add(entry.name);
+ return [[f.filename, entry.dataURL]];
+ }));
+ pendingPreviewsRef.current = pendingPreviewsRef.current.filter(e => !matchedNames.has(e.name));
+ if (Object.keys(updates).length > 0) setFilePreviews(prev => ({ ...prev, ...updates }));
+ }
+
setUploadingFiles(prev => prev.filter(f => !f.complete));
}, [value]);
const handleFileError = useCallback((file, message) => {
+ pendingPreviewsRef.current = pendingPreviewsRef.current.filter(
+ p => !(p.name === file.name && p.size === file.size)
+ );
setUploadingFiles(prev => prev.filter(f => !(f.name === file.name && f.size === file.size)));
setErrorFiles(prev => [...prev, { name: file.name, size: file.size, message }]);
}, []);
@@ -184,6 +220,9 @@ const UploadInputV3 = ({
}, []);
const handleDeleteUploading = useCallback((file) => {
+ pendingPreviewsRef.current = pendingPreviewsRef.current.filter(
+ p => !(p.name === file.name && p.size === file.size)
+ );
if (dropzoneInstanceRef.current) {
const dzFile = dropzoneInstanceRef.current.files?.find(
f => f.name === file.name && f.size === file.size
@@ -230,6 +269,7 @@ const UploadInputV3 = ({
onError={onError}
onDropzoneReady={handleDropzoneReady}
onAddedFile={handleAddedFile}
+ onThumbnail={handleThumbnail}
onUploadProgress={handleUploadProgress}
onFileRemoved={handleFileRemoved}
onFileCompleted={handleFileCompleted}
@@ -292,8 +332,18 @@ const UploadInputV3 = ({
key={`uploading-${index}`}
sx={fileRowSx}
>
-
-
+
+ {file.previewUrl ? (
+
+ ) : (
+
+
+
+ )}
@@ -377,7 +427,9 @@ const UploadInputV3 = ({
let src = file?.private_url || file?.public_url || file?.file_url;
if (src === '#') src = file?.public_url;
// custom replace for dropbox case ( download vs raw)
- const previewSrc = src ? src.replace("?dl=0", "?raw=1") : filename;
+ const serverPreviewSrc = src ? src.replace("?dl=0", "?raw=1") : filename;
+ // use the local dataURL preview from this session's upload if available
+ const previewSrc = filePreviews[filename] || serverPreviewSrc;
return (
{
+ describe('dataURL src', () => {
+ test('renders dataURL immediately without placeholder or blur', () => {
+ const dataURL = 'data:image/jpeg;base64,abc123';
+ render();
+ const img = screen.getByRole('img', { name: 'test image' });
+ expect(img).toHaveAttribute('src', dataURL);
+ expect(img.className).toContain('loaded');
+ expect(img.className).not.toContain('loading');
+ });
+
+ test('updates immediately when src changes to a dataURL', () => {
+ const serverURL = 'https://cdn.example.com/photo.jpg';
+ const dataURL = 'data:image/jpeg;base64,abc123';
+ const { rerender } = render();
+
+ rerender();
+
+ const img = screen.getByRole('img', { name: 'test image' });
+ expect(img).toHaveAttribute('src', dataURL);
+ expect(img.className).toContain('loaded');
+ });
+ });
+
+ describe('URL src', () => {
+ let mockImageInstances;
+
+ beforeEach(() => {
+ mockImageInstances = [];
+ global.Image = jest.fn().mockImplementation(() => {
+ const instance = { src: '', onload: null, onerror: null };
+ mockImageInstances.push(instance);
+ return instance;
+ });
+ });
+
+ afterEach(() => {
+ delete global.Image;
+ });
+
+ test('renders placeholder initially while loading a URL', () => {
+ render();
+ const img = screen.getByRole('img', { name: 'test' });
+ expect(img).toHaveAttribute('src', 'placeholder.png');
+ expect(img.className).toContain('loading');
+ });
+
+ test('switches to actual src on successful load', () => {
+ const src = 'https://cdn.example.com/photo.jpg';
+ render();
+ act(() => { mockImageInstances[0].onload(); });
+ const img = screen.getByRole('img', { name: 'test' });
+ expect(img).toHaveAttribute('src', src);
+ expect(img.className).toContain('loaded');
+ });
+
+ test('falls back to file_icon on error for unknown extension', () => {
+ render();
+ act(() => { mockImageInstances[0].onerror(); });
+ const img = screen.getByRole('img', { name: 'test' });
+ expect(img.className).toContain('loaded');
+ expect(img).not.toHaveAttribute('src', 'https://cdn.example.com/photo.jpg');
+ });
+
+ test('does not apply stale src when src changes before the first load completes', () => {
+ const firstURL = 'https://cdn.example.com/slow.jpg';
+ const secondURL = 'https://cdn.example.com/fast.jpg';
+ const { rerender } = render();
+
+ // Change src before firstURL loads — simulates the serverURL → dataURL swap in upload flow
+ rerender();
+
+ // Trigger the first (now-cancelled) effect's onload
+ act(() => { mockImageInstances[0].onload(); });
+
+ const img = screen.getByRole('img', { name: 'test' });
+ expect(img).not.toHaveAttribute('src', firstURL);
+ });
+ });
+});
diff --git a/src/components/progressive-img/index.js b/src/components/progressive-img/index.js
index 8c9ba7ec..e065db65 100644
--- a/src/components/progressive-img/index.js
+++ b/src/components/progressive-img/index.js
@@ -10,7 +10,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
**/
-import React,{ useState, useEffect, useRef } from "react";
+import React,{ useState, useEffect } from "react";
import styles from './index.module.scss';
import pdf_icon from "../inputs/upload-input/pdf.png";
import mov_icon from "../inputs/upload-input/mov.png";
@@ -26,23 +26,31 @@ import file_icon from "../inputs/upload-input/file.png";
* @constructor
*/
const ProgressiveImg = ({ placeholderSrc, src, ...props }) => {
- const isCancelled = useRef(false);
- const [imgSrc, setImgSrc] = useState(placeholderSrc || src);
- const [customClass, setCustomClass] = useState(styles.loading);
+ const isDataURL = src?.startsWith('data:');
+ const [imgSrc, setImgSrc] = useState(isDataURL ? src : (placeholderSrc || src));
+ const [customClass, setCustomClass] = useState(isDataURL ? styles.loaded : styles.loading);
useEffect(() => {
+ // dataURLs are already in memory — no async loading needed
+ if (src?.startsWith('data:')) {
+ setImgSrc(src);
+ setCustomClass(styles.loaded);
+ return;
+ }
+
+ let cancelled = false;
const img = new Image();
- const ext = src ? src.split('.').pop() : null;
+ const ext = src ? src.split('.').pop() : null;
img.src = src;
img.onload = () => {
- if (isCancelled.current) return
- setImgSrc(src)
- setCustomClass(styles.loaded)
+ if (cancelled) return;
+ setImgSrc(src);
+ setCustomClass(styles.loaded);
};
img.onerror = () => {
- if (isCancelled.current) return
+ if (cancelled) return;
img.onerror = null;
if(ext && ext.toString().toLowerCase().includes('pdf'))
setImgSrc(pdf_icon)
@@ -54,11 +62,11 @@ const ProgressiveImg = ({ placeholderSrc, src, ...props }) => {
setImgSrc(csv_icon);
else
setImgSrc(file_icon);
- setCustomClass(styles.loaded)
+ setCustomClass(styles.loaded);
};
return () => {
- isCancelled.current = true;
+ cancelled = true;
};
}, [src]);