Merge pull request #354 from open-craft/farhaan/bb-7522-fix-spinner-for-video

feat: Add loading spinner to the video upload page
This commit is contained in:
kenclary
2023-06-30 11:10:50 -04:00
committed by GitHub
8 changed files with 511 additions and 440 deletions

View File

@@ -1,166 +1,227 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`VideoUploadEditor renders without errors 1`] = `
exports[`VideoUploader snapshots renders as expected with default behavior 1`] = `
<div>
<div
class="marked-area"
className="d-flex flex-column justify-content-center align-items-center p-4 w-100 min-vh-100"
onBlur={[Function]}
onClick={[Function]}
onDragEnter={[Function]}
onDragLeave={[Function]}
onDragOver={[Function]}
onDrop={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
role="presentation"
tabIndex={0}
>
<div
class="d-flex justify-content-end close-button-container"
>
<iconbutton
iconas="Icon"
/>
</div>
<div>
<div
class="d-flex flex-column justify-content-center align-items-center p-4 w-100 min-vh-100"
role="presentation"
tabindex="0"
>
<div
class="d-flex flex-column justify-content-center align-items-center gap-2 text-center min-vh-100 w-100
className="d-flex flex-column justify-content-center align-items-center gap-2 text-center min-vh-100 w-100
dropzone-middle "
>
<div
class="d-flex justify-content-center align-items-center bg-light rounded-circle file-upload"
>
<icon
class="text-muted"
/>
</div>
<div
class="d-flex align-items-center justify-content-center gap-1 flex-wrap flex-column pt-5"
>
<span
style="font-size: 20px;"
>
FormattedMessage
</span>
<span
style="font-size: 12px;"
>
FormattedMessage
</span>
</div>
<div
class="d-flex align-items-center mt-3"
>
<span
class="mx-2 text-dark"
>
OR
</span>
</div>
</div>
<input
accept=""
data-testid="fileInput"
style="display: none;"
tabindex="-1"
type="file"
>
<div
className="d-flex justify-content-center align-items-center bg-light rounded-circle file-upload"
>
<Icon
className="text-muted"
/>
</div>
<div
class="d-flex video-id-container"
className="d-flex align-items-center justify-content-center gap-1 flex-wrap flex-column pt-5"
>
<div
class="d-flex video-id-prompt"
<span
style={
Object {
"fontSize": "20px",
}
}
>
<input
placeholder="Paste your video ID or URL"
type="text"
value=""
<FormattedMessage
defaultMessage="Drag and drop video here or click to upload"
description="Display message for Drag and Drop zone"
id="VideoUploadEditor.dropVideoFileHere"
/>
<button
class="border-start-0"
data-testid="inputSaveButton"
type="button"
>
<icon
class="rounded-circle text-dark"
/>
</button>
</div>
</span>
<span
style={
Object {
"fontSize": "12px",
}
}
>
<FormattedMessage
defaultMessage="Upload MP4 or MOV files (5 GB max)"
description="Info message for supported formats"
id="VideoUploadEditor.uploadInfo"
/>
</span>
</div>
<div
className="d-flex align-items-center mt-3"
>
<span
className="mx-2 text-dark"
>
OR
</span>
</div>
</div>
<input
accept=""
data-testid="fileInput"
multiple={false}
onChange={[Function]}
onClick={[Function]}
style={
Object {
"display": "none",
}
}
tabIndex={-1}
type="file"
/>
</div>
<div
className="d-flex video-id-container"
>
<div
className="d-flex video-id-prompt"
>
<input
onChange={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
placeholder="Paste your video ID or URL"
type="text"
value=""
/>
<button
className="border-start-0"
data-testid="inputSaveButton"
onClick={[Function]}
type="button"
>
<Icon
className="rounded-circle text-dark"
/>
</button>
</div>
</div>
</div>
`;
exports[`VideoUploader renders without errors 1`] = `
exports[`VideoUploader snapshots renders as expected with error message 1`] = `
<div>
<div>
<div
className="d-flex flex-column justify-content-center align-items-center p-4 w-100 min-vh-100"
onBlur={[Function]}
onClick={[Function]}
onDragEnter={[Function]}
onDragLeave={[Function]}
onDragOver={[Function]}
onDrop={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
role="presentation"
tabIndex={0}
>
<div
class="d-flex flex-column justify-content-center align-items-center p-4 w-100 min-vh-100"
role="presentation"
tabindex="0"
>
<div
class="d-flex flex-column justify-content-center align-items-center gap-2 text-center min-vh-100 w-100
className="d-flex flex-column justify-content-center align-items-center gap-2 text-center min-vh-100 w-100
dropzone-middle "
>
<div
class="d-flex justify-content-center align-items-center bg-light rounded-circle file-upload"
>
<icon
class="text-muted"
/>
</div>
<div
class="d-flex align-items-center justify-content-center gap-1 flex-wrap flex-column pt-5"
>
<span
style="font-size: 20px;"
>
FormattedMessage
</span>
<span
style="font-size: 12px;"
>
FormattedMessage
</span>
</div>
<div
class="d-flex align-items-center mt-3"
>
<span
class="mx-2 text-dark"
>
OR
</span>
</div>
</div>
<input
accept=""
data-testid="fileInput"
style="display: none;"
tabindex="-1"
type="file"
/>
</div>
<div
class="d-flex video-id-container"
>
<div
class="d-flex video-id-prompt"
className="d-flex justify-content-center align-items-center bg-light rounded-circle file-upload"
>
<input
placeholder="Paste your video ID or URL"
type="text"
value=""
<Icon
className="text-muted"
/>
<button
class="border-start-0"
data-testid="inputSaveButton"
type="button"
>
<icon
class="rounded-circle text-dark"
/>
</button>
</div>
<div
className="d-flex align-items-center justify-content-center gap-1 flex-wrap flex-column pt-5"
>
<span
style={
Object {
"fontSize": "20px",
}
}
>
<FormattedMessage
defaultMessage="Drag and drop video here or click to upload"
description="Display message for Drag and Drop zone"
id="VideoUploadEditor.dropVideoFileHere"
/>
</span>
<span
style={
Object {
"fontSize": "12px",
}
}
>
<FormattedMessage
defaultMessage="Upload MP4 or MOV files (5 GB max)"
description="Info message for supported formats"
id="VideoUploadEditor.uploadInfo"
/>
</span>
</div>
<div
className="d-flex align-items-center mt-3"
>
<span
className="mx-2 text-dark"
>
OR
</span>
</div>
</div>
<input
accept=""
data-testid="fileInput"
multiple={false}
onChange={[Function]}
onClick={[Function]}
style={
Object {
"display": "none",
}
}
tabIndex={-1}
type="file"
/>
</div>
<div
className="d-flex video-id-container"
>
<div
className="d-flex video-id-prompt"
>
<input
onChange={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
placeholder="Paste your video ID or URL"
type="text"
value=""
/>
<button
className="border-start-0"
data-testid="inputSaveButton"
onClick={[Function]}
type="button"
>
<Icon
className="rounded-circle text-dark"
/>
</button>
</div>
</div>
</div>
`;
exports[`VideoUploaderEdirtor snapshots renders as expected with default behavior 1`] = `
<ContextConsumer>
<Component />
</ContextConsumer>
`;

View File

@@ -1,66 +1,78 @@
import * as requests from '../../data/redux/thunkActions/requests';
import React from 'react';
import * as module from './hooks';
import { selectors } from '../../data/redux';
import store from '../../data/store';
import * as appHooks from '../../hooks';
const extToMime = {
mp4: 'video/mp4',
mov: 'video/quicktime',
};
export const {
navigateTo,
} = appHooks;
export const uploadVideo = async ({ dispatch, supportedFiles }) => {
const data = { files: [] };
supportedFiles.forEach((file) => {
data.files.push({
file_name: file.name,
content_type: file.type,
});
});
const onFileUploadedHook = module.onFileUploaded();
dispatch(await requests.uploadVideo({
data,
onSuccess: async (response) => {
const { files } = response.data;
await Promise.all(Object.values(files).map(async (fileObj) => {
const fileName = fileObj.file_name;
const edxVideoId = fileObj.edx_video_id;
const uploadUrl = fileObj.upload_url;
const uploadFile = supportedFiles.find((file) => file.name === fileName);
if (!uploadFile) {
console.error(`Could not find file object with name "${fileName}" in supportedFiles array.`);
return;
}
const formData = new FormData();
formData.append('uploaded-file', uploadFile);
await fetch(uploadUrl, {
method: 'PUT',
body: formData,
headers: {
'Content-Type': 'multipart/form-data',
},
})
.then(() => onFileUploadedHook(edxVideoId))
.catch((error) => console.error('Error uploading file:', error));
}));
},
}));
export const state = {
loading: (val) => React.useState(val),
errorMessage: (val) => React.useState(val),
textInputValue: (val) => React.useState(val),
};
export const onFileUploaded = () => {
const state = store.getState();
const learningContextId = selectors.app.learningContextId(state);
const blockId = selectors.app.blockId(state);
return (edxVideoId) => navigateTo(`/course/${learningContextId}/editor/video/${blockId}?selectedVideoId=${edxVideoId}`);
export const uploadEditor = () => {
const [loading, setLoading] = module.state.loading(false);
const [errorMessage, setErrorMessage] = module.state.errorMessage(null);
return {
loading,
setLoading,
errorMessage,
setErrorMessage,
};
};
export const onUrlUploaded = () => {
const state = store.getState();
const learningContextId = selectors.app.learningContextId(state);
const blockId = selectors.app.blockId(state);
export const uploader = () => {
const [textInputValue, settextInputValue] = module.state.textInputValue('');
return {
textInputValue,
settextInputValue,
};
};
export const postUploadRedirect = (storeState) => {
const learningContextId = selectors.app.learningContextId(storeState);
const blockId = selectors.app.blockId(storeState);
return (videoUrl) => navigateTo(`/course/${learningContextId}/editor/video/${blockId}?selectedVideoUrl=${videoUrl}`);
};
export default {
uploadVideo,
export const onVideoUpload = () => {
const storeState = store.getState();
return module.postUploadRedirect(storeState);
};
const getFileExtension = (filename) => filename.slice(Math.abs(filename.lastIndexOf('.') - 1) + 2);
export const fileValidator = (setLoading, setErrorMessage, uploadVideo) => (file) => {
const supportedFormats = Object.keys(extToMime);
const ext = getFileExtension(file.name);
const type = extToMime[ext] || '';
const newFile = new File([file], file.name, { type });
if (supportedFormats.includes(ext)) {
uploadVideo({
supportedFiles: [newFile],
setLoadSpinner: setLoading,
postUploadRedirect: onVideoUpload(),
});
} else {
const errorMsg = 'Video must be an MP4 or MOV file';
setErrorMessage(errorMsg);
}
};
export default {
postUploadRedirect,
uploadEditor,
uploader,
onVideoUpload,
fileValidator,
};

View File

@@ -1,92 +1,50 @@
import hooks from './hooks';
import * as requests from '../../data/redux/thunkActions/requests';
import * as hooks from './hooks';
import { MockUseState } from '../../../testUtils';
jest.mock('../../data/redux/thunkActions/requests');
describe('uploadVideo', () => {
const dispatch = jest.fn();
const supportedFiles = [
new File(['content1'], 'file1.mp4', { type: 'video/mp4' }),
new File(['content2'], 'file2.mov', { type: 'video/quicktime' }),
];
const state = new MockUseState(hooks);
const setLoading = jest.fn();
const setErrorMessage = jest.fn();
const uploadVideo = jest.fn();
describe('Video Upload Editor hooks', () => {
beforeEach(() => {
jest.resetAllMocks();
jest.restoreAllMocks();
jest.clearAllMocks();
});
it('should dispatch uploadVideo action with correct data and onSuccess callback', async () => {
requests.uploadVideo.mockImplementation(() => 'requests.uploadVideo');
const data = {
files: [
{ file_name: 'file1.mp4', content_type: 'video/mp4' },
{ file_name: 'file2.mov', content_type: 'video/quicktime' },
],
};
await hooks.uploadVideo({ dispatch, supportedFiles });
expect(requests.uploadVideo).toHaveBeenCalledWith({ data, onSuccess: expect.any(Function) });
expect(dispatch).toHaveBeenCalledWith('requests.uploadVideo');
describe('state hooks', () => {
state.testGetter(state.keys.loading);
state.testGetter(state.keys.errorMessage);
state.testGetter(state.keys.textInputValue);
});
describe('using state', () => {
beforeEach(() => { state.mock(); });
afterEach(() => { state.restore(); });
it('should call fetch with correct arguments for each file', async () => {
const mockResponseData = { success: true };
const mockFetchResponse = Promise.resolve({ data: mockResponseData });
global.fetch = jest.fn().mockImplementation(() => mockFetchResponse);
const response = {
files: [
{ file_name: 'file1.mp4', upload_url: 'http://example.com/put_video1' },
{ file_name: 'file2.mov', upload_url: 'http://example.com/put_video2' },
],
};
const mockRequestResponse = { data: response };
requests.uploadVideo.mockImplementation(async ({ onSuccess }) => {
await onSuccess(mockRequestResponse);
});
await hooks.uploadVideo({ dispatch, supportedFiles });
expect(fetch).toHaveBeenCalledTimes(2);
response.files.forEach(({ upload_url: uploadUrl }, index) => {
expect(fetch.mock.calls[index][0]).toEqual(uploadUrl);
});
supportedFiles.forEach((file, index) => {
expect(fetch.mock.calls[index][1].body.get('uploaded-file')).toBe(file);
describe('Hooks for Video Upload', () => {
beforeEach(() => {
hooks.uploadEditor();
hooks.uploader();
});
it('initialize state with correct values', () => {
expect(state.stateVals.loading).toEqual(false);
expect(state.stateVals.errorMessage).toEqual(null);
expect(state.stateVals.textInputValue).toEqual('');
});
});
});
it('should log an error if fetch failed to upload a file', async () => {
const error = new Error('Uh-oh!');
global.fetch = jest.fn().mockRejectedValue(error);
const response = {
files: [
{ file_name: 'file1.mp4', upload_url: 'http://example.com/put_video1' },
{ file_name: 'file2.mov', upload_url: 'http://example.com/put_video2' },
],
};
const mockRequestResponse = { data: response };
requests.uploadVideo.mockImplementation(async ({ onSuccess }) => {
await onSuccess(mockRequestResponse);
describe('File Validation', () => {
it('Checks with valid MIME type', () => {
const file = new File(['(⌐□_□)'], 'video.mp4', { type: 'video/mp4' });
const validator = hooks.fileValidator(setLoading, setErrorMessage, uploadVideo);
validator(file);
expect(uploadVideo).toHaveBeenCalled();
expect(setErrorMessage).not.toHaveBeenCalled();
});
await hooks.uploadVideo({ dispatch, supportedFiles });
});
it('should log an error if file object is not found in supportedFiles array', () => {
const response = {
files: [
{ file_name: 'file2.mov', upload_url: 'http://example.com/put_video2' },
],
};
const mockRequestResponse = { data: response };
const spyConsoleError = jest.spyOn(console, 'error');
requests.uploadVideo.mockImplementation(({ onSuccess }) => {
onSuccess(mockRequestResponse);
it('Checks with invalid MIME type', () => {
const file = new File(['(⌐□_□)'], 'video.gif', { type: 'video/mp4' });
const validator = hooks.fileValidator(setLoading, setErrorMessage, uploadVideo);
validator(file);
expect(uploadVideo).not.toHaveBeenCalled();
expect(setErrorMessage).toHaveBeenCalled();
});
hooks.uploadVideo({ dispatch, supportedFiles: [supportedFiles[0]] });
expect(spyConsoleError).toHaveBeenCalledWith('Could not find file object with name "file2.mov" in supportedFiles array.');
});
});

View File

@@ -1,19 +1,19 @@
import React, { useState } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { useDropzone } from 'react-dropzone';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Icon, IconButton } from '@edx/paragon';
import './index.scss';
import { useDispatch } from 'react-redux';
import { Icon, IconButton, Spinner } from '@edx/paragon';
import { ArrowForward, Close, FileUpload } from '@edx/paragon/icons';
import { connect } from 'react-redux';
import { thunkActions } from '../../data/redux';
import './index.scss';
import * as hooks from './hooks';
import messages from '../../messages';
import messages from './messages';
import * as editorHooks from '../EditorContainer/hooks';
export const VideoUploader = ({ onUpload, errorMessage }) => {
const [, setUploadedFile] = useState();
const [textInputValue, setTextInputValue] = useState('');
const onUrlUpdatedHook = hooks.onUrlUploaded();
const { textInputValue, setTextInputValue } = hooks.uploader();
const onURLUpload = hooks.onVideoUpload();
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: 'video/*',
@@ -21,7 +21,6 @@ export const VideoUploader = ({ onUpload, errorMessage }) => {
onDrop: (acceptedFiles) => {
if (acceptedFiles.length > 0) {
const uploadfile = acceptedFiles[0];
setUploadedFile(uploadfile);
onUpload(uploadfile);
}
},
@@ -32,7 +31,7 @@ export const VideoUploader = ({ onUpload, errorMessage }) => {
};
const handleSaveButtonClick = () => {
onUrlUpdatedHook(textInputValue);
onURLUpload(textInputValue);
};
if (errorMessage) {
@@ -85,52 +84,53 @@ VideoUploader.propTypes = {
intl: intlShape.isRequired,
};
const VideoUploadEditor = ({ intl, onClose }) => {
const dispatch = useDispatch();
const [errorMessage, setErrorMessage] = useState(null);
const handleCancel = () => {
editorHooks.handleCancel({ onClose });
};
const VideoUploadEditor = (
{
intl,
onClose,
// Redux states
uploadVideo,
},
) => {
const {
loading,
setLoading,
errorMessage,
setErrorMessage,
} = hooks.uploadEditor();
const handleCancel = editorHooks.handleCancel({ onClose });
const handleDrop = (file) => {
if (!file) {
console.log('No file selected.');
return;
}
const extToMime = {
mp4: 'video/mp4',
mov: 'video/quicktime',
};
const supportedFormats = Object.keys(extToMime);
function getFileExtension(filename) {
return filename.slice(Math.abs(filename.lastIndexOf('.') - 1) + 2);
}
const ext = getFileExtension(file.name);
const type = extToMime[ext] || '';
const newFile = new File([file], file.name, { type });
if (supportedFormats.includes(ext)) {
hooks.uploadVideo({ dispatch, supportedFiles: [newFile] });
} else {
const errorMsg = 'Video must be an MP4 or MOV file';
console.log(errorMsg);
setErrorMessage(errorMsg);
}
const validator = hooks.fileValidator(setLoading, setErrorMessage, uploadVideo);
validator(file);
};
return (
<div className="marked-area">
<div className="d-flex justify-content-end close-button-container">
<IconButton
src={Close}
iconAs={Icon}
onClick={handleCancel}
/>
</div>
<VideoUploader onUpload={handleDrop} errorMessage={errorMessage} intl={intl} />
<div>
{(!loading) ? (
<div className="marked-area">
<div className="d-flex justify-content-end close-button-container">
<IconButton
src={Close}
iconAs={Icon}
onClick={handleCancel}
/>
</div>
<VideoUploader onUpload={handleDrop} errorMessage={errorMessage} intl={intl} />
</div>
) : (
<div className="text-center p-6">
<Spinner
animation="border"
className="m-3"
screenreadertext={intl.formatMessage(messages.spinnerScreenReaderText)}
/>
</div>
)}
</div>
);
};
@@ -138,6 +138,13 @@ const VideoUploadEditor = ({ intl, onClose }) => {
VideoUploadEditor.propTypes = {
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
uploadVideo: PropTypes.func.isRequired,
};
export default injectIntl(VideoUploadEditor);
export const mapStateToProps = () => ({});
export const mapDispatchToProps = {
uploadVideo: thunkActions.video.uploadVideo,
};
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(VideoUploadEditor));

View File

@@ -1,142 +1,37 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { render, fireEvent, act } from '@testing-library/react';
import '@testing-library/jest-dom';
import { shallow } from 'enzyme';
import VideoUploadEditor, { VideoUploader } from '.';
import * as hooks from './hooks';
import * as appHooks from '../../hooks';
const mockDispatch = jest.fn();
const mockOnUpload = jest.fn();
jest.mock('react-redux', () => ({
useDispatch: () => mockDispatch,
}));
jest.mock('../../hooks', () => ({
...jest.requireActual('../../hooks'),
navigateTo: jest.fn((args) => ({ navigateTo: args })),
}));
import { formatMessage } from '../../../testUtils';
const defaultEditorProps = {
intl: {},
onClose: jest.fn().mockName('props.onClose'),
intl: { formatMessage },
uploadVideo: jest.fn(),
};
const defaultUploaderProps = {
onUpload: mockOnUpload,
errorMessage: '',
intl: {},
onUpload: jest.fn(),
errorMessage: null,
intl: { formatMessage },
};
const renderEditorComponent = (props = defaultEditorProps) => render(
<IntlProvider locale="en">
<VideoUploadEditor {...props} />
</IntlProvider>,
);
const renderUploaderComponent = (props = defaultUploaderProps) => render(
<IntlProvider locale="en">
<VideoUploader {...props} />
</IntlProvider>,
);
describe('VideoUploadEditor', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('renders without errors', () => {
const { container } = renderEditorComponent();
expect(container).toMatchSnapshot();
});
it('updates the input field value when user types', () => {
const { getByPlaceholderText } = renderEditorComponent();
const input = getByPlaceholderText('Paste your video ID or URL');
fireEvent.change(input, { target: { value: 'test value' } });
expect(input.value).toBe('test value');
});
it('click on the save button', () => {
const { getByPlaceholderText, getByTestId } = renderEditorComponent();
const testValue = 'test vale';
const input = getByPlaceholderText('Paste your video ID or URL');
fireEvent.change(input, { target: { value: testValue } });
const button = getByTestId('inputSaveButton');
fireEvent.click(button);
expect(appHooks.navigateTo).toHaveBeenCalled();
});
it('shows error message with unsupported files', async () => {
const { getByTestId, findByText } = renderEditorComponent();
const fileInput = getByTestId('fileInput');
const unsupportedFile = new File(['(⌐□_□)'], 'unsupported.avi', { type: 'video/avi' });
fireEvent.change(fileInput, { target: { files: [unsupportedFile] } });
const errorMsg = await findByText('Video must be an MP4 or MOV file');
expect(errorMsg).toBeInTheDocument();
});
it('calls uploadVideo with supported files', async () => {
const uploadVideoSpy = jest.spyOn(hooks, 'uploadVideo');
const { container } = renderEditorComponent();
const dropzone = container.querySelector('.dropzone-middle');
const supportedFile = new File(['(⌐□_□)'], 'supported.mp4', { type: 'video/mp4' });
await act(async () => {
fireEvent.drop(dropzone, {
dataTransfer: {
files: [supportedFile],
types: ['Files'],
},
});
});
expect(uploadVideoSpy).toHaveBeenCalledWith(expect.objectContaining({
dispatch: mockDispatch,
supportedFiles: expect.arrayContaining([
expect.objectContaining({
name: supportedFile.name,
type: supportedFile.type,
size: supportedFile.size,
}),
]),
}));
});
});
describe('VideoUploader', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('renders without errors', () => {
const { container } = renderUploaderComponent();
expect(container).toMatchSnapshot();
});
it('renders with an error message', () => {
const errorMessage = 'Video must be an MP4 or MOV file';
const { getByText } = renderUploaderComponent({ ...defaultUploaderProps, errorMessage });
expect(getByText(errorMessage)).toBeInTheDocument();
});
it('calls the onUpload function when a supported file is dropped', async () => {
const { container } = renderUploaderComponent();
const dropzone = container.querySelector('.dropzone-middle');
const file = new File(['(⌐□_□)'], 'video.mp4', { type: 'video/mp4' });
await act(async () => {
fireEvent.drop(dropzone, {
dataTransfer: {
files: [file],
types: ['Files'],
},
});
describe('snapshots', () => {
test('renders as expected with default behavior', () => {
expect(shallow(<VideoUploader {...defaultUploaderProps} />)).toMatchSnapshot();
});
test('renders as expected with error message', () => {
const defaultUploaderPropsWithError = { ...defaultUploaderProps, errorMessages: 'Some Error' };
expect(shallow(<VideoUploader {...defaultUploaderPropsWithError} />)).toMatchSnapshot();
});
});
});
describe('VideoUploaderEdirtor', () => {
describe('snapshots', () => {
test('renders as expected with default behavior', () => {
expect(shallow(<VideoUploadEditor {...defaultEditorProps} />)).toMatchSnapshot();
});
expect(mockOnUpload).toHaveBeenCalledWith(file);
});
});

View File

@@ -0,0 +1,21 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
spinnerScreenReaderText: {
id: 'authoring.videoUpload.spinnerScreenReaderText',
defaultMessage: 'loading',
description: 'Loading message for spinner screenreader text.',
},
dropVideoFileHere: {
defaultMessage: 'Drag and drop video here or click to upload',
id: 'VideoUploadEditor.dropVideoFileHere',
description: 'Display message for Drag and Drop zone',
},
info: {
id: 'VideoUploadEditor.uploadInfo',
defaultMessage: 'Upload MP4 or MOV files (5 GB max)',
description: 'Info message for supported formats',
},
});
export default messages;

View File

@@ -370,6 +370,45 @@ export const replaceTranscript = ({ newFile, newFilename, language }) => (dispat
}));
};
export const uploadVideo = ({ supportedFiles, setLoadSpinner, postUploadRedirect }) => (dispatch) => {
const data = { files: [] };
setLoadSpinner(true);
supportedFiles.forEach((file) => {
data.files.push({
file_name: file.name,
content_type: file.type,
});
});
dispatch(requests.uploadVideo({
data,
onSuccess: async (response) => {
const { files } = response.data;
await Promise.all(Object.values(files).map(async (fileObj) => {
const fileName = fileObj.file_name;
const edxVideoId = fileObj.edx_video_id;
const uploadUrl = fileObj.upload_url;
const uploadFile = supportedFiles.find((file) => file.name === fileName);
if (!uploadFile) {
console.error(`Could not find file object with name "${fileName}" in supportedFiles array.`);
return;
}
const formData = new FormData();
formData.append('uploaded-file', uploadFile);
await fetch(uploadUrl, {
method: 'PUT',
body: formData,
headers: {
'Content-Type': 'multipart/form-data',
},
})
.then(() => postUploadRedirect(edxVideoId))
.catch((error) => console.error('Error uploading file:', error));
}));
setLoadSpinner(false);
},
}));
};
export default {
loadVideoData,
determineVideoSources,
@@ -382,4 +421,5 @@ export default {
updateTranscriptLanguage,
replaceTranscript,
uploadHandout,
uploadVideo,
};

View File

@@ -32,6 +32,7 @@ jest.mock('./requests', () => ({
checkTranscriptsForImport: (args) => ({ checkTranscriptsForImport: args }),
importTranscript: (args) => ({ importTranscript: args }),
fetchVideoFeatures: (args) => ({ fetchVideoFeatures: args }),
uploadVideo: (args) => ({ uploadVideo: args }),
}));
jest.mock('../../../utils', () => ({
@@ -669,3 +670,79 @@ describe('video thunkActions', () => {
});
});
});
describe('uploadVideo', () => {
let dispatch;
let setLoadSpinner;
let postUploadRedirect;
let dispatchedAction;
const supportedFiles = [
new File(['content1'], 'file1.mp4', { type: 'video/mp4' }),
new File(['content2'], 'file2.mov', { type: 'video/quicktime' }),
];
beforeEach(() => {
dispatch = jest.fn((action) => ({ dispatch: action }));
setLoadSpinner = jest.fn();
postUploadRedirect = jest.fn();
jest.resetAllMocks();
jest.restoreAllMocks();
});
it('dispatch uploadVideo action with right data', async () => {
const data = {
files: [
{ file_name: 'file1.mp4', content_type: 'video/mp4' },
{ file_name: 'file2.mov', content_type: 'video/quicktime' },
],
};
thunkActions.uploadVideo({ supportedFiles, setLoadSpinner, postUploadRedirect })(dispatch);
[[dispatchedAction]] = dispatch.mock.calls;
expect(dispatchedAction.uploadVideo).not.toEqual(undefined);
expect(setLoadSpinner).toHaveBeenCalled();
expect(dispatchedAction.uploadVideo.data).toEqual(data);
});
it('should call fetch with correct arguments for each file', async () => {
const mockResponseData = { success: true };
const mockFetchResponse = Promise.resolve({ data: mockResponseData });
global.fetch = jest.fn().mockImplementation(() => mockFetchResponse);
const response = {
files: [
{ file_name: 'file1.mp4', upload_url: 'http://example.com/put_video1' },
{ file_name: 'file2.mov', upload_url: 'http://example.com/put_video2' },
],
};
const mockRequestResponse = { data: response };
thunkActions.uploadVideo({ supportedFiles, setLoadSpinner, postUploadRedirect })(dispatch);
[[dispatchedAction]] = dispatch.mock.calls;
dispatchedAction.uploadVideo.onSuccess(mockRequestResponse);
expect(fetch).toHaveBeenCalledTimes(2);
response.files.forEach(({ upload_url: uploadUrl }, index) => {
expect(fetch.mock.calls[index][0]).toEqual(uploadUrl);
});
supportedFiles.forEach((file, index) => {
expect(fetch.mock.calls[index][1].body.get('uploaded-file')).toBe(file);
});
});
it('should log an error if file object is not found in supportedFiles array', () => {
const mockResponseData = { success: true };
const mockFetchResponse = Promise.resolve({ data: mockResponseData });
global.fetch = jest.fn().mockImplementation(() => mockFetchResponse);
const response = {
files: [
{ file_name: 'file2.gif', upload_url: 'http://example.com/put_video2' },
],
};
const mockRequestResponse = { data: response };
const spyConsoleError = jest.spyOn(console, 'error');
thunkActions.uploadVideo({ supportedFiles: [supportedFiles[0]], setLoadSpinner, postUploadRedirect })(dispatch);
dispatchedAction.uploadVideo.onSuccess(mockRequestResponse);
expect(spyConsoleError).toHaveBeenCalledWith('Could not find file object with name "file2.gif" in supportedFiles array.');
});
});