Merge pull request #262 from open-craft/pooja/bb7157-add-video-upload-feature

[BB-7157] Create new editor page for video upload
This commit is contained in:
kenclary
2023-04-26 13:41:37 -04:00
committed by GitHub
19 changed files with 29511 additions and 43230 deletions

72045
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -40,7 +40,7 @@
"@edx/reactifex": "^2.1.1",
"@testing-library/dom": "^8.13.0",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^12.1.1",
"@testing-library/react": "^12.1.5",
"@testing-library/user-event": "^13.5.0",
"codecov": "3.8.3",
"enzyme": "3.11.0",
@@ -72,6 +72,7 @@
"lodash-es": "^4.17.21",
"moment": "^2.29.4",
"moment-shortformat": "^2.1.0",
"react-dropzone": "^14.2.3",
"react-redux": "^7.2.8",
"react-responsive": "8.2.0",
"react-transition-group": "4.4.2",

View File

@@ -0,0 +1,156 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`VideoUploadEditor renders without errors 1`] = `
<div>
<div
class="marked-area"
>
<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
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-prompt"
>
<input
placeholder="Paste your video ID or URL"
type="text"
value=""
/>
<button
class="border-start-0"
type="button"
>
<icon
class="rounded-circle text-dark"
/>
</button>
</div>
</div>
</div>
</div>
`;
exports[`VideoUploader renders without errors 1`] = `
<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
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-prompt"
>
<input
placeholder="Paste your video ID or URL"
type="text"
value=""
/>
<button
class="border-start-0"
type="button"
>
<icon
class="rounded-circle text-dark"
/>
</button>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,42 @@
import * as requests from '../../data/redux/thunkActions/requests';
export const uploadVideo = async ({ dispatch, supportedFiles }) => {
const data = { files: [] };
supportedFiles.forEach((file) => {
data.files.push({
file_name: file.name,
content_type: file.type,
});
});
dispatch(await requests.uploadVideo({
data,
onSuccess: async (response) => {
const { files } = response.json();
await Promise.all(Object.values(files).map(async (fileObj) => {
const fileName = fileObj.file_name;
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((resp) => resp.json())
.then((responseData) => console.log('File uploaded:', responseData))
.catch((error) => console.error('Error uploading file:', error));
}));
},
}));
};
export default {
uploadVideo,
};

View File

@@ -0,0 +1,99 @@
import hooks from './hooks';
import * as requests from '../../data/redux/thunkActions/requests';
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' }),
];
beforeEach(() => {
jest.resetAllMocks();
jest.restoreAllMocks();
});
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');
});
it('should call fetch with correct arguments for each file', async () => {
const mockResponseData = { success: true };
const mockFetchResponse = Promise.resolve({ json: () => Promise.resolve(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 spyConsoleLog = jest.spyOn(console, 'log');
const mockRequestResponse = { json: () => response };
requests.uploadVideo.mockImplementation(async ({ onSuccess }) => {
await onSuccess(mockRequestResponse);
});
await hooks.uploadVideo({ dispatch, supportedFiles });
expect(fetch).toHaveBeenCalledTimes(2);
expect(spyConsoleLog).toHaveBeenCalledTimes(2);
expect(spyConsoleLog).toHaveBeenCalledWith('File uploaded:', mockResponseData);
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 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 spyConsoleError = jest.spyOn(console, 'error');
const mockRequestResponse = { json: () => response };
requests.uploadVideo.mockImplementation(async ({ onSuccess }) => {
await onSuccess(mockRequestResponse);
});
await hooks.uploadVideo({ dispatch, supportedFiles });
expect(spyConsoleError).toHaveBeenCalledTimes(2);
expect(spyConsoleError).toHaveBeenCalledWith('Error uploading file:', error);
});
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 = { json: () => response };
const spyConsoleError = jest.spyOn(console, 'error');
requests.uploadVideo.mockImplementation(({ onSuccess }) => {
onSuccess(mockRequestResponse);
});
hooks.uploadVideo({ dispatch, supportedFiles: [supportedFiles[0]] });
expect(spyConsoleError).toHaveBeenCalledWith('Could not find file object with name "file2.mov" in supportedFiles array.');
});
});

View File

@@ -0,0 +1,141 @@
import React, { useState } 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 { ArrowForward, Close, FileUpload } from '@edx/paragon/icons';
import * as hooks from './hooks';
import messages from '../../messages';
import * as editorHooks from '../EditorContainer/hooks';
export const VideoUploader = ({ onUpload, errorMessage }) => {
const [, setUploadedFile] = useState();
const [textInputValue, setTextInputValue] = useState('');
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: 'video/*',
multiple: false,
onDrop: (acceptedFiles) => {
if (acceptedFiles.length > 0) {
const uploadfile = acceptedFiles[0];
setUploadedFile(uploadfile);
onUpload(uploadfile);
}
},
});
const handleInputChange = (event) => {
setTextInputValue(event.target.value);
};
const handleSaveButtonClick = () => {
// do something with the textInputValue, e.g. save to state or send to server
console.log(`Saving input value: ${textInputValue}`);
};
if (errorMessage) {
return (
<div className="d-flex flex-column justify-content-center align-items-center text-center error-message">{errorMessage}</div>
);
}
return (
<div>
<div className="d-flex flex-column justify-content-center align-items-center p-4 w-100 min-vh-100" {...getRootProps()}>
<div className={`d-flex flex-column justify-content-center align-items-center gap-2 text-center min-vh-100 w-100
dropzone-middle ${isDragActive ? 'active' : ''}`}
>
<div className="d-flex justify-content-center align-items-center bg-light rounded-circle file-upload">
<Icon src={FileUpload} className="text-muted" />
</div>
<div className="d-flex align-items-center justify-content-center gap-1 flex-wrap flex-column pt-5">
<span style={{ fontSize: '20px' }}><FormattedMessage {...messages.dropVideoFileHere} /></span>
<span style={{ fontSize: '12px' }}><FormattedMessage {...messages.info} /></span>
</div>
<div className="d-flex align-items-center mt-3">
<span className="mx-2 text-dark">OR</span>
</div>
</div>
<input {...getInputProps()} data-testid="fileInput" />
</div>
<div className="d-flex video-id-prompt">
<input
type="text"
placeholder="Paste your video ID or URL"
value={textInputValue}
onChange={handleInputChange}
onKeyDown={(e) => e.key === 'Enter' && handleSaveButtonClick()}
onClick={(event) => event.preventDefault()}
/>
<button className="border-start-0" type="button" onClick={handleSaveButtonClick}>
<Icon src={ArrowForward} className="rounded-circle text-dark" />
</button>
</div>
</div>
);
};
VideoUploader.propTypes = {
onUpload: PropTypes.func.isRequired,
errorMessage: PropTypes.string.isRequired,
intl: intlShape.isRequired,
};
const VideoUploadEditor = ({ intl, onClose }) => {
const dispatch = useDispatch();
const [errorMessage, setErrorMessage] = useState(null);
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);
}
};
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>
);
};
VideoUploadEditor.propTypes = {
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
export default injectIntl(VideoUploadEditor);

View File

@@ -0,0 +1,47 @@
.dropzone-middle {
border: 2px dashed #ccc;
&.active {
border: 2px solid #262626; /* change color when active */
}
.file-upload {
width: 56px;
height: 56px;
}
}
.video-id-prompt {
position: absolute;
top: 68%;
left: 50%;
transform: translateX(-50%);
margin-top: 1rem;
border: 1px solid #707070;
width: 308px;
input {
border: none !important;
width: 90%;
}
input::placeholder {
color: #454545;
}
button {
border: none !important;
background-color: #FFFFFF;
}
}
.error-message {
color: #AB0D02;
margin-top: 20rem;
}
.close-button-container {
position: absolute;
top: 25px;
right: 30px;
}

View File

@@ -0,0 +1,127 @@
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 VideoUploadEditor, { VideoUploader } from '.';
import * as hooks from './hooks';
const mockDispatch = jest.fn();
const mockOnUpload = jest.fn();
jest.mock('react-redux', () => ({
useDispatch: () => mockDispatch,
}));
const defaultEditorProps = {
intl: {},
};
const defaultUploaderProps = {
onUpload: mockOnUpload,
errorMessage: '',
intl: {},
};
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('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'],
},
});
});
expect(mockOnUpload).toHaveBeenCalledWith(file);
});
});

View File

@@ -6,4 +6,5 @@ export const blockTypes = StrictDict({
video: 'video',
problem: 'problem',
// ADDED_EDITORS GO BELOW
video_upload: 'video_upload',
});

View File

@@ -1,4 +1,5 @@
import { StrictDict, camelizeKeys } from '../../../utils';
/* eslint-disable import/no-cycle */
import { actions } from '..';
import * as requests from './requests';
import * as module from './app';

View File

@@ -1,5 +1,6 @@
import { StrictDict } from '../../../utils';
/* eslint-disable import/no-cycle */
import app from './app';
import video from './video';
import problem from './problem';

View File

@@ -1,4 +1,5 @@
import _ from 'lodash-es';
/* eslint-disable import/no-cycle */
import { actions } from '..';
import * as requests from './requests';
import { OLXParser } from '../../../containers/ProblemEditor/data/OLXParser';

View File

@@ -1,6 +1,7 @@
import { StrictDict } from '../../../utils';
import { RequestKeys } from '../../constants/requests';
/* eslint-disable import/no-cycle */
import { actions, selectors } from '..';
import api, { loadImages } from '../../services/cms/api';
@@ -295,6 +296,18 @@ export const fetchVideoFeatures = ({ ...rest }) => (dispatch, getState) => {
}));
};
export const uploadVideo = ({ data, ...rest }) => (dispatch, getState) => {
dispatch(module.networkRequest({
requestKey: RequestKeys.uploadVideo,
promise: api.uploadVideo({
data,
studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
learningContextId: selectors.app.learningContextId(getState()),
}),
...rest,
}));
};
export default StrictDict({
fetchBlock,
fetchStudioView,
@@ -314,4 +327,5 @@ export default StrictDict({
importTranscript,
fetchAdvancedSettings,
fetchVideoFeatures,
uploadVideo,
});

View File

@@ -37,6 +37,7 @@ jest.mock('../../services/cms/api', () => ({
checkTranscriptsForImport: (args) => args,
importTranscript: (args) => args,
fetchVideoFeatures: (args) => args,
uploadVideo: (args) => args,
}));
const apiKeys = keyStore(api);
@@ -490,5 +491,22 @@ describe('requests thunkActions module', () => {
},
});
});
describe('uploadVideo', () => {
const data = { files: [{ file_name: 'video.mp4', content_type: 'mp4' }] };
testNetworkRequestAction({
action: requests.uploadVideo,
args: { ...fetchParams, data },
expectedString: 'with uploadVideo promise',
expectedData: {
...fetchParams,
requestKey: RequestKeys.uploadVideo,
promise: api.uploadVideo({
studioEndpointUrl: selectors.app.studioEndpointUrl(testState),
learningContextId: selectors.app.learningContextId(testState),
data,
}),
},
});
});
});
});

View File

@@ -1,3 +1,4 @@
/* eslint-disable import/no-cycle */
import { actions, selectors } from '..';
import { removeItemOnce } from '../../../utils';
import * as requests from './requests';

View File

@@ -208,6 +208,14 @@ export const apiMethods = {
}) => get(
urls.videoFeatures({ studioEndpointUrl, learningContextId }),
),
uploadVideo: ({
data,
studioEndpointUrl,
learningContextId,
}) => post(
urls.courseVideos({ studioEndpointUrl, learningContextId }),
data,
),
};
export const loadImage = (imageData) => ({

View File

@@ -26,6 +26,11 @@ jest.mock('./urls', () => ({
replaceTranscript: jest.fn().mockName('urls.replaceTranscript'),
videoFeatures: jest.fn().mockName('urls.videoFeatures'),
courseVideos: jest.fn().mockName('urls.courseVideos'),
videoUpload: jest.fn()
.mockName('urls.courseVideos')
.mockImplementation(
({ studioEndpointUrl, learningContextId }) => `${studioEndpointUrl}/some_video_upload_url/${learningContextId}`,
),
}));
jest.mock('./utils', () => ({
@@ -219,6 +224,20 @@ describe('cms api', () => {
);
});
});
describe('uploadVideo', () => {
it('should call post with urls.courseVideos and data', () => {
const data = { files: [{ file_name: 'video.mp4', content_type: 'mp4' }] };
apiMethods.uploadVideo({ data, studioEndpointUrl, learningContextId });
expect(urls.courseVideos).toHaveBeenCalledWith({ studioEndpointUrl, learningContextId });
expect(post).toHaveBeenCalledWith(
urls.courseVideos({ studioEndpointUrl, learningContextId }),
data,
);
});
});
});
describe('loadImage', () => {
it('loads incoming image data, replacing the dateAdded with a date field', () => {

View File

@@ -7,6 +7,21 @@ const messages = defineMessages({
defaultMessage: 'Error: Could Not find Editor',
description: 'Error Message Dispayed When An unsopported Editor is desired in V2',
},
dropVideoFileHere: {
defaultMessage: 'Drag and drop video here or click to upload',
id: 'VideoUploadEditor.dropVideoFileHere',
description: 'Display message for Drag and Drop zone',
},
browse: {
defaultMessage: 'Browse files',
id: 'VideoUploadEditor.browse',
description: 'Display message for browse files button',
},
info: {
id: 'VideoUploadEditor.uploadInfo',
defaultMessage: 'Upload MP4 or MOV files (5 GB max)',
description: 'Info message for supported formats',
},
});
export default messages;

View File

@@ -3,6 +3,7 @@ import VideoEditor from './containers/VideoEditor';
import ProblemEditor from './containers/ProblemEditor';
// ADDED_EDITOR_IMPORTS GO HERE
import VideoUploadEditor from './containers/VideoUploadEditor';
import { blockTypes } from './data/constants/app';
@@ -11,6 +12,7 @@ const supportedEditors = {
[blockTypes.video]: VideoEditor,
[blockTypes.problem]: ProblemEditor,
// ADDED_EDITORS GO BELOW
[blockTypes.video_upload]: VideoUploadEditor,
};
export default supportedEditors;