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]);