diff --git a/package.json b/package.json index b13b90cd..05558619 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "5.0.16-beta.1", + "version": "5.0.15-beta.2", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": { diff --git a/src/components/inputs/dropzone/index.js b/src/components/inputs/dropzone/index.js index 1706d255..514510b5 100644 --- a/src/components/inputs/dropzone/index.js +++ b/src/components/inputs/dropzone/index.js @@ -154,6 +154,7 @@ export class DropzoneJS extends React.Component { } if (options.maxFiles && options.maxFiles < (this.state.files.length + this.props.uploadCount)) { done('Max files reached.'); + return; } done(); 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 ebf37d8d..78e26f44 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 @@ -308,5 +308,119 @@ describe('UploadInputV3', () => { const { container } = render(); expect(container.firstChild).not.toBeNull(); }); + + test('cleans up completed uploading file when value updates with server-renamed filename', () => { + const { rerender } = render(); + + // Simulate file added + act(() => { + dropzoneCallbacks.onAddedFile({ name: 'image.png', size: 75000 }); + }); + + // Simulate file completed + act(() => { + dropzoneCallbacks.onFileCompleted({ name: 'image.png', size: 75000 }); + }); + + // Parent updates value with server-renamed file + rerender(); + + // Assert: only the server-renamed file is visible + expect(screen.getByText('image_abc123.png')).toBeInTheDocument(); + expect(screen.queryByText('image.png')).not.toBeInTheDocument(); + }); + + test('does not mark file as complete when _asyncProcessing is true', () => { + render(); + + // Simulate file added + act(() => { + dropzoneCallbacks.onAddedFile({ name: 'video.mp4', size: 5000000 }); + }); + + // Simulate file completed with _asyncProcessing flag (HTTP 202 case) + act(() => { + dropzoneCallbacks.onFileCompleted({ name: 'video.mp4', size: 5000000, _asyncProcessing: true }); + }); + + // Assert: file should still show "Loading" (not "Complete") because async processing is in progress + expect(screen.getByText('video.mp4')).toBeInTheDocument(); + expect(screen.getByText(/Loading/)).toBeInTheDocument(); + expect(screen.queryByText(/Complete/)).not.toBeInTheDocument(); + }); + + test('async file transitions from Loading to Complete when onUploadComplete fires', () => { + render(); + + // Simulate file added + act(() => { + dropzoneCallbacks.onAddedFile({ name: 'video.mp4', size: 5000000 }); + }); + + // All chunks finish uploading — progress reaches 100 before async polling starts + act(() => { + dropzoneCallbacks.onUploadProgress({ name: 'video.mp4', size: 5000000 }, 100); + }); + + // handleFileCompleted fires with _asyncProcessing flag (HTTP 202 case) — file stays Loading + act(() => { + dropzoneCallbacks.onFileCompleted({ name: 'video.mp4', size: 5000000, _asyncProcessing: true }); + }); + + // File should still be Loading despite progress being 100 + expect(screen.getByText(/Loading/)).toBeInTheDocument(); + expect(screen.queryByText(/Complete/)).not.toBeInTheDocument(); + + // Polling completes — onUploadComplete fires after async processing finishes + act(() => { + dropzoneCallbacks.onUploadComplete({ name: 'video_final.mp4' }, 'test-upload', {}); + }); + + // Assert: file should now show "Complete" + expect(screen.getByText('video.mp4')).toBeInTheDocument(); + expect(screen.getByText(/Complete/)).toBeInTheDocument(); + expect(screen.queryByText(/Loading/)).not.toBeInTheDocument(); + }); + + test('completing one file does not mark other in-flight files as complete when maxFiles > 1', () => { + render(); + + // Simulate two files added + act(() => { + dropzoneCallbacks.onAddedFile({ name: 'file-a.png', size: 10000 }); + }); + act(() => { + dropzoneCallbacks.onAddedFile({ name: 'file-b.png', size: 20000 }); + }); + + // Both should be Loading + expect(screen.getByText('file-a.png')).toBeInTheDocument(); + expect(screen.getByText('file-b.png')).toBeInTheDocument(); + expect(screen.getAllByText(/Loading/)).toHaveLength(2); + + // File A finishes uploading all chunks — progress reaches 100 + act(() => { + dropzoneCallbacks.onUploadProgress({ name: 'file-a.png', size: 10000 }, 100); + }); + + // File B is still mid-upload — progress at 40 + act(() => { + dropzoneCallbacks.onUploadProgress({ name: 'file-b.png', size: 20000 }, 40); + }); + + // File A completes (sync path): handleFileCompleted marks A complete + act(() => { + dropzoneCallbacks.onFileCompleted({ name: 'file-a.png', size: 10000 }); + }); + + // Then onUploadComplete fires for file A + act(() => { + dropzoneCallbacks.onUploadComplete({ name: 'file-a.png' }, 'test-upload', {}); + }); + + // Assert: file B should still show "Loading" — it has not finished uploading + expect(screen.getByText('file-b.png')).toBeInTheDocument(); + expect(screen.getByText(/Loading/)).toBeInTheDocument(); + }); }); }); diff --git a/src/components/inputs/upload-input-v3/index.js b/src/components/inputs/upload-input-v3/index.js index a1b31b1d..d90e6a75 100644 --- a/src/components/inputs/upload-input-v3/index.js +++ b/src/components/inputs/upload-input-v3/index.js @@ -151,18 +151,18 @@ const UploadInputV3 = ({ // Mark as complete instead of removing — keep it visible until value is updated by the parent const handleFileCompleted = useCallback((file) => { + // Skip marking complete for async processing (HTTP 202) — file stays "Loading" until polling confirms + if (file._asyncProcessing) return; + setUploadingFiles(prev => prev.map(f => f.name === file.name && f.size === file.size ? { ...f, progress: 100, complete: true } : f )); }, []); - // Once the parent updates value, remove the matching completed file from uploadingFiles + // Once the parent updates value, remove all completed files from uploadingFiles useEffect(() => { if (uploadingFiles.length === 0 || value.length === 0) return; - setUploadingFiles(prev => prev.filter(f => { - if (!f.complete) return true; - return !value.some(v => v.filename === f.name); - })); + setUploadingFiles(prev => prev.filter(f => !f.complete)); }, [value]); const handleFileError = useCallback((file, message) => { @@ -191,6 +191,9 @@ const UploadInputV3 = ({ }, []); 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))); if (onUploadComplete) onUploadComplete(response, dzId, dzData); }, [onUploadComplete]);