Skip to content

Commit

Permalink
feat: add progress bar for video uploads and refactor (#860)
Browse files Browse the repository at this point in the history
* feat: add progress bar for video uploads and refactor

---------

Co-authored-by: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com>
  • Loading branch information
connorhaugh and KristinAoki authored Mar 13, 2024
1 parent 4e70813 commit d76aaa7
Show file tree
Hide file tree
Showing 12 changed files with 277 additions and 71 deletions.
1 change: 0 additions & 1 deletion src/files-and-videos/files-page/data/thunks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
20 changes: 12 additions & 8 deletions src/files-and-videos/generic/FileTable.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
35 changes: 35 additions & 0 deletions src/files-and-videos/videos-page/AddVideoProgressBarToast.jsx
Original file line number Diff line number Diff line change
@@ -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);
5 changes: 5 additions & 0 deletions src/files-and-videos/videos-page/VideosPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -62,6 +63,7 @@ const VideosPage = ({
videoIds,
loadingStatus,
transcriptStatus,
uploadNewVideoProgress,
addingStatus: addVideoStatus,
deletingStatus: deleteVideoStatus,
updatingStatus: updateVideoStatus,
Expand Down Expand Up @@ -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}
Expand Down
3 changes: 1 addition & 2 deletions src/files-and-videos/videos-page/VideosPage.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
48 changes: 18 additions & 30 deletions src/files-and-videos/videos-page/data/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -180,50 +180,38 @@ 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 uploadVideo(
export async function sendVideoUploadStatus(
courseId,
uploadUrl,
uploadFile,
edxVideoId,
message,
status,
) {
const uploadErrors = [];
return getAuthenticatedHttpClient()
.post(getCourseVideosApiUrl(courseId), [{
edxVideoId,
message,
status,
}]);
}

await fetch(uploadUrl, {
export async function uploadVideo(
uploadUrl,
uploadFile,
) {
return fetch(uploadUrl, {
method: 'PUT',
headers: {
'Content-Disposition': `attachment; filename="${uploadFile.name}"`,
'Content-Type': uploadFile.type,
},
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) {
Expand Down
34 changes: 33 additions & 1 deletion src/files-and-videos/videos-page/data/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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();
});
});
});
7 changes: 6 additions & 1 deletion src/files-and-videos/videos-page/data/slice.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const slice = createSlice({
loadingStatus: RequestStatus.IN_PROGRESS,
updatingStatus: '',
addingStatus: '',
uploadNewVideoProgress: 0,
deletingStatus: '',
usageStatus: '',
transcriptStatus: '',
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -102,6 +106,7 @@ export const {
updateEditStatus,
updateTranscriptCredentialsSuccess,
updateTranscriptPreferenceSuccess,
updateVideoUploadProgress,
} = slice.actions;

export const {
Expand Down
78 changes: 50 additions & 28 deletions src/files-and-videos/videos-page/data/thunks.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
uploadTranscript,
getVideoUsagePaths,
deleteTranscriptPreferences,
sendVideoUploadStatus,
setTranscriptCredentials,
setTranscriptPreferences,
getAllUsagePaths,
Expand All @@ -29,20 +30,19 @@ import {
setPageSettings,
updateLoadingStatus,
deleteVideoSuccess,
addVideoSuccess,
updateErrors,
clearErrors,
updateEditStatus,
updateTranscriptCredentialsSuccess,
updateTranscriptPreferenceSuccess,
updateVideoUploadProgress,
} from './slice';

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 }));
Expand Down Expand Up @@ -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 }));
Expand All @@ -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 }));
};
}

Expand Down
Loading

0 comments on commit d76aaa7

Please sign in to comment.