Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,143 @@ describe('UploadInputV3', () => {
});
});

describe('Image Preview', () => {
test('shows thumbnail preview image during upload when onThumbnail fires', () => {
render(<UploadInputV3 {...defaultProps} />);
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(<UploadInputV3 {...defaultProps} />);
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(<UploadInputV3 {...defaultProps} value={[]} />);

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(<UploadInputV3 {...defaultProps} value={[{ filename: 'server_246_abc123.jpg', size: 136000 }]} />);

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(<UploadInputV3 {...defaultProps} value={[]} maxFiles={2} />);

// 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(<UploadInputV3 {...defaultProps} value={[{ filename: 'server_photo_b.jpg', size: 20000 }]} maxFiles={2} />);

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(<UploadInputV3 {...defaultProps} value={[]} maxFiles={2} />);

// 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(<UploadInputV3 {...defaultProps} maxFiles={2} value={[
{ filename: '246_portrait_abc123.jpg', size: 20000 },
{ filename: '246_sunset_def456.jpg', size: 10000 },
]} />);

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(<UploadInputV3 {...defaultProps} value={[]} maxFiles={2} />);

// 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(<UploadInputV3 {...defaultProps} value={[{ filename: 'server_photo_b.jpg', size: 20000 }]} maxFiles={2} />);

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(<UploadInputV3 {...defaultProps} value={[]} />);
Expand Down
5 changes: 5 additions & 0 deletions src/components/inputs/upload-input-v3/dropzone-v3.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const DropzoneV3 = ({
onFileRemoved,
onFileCompleted,
onFileError,
onThumbnail,
onDropzoneReady,
eventHandlers = {},
children,
Expand All @@ -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);
Expand Down
66 changes: 59 additions & 7 deletions src/components/inputs/upload-input-v3/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand All @@ -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 }));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 }]);
}, []);
Expand All @@ -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
Expand Down Expand Up @@ -230,6 +269,7 @@ const UploadInputV3 = ({
onError={onError}
onDropzoneReady={handleDropzoneReady}
onAddedFile={handleAddedFile}
onThumbnail={handleThumbnail}
onUploadProgress={handleUploadProgress}
onFileRemoved={handleFileRemoved}
onFileCompleted={handleFileCompleted}
Expand Down Expand Up @@ -292,8 +332,18 @@ const UploadInputV3 = ({
key={`uploading-${index}`}
sx={fileRowSx}
>
<Box sx={{ color: 'primary.main', display: 'flex', alignItems: 'center', mr: 2, minWidth: 32 }}>
<UploadFileIcon fontSize="medium" />
<Box sx={{ display: 'flex', alignItems: 'center', mr: 2, width: 64, height: 64, flexShrink: 0 }}>
{file.previewUrl ? (
<ProgressiveImg
alt={file.name}
src={file.previewUrl}
placeholderSrc={file_icon}
/>
) : (
<Box sx={{ color: 'primary.main', display: 'flex', alignItems: 'center' }}>
<UploadFileIcon fontSize="medium" />
</Box>
)}
</Box>

<Box sx={{ flex: 1, minWidth: 0 }}>
Expand Down Expand Up @@ -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 (
<Box
Expand Down
98 changes: 98 additions & 0 deletions src/components/progressive-img/__tests__/progressive-img.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* Copyright 2018 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/

import React from 'react';
import { render, screen, fireEvent, act } from '@testing-library/react';
import '@testing-library/jest-dom';
import ProgressiveImg from '../index';

describe('ProgressiveImg', () => {
describe('dataURL src', () => {
test('renders dataURL immediately without placeholder or blur', () => {
const dataURL = 'data:image/jpeg;base64,abc123';
render(<ProgressiveImg src={dataURL} alt="test image" placeholderSrc="placeholder.png" />);
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(<ProgressiveImg src={serverURL} alt="test image" placeholderSrc="placeholder.png" />);

rerender(<ProgressiveImg src={dataURL} alt="test image" placeholderSrc="placeholder.png" />);

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(<ProgressiveImg src="https://cdn.example.com/photo.jpg" alt="test" placeholderSrc="placeholder.png" />);
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(<ProgressiveImg src={src} alt="test" placeholderSrc="placeholder.png" />);
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(<ProgressiveImg src="https://cdn.example.com/photo.jpg" alt="test" />);
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(<ProgressiveImg src={firstURL} alt="test" placeholderSrc="placeholder.png" />);

// Change src before firstURL loads — simulates the serverURL → dataURL swap in upload flow
rerender(<ProgressiveImg src={secondURL} alt="test" placeholderSrc="placeholder.png" />);

// 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);
});
});
});
Loading
Loading