Feat: refactor upload thunk, no progress bar pt 2 (#899)
Attempts to fix: https://2u-internal.atlassian.net/browse/TNL-1141
This commit is contained in:
@@ -189,7 +189,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,6 +244,7 @@ const FileTable = ({
|
||||
setSelectedRows={setSelectedRows}
|
||||
fileType={fileType}
|
||||
/>
|
||||
|
||||
<ApiStatusToast
|
||||
actionType={intl.formatMessage(messages.apiStatusAddingAction)}
|
||||
selectedRowCount={selectedRows.length}
|
||||
@@ -252,6 +253,7 @@ const FileTable = ({
|
||||
setSelectedRows={setSelectedRows}
|
||||
fileType={fileType}
|
||||
/>
|
||||
|
||||
<ApiStatusToast
|
||||
actionType={intl.formatMessage(messages.apiStatusDownloadingAction)}
|
||||
selectedRowCount={selectedRows.length}
|
||||
|
||||
@@ -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,28 @@ export async function addVideo(courseId, file) {
|
||||
const postJson = {
|
||||
files: [{ file_name: file.name, content_type: file.type }],
|
||||
};
|
||||
return getAuthenticatedHttpClient().post(getCourseVideosApiUrl(courseId), postJson);
|
||||
}
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseVideosApiUrl(courseId), postJson);
|
||||
return camelCaseObject(data);
|
||||
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 +209,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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
8
src/files-and-videos/videos-page/data/errors.js
Normal file
8
src/files-and-videos/videos-page/data/errors.js
Normal file
@@ -0,0 +1,8 @@
|
||||
export class ServerError extends Error {
|
||||
constructor(message, statusCode) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
this.name = this.constructor.name;
|
||||
}
|
||||
}
|
||||
export default { ServerError };
|
||||
@@ -62,7 +62,7 @@ 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];
|
||||
},
|
||||
updateTranscriptCredentialsSuccess: (state, { payload }) => {
|
||||
@@ -88,6 +88,12 @@ const slice = createSlice({
|
||||
const { error } = payload;
|
||||
state.errors[error] = [];
|
||||
},
|
||||
failAddVideo: (state, { payload }) => {
|
||||
const { fileName } = payload;
|
||||
const currentErrorState = state.errors.add;
|
||||
state.errors.add = [...currentErrorState, `Failed to add ${fileName}.`];
|
||||
state.addingStatus = RequestStatus.FAILED;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -102,6 +108,8 @@ export const {
|
||||
updateEditStatus,
|
||||
updateTranscriptCredentialsSuccess,
|
||||
updateTranscriptPreferenceSuccess,
|
||||
updateVideoUploadProgress,
|
||||
failAddVideo,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { camelCase, isEmpty } from 'lodash';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getConfig, camelCaseObject } from '@edx/frontend-platform';
|
||||
import { RequestStatus } from '../../../data/constants';
|
||||
import {
|
||||
addModels,
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
uploadTranscript,
|
||||
getVideoUsagePaths,
|
||||
deleteTranscriptPreferences,
|
||||
sendVideoUploadStatus,
|
||||
setTranscriptCredentials,
|
||||
setTranscriptPreferences,
|
||||
getAllUsagePaths,
|
||||
@@ -29,20 +30,20 @@ import {
|
||||
setPageSettings,
|
||||
updateLoadingStatus,
|
||||
deleteVideoSuccess,
|
||||
addVideoSuccess,
|
||||
updateErrors,
|
||||
clearErrors,
|
||||
updateEditStatus,
|
||||
updateTranscriptCredentialsSuccess,
|
||||
updateTranscriptPreferenceSuccess,
|
||||
failAddVideo,
|
||||
} from './slice';
|
||||
import { ServerError } from './errors';
|
||||
|
||||
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 +88,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 +103,52 @@ export function deleteVideoFile(courseId, id) {
|
||||
export function addVideoFile(courseId, file, videoIds) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
let edxVideoId; let uploadUrl;
|
||||
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 }));
|
||||
});
|
||||
dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.FAILED }));
|
||||
const createUrlResponse = await addVideo(courseId, file);
|
||||
// eslint-disable-next-line
|
||||
console.log(`Post Response: ${createUrlResponse}`);
|
||||
if (createUrlResponse.status < 200 || createUrlResponse.status >= 300) {
|
||||
dispatch(failAddVideo({ fileName: file.name }));
|
||||
}
|
||||
// eslint-disable-next-line prefer-destructuring
|
||||
[{ edxVideoId, uploadUrl }] = camelCaseObject(createUrlResponse.data).files;
|
||||
} catch (error) {
|
||||
dispatch(failAddVideo({ fileName: file.name }));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const putToServerResponse = await uploadVideo(uploadUrl, file);
|
||||
if (putToServerResponse.status < 200 || putToServerResponse.status >= 300) {
|
||||
throw new ServerError('Server responded with an error status', putToServerResponse.status);
|
||||
} else {
|
||||
await 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}.` }));
|
||||
}
|
||||
await sendVideoUploadStatus(courseId, edxVideoId || '', 'Upload failed', 'upload_failed');
|
||||
dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.FAILED }));
|
||||
}
|
||||
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 }));
|
||||
// eslint-disable-next-line
|
||||
console.error(`fetchVideoList failed with message: ${error.message}`);
|
||||
dispatch(updateErrors({ error: 'add', message: 'Failed to load videos' }));
|
||||
return;
|
||||
}
|
||||
dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.SUCCESSFUL }));
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
76
src/files-and-videos/videos-page/data/thunks.test.js
Normal file
76
src/files-and-videos/videos-page/data/thunks.test.js
Normal file
@@ -0,0 +1,76 @@
|
||||
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 if url cannot be created.', async () => {
|
||||
jest.spyOn(api, 'addVideo').mockResolvedValue({
|
||||
status: 404,
|
||||
});
|
||||
|
||||
await addVideoFile(courseId, mockFile)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: {
|
||||
fileName: mockFile.name,
|
||||
},
|
||||
type: 'videos/failAddVideo',
|
||||
});
|
||||
});
|
||||
it('Failed video upload dispatches updateEditStatus with failed, and sends the failure to the api', async () => {
|
||||
const videoStatusMock = jest.spyOn(api, 'sendVideoUploadStatus').mockResolvedValue({
|
||||
status: 200,
|
||||
});
|
||||
const mockEdxVideoId = 'iD';
|
||||
jest.spyOn(api, 'addVideo').mockResolvedValue({
|
||||
status: 200,
|
||||
data: {
|
||||
files: [
|
||||
{ edxVideoId: mockEdxVideoId, uploadUrl: 'a Url' },
|
||||
],
|
||||
},
|
||||
});
|
||||
jest.spyOn(api, 'uploadVideo').mockResolvedValue({
|
||||
status: 404,
|
||||
});
|
||||
await addVideoFile(courseId, mockFile)(dispatch, getState);
|
||||
expect(videoStatusMock).toHaveBeenCalledWith(courseId, mockEdxVideoId, 'Upload failed', 'upload_failed');
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: {
|
||||
error: 'add',
|
||||
message: `Failed to upload ${mockFile.name}.`,
|
||||
},
|
||||
|
||||
type: 'videos/updateErrors',
|
||||
});
|
||||
});
|
||||
it('Successful video upload sends the success to the api', async () => {
|
||||
const videoStatusMock = jest.spyOn(api, 'sendVideoUploadStatus').mockResolvedValue({
|
||||
status: 200,
|
||||
});
|
||||
const mockEdxVideoId = 'iD';
|
||||
jest.spyOn(api, 'addVideo').mockResolvedValue({
|
||||
status: 200,
|
||||
data: {
|
||||
files: [
|
||||
{ edxVideoId: mockEdxVideoId, uploadUrl: 'a Url' },
|
||||
],
|
||||
},
|
||||
});
|
||||
jest.spyOn(api, 'uploadVideo').mockResolvedValue({
|
||||
status: 200,
|
||||
});
|
||||
await addVideoFile(courseId, mockFile)(dispatch, getState);
|
||||
expect(videoStatusMock).toHaveBeenCalledWith(courseId, mockEdxVideoId, 'Upload completed', 'upload_completed');
|
||||
});
|
||||
});
|
||||
@@ -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