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..92ca491c 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,117 @@ describe('UploadInputV3', () => {
});
});
+ describe('Image Preview', () => {
+ beforeEach(() => {
+ URL.createObjectURL = jest.fn(file => `blob:${file.name}`);
+ URL.revokeObjectURL = jest.fn();
+ });
+
+ afterEach(() => {
+ delete URL.createObjectURL;
+ delete URL.revokeObjectURL;
+ });
+
+ test('shows preview immediately when an image file is added', () => {
+ render();
+ act(() => {
+ dropzoneCallbacks.onAddedFile({ name: 'photo.jpg', size: 136000, type: 'image/jpeg' });
+ });
+ const img = screen.getByRole('img', { name: 'photo.jpg' });
+ expect(img).toHaveAttribute('src', 'blob:photo.jpg');
+ });
+
+ test('shows no preview for non-image files', () => {
+ render();
+ act(() => {
+ dropzoneCallbacks.onAddedFile({ name: 'document.pdf', size: 50000, type: 'application/pdf' });
+ });
+ expect(screen.queryByRole('img', { name: 'document.pdf' })).not.toBeInTheDocument();
+ });
+
+ test('preserves blob URL preview after value updates with server-renamed filename', () => {
+ const { rerender } = render();
+
+ act(() => {
+ dropzoneCallbacks.onAddedFile({ name: 'photo.jpg', size: 136000, type: 'image/jpeg' });
+ dropzoneCallbacks.onFileCompleted({ name: 'photo.jpg', size: 136000 });
+ dropzoneCallbacks.onUploadComplete({ name: 'server_photo_abc123.jpg', size: 136000 }, 'test-upload', {});
+ });
+
+ rerender();
+
+ const img = screen.getByRole('img', { name: 'server_photo_abc123.jpg' });
+ expect(img).toHaveAttribute('src', 'blob:photo.jpg');
+ });
+
+ test('revokes blob URL on cancel and does not assign it to the next upload', () => {
+ const { rerender } = render();
+
+ act(() => {
+ dropzoneCallbacks.onAddedFile({ name: 'photo-a.jpg', size: 10000, type: 'image/jpeg' });
+ });
+ act(() => { fireEvent.click(screen.getByRole('button')); });
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:photo-a.jpg');
+
+ act(() => {
+ dropzoneCallbacks.onAddedFile({ name: 'photo-b.jpg', size: 20000, type: 'image/jpeg' });
+ dropzoneCallbacks.onFileCompleted({ name: 'photo-b.jpg', size: 20000 });
+ dropzoneCallbacks.onUploadComplete({ name: 'server_photo-b_xyz.jpg', size: 20000 }, 'test-upload', {});
+ });
+
+ rerender();
+
+ const img = screen.getByRole('img', { name: 'server_photo-b_xyz.jpg' });
+ expect(img).toHaveAttribute('src', 'blob:photo-b.jpg');
+ expect(img).not.toHaveAttribute('src', 'blob:photo-a.jpg');
+ });
+
+ test('correctly maps previews for parallel uploads using response size', () => {
+ const { rerender } = render();
+
+ act(() => {
+ dropzoneCallbacks.onAddedFile({ name: 'sunset.jpg', size: 10000, type: 'image/jpeg' });
+ dropzoneCallbacks.onAddedFile({ name: 'portrait.jpg', size: 20000, type: 'image/jpeg' });
+ dropzoneCallbacks.onFileCompleted({ name: 'sunset.jpg', size: 10000 });
+ dropzoneCallbacks.onFileCompleted({ name: 'portrait.jpg', size: 20000 });
+ // server returns files in reverse order
+ dropzoneCallbacks.onUploadComplete({ name: '246_portrait_abc123.jpg', size: 20000 }, 'test-upload', {});
+ dropzoneCallbacks.onUploadComplete({ name: '246_sunset_def456.jpg', size: 10000 }, 'test-upload', {});
+ });
+
+ rerender();
+
+ expect(screen.getByRole('img', { name: '246_portrait_abc123.jpg' })).toHaveAttribute('src', 'blob:portrait.jpg');
+ expect(screen.getByRole('img', { name: '246_sunset_def456.jpg' })).toHaveAttribute('src', 'blob:sunset.jpg');
+ });
+
+ test('revokes blob URL on error and does not assign it to the next upload', () => {
+ const { rerender } = render();
+
+ act(() => {
+ dropzoneCallbacks.onAddedFile({ name: 'photo-a.jpg', size: 10000, type: 'image/jpeg' });
+ dropzoneCallbacks.onFileError({ name: 'photo-a.jpg', size: 10000 }, 'Upload failed');
+ });
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:photo-a.jpg');
+
+ act(() => { fireEvent.click(screen.getByRole('button')); });
+ act(() => {
+ dropzoneCallbacks.onAddedFile({ name: 'photo-b.jpg', size: 20000, type: 'image/jpeg' });
+ dropzoneCallbacks.onFileCompleted({ name: 'photo-b.jpg', size: 20000 });
+ dropzoneCallbacks.onUploadComplete({ name: 'server_photo-b_xyz.jpg', size: 20000 }, 'test-upload', {});
+ });
+
+ rerender();
+
+ const img = screen.getByRole('img', { name: 'server_photo-b_xyz.jpg' });
+ expect(img).toHaveAttribute('src', 'blob:photo-b.jpg');
+ expect(img).not.toHaveAttribute('src', 'blob:photo-a.jpg');
+ });
+ });
+
describe('Edge Cases', () => {
test('handles empty value array', () => {
const { container } = render();
diff --git a/src/components/inputs/upload-input-v3/index.js b/src/components/inputs/upload-input-v3/index.js
index 134c8855..947e1898 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,
@@ -30,6 +30,19 @@ import ProgressiveImg from '../../progressive-img';
import file_icon from '../upload-input/file.png';
import './index.less';
+const fileRowSx = {
+ display: 'flex',
+ alignItems: 'center',
+ py: 1.5,
+ mb: 1,
+};
+
+const formatFileSize = (bytes) => {
+ if (!bytes) return '0 KB';
+ if (bytes >= 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))} MB`;
+ return `${Math.round(bytes / 1024)} KB`;
+};
+
const UploadInputV3 = ({
value = [],
onRemove,
@@ -54,26 +67,17 @@ const UploadInputV3 = ({
const dropzoneInstanceRef = useRef(null);
const [uploadingFiles, setUploadingFiles] = useState([]);
const [errorFiles, setErrorFiles] = useState([]);
+ const [filePreviews, setFilePreviews] = useState({});
- const getDefaultAllowedExtensions = useCallback(() => {
- return mediaType && mediaType.type
- ? mediaType?.type?.allowed_extensions.map((ext) => `.${ext.toLowerCase()}`).join(",")
- : '';
- }, [mediaType]);
-
- const getDefaultMaxSize = useCallback(() => {
- return mediaType ? mediaType?.max_size / (1024 * 1024) : 100;
- }, [mediaType]);
+ const allowedExt = useMemo(() => {
+ if (getAllowedExtensions) return getAllowedExtensions();
+ return mediaType?.type?.allowed_extensions?.map(ext => `.${ext.toLowerCase()}`).join(',') ?? '';
+ }, [getAllowedExtensions, mediaType]);
- const allowedExt = useMemo(() =>
- getAllowedExtensions ? getAllowedExtensions() : getDefaultAllowedExtensions(),
- [getAllowedExtensions, getDefaultAllowedExtensions]
- );
-
- const maxSize = useMemo(() =>
- getMaxSize ? getMaxSize() : getDefaultMaxSize(),
- [getMaxSize, getDefaultMaxSize]
- );
+ const maxSize = useMemo(() => {
+ if (getMaxSize) return getMaxSize();
+ return mediaType ? mediaType.max_size / (1024 * 1024) : 100;
+ }, [getMaxSize, mediaType]);
const canUpload = useMemo(() =>
!maxFiles || value.length < maxFiles,
@@ -113,13 +117,7 @@ const UploadInputV3 = ({
media_upload: value,
}), [mediaType, value]);
- const formatFileSize = useCallback((bytes) => {
- if (!bytes) return '0 KB';
- if (bytes >= 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))} MB`;
- return `${Math.round(bytes / 1024)} KB`;
- }, []);
-
- const formatExtensionsDisplay = useCallback(() => {
+ const extDisplay = useMemo(() => {
if (!allowedExt) return '';
const exts = allowedExt.split(',')
.map(e => e.trim().replace('.', '').toUpperCase())
@@ -131,15 +129,21 @@ const UploadInputV3 = ({
const handleRemove = useCallback((file) => (ev) => {
ev.preventDefault();
+ const blobUrl = filePreviews[file.filename];
+ if (blobUrl) {
+ URL.revokeObjectURL(blobUrl);
+ setFilePreviews(prev => { const next = { ...prev }; delete next[file.filename]; return next; });
+ }
onRemove(file);
- }, [onRemove]);
+ }, [onRemove, filePreviews]);
const handleDropzoneReady = useCallback((dz) => {
dropzoneInstanceRef.current = dz;
}, []);
const handleAddedFile = useCallback((file) => {
- setUploadingFiles(prev => [...prev, { name: file.name, size: file.size, progress: 0, complete: false }]);
+ const previewUrl = file.type?.startsWith('image/') ? URL.createObjectURL(file) : null;
+ setUploadingFiles(prev => [...prev, { name: file.name, size: file.size, progress: 0, complete: false, previewUrl }]);
}, []);
const handleUploadProgress = useCallback((file, progress) => {
@@ -162,14 +166,17 @@ const UploadInputV3 = ({
));
}, []);
- // Once the parent updates value, remove all completed files from uploadingFiles
- useEffect(() => {
+ useLayoutEffect(() => {
if (uploadingFiles.length === 0 || value.length === 0) return;
setUploadingFiles(prev => prev.filter(f => !f.complete));
}, [value]);
const handleFileError = useCallback((file, message) => {
- setUploadingFiles(prev => prev.filter(f => !(f.name === file.name && f.size === file.size)));
+ setUploadingFiles(prev => {
+ const entry = prev.find(f => f.name === file.name && f.size === file.size);
+ if (entry?.previewUrl) URL.revokeObjectURL(entry.previewUrl);
+ return prev.filter(f => !(f.name === file.name && f.size === file.size));
+ });
setErrorFiles(prev => [...prev, { name: file.name, size: file.size, message }]);
}, []);
@@ -184,24 +191,32 @@ const UploadInputV3 = ({
}, []);
const handleDeleteUploading = useCallback((file) => {
+ setUploadingFiles(prev => {
+ const entry = prev.find(f => f.name === file.name && f.size === file.size);
+ if (entry?.previewUrl) URL.revokeObjectURL(entry.previewUrl);
+ return prev.filter(f => !(f.name === file.name && f.size === file.size));
+ });
if (dropzoneInstanceRef.current) {
const dzFile = dropzoneInstanceRef.current.files?.find(
f => f.name === file.name && f.size === file.size
);
if (dzFile) dropzoneInstanceRef.current.removeFile(dzFile);
}
- setUploadingFiles(prev => prev.filter(f => !(f.name === file.name && f.size === file.size)));
}, []);
const wrappedOnUploadComplete = useCallback((response, dzId, dzData) => {
// Mark fully-uploaded rows complete (covers HTTP 202 flow where handleFileCompleted was skipped).
// Guard against flipping rows whose bytes are still in flight when maxFiles > 1.
- setUploadingFiles(prev => prev.map(f => (f.progress >= 100 ? { ...f, complete: true } : f)));
+ setUploadingFiles(prev => {
+ if (response?.name && response?.size) {
+ const entry = prev.find(f => f.size === response.size && f.previewUrl);
+ if (entry) setFilePreviews(p => ({ ...p, [response.name]: entry.previewUrl }));
+ }
+ return prev.map(f => (f.progress >= 100 ? { ...f, complete: true } : f));
+ });
if (onUploadComplete) onUploadComplete(response, dzId, dzData);
}, [onUploadComplete]);
- const extDisplay = formatExtensionsDisplay();
-
const renderDropzone = () => {
if (!postUrl) {
return (
@@ -252,13 +267,6 @@ const UploadInputV3 = ({
);
};
- const fileRowSx = {
- display: 'flex',
- alignItems: 'center',
- py: 1.5,
- mb: 1,
- };
-
return (
{label && (
@@ -292,8 +300,18 @@ const UploadInputV3 = ({
key={`uploading-${index}`}
sx={fileRowSx}
>
-
-
+
+ {file.previewUrl ? (
+
+ ) : (
+
+
+
+ )}
@@ -377,7 +395,8 @@ 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;
+ const previewSrc = filePreviews[filename] || serverPreviewSrc;
return (
{
+ describe('local src (dataURL / blob URL)', () => {
+ 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('renders blob URL immediately without placeholder or blur', () => {
+ const blobURL = 'blob:photo.jpg';
+ render();
+ const img = screen.getByRole('img', { name: 'test image' });
+ expect(img).toHaveAttribute('src', blobURL);
+ expect(img.className).toContain('loaded');
+ expect(img.className).not.toContain('loading');
+ });
+
+ test('updates immediately when src changes to a local URL', () => {
+ const serverURL = 'https://cdn.example.com/photo.jpg';
+ const blobURL = 'blob:photo.jpg';
+ const { rerender } = render();
+
+ rerender();
+
+ const img = screen.getByRole('img', { name: 'test image' });
+ expect(img).toHaveAttribute('src', blobURL);
+ 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..f48c171b 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 isLocal = src?.startsWith('data:') || src?.startsWith('blob:');
+ const [imgSrc, setImgSrc] = useState(isLocal ? src : (placeholderSrc || src));
+ const [customClass, setCustomClass] = useState(isLocal ? styles.loaded : styles.loading);
useEffect(() => {
+ // dataURLs and blob URLs are already in memory — no async loading needed
+ if (src?.startsWith('data:') || src?.startsWith('blob:')) {
+ 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]);