diff --git a/src/files-and-videos/files-page/data/thunks.js b/src/files-and-videos/files-page/data/thunks.js index 9b0a62397..d07c3c6a4 100644 --- a/src/files-and-videos/files-page/data/thunks.js +++ b/src/files-and-videos/files-page/data/thunks.js @@ -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]; diff --git a/src/files-and-videos/generic/FileTable.jsx b/src/files-and-videos/generic/FileTable.jsx index 60d364c5e..a77402922 100644 --- a/src/files-and-videos/generic/FileTable.jsx +++ b/src/files-and-videos/generic/FileTable.jsx @@ -244,6 +244,7 @@ const FileTable = ({ setSelectedRows={setSelectedRows} fileType={fileType} /> + + { 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); diff --git a/src/files-and-videos/videos-page/data/api.js b/src/files-and-videos/videos-page/data/api.js index 43a858526..807b21575 100644 --- a/src/files-and-videos/videos-page/data/api.js +++ b/src/files-and-videos/videos-page/data/api.js @@ -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) { diff --git a/src/files-and-videos/videos-page/data/api.test.js b/src/files-and-videos/videos-page/data/api.test.js index 605edbb4a..20a504aaf 100644 --- a/src/files-and-videos/videos-page/data/api.test.js +++ b/src/files-and-videos/videos-page/data/api.test.js @@ -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(); + }); + }); }); diff --git a/src/files-and-videos/videos-page/data/errors.js b/src/files-and-videos/videos-page/data/errors.js new file mode 100644 index 000000000..40e3434c3 --- /dev/null +++ b/src/files-and-videos/videos-page/data/errors.js @@ -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 }; diff --git a/src/files-and-videos/videos-page/data/slice.js b/src/files-and-videos/videos-page/data/slice.js index 74574660f..1962c1746 100644 --- a/src/files-and-videos/videos-page/data/slice.js +++ b/src/files-and-videos/videos-page/data/slice.js @@ -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 { diff --git a/src/files-and-videos/videos-page/data/thunks.js b/src/files-and-videos/videos-page/data/thunks.js index 12c893728..57cfb5781 100644 --- a/src/files-and-videos/videos-page/data/thunks.js +++ b/src/files-and-videos/videos-page/data/thunks.js @@ -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 })); }; } diff --git a/src/files-and-videos/videos-page/data/thunks.test.js b/src/files-and-videos/videos-page/data/thunks.test.js new file mode 100644 index 000000000..20c61d6ae --- /dev/null +++ b/src/files-and-videos/videos-page/data/thunks.test.js @@ -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'); + }); +}); diff --git a/src/files-and-videos/videos-page/factories/mockApiResponses.jsx b/src/files-and-videos/videos-page/factories/mockApiResponses.jsx index be3d93941..39e38cbfe 100644 --- a/src/files-and-videos/videos-page/factories/mockApiResponses.jsx +++ b/src/files-and-videos/videos-page/factories/mockApiResponses.jsx @@ -213,6 +213,7 @@ export const generateFetchVideosApiResponse = () => ({ }); export const generateAddVideoApiResponse = () => ({ + ok: true, videos: [ { edx_video_id: 'mOckID4', diff --git a/src/files-and-videos/videos-page/messages.js b/src/files-and-videos/videos-page/messages.js index db10abaf7..774dfe2f0 100644 --- a/src/files-and-videos/videos-page/messages.js +++ b/src/files-and-videos/videos-page/messages.js @@ -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;