From 32e967f9134e9b6cad9af1ddeb6331198fe994cb Mon Sep 17 00:00:00 2001 From: Priscila Moneo Date: Fri, 5 Jun 2026 17:58:08 -0300 Subject: [PATCH] feat: upload input v3 image preview improvement Signed-off-by: Priscila Moneo --- .../__tests__/upload-input-v3.test.js | 137 ++++++++++++++++++ .../inputs/upload-input-v3/dropzone-v3.js | 5 + .../inputs/upload-input-v3/index.js | 66 ++++++++- .../__tests__/progressive-img.test.js | 98 +++++++++++++ src/components/progressive-img/index.js | 30 ++-- 5 files changed, 318 insertions(+), 18 deletions(-) create mode 100644 src/components/progressive-img/__tests__/progressive-img.test.js 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]);