Fix video upload failures (#952)

This commit is contained in:
Jesper Hodge
2024-04-15 16:49:07 -04:00
committed by GitHub
parent 2fda48fa5f
commit 0f440c6b3a
11 changed files with 557 additions and 135 deletions

View File

@@ -35,7 +35,8 @@ ENABLE_TEAM_TYPE_SETTING=false
ENABLE_NEW_EDITOR_PAGES=true
ENABLE_UNIT_PAGE=false
ENABLE_ASSETS_PAGE=false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
ENABLE_NEW_VIDEO_UPLOAD_PAGE=true
ENABLE_TAGGING_TAXONOMY_PAGES=true
BBB_LEARN_MORE_URL=''
HOTJAR_APP_ID=''

View File

@@ -199,6 +199,10 @@ const messages = defineMessages({
defaultMessage: 'Apply',
description: 'Label for apply sort button in sort and filter modal',
},
failedLabel: {
id: 'course-authoring.files-and-uploads.filter.failed.label',
defaultMessage: 'Failed',
},
});
export default messages;

View File

@@ -1,10 +1,16 @@
import React from 'react';
import { PropTypes } from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Badge } from '@openedx/paragon';
import { VIDEO_FAILURE_STATUSES } from '../../../videos-page/data/constants';
import messages from '../../messages';
const StatusColumn = ({ row }) => {
const { status } = row.original;
const isUploaded = status === 'Success';
const isFailed = VIDEO_FAILURE_STATUSES.includes(status);
const intl = useIntl();
const failedText = intl.formatMessage(messages.failedLabel);
if (isUploaded) {
return null;
@@ -12,7 +18,7 @@ const StatusColumn = ({ row }) => {
return (
<Badge variant="light">
{status}
{isFailed ? failedText : status}
</Badge>
);
};

View File

@@ -71,3 +71,12 @@
gap: 24px 16px;
grid-template-columns: repeat(3, 33%);
}
.video-upload-spinner {
width: 1.3rem;
height: 1.3rem;
}
.video-upload-warning-text {
font-size: 18px;
}

View File

@@ -10,7 +10,7 @@ import {
} from '@openedx/paragon';
import { FileInput, useFileInput } from '../generic';
import messages from './messages';
import { VIDEO_SUCCESS_STATUSES } from './data/constants';
import { VIDEO_SUCCESS_STATUSES, VIDEO_FAILURE_STATUSES } from './data/constants';
import { RequestStatus } from '../../data/constants';
const VideoThumbnail = ({
@@ -45,6 +45,8 @@ const VideoThumbnail = ({
const supportedFiles = videoImageSettings?.supportedFileFormats
? Object.values(videoImageSettings.supportedFileFormats) : null;
const isUploaded = VIDEO_SUCCESS_STATUSES.includes(status);
const isFailed = VIDEO_FAILURE_STATUSES.includes(status);
const failedMessage = intl.formatMessage(messages.failedCheckboxLabel);
const showThumbnail = allowThumbnailUpload && thumbnail && isUploaded;
@@ -84,7 +86,7 @@ const VideoThumbnail = ({
<div className="status-badge">
{!isUploaded && (
<Badge variant="light">
{status}
{!isFailed ? status : failedMessage}
</Badge>
)}
</div>

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React, { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import {
@@ -12,6 +12,8 @@ import {
Button,
CheckboxFilter,
Container,
Alert,
Spinner,
} from '@openedx/paragon';
import Placeholder from '@edx/frontend-lib-content-components';
@@ -24,6 +26,7 @@ import {
fetchVideoDownload,
fetchVideos,
getUsagePaths,
markVideoUploadsInProgressAsFailed,
resetErrors,
updateVideoOrder,
} from './data/thunks';
@@ -50,9 +53,16 @@ const VideosPage = ({
intl,
}) => {
const dispatch = useDispatch();
const [isTranscriptSettingsOpen, openTranscriptSettings, closeTranscriptSettings] = useToggle(false);
const [
isTranscriptSettingsOpen,
openTranscriptSettings,
closeTranscriptSettings,
] = useToggle(false);
const courseDetails = useModel('courseDetails', courseId);
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.heading));
document.title = getPageHeadTitle(
courseDetails?.name,
intl.formatMessage(messages.heading),
);
useEffect(() => {
dispatch(fetchVideos(courseId));
@@ -68,7 +78,16 @@ const VideosPage = ({
usageStatus: usagePathStatus,
errors: errorMessages,
pageSettings,
} = useSelector(state => state.videos);
} = useSelector((state) => state.videos);
const uploadingIdsRef = useRef([]);
useEffect(() => {
window.onbeforeunload = () => {
dispatch(markVideoUploadsInProgressAsFailed({ uploadingIdsRef, courseId }));
return undefined;
};
}, []);
const {
isVideoTranscriptEnabled,
@@ -78,12 +97,14 @@ const VideosPage = ({
videoImageSettings,
} = pageSettings;
const supportedFileFormats = { 'video/*': videoSupportedFileFormats || FILES_AND_UPLOAD_TYPE_FILTERS.video };
const supportedFileFormats = {
'video/*': videoSupportedFileFormats || FILES_AND_UPLOAD_TYPE_FILTERS.video,
};
const handleErrorReset = (error) => dispatch(resetErrors(error));
const handleAddFile = (files) => {
handleErrorReset({ errorType: 'add' });
files.forEach(file => dispatch(addVideoFile(courseId, file, videoIds)));
files.forEach((file) => dispatch(addVideoFile(courseId, file, videoIds, uploadingIdsRef)));
};
const handleDeleteFile = (id) => dispatch(deleteVideoFile(courseId, id));
const handleDownloadFile = (selectedRows) => dispatch(fetchVideoDownload({ selectedRows, courseId }));
@@ -128,8 +149,14 @@ const VideosPage = ({
Filter: CheckboxFilter,
filter: 'exactTextCase',
filterChoices: [
{ name: intl.formatMessage(messages.transcribedCheckboxLabel), value: 'transcribed' },
{ name: intl.formatMessage(messages.notTranscribedCheckboxLabel), value: 'notTranscribed' },
{
name: intl.formatMessage(messages.transcribedCheckboxLabel),
value: 'transcribed',
},
{
name: intl.formatMessage(messages.notTranscribedCheckboxLabel),
value: 'notTranscribed',
},
],
};
const activeColumn = {
@@ -201,6 +228,11 @@ const VideosPage = ({
updateFileStatus={updateVideoStatus}
loadingStatus={loadingStatus}
/>
<Alert variant="warning" show={addVideoStatus === RequestStatus.IN_PROGRESS}>
<div className="video-upload-warning-text"><Spinner animation="border" variant="warning" className="video-upload-spinner mr-3" screenReaderText="loading" />
<p className="d-inline"><FormattedMessage {...messages.videoUploadAlertLabel} /></p>
</div>
</Alert>
<ActionRow>
<div className="h2">
<FormattedMessage {...messages.heading} />

View File

@@ -36,10 +36,12 @@ import {
addVideoThumbnail,
fetchVideoDownload,
} from './data/thunks';
import { getVideosUrl, getCourseVideosApiUrl, getApiBaseUrl } from './data/api';
import * as api from './data/api';
import videoMessages from './messages';
import messages from '../generic/messages';
const { getVideosUrl, getCourseVideosApiUrl, getApiBaseUrl } = api;
let axiosMock;
let store;
let file;
@@ -136,7 +138,7 @@ describe('Videos page', () => {
value: [file],
});
fireEvent.drop(dropzone);
await executeThunk(addVideoFile(courseId, file, []), store.dispatch);
await executeThunk(addVideoFile(courseId, file, [], { current: [] }), store.dispatch);
});
const addStatus = store.getState().videos.addingStatus;
expect(addStatus).toEqual(RequestStatus.SUCCESSFUL);
@@ -157,7 +159,7 @@ describe('Videos page', () => {
roles: [],
},
});
store = initializeStore(initialState);
store = initializeStore({ ...initialState });
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
file = new File(['(⌐□_□)'], 'download.png', { type: 'image/png' });
});
@@ -224,6 +226,13 @@ describe('Videos page', () => {
expect(addThumbnailButton).toBeNull();
});
describe('with videos with backend status in_progress', () => {
it('should render video with in progress status', async () => {
await mockStore(RequestStatus.IN_PROGRESS);
expect(screen.getByText('Failed')).toBeVisible();
expect(screen.queryByText('In Progress')).not.toBeInTheDocument();
});
});
});
describe('table actions', () => {
@@ -240,12 +249,46 @@ describe('Videos page', () => {
const { videoIds } = store.getState().videos;
await act(async () => {
userEvent.upload(addFilesButton, file);
await executeThunk(addVideoFile(courseId, file, videoIds), store.dispatch);
await executeThunk(addVideoFile(courseId, file, videoIds, { current: [] }), store.dispatch);
});
const addStatus = store.getState().videos.addingStatus;
expect(addStatus).toEqual(RequestStatus.SUCCESSFUL);
});
it('when uploads are in progress, should show alert and set them to failed on page leave', async () => {
await mockStore(RequestStatus.SUCCESSFUL);
const mockResponseData = { status: '200', ok: true, blob: () => 'Data' };
const mockFetchResponse = Promise.resolve(mockResponseData);
global.fetch = jest.fn().mockImplementation(() => mockFetchResponse);
axiosMock.onPost(getCourseVideosApiUrl(courseId)).reply(204, generateNewVideoApiResponse());
axiosMock.onGet(getCourseVideosApiUrl(courseId)).reply(200, generateAddVideoApiResponse());
const uploadSpy = jest.spyOn(api, 'uploadVideo');
const setFailedSpy = jest.spyOn(api, 'sendVideoUploadStatus').mockImplementation(() => {});
uploadSpy.mockResolvedValue(new Promise(() => {}));
const addFilesButton = screen.getAllByLabelText('file-input')[3];
act(async () => {
userEvent.upload(addFilesButton, file);
});
await waitFor(() => {
const addStatus = store.getState().videos.addingStatus;
expect(addStatus).toEqual(RequestStatus.IN_PROGRESS);
expect(uploadSpy).toHaveBeenCalled();
expect(screen.getByText(videoMessages.videoUploadAlertLabel.defaultMessage)).toBeVisible();
});
act(() => {
window.dispatchEvent(new Event('beforeunload'));
});
await waitFor(() => {
expect(setFailedSpy).toHaveBeenCalledWith(courseId, expect.any(String), expect.any(String), 'upload_failed');
});
uploadSpy.mockRestore();
setFailedSpy.mockRestore();
});
it('should have disabled action buttons', async () => {
await mockStore(RequestStatus.SUCCESSFUL);
const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage);
@@ -573,7 +616,7 @@ describe('Videos page', () => {
const addFilesButton = screen.getAllByLabelText('file-input')[3];
await act(async () => {
userEvent.upload(addFilesButton, file);
await executeThunk(addVideoFile(courseId, file), store.dispatch);
await executeThunk(addVideoFile(courseId, file, undefined, { current: [] }), store.dispatch);
});
const addStatus = store.getState().videos.addingStatus;
expect(addStatus).toEqual(RequestStatus.FAILED);
@@ -588,7 +631,7 @@ describe('Videos page', () => {
const addFilesButton = screen.getAllByLabelText('file-input')[3];
await act(async () => {
userEvent.upload(addFilesButton, file);
await executeThunk(addVideoFile(courseId, file), store.dispatch);
await executeThunk(addVideoFile(courseId, file, undefined, { current: [] }), store.dispatch);
});
const addStatus = store.getState().videos.addingStatus;
expect(addStatus).toEqual(RequestStatus.FAILED);
@@ -623,7 +666,7 @@ describe('Videos page', () => {
const addFilesButton = screen.getAllByLabelText('file-input')[3];
await act(async () => {
userEvent.upload(addFilesButton, file);
await executeThunk(addVideoFile(courseId, file), store.dispatch);
await executeThunk(addVideoFile(courseId, file, undefined, { current: [] }), store.dispatch);
});
const addStatus = store.getState().videos.addingStatus;
expect(addStatus).toEqual(RequestStatus.FAILED);
@@ -636,7 +679,7 @@ describe('Videos page', () => {
const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1');
await waitFor(() => {
await waitFor(async () => {
axiosMock.onDelete(`${getCourseVideosApiUrl(courseId)}/mOckID1`).reply(404);
fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle'));
fireEvent.click(screen.getByTestId('open-delete-confirmation-button'));
@@ -644,11 +687,12 @@ describe('Videos page', () => {
fireEvent.click(screen.getByText(messages.deleteFileButtonLabel.defaultMessage));
expect(screen.queryByText('Delete mOckID1.mp4')).toBeNull();
executeThunk(deleteVideoFile(courseId, 'mOckID1', 5), store.dispatch);
});
const deleteStatus = store.getState().videos.deletingStatus;
expect(deleteStatus).toEqual(RequestStatus.FAILED);
executeThunk(deleteVideoFile(courseId, 'mOckID1', 5), store.dispatch);
await waitFor(() => {
const deleteStatus = store.getState().videos.deletingStatus;
expect(deleteStatus).toEqual(RequestStatus.FAILED);
});
expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible();
@@ -669,8 +713,10 @@ describe('Videos page', () => {
video: { id: 'mOckID3', displayName: 'mOckID3' },
}), store.dispatch);
});
const { usageStatus } = store.getState().videos;
expect(usageStatus).toEqual(RequestStatus.FAILED);
await waitFor(() => {
const { usageStatus } = store.getState().videos;
expect(usageStatus).toEqual(RequestStatus.FAILED);
});
});
it('multiple video files fetch failure should show error', async () => {

View File

@@ -7,5 +7,6 @@ export const MIN_HEIGHT = 360;
export const ASPECT_RATIO = 16 / 9;
export const ASPECT_RATIO_ERROR_MARGIN = 0.1;
export const TRANSCRIPT_FAILURE_STATUSES = ['Transcript Failed', 'Partial Failure'];
export const VIDEO_PROCESSING_STATUSES = ['Uploading', 'In Progress', 'Uploaded'];
export const VIDEO_PROCESSING_STATUSES = ['In Progress', 'Uploaded']; // Don't add "Uploading" here. Otherwise interrupted uploads will be considered as processing.
export const VIDEO_SUCCESS_STATUSES = ['Ready', 'Imported'];
export const VIDEO_FAILURE_STATUSES = ['Failed', 'Partial Failure', 'Uploading']; // 'Uploading' is added here to handle interrupted uploads.

View File

@@ -1,3 +1,4 @@
/* eslint-disable no-param-reassign */
import { camelCase, isEmpty } from 'lodash';
import { getConfig, camelCaseObject } from '@edx/frontend-platform';
import { RequestStatus } from '../../../data/constants';
@@ -43,7 +44,9 @@ import { updateFileValues } from './utils';
export function fetchVideos(courseId) {
return async (dispatch) => {
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.IN_PROGRESS }));
dispatch(
updateLoadingStatus({ courseId, status: RequestStatus.IN_PROGRESS }),
);
try {
const { previousUploads, ...data } = await getVideos(courseId);
dispatch(setPageSettings({ ...data }));
@@ -51,110 +54,228 @@ export function fetchVideos(courseId) {
// If previous uploads are empty there is no need to add an empty model
// or loop through and empty list so automatically set loading to successful
if (isEmpty(previousUploads)) {
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.SUCCESSFUL }));
dispatch(
updateLoadingStatus({ courseId, status: RequestStatus.SUCCESSFUL }),
);
} else {
const parsedVideos = updateFileValues(previousUploads);
const videoIds = parsedVideos.map(video => video.id);
const videoIds = parsedVideos.map((video) => video.id);
dispatch(addModels({ modelType: 'videos', models: parsedVideos }));
dispatch(setVideoIds({ videoIds }));
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.PARTIAL }));
const allUsageLocations = await getAllUsagePaths({ courseId, videoIds });
dispatch(updateModels({ modelType: 'videos', models: allUsageLocations }));
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.SUCCESSFUL }));
dispatch(
updateLoadingStatus({ courseId, status: RequestStatus.PARTIAL }),
);
const allUsageLocations = await getAllUsagePaths({
courseId,
videoIds,
});
dispatch(
updateModels({ modelType: 'videos', models: allUsageLocations }),
);
dispatch(
updateLoadingStatus({ courseId, status: RequestStatus.SUCCESSFUL }),
);
}
} catch (error) {
if (error.response && error.response.status === 403) {
dispatch(updateLoadingStatus({ status: RequestStatus.DENIED }));
} else {
dispatch(updateErrors({ error: 'loading', message: 'Failed to load videos' }));
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.FAILED }));
dispatch(
updateErrors({ error: 'loading', message: 'Failed to load videos' }),
);
dispatch(
updateLoadingStatus({ courseId, status: RequestStatus.FAILED }),
);
}
}
};
}
export function resetErrors({ errorType }) {
return (dispatch) => { dispatch(clearErrors({ error: errorType })); };
return (dispatch) => {
dispatch(clearErrors({ error: errorType }));
};
}
export function updateVideoOrder(courseId, videoIds) {
return async (dispatch) => {
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.IN_PROGRESS }));
dispatch(
updateLoadingStatus({ courseId, status: RequestStatus.IN_PROGRESS }),
);
dispatch(setVideoIds({ videoIds }));
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.SUCCESSFUL }));
dispatch(
updateLoadingStatus({ courseId, status: RequestStatus.SUCCESSFUL }),
);
};
}
export function deleteVideoFile(courseId, id) {
return async (dispatch) => {
dispatch(updateEditStatus({ editType: 'delete', status: RequestStatus.IN_PROGRESS }));
dispatch(
updateEditStatus({
editType: 'delete',
status: RequestStatus.IN_PROGRESS,
}),
);
try {
await deleteVideo(courseId, id);
dispatch(deleteVideoSuccess({ videoId: id }));
dispatch(removeModel({ modelType: 'videos', id }));
dispatch(updateEditStatus({ editType: 'delete', status: RequestStatus.SUCCESSFUL }));
dispatch(
updateEditStatus({
editType: 'delete',
status: RequestStatus.SUCCESSFUL,
}),
);
} catch (error) {
dispatch(updateErrors({ error: 'delete', message: `Failed to delete file id ${id}.` }));
dispatch(updateEditStatus({ editType: 'delete', status: RequestStatus.FAILED }));
dispatch(
updateErrors({
error: 'delete',
message: `Failed to delete file id ${id}.`,
}),
);
dispatch(
updateEditStatus({ editType: 'delete', status: RequestStatus.FAILED }),
);
}
};
}
export function addVideoFile(courseId, file, videoIds) {
export function markVideoUploadsInProgressAsFailed({ uploadingIdsRef, courseId }) {
return (dispatch) => {
uploadingIdsRef.current.forEach((edxVideoId) => {
try {
sendVideoUploadStatus(
courseId,
edxVideoId || '',
'Upload failed',
'upload_failed',
);
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Failed to send "Failed" upload status for ${edxVideoId} onbeforeunload`);
}
dispatch(
updateEditStatus({ editType: 'add', status: RequestStatus.FAILED }),
);
});
// eslint-disable-next-line no-param-reassign
uploadingIdsRef.current = [];
};
}
export function addVideoFile(
courseId,
file,
videoIds,
uploadingIdsRef,
) {
return async (dispatch) => {
dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.IN_PROGRESS }));
let edxVideoId; let uploadUrl;
dispatch(
updateEditStatus({ editType: 'add', status: RequestStatus.IN_PROGRESS }),
);
let edxVideoId;
let uploadUrl;
try {
const createUrlResponse = await addVideo(courseId, file);
// eslint-disable-next-line
console.log(`Post Response: ${createUrlResponse}`);
console.log(`Post Response: ${JSON.stringify(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;
[{ edxVideoId, uploadUrl }] = camelCaseObject(
createUrlResponse.data,
).files;
} catch (error) {
dispatch(failAddVideo({ fileName: file.name }));
updateEditStatus({ editType: 'add', status: RequestStatus.FAILED });
return;
}
try {
uploadingIdsRef.current = [...uploadingIdsRef.current, edxVideoId];
const putToServerResponse = await uploadVideo(uploadUrl, file);
if (putToServerResponse.status < 200 || putToServerResponse.status >= 300) {
throw new ServerError('Server responded with an error status', putToServerResponse.status);
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');
await sendVideoUploadStatus(
courseId,
edxVideoId,
'Upload completed',
'upload_completed',
);
}
uploadingIdsRef.current = uploadingIdsRef.current.filter(
(id) => id !== edxVideoId,
);
} 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 upload ${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 }));
await sendVideoUploadStatus(
courseId,
edxVideoId || '',
'Upload failed',
'upload_failed',
);
dispatch(
updateEditStatus({ editType: 'add', status: RequestStatus.FAILED }),
);
uploadingIdsRef.current = uploadingIdsRef.current.filter(
(id) => id !== edxVideoId,
);
// return;
}
try {
const { videos } = await fetchVideoList(courseId);
const newVideos = videos.filter(video => !videoIds.includes(video.edxVideoId));
const newVideoIds = newVideos.map(video => video.edxVideoId);
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: newVideoIds.concat(videoIds) }));
} catch (error) {
dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.FAILED }));
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' }));
dispatch(
updateErrors({ error: 'add', message: 'Failed to load videos' }),
);
return;
}
dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.SUCCESSFUL }));
dispatch(
updateEditStatus({ editType: 'add', status: RequestStatus.SUCCESSFUL }),
);
};
}
export function addVideoThumbnail({ file, videoId, courseId }) {
return async (dispatch) => {
dispatch(updateEditStatus({ editType: 'thumbnail', status: RequestStatus.IN_PROGRESS }));
dispatch(
updateEditStatus({
editType: 'thumbnail',
status: RequestStatus.IN_PROGRESS,
}),
);
dispatch(resetErrors({ errorType: 'thumbnail' }));
try {
const { imageUrl } = await addThumbnail({ courseId, videoId, file });
@@ -162,22 +283,39 @@ export function addVideoThumbnail({ file, videoId, courseId }) {
if (thumbnail.startsWith('/')) {
thumbnail = `${getConfig().STUDIO_BASE_URL}${imageUrl}`;
}
dispatch(updateModel({
modelType: 'videos',
model: {
id: videoId,
thumbnail,
},
}));
dispatch(updateEditStatus({ editType: 'thumbnail', status: RequestStatus.SUCCESSFUL }));
dispatch(
updateModel({
modelType: 'videos',
model: {
id: videoId,
thumbnail,
},
}),
);
dispatch(
updateEditStatus({
editType: 'thumbnail',
status: RequestStatus.SUCCESSFUL,
}),
);
} catch (error) {
if (error.response?.data?.error) {
const message = error.response.data.error;
dispatch(updateErrors({ error: 'thumbnail', message }));
} else {
dispatch(updateErrors({ error: 'thumbnail', message: `Failed to add thumbnail for video id ${videoId}.` }));
dispatch(
updateErrors({
error: 'thumbnail',
message: `Failed to add thumbnail for video id ${videoId}.`,
}),
);
}
dispatch(updateEditStatus({ editType: 'thumbnail', status: RequestStatus.FAILED }));
dispatch(
updateEditStatus({
editType: 'thumbnail',
status: RequestStatus.FAILED,
}),
);
}
};
}
@@ -189,7 +327,12 @@ export function deleteVideoTranscript({
apiUrl,
}) {
return async (dispatch) => {
dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.IN_PROGRESS }));
dispatch(
updateEditStatus({
editType: 'transcript',
status: RequestStatus.IN_PROGRESS,
}),
);
try {
await deleteTranscript({
@@ -197,22 +340,41 @@ export function deleteVideoTranscript({
language,
apiUrl,
});
const updatedTranscripts = transcripts.filter(transcript => transcript !== language);
const updatedTranscripts = transcripts.filter(
(transcript) => transcript !== language,
);
const transcriptStatus = updatedTranscripts?.length > 0 ? 'transcribed' : 'notTranscribed';
dispatch(updateModel({
modelType: 'videos',
model: {
id: videoId,
transcripts: updatedTranscripts,
transcriptStatus,
},
}));
dispatch(
updateModel({
modelType: 'videos',
model: {
id: videoId,
transcripts: updatedTranscripts,
transcriptStatus,
},
}),
);
dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.SUCCESSFUL }));
dispatch(
updateEditStatus({
editType: 'transcript',
status: RequestStatus.SUCCESSFUL,
}),
);
} catch (error) {
dispatch(updateErrors({ error: 'transcript', message: `Failed to delete ${language} transcript.` }));
dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.FAILED }));
dispatch(
updateErrors({
error: 'transcript',
message: `Failed to delete ${language} transcript.`,
}),
);
dispatch(
updateEditStatus({
editType: 'transcript',
status: RequestStatus.FAILED,
}),
);
}
};
}
@@ -224,7 +386,12 @@ export function downloadVideoTranscript({
apiUrl,
}) {
return async (dispatch) => {
dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.IN_PROGRESS }));
dispatch(
updateEditStatus({
editType: 'transcript',
status: RequestStatus.IN_PROGRESS,
}),
);
try {
await downloadTranscript({
@@ -233,10 +400,25 @@ export function downloadVideoTranscript({
apiUrl,
filename,
});
dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.SUCCESSFUL }));
dispatch(
updateEditStatus({
editType: 'transcript',
status: RequestStatus.SUCCESSFUL,
}),
);
} catch (error) {
dispatch(updateErrors({ error: 'transcript', message: `Failed to download ${filename}.` }));
dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.FAILED }));
dispatch(
updateErrors({
error: 'transcript',
message: `Failed to download ${filename}.`,
}),
);
dispatch(
updateEditStatus({
editType: 'transcript',
status: RequestStatus.FAILED,
}),
);
}
};
}
@@ -250,7 +432,12 @@ export function uploadVideoTranscript({
transcripts,
}) {
return async (dispatch) => {
dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.IN_PROGRESS }));
dispatch(
updateEditStatus({
editType: 'transcript',
status: RequestStatus.IN_PROGRESS,
}),
);
const isReplacement = !isEmpty(language);
try {
@@ -263,7 +450,9 @@ export function uploadVideoTranscript({
});
let updatedTranscripts = transcripts;
if (isReplacement) {
const removeTranscript = transcripts.filter(transcript => transcript !== language);
const removeTranscript = transcripts.filter(
(transcript) => transcript !== language,
);
updatedTranscripts = [...removeTranscript, newLanguage];
} else {
updatedTranscripts = [...transcripts, newLanguage];
@@ -271,118 +460,246 @@ export function uploadVideoTranscript({
const transcriptStatus = updatedTranscripts?.length > 0 ? 'transcribed' : 'notTranscribed';
dispatch(updateModel({
modelType: 'videos',
model: {
id: videoId,
transcripts: updatedTranscripts,
transcriptStatus,
},
}));
dispatch(
updateModel({
modelType: 'videos',
model: {
id: videoId,
transcripts: updatedTranscripts,
transcriptStatus,
},
}),
);
dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.SUCCESSFUL }));
dispatch(
updateEditStatus({
editType: 'transcript',
status: RequestStatus.SUCCESSFUL,
}),
);
} catch (error) {
if (error.response?.data?.error) {
const message = error.response.data.error;
dispatch(updateErrors({ error: 'transcript', message }));
} else {
const message = isReplacement ? `Failed to replace ${language} with ${newLanguage}.` : `Failed to add ${newLanguage}.`;
const message = isReplacement
? `Failed to replace ${language} with ${newLanguage}.`
: `Failed to add ${newLanguage}.`;
dispatch(updateErrors({ error: 'transcript', message }));
}
dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.FAILED }));
dispatch(
updateEditStatus({
editType: 'transcript',
status: RequestStatus.FAILED,
}),
);
}
};
}
export function getUsagePaths({ video, courseId }) {
return async (dispatch) => {
dispatch(updateEditStatus({ editType: 'usageMetrics', status: RequestStatus.IN_PROGRESS }));
dispatch(
updateEditStatus({
editType: 'usageMetrics',
status: RequestStatus.IN_PROGRESS,
}),
);
try {
const { usageLocations } = await getVideoUsagePaths({ videoId: video.id, courseId });
const { usageLocations } = await getVideoUsagePaths({
videoId: video.id,
courseId,
});
const activeStatus = usageLocations?.length > 0 ? 'active' : 'inactive';
dispatch(updateModel({
modelType: 'videos',
model: {
id: video.id,
usageLocations,
activeStatus,
},
}));
dispatch(updateEditStatus({ editType: 'usageMetrics', status: RequestStatus.SUCCESSFUL }));
dispatch(
updateModel({
modelType: 'videos',
model: {
id: video.id,
usageLocations,
activeStatus,
},
}),
);
dispatch(
updateEditStatus({
editType: 'usageMetrics',
status: RequestStatus.SUCCESSFUL,
}),
);
} catch (error) {
dispatch(updateErrors({ error: 'usageMetrics', message: `Failed to get usage metrics for ${video.displayName}.` }));
dispatch(updateEditStatus({ editType: 'usageMetrics', status: RequestStatus.FAILED }));
dispatch(
updateErrors({
error: 'usageMetrics',
message: `Failed to get usage metrics for ${video.displayName}.`,
}),
);
dispatch(
updateEditStatus({
editType: 'usageMetrics',
status: RequestStatus.FAILED,
}),
);
}
};
}
export function fetchVideoDownload({ selectedRows, courseId }) {
return async (dispatch) => {
dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.IN_PROGRESS }));
dispatch(
updateEditStatus({
editType: 'download',
status: RequestStatus.IN_PROGRESS,
}),
);
try {
const errors = await getDownload(selectedRows, courseId);
dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.SUCCESSFUL }));
dispatch(
updateEditStatus({
editType: 'download',
status: RequestStatus.SUCCESSFUL,
}),
);
if (!isEmpty(errors)) {
errors.forEach(error => {
errors.forEach((error) => {
dispatch(updateErrors({ error: 'download', message: error }));
});
dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.FAILED }));
dispatch(
updateEditStatus({
editType: 'download',
status: RequestStatus.FAILED,
}),
);
}
} catch (error) {
dispatch(updateErrors({ error: 'download', message: 'Failed to download zip file of videos.' }));
dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.FAILED }));
dispatch(
updateErrors({
error: 'download',
message: 'Failed to download zip file of videos.',
}),
);
dispatch(
updateEditStatus({
editType: 'download',
status: RequestStatus.FAILED,
}),
);
}
};
}
export function clearAutomatedTranscript({ courseId }) {
return async (dispatch) => {
dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.IN_PROGRESS }));
dispatch(
updateEditStatus({
editType: 'transcript',
status: RequestStatus.IN_PROGRESS,
}),
);
try {
await deleteTranscriptPreferences(courseId);
dispatch(updateTranscriptPreferenceSuccess({ modified: new Date() }));
dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.SUCCESSFUL }));
dispatch(
updateEditStatus({
editType: 'transcript',
status: RequestStatus.SUCCESSFUL,
}),
);
} catch (error) {
dispatch(updateErrors({ error: 'transcript', message: 'Failed to update order transcripts settings.' }));
dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.FAILED }));
dispatch(
updateErrors({
error: 'transcript',
message: 'Failed to update order transcripts settings.',
}),
);
dispatch(
updateEditStatus({
editType: 'transcript',
status: RequestStatus.FAILED,
}),
);
}
};
}
export function updateTranscriptCredentials({ courseId, data }) {
return async (dispatch) => {
dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.IN_PROGRESS }));
dispatch(
updateEditStatus({
editType: 'transcript',
status: RequestStatus.IN_PROGRESS,
}),
);
try {
await setTranscriptCredentials(courseId, data);
dispatch(updateTranscriptCredentialsSuccess({ provider: camelCase(data.provider) }));
dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.SUCCESSFUL }));
dispatch(
updateTranscriptCredentialsSuccess({
provider: camelCase(data.provider),
}),
);
dispatch(
updateEditStatus({
editType: 'transcript',
status: RequestStatus.SUCCESSFUL,
}),
);
} catch (error) {
dispatch(updateErrors({ error: 'transcript', message: `Failed to update ${data.provider} credentials.` }));
dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.FAILED }));
dispatch(
updateErrors({
error: 'transcript',
message: `Failed to update ${data.provider} credentials.`,
}),
);
dispatch(
updateEditStatus({
editType: 'transcript',
status: RequestStatus.FAILED,
}),
);
}
};
}
export function updateTranscriptPreference({ courseId, data }) {
return async (dispatch) => {
dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.IN_PROGRESS }));
dispatch(
updateEditStatus({
editType: 'transcript',
status: RequestStatus.IN_PROGRESS,
}),
);
try {
const preferences = await setTranscriptPreferences(courseId, data);
dispatch(updateTranscriptPreferenceSuccess(preferences));
dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.SUCCESSFUL }));
dispatch(
updateEditStatus({
editType: 'transcript',
status: RequestStatus.SUCCESSFUL,
}),
);
} catch (error) {
if (error.response?.data?.error) {
const message = error.response.data.error;
dispatch(updateErrors({ error: 'transcript', message }));
} else {
dispatch(updateErrors({ error: 'transcript', message: `Failed to update ${data.provider} transcripts settings.` }));
dispatch(
updateErrors({
error: 'transcript',
message: `Failed to update ${data.provider} transcripts settings.`,
}),
);
}
dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.FAILED }));
dispatch(
updateEditStatus({
editType: 'transcript',
status: RequestStatus.FAILED,
}),
);
}
};
}

View File

@@ -18,7 +18,7 @@ describe('addVideoFile', () => {
status: 404,
});
await addVideoFile(courseId, mockFile)(dispatch, getState);
await addVideoFile(courseId, mockFile, undefined, { current: [] })(dispatch, getState);
expect(dispatch).toHaveBeenCalledWith({
payload: {
@@ -43,7 +43,7 @@ describe('addVideoFile', () => {
jest.spyOn(api, 'uploadVideo').mockResolvedValue({
status: 404,
});
await addVideoFile(courseId, mockFile)(dispatch, getState);
await addVideoFile(courseId, mockFile, undefined, { current: [] })(dispatch, getState);
expect(videoStatusMock).toHaveBeenCalledWith(courseId, mockEdxVideoId, 'Upload failed', 'upload_failed');
expect(dispatch).toHaveBeenCalledWith({
payload: {
@@ -70,7 +70,7 @@ describe('addVideoFile', () => {
jest.spyOn(api, 'uploadVideo').mockResolvedValue({
status: 200,
});
await addVideoFile(courseId, mockFile)(dispatch, getState);
await addVideoFile(courseId, mockFile, undefined, { current: [] })(dispatch, getState);
expect(videoStatusMock).toHaveBeenCalledWith(courseId, mockEdxVideoId, 'Upload completed', 'upload_completed');
});
});

View File

@@ -41,6 +41,10 @@ const messages = defineMessages({
id: 'course-authoring.files-and-videos.add-video-progress-bar.progress-bar.label',
defaultMessage: 'Video upload progress:',
},
videoUploadAlertLabel: {
id: 'course-authoring.files-and-videos.video-upload-alert',
defaultMessage: 'Upload in progress. Please wait for the upload to complete before navigating away from this page.',
},
});
export default messages;