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:
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
21
src/editors/containers/VideoUploadEditor/messages.js
Normal file
21
src/editors/containers/VideoUploadEditor/messages.js
Normal 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;
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user