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:
connorhaugh
2024-03-18 11:07:13 -04:00
committed by GitHub
parent d57ecc6779
commit 60917c6ab5
11 changed files with 190 additions and 65 deletions

View File

@@ -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];

View File

@@ -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}

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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();
});
});
});

View 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 };

View File

@@ -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 {

View File

@@ -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 }));
};
}

View 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');
});
});

View File

@@ -213,6 +213,7 @@ export const generateFetchVideosApiResponse = () => ({
});
export const generateAddVideoApiResponse = () => ({
ok: true,
videos: [
{
edx_video_id: 'mOckID4',

View File

@@ -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;