feat: add progress bar for video uploads and refactor (#860)
* feat: add progress bar for video uploads and refactor --------- Co-authored-by: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com>
This commit is contained in:
@@ -161,7 +161,6 @@ export function resetErrors({ errorType }) {
|
||||
export function getUsagePaths({ asset, courseId }) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateEditStatus({ editType: 'usageMetrics', status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
const { usageLocations } = await getAssetUsagePaths({ assetId: asset.id, courseId });
|
||||
const assetLocations = usageLocations[asset.id];
|
||||
|
||||
@@ -244,14 +244,18 @@ const FileTable = ({
|
||||
setSelectedRows={setSelectedRows}
|
||||
fileType={fileType}
|
||||
/>
|
||||
<ApiStatusToast
|
||||
actionType={intl.formatMessage(messages.apiStatusAddingAction)}
|
||||
selectedRowCount={selectedRows.length}
|
||||
isOpen={isAddOpen}
|
||||
setClose={setAddClose}
|
||||
setSelectedRows={setSelectedRows}
|
||||
fileType={fileType}
|
||||
/>
|
||||
{
|
||||
fileType !== 'video' && (
|
||||
<ApiStatusToast
|
||||
actionType={intl.formatMessage(messages.apiStatusAddingAction)}
|
||||
selectedRowCount={selectedRows.length}
|
||||
isOpen={isAddOpen}
|
||||
setClose={setAddClose}
|
||||
setSelectedRows={setSelectedRows}
|
||||
fileType={fileType}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<ApiStatusToast
|
||||
actionType={intl.formatMessage(messages.apiStatusDownloadingAction)}
|
||||
selectedRowCount={selectedRows.length}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Toast, ProgressBar } from '@openedx/paragon';
|
||||
import messages from './messages';
|
||||
|
||||
const AddVideoProgressBarToast = ({
|
||||
uploadVideoProgress,
|
||||
intl,
|
||||
}) => {
|
||||
let isOpen = false;
|
||||
useEffect(() => {
|
||||
isOpen = !!uploadVideoProgress;
|
||||
}, [uploadVideoProgress]);
|
||||
|
||||
return (
|
||||
<Toast
|
||||
show={isOpen}
|
||||
>
|
||||
{intl.formatMessage(messages.videoUploadProgressBarLabel)}
|
||||
<ProgressBar now={uploadVideoProgress} label={uploadVideoProgress.toString()} variant="primary" />
|
||||
</Toast>
|
||||
);
|
||||
};
|
||||
|
||||
AddVideoProgressBarToast.defaultProps = {
|
||||
uploadVideoProgress: 0,
|
||||
};
|
||||
AddVideoProgressBarToast.propTypes = {
|
||||
uploadVideoProgress: PropTypes.number,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(AddVideoProgressBarToast);
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
ThumbnailColumn,
|
||||
TranscriptColumn,
|
||||
} from '../generic';
|
||||
import AddVideoProgressBarToast from './AddVideoProgressBarToast';
|
||||
import TranscriptSettings from './transcript-settings';
|
||||
import VideoThumbnail from './VideoThumbnail';
|
||||
import { getFormattedDuration, resampleFile } from './data/utils';
|
||||
@@ -62,6 +63,7 @@ const VideosPage = ({
|
||||
videoIds,
|
||||
loadingStatus,
|
||||
transcriptStatus,
|
||||
uploadNewVideoProgress,
|
||||
addingStatus: addVideoStatus,
|
||||
deletingStatus: deleteVideoStatus,
|
||||
updatingStatus: updateVideoStatus,
|
||||
@@ -190,6 +192,9 @@ const VideosPage = ({
|
||||
return (
|
||||
<VideosPageProvider courseId={courseId}>
|
||||
<Container size="xl" className="p-4 pt-4.5">
|
||||
<AddVideoProgressBarToast
|
||||
uploadNewVideoProgress={uploadNewVideoProgress}
|
||||
/>
|
||||
<EditFileErrors
|
||||
resetErrors={handleErrorReset}
|
||||
errorMessages={errorMessages}
|
||||
|
||||
@@ -132,12 +132,11 @@ describe('Videos page', () => {
|
||||
|
||||
axiosMock.onPost(getCourseVideosApiUrl(courseId)).reply(204, generateNewVideoApiResponse());
|
||||
axiosMock.onGet(getCourseVideosApiUrl(courseId)).reply(200, generateAddVideoApiResponse());
|
||||
|
||||
Object.defineProperty(dropzone, 'files', {
|
||||
value: [file],
|
||||
});
|
||||
fireEvent.drop(dropzone);
|
||||
await executeThunk(addVideoFile(courseId, file), store.dispatch);
|
||||
await executeThunk(addVideoFile(courseId, file, []), store.dispatch);
|
||||
});
|
||||
const addStatus = store.getState().videos.addingStatus;
|
||||
expect(addStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
|
||||
@@ -180,21 +180,30 @@ export async function addVideo(courseId, file) {
|
||||
const postJson = {
|
||||
files: [{ file_name: file.name, content_type: file.type }],
|
||||
};
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
const response = await getAuthenticatedHttpClient()
|
||||
.post(getCourseVideosApiUrl(courseId), postJson);
|
||||
return camelCaseObject(data);
|
||||
return { data: camelCaseObject(response.data), ...response };
|
||||
}
|
||||
|
||||
export async function sendVideoUploadStatus(
|
||||
courseId,
|
||||
edxVideoId,
|
||||
message,
|
||||
status,
|
||||
) {
|
||||
return getAuthenticatedHttpClient()
|
||||
.post(getCourseVideosApiUrl(courseId), [{
|
||||
edxVideoId,
|
||||
message,
|
||||
status,
|
||||
}]);
|
||||
}
|
||||
|
||||
export async function uploadVideo(
|
||||
courseId,
|
||||
uploadUrl,
|
||||
uploadFile,
|
||||
edxVideoId,
|
||||
) {
|
||||
const uploadErrors = [];
|
||||
|
||||
await fetch(uploadUrl, {
|
||||
return fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Disposition': `attachment; filename="${uploadFile.name}"`,
|
||||
@@ -202,28 +211,7 @@ export async function uploadVideo(
|
||||
},
|
||||
multipart: false,
|
||||
body: uploadFile,
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error();
|
||||
}
|
||||
await getAuthenticatedHttpClient()
|
||||
.post(getCourseVideosApiUrl(courseId), [{
|
||||
edxVideoId,
|
||||
message: 'Upload completed',
|
||||
status: 'upload_completed',
|
||||
}]);
|
||||
})
|
||||
.catch(async () => {
|
||||
uploadErrors.push(`Failed to upload ${uploadFile.name} to server.`);
|
||||
await getAuthenticatedHttpClient()
|
||||
.post(getCourseVideosApiUrl(courseId), [{
|
||||
edxVideoId,
|
||||
message: 'Upload failed',
|
||||
status: 'upload_failed',
|
||||
}]);
|
||||
});
|
||||
return uploadErrors;
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteTranscriptPreferences(courseId) {
|
||||
|
||||
@@ -3,7 +3,9 @@ import MockAdapter from 'axios-mock-adapter';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { getDownload, getVideosUrl, getAllUsagePaths } from './api';
|
||||
import {
|
||||
getDownload, getVideosUrl, getAllUsagePaths, getCourseVideosApiUrl, uploadVideo, sendVideoUploadStatus,
|
||||
} from './api';
|
||||
|
||||
jest.mock('file-saver');
|
||||
|
||||
@@ -103,4 +105,34 @@ describe('api.js', () => {
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadVideo', () => {
|
||||
it('PUTs to the provided URL', async () => {
|
||||
const mockUrl = 'mock.com';
|
||||
const mockFile = { mock: 'file' };
|
||||
const expectedResult = 'Something';
|
||||
global.fetch = jest.fn().mockResolvedValue(expectedResult);
|
||||
const actual = await uploadVideo(mockUrl, mockFile);
|
||||
expect(actual).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
describe('sendVideoUploadStatus', () => {
|
||||
it('Posts to the correct url', async () => {
|
||||
const mockCourseId = 'wiZard101';
|
||||
const mockEdxVideoId = 'wIzOz.mp3';
|
||||
const mockStatus = 'Im mElTinG';
|
||||
const mockMessage = 'DinG DOng The WiCked WiTCH isDead';
|
||||
const expectedResult = 'Something';
|
||||
axiosMock.onPost(`${getCourseVideosApiUrl(mockCourseId)}`)
|
||||
.reply(200, expectedResult);
|
||||
const actual = await sendVideoUploadStatus(
|
||||
mockCourseId,
|
||||
mockEdxVideoId,
|
||||
mockMessage,
|
||||
mockStatus,
|
||||
);
|
||||
expect(actual.data).toEqual(expectedResult);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ const slice = createSlice({
|
||||
loadingStatus: RequestStatus.IN_PROGRESS,
|
||||
updatingStatus: '',
|
||||
addingStatus: '',
|
||||
uploadNewVideoProgress: 0,
|
||||
deletingStatus: '',
|
||||
usageStatus: '',
|
||||
transcriptStatus: '',
|
||||
@@ -62,9 +63,12 @@ const slice = createSlice({
|
||||
deleteVideoSuccess: (state, { payload }) => {
|
||||
state.videoIds = state.videoIds.filter(id => id !== payload.videoId);
|
||||
},
|
||||
addVideoSuccess: (state, { payload }) => {
|
||||
addVideoById: (state, { payload }) => {
|
||||
state.videoIds = [payload.videoId, ...state.videoIds];
|
||||
},
|
||||
updateVideoUploadProgress: (state, { payload }) => {
|
||||
state.uploadNewVideoProgress = payload.uploadNewVideoProgress;
|
||||
},
|
||||
updateTranscriptCredentialsSuccess: (state, { payload }) => {
|
||||
const { provider } = payload;
|
||||
state.pageSettings.transcriptCredentials = {
|
||||
@@ -102,6 +106,7 @@ export const {
|
||||
updateEditStatus,
|
||||
updateTranscriptCredentialsSuccess,
|
||||
updateTranscriptPreferenceSuccess,
|
||||
updateVideoUploadProgress,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
uploadTranscript,
|
||||
getVideoUsagePaths,
|
||||
deleteTranscriptPreferences,
|
||||
sendVideoUploadStatus,
|
||||
setTranscriptCredentials,
|
||||
setTranscriptPreferences,
|
||||
getAllUsagePaths,
|
||||
@@ -29,12 +30,12 @@ import {
|
||||
setPageSettings,
|
||||
updateLoadingStatus,
|
||||
deleteVideoSuccess,
|
||||
addVideoSuccess,
|
||||
updateErrors,
|
||||
clearErrors,
|
||||
updateEditStatus,
|
||||
updateTranscriptCredentialsSuccess,
|
||||
updateTranscriptPreferenceSuccess,
|
||||
updateVideoUploadProgress,
|
||||
} from './slice';
|
||||
|
||||
import { updateFileValues } from './utils';
|
||||
@@ -42,7 +43,6 @@ import { updateFileValues } from './utils';
|
||||
export function fetchVideos(courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
const { previousUploads, ...data } = await getVideos(courseId);
|
||||
dispatch(setPageSettings({ ...data }));
|
||||
@@ -87,7 +87,6 @@ export function updateVideoOrder(courseId, videoIds) {
|
||||
export function deleteVideoFile(courseId, id) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateEditStatus({ editType: 'delete', status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
await deleteVideo(courseId, id);
|
||||
dispatch(deleteVideoSuccess({ videoId: id }));
|
||||
@@ -103,42 +102,65 @@ export function deleteVideoFile(courseId, id) {
|
||||
export function addVideoFile(courseId, file, videoIds) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
const { files } = await addVideo(courseId, file);
|
||||
const { edxVideoId, uploadUrl } = files[0];
|
||||
const errors = await uploadVideo(
|
||||
courseId,
|
||||
uploadUrl,
|
||||
file,
|
||||
edxVideoId,
|
||||
);
|
||||
const { videos } = await fetchVideoList(courseId);
|
||||
const newVideos = videos.filter(video => !videoIds.includes(video.edxVideoId));
|
||||
const parsedVideos = updateFileValues(newVideos, true);
|
||||
dispatch(addModels({
|
||||
modelType: 'videos',
|
||||
models: parsedVideos,
|
||||
}));
|
||||
dispatch(addVideoSuccess({
|
||||
videoId: edxVideoId,
|
||||
}));
|
||||
dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.SUCCESSFUL }));
|
||||
if (!isEmpty(errors)) {
|
||||
errors.forEach(error => {
|
||||
dispatch(updateErrors({ error: 'add', message: error }));
|
||||
});
|
||||
const createUrlResponse = await addVideo(courseId, file);
|
||||
if (createUrlResponse.status < 200 && createUrlResponse.status >= 300) {
|
||||
dispatch(updateErrors({ error: 'add', message: `Failed to add ${file.name}.` }));
|
||||
dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.FAILED }));
|
||||
return;
|
||||
}
|
||||
const { edxVideoId, uploadUrl } = createUrlResponse.data.files[0];
|
||||
const putToServerResponse = await uploadVideo(uploadUrl, file);
|
||||
if (putToServerResponse.status < 200 && putToServerResponse.status >= 300) {
|
||||
dispatch(updateErrors({ error: 'add', message: `Failed to upload ${file.name}.` }));
|
||||
sendVideoUploadStatus(courseId, edxVideoId, 'Upload failed', 'upload_failed');
|
||||
dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.FAILED }));
|
||||
return;
|
||||
}
|
||||
if (putToServerResponse.body) {
|
||||
const reader = putToServerResponse.body.getReader();
|
||||
const contentLength = +putToServerResponse.headers.get('Content-Length');
|
||||
let loaded = 0;
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
dispatch(updateVideoUploadProgress({ uploadNewVideoProgress: 100 }));
|
||||
break;
|
||||
}
|
||||
loaded += value.byteLength;
|
||||
const progress = Math.round((loaded / contentLength) * 100);
|
||||
dispatch(updateVideoUploadProgress({ uploadNewVideoProgress: progress }));
|
||||
}
|
||||
|
||||
dispatch(updateVideoUploadProgress({ uploadNewVideoProgress: 0 }));
|
||||
sendVideoUploadStatus(courseId, edxVideoId, 'Upload completed', 'upload_completed');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.response && error.response.status === 413) {
|
||||
const message = error.response.data.error;
|
||||
dispatch(updateErrors({ error: 'add', message }));
|
||||
} else {
|
||||
dispatch(updateErrors({ error: 'add', message: `Failed to add ${file.name}.` }));
|
||||
dispatch(updateErrors({ error: 'add', message: `Failed to upload ${file.name}.` }));
|
||||
}
|
||||
dispatch(updateVideoUploadProgress({ uploadNewVideoProgress: 0 }));
|
||||
dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.FAILED }));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { videos } = await fetchVideoList(courseId);
|
||||
const newVideos = videos.filter(video => !videoIds.includes(video.edxVideoId));
|
||||
const newVideoIds = newVideos.map(video => video.edxVideoId);
|
||||
const parsedVideos = updateFileValues(newVideos, true);
|
||||
dispatch(addModels({ modelType: 'videos', models: parsedVideos }));
|
||||
dispatch(setVideoIds({ videoIds: videoIds.concat(newVideoIds) }));
|
||||
} catch (error) {
|
||||
dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.FAILED }));
|
||||
dispatch(updateErrors({ error: 'add', message: error.message }));
|
||||
return;
|
||||
}
|
||||
dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.SUCCESSFUL }));
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
112
src/files-and-videos/videos-page/data/thunks.test.js
Normal file
112
src/files-and-videos/videos-page/data/thunks.test.js
Normal file
@@ -0,0 +1,112 @@
|
||||
import { addVideoFile } from './thunks';
|
||||
import * as api from './api';
|
||||
|
||||
describe('addVideoFile', () => {
|
||||
const dispatch = jest.fn();
|
||||
const getState = jest.fn();
|
||||
const courseId = 'course-123';
|
||||
const mockFile = {
|
||||
name: 'mockName',
|
||||
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
it('Should dispatch failed status if url cannot be created.', async () => {
|
||||
jest.spyOn(api, 'addVideo').mockResolvedValue({
|
||||
status: 404,
|
||||
});
|
||||
|
||||
await addVideoFile(courseId, mockFile)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: {
|
||||
editType: 'add',
|
||||
status: 'failed',
|
||||
},
|
||||
type: 'videos/updateEditStatus',
|
||||
});
|
||||
});
|
||||
it('Failed video upload dispatches updateEditStatus with failed', async () => {
|
||||
jest.spyOn(api, 'addVideo').mockResolvedValue({
|
||||
status: 200,
|
||||
data: {
|
||||
files: [
|
||||
{ edxVideoId: 'iD', uploadUrl: 'a Url' },
|
||||
],
|
||||
},
|
||||
});
|
||||
jest.spyOn(api, 'uploadVideo').mockResolvedValue({
|
||||
status: 404,
|
||||
});
|
||||
await addVideoFile(courseId, mockFile)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: {
|
||||
editType: 'add',
|
||||
status: 'failed',
|
||||
},
|
||||
type: 'videos/updateEditStatus',
|
||||
});
|
||||
});
|
||||
it('should handle successful upload with progress bar', async () => {
|
||||
const mockPutToServerResponse = {
|
||||
body: {
|
||||
getReader: jest.fn(() => ({
|
||||
read: jest.fn().mockResolvedValueOnce({ done: true }),
|
||||
})),
|
||||
},
|
||||
headers: new Map([['Content-Length', '100']]),
|
||||
};
|
||||
jest.spyOn(api, 'addVideo').mockResolvedValue({
|
||||
status: 200,
|
||||
data: {
|
||||
files: [
|
||||
{ edxVideoId: 'iD', uploadUrl: 'a Url' },
|
||||
],
|
||||
},
|
||||
});
|
||||
jest.spyOn(api, 'sendVideoUploadStatus').mockResolvedValue({ status: 200 });
|
||||
jest.spyOn(api, 'uploadVideo').mockResolvedValue(mockPutToServerResponse);
|
||||
|
||||
await addVideoFile(courseId, mockFile)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: { uploadNewVideoProgress: 100 },
|
||||
type: 'videos/updateVideoUploadProgress',
|
||||
});
|
||||
});
|
||||
it('should handle successful upload with progress bar', async () => {
|
||||
const mockPutToServerResponse = {
|
||||
body: {
|
||||
getReader: jest.fn(() => ({
|
||||
read: jest.fn().mockResolvedValueOnce({
|
||||
value: {
|
||||
byteLength: 50,
|
||||
|
||||
},
|
||||
}),
|
||||
})),
|
||||
},
|
||||
headers: new Map([['Content-Length', '100']]),
|
||||
};
|
||||
jest.spyOn(api, 'addVideo').mockResolvedValue({
|
||||
status: 200,
|
||||
data: {
|
||||
files: [
|
||||
{ edxVideoId: 'iD', uploadUrl: 'a Url' },
|
||||
],
|
||||
},
|
||||
});
|
||||
jest.spyOn(api, 'sendVideoUploadStatus').mockResolvedValue({ status: 200 });
|
||||
jest.spyOn(api, 'uploadVideo').mockResolvedValue(mockPutToServerResponse);
|
||||
|
||||
await addVideoFile(courseId, mockFile)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: { uploadNewVideoProgress: 50 },
|
||||
type: 'videos/updateVideoUploadProgress',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -213,6 +213,7 @@ export const generateFetchVideosApiResponse = () => ({
|
||||
});
|
||||
|
||||
export const generateAddVideoApiResponse = () => ({
|
||||
ok: true,
|
||||
videos: [
|
||||
{
|
||||
edx_video_id: 'mOckID4',
|
||||
|
||||
@@ -37,6 +37,10 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.failedCheckbox.label',
|
||||
defaultMessage: 'Failed',
|
||||
},
|
||||
videoUploadProgressBarLabel: {
|
||||
id: 'course-authoring.files-and-videos.add-video-progress-bar.progress-bar.label',
|
||||
defaultMessage: 'Video upload progress:',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
Reference in New Issue
Block a user