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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "openstack-uicore-foundation",
"version": "5.0.54",
"version": "5.0.58-beta.0",
"description": "ui reactjs components for openstack marketing site",
"main": "lib/openstack-uicore-foundation.js",
"scripts": {
Expand Down
147 changes: 146 additions & 1 deletion src/components/inputs/dropzone/__tests__/dropzone.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
});

Expand Down Expand Up @@ -515,6 +524,142 @@ 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(
<DropzoneJS
{...defaultProps}
ref={ref}
onUploadComplete={onUploadCompleteMock}
onError={onErrorMock}
/>
);

const instance = ref.current;
const mockFile = { name: 'test.pdf', size: 1024000 };
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 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({ json: () => Promise.resolve({ status: 'uploading' }) })
);

const ref = React.createRef();
render(
<DropzoneJS
{...defaultProps}
ref={ref}
onUploadComplete={onUploadCompleteMock}
onError={onErrorMock}
/>
);

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({
json: () => Promise.resolve({ status: 'error', message: 'processing failed' })
})
);

const ref = React.createRef();
render(
<DropzoneJS
{...defaultProps}
ref={ref}
onUploadComplete={onUploadCompleteMock}
onError={onErrorMock}
/>
);

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('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(
<DropzoneJS
{...defaultProps}
ref={ref}
onUploadComplete={onUploadCompleteMock}
onError={onErrorMock}
/>
);

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', () => {
Expand Down
19 changes: 14 additions & 5 deletions src/components/inputs/dropzone/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export class DropzoneJS extends React.Component {
attempts++;
if (attempts > maxAttempts) {
this.stopPolling(file);
this.onError({ message: 'Upload timed out' });
this.dropzone.emit('error', file, 'Upload timed out');
return;
}
try {
Expand All @@ -131,11 +131,12 @@ export class DropzoneJS extends React.Component {
this.onUploadComplete(data);
} else if (data.status === 'error') {
this.stopPolling(file);
this.onError(data);
this.dropzone.emit('error', file, data.message || data.error || 'Upload failed');
}
} catch (error) {
this.stopPolling(file);
this.onError(error);
// fetch fail is always connection error
this.dropzone.emit('error', file, 'Network error');
}
}, 2000);

Expand Down Expand Up @@ -494,11 +495,19 @@ export class DropzoneJS extends React.Component {
_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) {
_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);
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<DropzoneV3
id="test-dropzone-v3"
config={{ postUrl: 'https://example.com/upload' }}
djsConfig={{}}
onFileError={onFileError}
/>
);

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(
<DropzoneV3
id="test-dropzone-v3"
config={{ postUrl: 'https://example.com/upload' }}
djsConfig={{}}
onFileError={onFileError}
/>
);

const file = { name: 'huge.mp4' };
capturedEventHandlers.error(file, 'File is too big.');

expect(onFileError).toHaveBeenCalledWith(file, 'File is too big.', undefined);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,47 @@ describe('UploadInputV3', () => {
expect(screen.getByText(/File is too big/)).toBeInTheDocument();
});

test('two failed chunks for the same file collapse into a single error row', () => {
render(<UploadInputV3 {...defaultProps} />);
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(<UploadInputV3 {...defaultProps} />);
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(<UploadInputV3 {...defaultProps} />);
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(<UploadInputV3 {...defaultProps} />);
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(<UploadInputV3 {...defaultProps} />);
act(() => {
Expand Down
6 changes: 3 additions & 3 deletions src/components/inputs/upload-input-v3/dropzone-v3.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
};

Expand Down
24 changes: 17 additions & 7 deletions src/components/inputs/upload-input-v3/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -205,19 +205,29 @@ 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 }]);

// 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 || message === 'Network error'
? T.translate('upload_input_v3.network_error')
: message;

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) => {
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,8 @@
"drag_and_drop": "or drag and drop",
"see_preview": "See Preview",
"preview_file": "Preview file",
"complete": "Complete"
"complete": "Complete",
"network_error": "Upload interrupted by a connection error. Please retry."
},
"grid_filter": {
"filter_by": "Filter by ",
Expand Down
Loading