diff --git a/package.json b/package.json
index 565cb8d7..1e862b46 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "openstack-uicore-foundation",
- "version": "5.0.56",
+ "version": "5.0.58-beta.0",
"description": "ui reactjs components for openstack marketing site",
"main": "lib/openstack-uicore-foundation.js",
"scripts": {
diff --git a/src/components/inputs/dropzone/__tests__/dropzone.test.js b/src/components/inputs/dropzone/__tests__/dropzone.test.js
index 9a1c2497..53c1f1a4 100644
--- a/src/components/inputs/dropzone/__tests__/dropzone.test.js
+++ b/src/components/inputs/dropzone/__tests__/dropzone.test.js
@@ -31,13 +31,22 @@ let mockCapturedOptions = {};
jest.mock('dropzone', () => {
return jest.fn().mockImplementation((element, options) => {
mockCapturedOptions = options;
- return {
+ const dz = {
options,
on: jest.fn(),
off: jest.fn(),
destroy: jest.fn(() => null),
getActiveFiles: jest.fn(() => [])
};
+ // Mimics Dropzone's real Emitter: replays every handler registered via `on`
+ // for that event, in registration order - the same "multiple listeners on
+ // one event" behavior the ontimeout/pollUploadStatus fixes rely on.
+ dz.emit = jest.fn((event, ...args) => {
+ dz.on.mock.calls
+ .filter(([evt]) => evt === event)
+ .forEach(([, handler]) => handler(...args));
+ });
+ return dz;
});
});
@@ -183,6 +192,7 @@ describe('DropzoneJS - HTTP 202 Polling UX', () => {
// Mock fetch to return complete status
global.fetch = jest.fn(() =>
Promise.resolve({
+ ok: true,
json: () => Promise.resolve({
status: 'complete',
name: 'test.pdf',
@@ -244,6 +254,7 @@ describe('DropzoneJS - HTTP 202 Polling UX', () => {
test('test_dropzone_poll_upload_status_encodes_file_id_in_url', (done) => {
global.fetch = jest.fn(() =>
Promise.resolve({
+ ok: true,
json: () => Promise.resolve({
status: 'complete',
name: 'HPE_OCPSanJose_Backdrop_20x10ft50_Ver1.4_PRINT.pdf',
@@ -308,7 +319,7 @@ describe('DropzoneJS - HTTP 202 Polling UX', () => {
test('test_dropzone_cancel_stops_polling_for_that_file_only', (done) => {
// Server is still processing: every poll answers 'uploading', so polling keeps going.
global.fetch = jest.fn(() =>
- Promise.resolve({ json: () => Promise.resolve({ status: 'uploading' }) })
+ Promise.resolve({ ok: true, json: () => Promise.resolve({ status: 'uploading' }) })
);
const ref = React.createRef();
@@ -369,6 +380,7 @@ describe('DropzoneJS - HTTP 202 Polling UX', () => {
new Promise((resolve) => {
respondComplete = () =>
resolve({
+ ok: true,
json: () =>
Promise.resolve({ status: 'complete', name: 'test.pdf', size: 1024000 })
});
@@ -424,7 +436,7 @@ describe('DropzoneJS - HTTP 202 Polling UX', () => {
*/
test('test_dropzone_unmount_stops_polling_for_every_file', (done) => {
global.fetch = jest.fn(() =>
- Promise.resolve({ json: () => Promise.resolve({ status: 'uploading' }) })
+ Promise.resolve({ ok: true, json: () => Promise.resolve({ status: 'uploading' }) })
);
const ref = React.createRef();
@@ -515,6 +527,249 @@ describe('DropzoneJS - HTTP 202 Polling UX', () => {
done();
}, 10);
});
+
+ /**
+ * Test Case 10: a chunk that times out releases its concurrency slot
+ *
+ * Dropzone's own default xhr.ontimeout (set before the 'sending' handler runs)
+ * still fires 'error', but without wrapping it here onChunkComplete() never
+ * runs, so chunksInFlight never decrements and later chunks stay queued forever.
+ */
+ test('test_dropzone_ontimeout_releases_chunk_slot', () => {
+ const ref = React.createRef();
+
+ render(
+
+ );
+
+ const instance = ref.current;
+ // Only requests that went through the chunk-throttle queue occupy a concurrency slot.
+ const mockFile = { name: 'test.pdf', size: 1024000, _isThrottledChunk: true };
+ const dropzoneOnTimeout = jest.fn();
+ const mockXhr = {
+ readyState: XMLHttpRequest.DONE,
+ setRequestHeader: jest.fn(),
+ onload: jest.fn(),
+ onerror: jest.fn(),
+ ontimeout: dropzoneOnTimeout,
+ abort: jest.fn()
+ };
+
+ instance.chunksInFlight = 1;
+ getEventHandler(instance, 'sending')(mockFile, mockXhr, { append: jest.fn() });
+
+ mockXhr.ontimeout({});
+
+ expect(instance.chunksInFlight).toBe(0);
+ // Dropzone's own timeout handling (which still reports the error) must still run.
+ expect(dropzoneOnTimeout).toHaveBeenCalledTimes(1);
+ });
+
+ /**
+ * Test Case 10b: a non-chunked upload's completion must not free a concurrency slot
+ * it never occupied.
+ *
+ * chunksInFlight is only ever incremented for requests routed through the chunk-throttle
+ * queue (setupChunkThrottle). A file that bypasses the queue (no _isThrottledChunk tag)
+ * finishing its single request used to still call onChunkComplete() unconditionally,
+ * decrementing the counter for chunked uploads that were still genuinely in flight and
+ * letting more than maxConcurrentChunks run at once.
+ */
+ test('test_dropzone_non_chunked_upload_does_not_release_a_chunk_slot', () => {
+ const ref = React.createRef();
+
+ render(
+
+ );
+
+ const instance = ref.current;
+ // No _isThrottledChunk tag - this file bypassed the throttle queue.
+ const mockFile = { name: 'small.pdf', size: 1024 };
+ const mockXhr = {
+ readyState: XMLHttpRequest.DONE,
+ status: 200,
+ responseText: JSON.stringify({ name: 'small.pdf', path: 'uploads/', size: 1024 }),
+ setRequestHeader: jest.fn(),
+ onload: jest.fn(),
+ onerror: jest.fn(),
+ abort: jest.fn()
+ };
+
+ // Two real chunked uploads for a different file are genuinely in flight.
+ instance.chunksInFlight = 2;
+ getEventHandler(instance, 'sending')(mockFile, mockXhr, { append: jest.fn() });
+
+ mockXhr.onload({});
+
+ expect(instance.chunksInFlight).toBe(2);
+ });
+
+ /**
+ * Test Cases 11-13: pollUploadStatus's three failure branches route through the
+ * file-level error channel (dropzone.emit('error', file, message)) instead of
+ * calling onError directly, so the row clears and the consumer is told exactly once.
+ */
+ test('test_dropzone_poll_timeout_emits_a_string_message_and_calls_onError_once', async () => {
+ jest.useFakeTimers({ doNotFake: ['queueMicrotask'] });
+ global.fetch = jest.fn(() =>
+ Promise.resolve({ ok: true, json: () => Promise.resolve({ status: 'uploading' }) })
+ );
+
+ const ref = React.createRef();
+ render(
+
+ );
+
+ const instance = ref.current;
+ const mockFile = { name: 'big.pdf', size: 1024000 };
+ instance.pollUploadStatus('file-timeout', 'https://example.com/upload', mockFile);
+
+ // maxAttempts is 300 at 2s/tick - advance one tick past the ceiling.
+ await jest.advanceTimersByTimeAsync(2000 * 301);
+
+ expect(onErrorMock).toHaveBeenCalledTimes(1);
+ const [message] = onErrorMock.mock.calls[0];
+ expect(typeof message).toBe('string');
+ expect(message).toBe('Upload timed out');
+
+ jest.useRealTimers();
+ }, 20000);
+
+ test('test_dropzone_poll_server_error_status_emits_readable_message_not_object', (done) => {
+ global.fetch = jest.fn(() =>
+ Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ status: 'error', message: 'processing failed' })
+ })
+ );
+
+ const ref = React.createRef();
+ render(
+
+ );
+
+ setTimeout(() => {
+ const instance = ref.current;
+ const mockFile = { name: 'test.pdf', size: 1024000 };
+ instance.pollUploadStatus('file-server-error', 'https://example.com/upload', mockFile);
+
+ setTimeout(() => {
+ expect(onErrorMock).toHaveBeenCalledTimes(1);
+ const [message] = onErrorMock.mock.calls[0];
+ expect(message).toBe('processing failed');
+ expect(message).not.toBe('[object Object]');
+ done();
+ }, 2500);
+ }, 10);
+ }, 10000);
+
+ /**
+ * Test Case 14: a slow status request must not let a second one start alongside it.
+ *
+ * The old setInterval-based poll fired every 2s regardless of whether the previous
+ * tick's fetch had resolved yet, so a slow response could still be in flight when a
+ * later tick's request resolved first - letting two terminal results race (e.g. a
+ * stray error landing after an already-committed completion). The self-scheduling loop
+ * only calls sleep(2000) again after its current request fully resolves, so no second
+ * request can ever start while one is outstanding - this asserts that directly, then
+ * confirms the single in-flight request still completes normally once it resolves.
+ */
+ test('test_dropzone_poll_does_not_start_a_new_request_while_one_is_still_in_flight', async () => {
+ jest.useFakeTimers({ doNotFake: ['queueMicrotask'] });
+ let resolveFetch;
+ global.fetch = jest.fn(
+ () => new Promise((resolve) => { resolveFetch = resolve; })
+ );
+
+ const ref = React.createRef();
+ render(
+
+ );
+
+ const instance = ref.current;
+ const mockFile = {
+ name: 'test.pdf',
+ size: 1024000,
+ _asyncProcessing: true,
+ _chunksUploadedDone: jest.fn()
+ };
+
+ instance.pollUploadStatus('file-123', 'https://example.com/upload', mockFile);
+
+ // First tick fires at 2000ms and parks on the still-unresolved request.
+ await jest.advanceTimersByTimeAsync(2000);
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+
+ // Several more tick-intervals' worth of time pass while that request is still
+ // outstanding - a second request must not start.
+ await jest.advanceTimersByTimeAsync(2000 * 5);
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+
+ // Now the one in-flight request finally resolves with a terminal result.
+ resolveFetch({
+ ok: true,
+ json: () => Promise.resolve({ status: 'complete', name: 'test.pdf', size: 1024000 })
+ });
+ await jest.advanceTimersByTimeAsync(0);
+
+ expect(mockFile._chunksUploadedDone).toHaveBeenCalledTimes(1);
+ expect(onUploadCompleteMock).toHaveBeenCalledTimes(1);
+ expect(onErrorMock).not.toHaveBeenCalled();
+
+ jest.useRealTimers();
+ }, 10000);
+
+ test('test_dropzone_poll_fetch_rejection_emits_readable_message_and_calls_onError_once', (done) => {
+ global.fetch = jest.fn(() => Promise.reject(new Error('network down')));
+
+ const ref = React.createRef();
+ render(
+
+ );
+
+ setTimeout(() => {
+ const instance = ref.current;
+ const mockFile = { name: 'test.pdf', size: 1024000 };
+ instance.pollUploadStatus('file-network-error', 'https://example.com/upload', mockFile);
+
+ setTimeout(() => {
+ expect(onErrorMock).toHaveBeenCalledTimes(1);
+ const [message] = onErrorMock.mock.calls[0];
+ expect(message).toBe('Network error');
+ done();
+ }, 2500);
+ }, 10);
+ }, 10000);
});
describe('DropzoneJS - Progress Bar Monotonicity', () => {
diff --git a/src/components/inputs/dropzone/index.js b/src/components/inputs/dropzone/index.js
index d619fc1e..81d3f130 100644
--- a/src/components/inputs/dropzone/index.js
+++ b/src/components/inputs/dropzone/index.js
@@ -22,25 +22,22 @@ export class DropzoneJS extends React.Component {
this.activeXHRs = new Map(); // Track active XHR requests per file
this.chunkQueue = [];
this.chunksInFlight = 0;
- // Status-poll interval ids, one per file in flight. Kept as a set (and mirrored on
- // the file itself) rather than a single slot so a second file starting to poll
- // cannot orphan the first one's interval.
- this._pollIntervals = new Set();
+ // Checked by every in-flight pollUploadStatus loop so unmount stops all of them.
+ this._unmounted = false;
+ }
+
+ sleep(ms) {
+ return new Promise(resolve => setTimeout(resolve, ms));
}
/**
- * Stops the status polling started by pollUploadStatus for this file, if any.
- * Cancelling an upload has to reach the interval too: a file the user removed while the
+ * Stops the status polling loop started by pollUploadStatus for this file, if any.
+ * Cancelling an upload has to reach the loop too: a file the user removed while the
* server was still processing it must stop asking for its status, otherwise the result
* lands later and gets committed as if the upload had been kept.
*/
stopPolling(file) {
if (!file) return;
- if (file._pollIntervalId) {
- clearInterval(file._pollIntervalId);
- this._pollIntervals.delete(file._pollIntervalId);
- file._pollIntervalId = null;
- }
file._pollingActive = false;
}
@@ -49,6 +46,16 @@ export class DropzoneJS extends React.Component {
this.props.onError(e, status, this.props.id);
}
+ // this.dropzone may already be dropzone.destroy()'s return value (an Array,
+ // not the Dropzone instance) if a poll tick resolves after unmount.
+ reportPollingError(file, message) {
+ if (typeof this.dropzone?.emit === 'function') {
+ this.dropzone.emit('error', file, message);
+ } else {
+ this.onError(message);
+ }
+ }
+
onUploadComplete(response){
if(this.props.onUploadComplete)
this.props.onUploadComplete(response, this.props.id, this.props.data);
@@ -68,8 +75,10 @@ export class DropzoneJS extends React.Component {
// Wrap _uploadData to queue chunked uploads with concurrency limit
this._originalUploadData = this.dropzone._uploadData.bind(this.dropzone);
this.dropzone._uploadData = (files, dataBlocks) => {
- // Only throttle chunked uploads (single dataBlock with chunkIndex)
- if (dataBlocks.length === 1 && dataBlocks[0].chunkIndex !== undefined) {
+ // Tag the file so 'sending' below only releases a slot for requests that took one.
+ const isThrottledChunk = dataBlocks.length === 1 && dataBlocks[0].chunkIndex !== undefined;
+ files.forEach(file => { file._isThrottledChunk = isThrottledChunk; });
+ if (isThrottledChunk) {
this.chunkQueue.push({ files, dataBlocks });
this.processChunkQueue();
} else {
@@ -84,8 +93,8 @@ export class DropzoneJS extends React.Component {
this.processChunkQueue();
}
- pollUploadStatus(fileId, baseUrl, file) {
- // Guard against multiple polling intervals for the same file
+ async pollUploadStatus(fileId, baseUrl, file) {
+ // Guard against multiple polling loops for the same file
if (file._pollingActive) {
return;
}
@@ -97,16 +106,19 @@ export class DropzoneJS extends React.Component {
const maxAttempts = 300; // 10 minutes at 2s intervals
let attempts = 0;
- const intervalId = setInterval(async () => {
- // The file may have been removed since the last tick.
- if (file._canceled) {
+ // A self-scheduling loop (not setInterval) keeps only one request in flight at a time.
+ while (true) {
+ await this.sleep(2000);
+
+ // The file may have been removed, or the component unmounted, since the last check.
+ if (file._canceled || this._unmounted) {
this.stopPolling(file);
return;
}
attempts++;
if (attempts > maxAttempts) {
this.stopPolling(file);
- this.onError({ message: 'Upload timed out' });
+ this.reportPollingError(file, 'Upload timed out');
return;
}
try {
@@ -114,11 +126,19 @@ export class DropzoneJS extends React.Component {
const response = await fetch(statusUrl, {
headers: { 'Authorization': `Bearer ${accessToken}` }
});
+ if (!response.ok) {
+ this.stopPolling(file);
+ // Don't report an error for a file the user already removed, or after unmount.
+ if (!file._canceled && !this._unmounted) {
+ this.reportPollingError(file, response.status === 403 ? 'Auth error' : 'Network error');
+ }
+ return;
+ }
const data = await response.json();
- // Clearing the interval is not enough on its own: this tick was already
- // awaiting its response when the user cancelled, and committing it now
- // would restore a file they removed.
- if (file._canceled) {
+ // This request was already awaiting its response when the user cancelled
+ // (or the component unmounted), and committing it now would restore a file
+ // they removed.
+ if (file._canceled || this._unmounted) {
this.stopPolling(file);
return;
}
@@ -129,18 +149,20 @@ export class DropzoneJS extends React.Component {
file._chunksUploadedDone();
}
this.onUploadComplete(data);
+ return;
} else if (data.status === 'error') {
this.stopPolling(file);
- this.onError(data);
+ this.reportPollingError(file, data.message || 'Upload failed');
+ return;
}
+ // any other status (e.g. 'uploading') means keep polling
} catch (error) {
this.stopPolling(file);
- this.onError(error);
+ // fetch fail is always connection error
+ this.reportPollingError(file, 'Network error');
+ return;
}
- }, 2000);
-
- file._pollIntervalId = intervalId;
- this._pollIntervals.add(intervalId);
+ }
}
/**
@@ -231,8 +253,7 @@ export class DropzoneJS extends React.Component {
* Removes dropzone.js (and all its globals) if the component is being unmounted
*/
componentWillUnmount () {
- this._pollIntervals.forEach(intervalId => clearInterval(intervalId));
- this._pollIntervals.clear();
+ this._unmounted = true;
// Clear chunk queue and cancel all pending XHR requests
this.chunkQueue = [];
@@ -447,8 +468,10 @@ export class DropzoneJS extends React.Component {
if (index > -1) xhrs.splice(index, 1);
}
- // Release a slot in the chunk queue for the next chunk
- _this.onChunkComplete();
+ // Release a slot only if this request actually took one from the queue.
+ if (file._isThrottledChunk) {
+ _this.onChunkComplete();
+ }
// Track completed bytes for accurate progress (prevents oscillation)
const chunkSize = _this.dropzone?.options?.chunkSize || 2000000;
@@ -491,14 +514,26 @@ export class DropzoneJS extends React.Component {
let dropzoneOnError = xhr.onerror;
xhr.onerror = function(e) {
- _this.onChunkComplete();
+ if (file._isThrottledChunk) {
+ _this.onChunkComplete();
+ }
if (dropzoneOnError) dropzoneOnError(e);
}
+
+ // Without this wrapper a timed-out chunk never releases its concurrency slot.
+ let dropzoneOnTimeout = xhr.ontimeout;
+ xhr.ontimeout = function(e) {
+ if (file._isThrottledChunk) {
+ _this.onChunkComplete();
+ }
+ if (dropzoneOnTimeout) dropzoneOnTimeout(e);
+ }
})
- this.dropzone.on('error', (file, message) => {
+ // xhr.status is 0 for a transport failure, vs a real non-2xx server response.
+ this.dropzone.on('error', (file, message, xhr) => {
console.log(`DropzoneJS::error`, message);
- this.onError(message);
+ this.onError(message, xhr?.status);
});
}
diff --git a/src/components/inputs/upload-input-v3/__tests__/dropzone-v3.test.js b/src/components/inputs/upload-input-v3/__tests__/dropzone-v3.test.js
index 3bdae599..a9042f8b 100644
--- a/src/components/inputs/upload-input-v3/__tests__/dropzone-v3.test.js
+++ b/src/components/inputs/upload-input-v3/__tests__/dropzone-v3.test.js
@@ -90,3 +90,43 @@ describe('DropzoneV3 - uploadprogress to React bridging', () => {
expect(onUploadProgress).toHaveBeenCalledWith(file, 40);
});
});
+
+describe('DropzoneV3 - error status forwarding', () => {
+ beforeEach(() => {
+ capturedEventHandlers = null;
+ });
+
+ test('forwards the xhr status from the error event to onFileError', () => {
+ const onFileError = jest.fn();
+ render(
+
+ );
+
+ const file = { name: 'video.mp4' };
+ capturedEventHandlers.error(file, 'Server responded with 0 code.', { status: 0 });
+
+ expect(onFileError).toHaveBeenCalledWith(file, 'Server responded with 0 code.', 0);
+ });
+
+ test('passes undefined status when Dropzone emits error without an xhr (e.g. client-side validation)', () => {
+ const onFileError = jest.fn();
+ render(
+
+ );
+
+ const file = { name: 'huge.mp4' };
+ capturedEventHandlers.error(file, 'File is too big.');
+
+ expect(onFileError).toHaveBeenCalledWith(file, 'File is too big.', undefined);
+ });
+});
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 59f3b058..05973137 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
@@ -185,7 +185,7 @@ describe('UploadInputV3', () => {
dropzoneCallbacks.onAddedFile({ name: 'sample.png', size: 11264 });
});
expect(screen.getByText('sample.png')).toBeInTheDocument();
- expect(screen.getByText(/Loading/)).toBeInTheDocument();
+ expect(screen.getByText(/Uploading/)).toBeInTheDocument();
});
test('shows Complete status and hides progress bar after upload finishes', () => {
@@ -197,7 +197,7 @@ describe('UploadInputV3', () => {
dropzoneCallbacks.onFileCompleted({ name: 'sample.png', size: 11264 });
});
expect(screen.getByText(/Complete/)).toBeInTheDocument();
- expect(screen.queryByText(/Loading/)).not.toBeInTheDocument();
+ expect(screen.queryByText(/Uploading/)).not.toBeInTheDocument();
});
test('hides dropzone when max files reached', () => {
@@ -234,7 +234,7 @@ describe('UploadInputV3', () => {
act(() => {
dropzoneCallbacks.onAddedFile({ name: 'big-file.png', size: 9999999 });
});
- expect(screen.getByText(/Loading/)).toBeInTheDocument();
+ expect(screen.getByText(/Uploading/)).toBeInTheDocument();
act(() => {
dropzoneCallbacks.onFileError(
@@ -242,10 +242,51 @@ describe('UploadInputV3', () => {
'File is too big (9.54MiB). Max filesize: 5MiB.'
);
});
- expect(screen.queryByText(/Loading/)).not.toBeInTheDocument();
+ expect(screen.queryByText(/Uploading/)).not.toBeInTheDocument();
expect(screen.getByText(/File is too big/)).toBeInTheDocument();
});
+ test('two failed chunks for the same file collapse into a single error row', () => {
+ render();
+ act(() => {
+ dropzoneCallbacks.onAddedFile({ name: 'big-file.png', size: 9999999 });
+ });
+ act(() => {
+ dropzoneCallbacks.onFileError({ name: 'big-file.png', size: 9999999 }, 'Server responded with 0 code.', 0);
+ });
+ act(() => {
+ dropzoneCallbacks.onFileError({ name: 'big-file.png', size: 9999999 }, 'Server responded with 0 code.', 0);
+ });
+ expect(screen.getAllByText('big-file.png')).toHaveLength(1);
+ expect(screen.queryByText('Server responded with 0 code.')).not.toBeInTheDocument();
+ expect(screen.getAllByText('Upload interrupted by a connection error. Please retry.')).toHaveLength(1);
+ });
+
+ test('a status-0 connection failure shows a readable message instead of the raw "Server responded with 0 code."', () => {
+ render();
+ act(() => {
+ dropzoneCallbacks.onFileError({ name: 'video.mp4', size: 9999999 }, 'Server responded with 0 code.', 0);
+ });
+ expect(screen.getByText('Upload interrupted by a connection error. Please retry.')).toBeInTheDocument();
+ expect(screen.queryByText('Server responded with 0 code.')).not.toBeInTheDocument();
+ });
+
+ test('a fetch-rejection during status polling ("Network error") shows the same readable connection message', () => {
+ render();
+ act(() => {
+ dropzoneCallbacks.onFileError({ name: 'video.mp4', size: 9999999 }, 'Network error');
+ });
+ expect(screen.getByText('Upload interrupted by a connection error. Please retry.')).toBeInTheDocument();
+ });
+
+ test('a real non-zero-status server error still shows the server-provided message', () => {
+ render();
+ act(() => {
+ dropzoneCallbacks.onFileError({ name: 'video.mp4', size: 9999999 }, 'File type not allowed', 415);
+ });
+ expect(screen.getByText('File type not allowed')).toBeInTheDocument();
+ });
+
test('dismissing an error removes it from the view and restores the dropzone', () => {
const { container } = render();
act(() => {
@@ -276,7 +317,7 @@ describe('UploadInputV3', () => {
act(() => {
dropzoneCallbacks.onAddedFile({ name: 'sample.png', size: 11264 });
});
- expect(screen.getByText(/Loading/)).toBeInTheDocument();
+ expect(screen.getByText(/Uploading/)).toBeInTheDocument();
// The delete button on the uploading row is the only button on screen.
act(() => {
@@ -621,7 +662,7 @@ describe('UploadInputV3', () => {
// 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.getByText(/Uploading/)).toBeInTheDocument();
expect(screen.queryByText(/Complete/)).not.toBeInTheDocument();
});
@@ -644,7 +685,7 @@ describe('UploadInputV3', () => {
});
// File should still be Loading despite progress being 100
- expect(screen.getByText(/Loading/)).toBeInTheDocument();
+ expect(screen.getByText(/Uploading/)).toBeInTheDocument();
expect(screen.queryByText(/Complete/)).not.toBeInTheDocument();
// Polling completes - onUploadComplete fires after async processing finishes
@@ -655,7 +696,7 @@ describe('UploadInputV3', () => {
// Assert: file should now show "Complete"
expect(screen.getByText('video.mp4')).toBeInTheDocument();
expect(screen.getByText(/Complete/)).toBeInTheDocument();
- expect(screen.queryByText(/Loading/)).not.toBeInTheDocument();
+ expect(screen.queryByText(/Uploading/)).not.toBeInTheDocument();
});
test('completing one file does not mark other in-flight files as complete when maxFiles > 1', () => {
@@ -672,7 +713,7 @@ describe('UploadInputV3', () => {
// Both should be Loading
expect(screen.getByText('file-a.png')).toBeInTheDocument();
expect(screen.getByText('file-b.png')).toBeInTheDocument();
- expect(screen.getAllByText(/Loading/)).toHaveLength(2);
+ expect(screen.getAllByText(/Uploading/)).toHaveLength(2);
// File A finishes uploading all chunks - progress reaches 100
act(() => {
@@ -696,7 +737,7 @@ describe('UploadInputV3', () => {
// Assert: file B should still show "Loading" - it has not finished uploading
expect(screen.getByText('file-b.png')).toBeInTheDocument();
- expect(screen.getByText(/Loading/)).toBeInTheDocument();
+ expect(screen.getByText(/Uploading/)).toBeInTheDocument();
});
test('HTTP 202 parallel: onUploadComplete for one file does not prematurely mark sibling file as complete', () => {
@@ -717,7 +758,7 @@ describe('UploadInputV3', () => {
dropzoneCallbacks.onFileCompleted({ name: 'clip-b.mp4', size: 8000000, _asyncProcessing: true });
});
- expect(screen.getAllByText(/Loading/)).toHaveLength(2);
+ expect(screen.getAllByText(/Uploading/)).toHaveLength(2);
// Only clip-a.mp4 polling finishes
act(() => {
@@ -726,9 +767,9 @@ describe('UploadInputV3', () => {
// clip-b must still be Loading - it has not finished async processing
expect(screen.getByText('clip-b.mp4')).toBeInTheDocument();
- expect(screen.getByText(/Loading/)).toBeInTheDocument();
+ expect(screen.getByText(/Uploading/)).toBeInTheDocument();
// Only one Loading entry should remain (clip-b)
- expect(screen.getAllByText(/Loading/)).toHaveLength(1);
+ expect(screen.getAllByText(/Uploading/)).toHaveLength(1);
});
});
});
diff --git a/src/components/inputs/upload-input-v3/dropzone-v3.js b/src/components/inputs/upload-input-v3/dropzone-v3.js
index c38495b7..f351d770 100644
--- a/src/components/inputs/upload-input-v3/dropzone-v3.js
+++ b/src/components/inputs/upload-input-v3/dropzone-v3.js
@@ -63,9 +63,9 @@ export const DropzoneV3 = ({
if (onFileCompleted) onFileCompleted(file);
if (eventHandlers.success) eventHandlers.success(file);
},
- error: (file, message) => {
- if (onFileError) onFileError(file, message);
- if (eventHandlers.error) eventHandlers.error(file, message);
+ error: (file, message, xhr) => {
+ if (onFileError) onFileError(file, message, xhr?.status);
+ if (eventHandlers.error) eventHandlers.error(file, message, xhr?.status);
},
};
diff --git a/src/components/inputs/upload-input-v3/index.js b/src/components/inputs/upload-input-v3/index.js
index 8e2d474c..7192ab40 100644
--- a/src/components/inputs/upload-input-v3/index.js
+++ b/src/components/inputs/upload-input-v3/index.js
@@ -205,19 +205,45 @@ const UploadInputV3 = ({
}));
}, [value]);
- const handleFileError = useCallback((file, message) => {
+ const handleFileError = useCallback((file, message, status) => {
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));
});
- // Dropzone turns a cancelled upload into an error carrying dictUploadCanceled. A cancel
- // is not a failure to report back to the user - the row just goes away. 'canceled' is the
- // value of Dropzone.CANCELED, matched as a literal so this does not depend on the
- // Dropzone module being loaded here; _userCanceled also covers files Dropzone never got
- // to mark, such as one removed before its upload reached the UPLOADING state.
+ // 'canceled' is the value of Dropzone.CANCELED, matched as a literal so this does not depend on the
+ // Dropzone module being loaded here; _userCanceled is when removed before its upload reached the UPLOADING state.
if (file._userCanceled || file.status === 'canceled') return;
- setErrorFiles(prev => [...prev, { name: file.name, size: file.size, message }]);
+
+ // Dropzone parses any application/json error body before checking status, so a real
+ // server validation error (e.g. file-upload-api's {"message": "..."}) arrives as an
+ // object here, not a string - render it as one or React crashes on the JSX below.
+ const text = typeof message === 'string'
+ ? message
+ : (message?.message ?? T.translate('upload_input_v3.upload_failed'));
+
+ // status 0 means the request never got a real server response (connection dropped/timed
+ // out) - Dropzone's own message for that case is "Server responded with 0 code.", which
+ // is not something a user can act on.
+ const displayMessage =
+ status === 0 || text === 'Network error'
+ ? T.translate('upload_input_v3.network_error')
+ : text === 'Upload timed out'
+ ? T.translate('upload_input_v3.upload_timed_out')
+ : text === 'Upload failed'
+ ? T.translate('upload_input_v3.upload_failed')
+ : text === 'Auth error'
+ ? T.translate('upload_input_v3.auth_error')
+ : text === 'Max files reached.'
+ ? T.translate('upload_input_v3.max_files_reached')
+ : text;
+
+ setErrorFiles(prev => {
+ const existingIndex = prev.findIndex(f => f.name === file.name && f.size === file.size);
+ const entry = { name: file.name, size: file.size, message: displayMessage };
+ if (existingIndex === -1) return [...prev, entry];
+ return prev.map((f, i) => (i === existingIndex ? entry : f));
+ });
}, []);
const handleDismissError = useCallback((file) => {
@@ -381,7 +407,7 @@ const UploadInputV3 = ({
{file.name}
- {formatFileSize(file.size)} · {file.complete ? 'Complete' : 'Loading'}
+ {formatFileSize(file.size)} · {file.complete ? T.translate("upload_input_v3.complete") : T.translate("upload_input_v3.loading")}
{!file.complete && (