feat: video upload progress modal (#1131)

* feat: add upload progress modal

* fix: increase code coverage

* fix: fix code to be more readable

* fix: delete empty file

* fix: failing test and lint

* fix: progress bar not updating

* feat: add missing abort controller on POST to edxVal
This commit is contained in:
Kristin Aoki
2024-06-26 18:00:07 -04:00
committed by GitHub
parent 8b759bc867
commit 22ea32cf01
17 changed files with 607 additions and 202 deletions

View File

@@ -563,7 +563,8 @@ describe('FilesAndUploads', () => {
const addStatus = store.getState().assets.addingStatus;
expect(addStatus).toEqual(RequestStatus.FAILED);
});
expect(screen.getByText('Error')).toBeVisible();
expect(screen.getByText('Upload error')).toBeVisible();
});
it('404 validation should show error', async () => {
@@ -575,7 +576,7 @@ describe('FilesAndUploads', () => {
const addStatus = store.getState().assets.addingStatus;
expect(addStatus).toEqual(RequestStatus.FAILED);
expect(screen.getByText('Error')).toBeVisible();
expect(screen.getByText('Upload error')).toBeVisible();
});
it('404 upload should show error', async () => {
@@ -588,7 +589,7 @@ describe('FilesAndUploads', () => {
const addStatus = store.getState().assets.addingStatus;
expect(addStatus).toEqual(RequestStatus.FAILED);
expect(screen.getByText('Error')).toBeVisible();
expect(screen.getByText('Upload error')).toBeVisible();
});
it('404 delete should show error', async () => {

View File

@@ -111,6 +111,15 @@ const messages = defineMessages({
defaultMessage: 'Cancel',
description: 'The message displayed in the button to confirm cancelling the upload',
},
lockFileTooltipContent: {
id: 'course-authoring.files-and-uploads.file-info.lockFile.tooltip.content',
defaultMessage: `By default, anyone can access a file you upload if
they know the web URL, even if they are not enrolled in your course.
You can prevent outside access to a file by locking the file. When
you lock a file, the web URL only allows learners who are enrolled
in your course and signed in to access the file.`,
description: 'Tooltip message for the lock icon in the table view of files',
},
});
export default messages;

View File

@@ -1,6 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Alert } from '@openedx/paragon';
import { ErrorAlert } from '@edx/frontend-lib-content-components';
import { RequestStatus } from '../../data/constants';
import messages from './messages';
@@ -24,10 +25,13 @@ const EditFileErrors = ({
{intl.formatMessage(messages.errorAlertMessage, { message: errorMessages.loading })}
</ErrorAlert>
<ErrorAlert
hideHeading={false}
hideHeading
dismissError={() => resetErrors({ errorType: 'add' })}
isError={addFileStatus === RequestStatus.FAILED}
>
<Alert.Heading>
{intl.formatMessage(messages.uploadErrorAlertTitle)}
</Alert.Heading>
<ul className="p-0">
{errorMessages.add.map(message => (
<li key={`add-error-${message}`} style={{ listStyle: 'none' }}>

View File

@@ -202,6 +202,12 @@ const messages = defineMessages({
failedLabel: {
id: 'course-authoring.files-and-uploads.filter.failed.label',
defaultMessage: 'Failed',
description: 'Label for failed sort button in sort and filter modal',
},
uploadErrorAlertTitle: {
id: 'course-authoring.files-and-uploads.error.upload.title',
defaultMessage: 'Upload error',
description: 'Title for upload error alert',
},
});

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { Helmet } from 'react-helmet';
import { useDispatch, useSelector } from 'react-redux';
import {
injectIntl,
@@ -7,13 +8,11 @@ import {
intlShape,
} from '@edx/frontend-platform/i18n';
import {
useToggle,
ActionRow,
Button,
CheckboxFilter,
Container,
Alert,
Spinner,
useToggle,
} from '@openedx/paragon';
import Placeholder from '@edx/frontend-lib-content-components';
@@ -29,6 +28,7 @@ import {
markVideoUploadsInProgressAsFailed,
resetErrors,
updateVideoOrder,
cancelAllUploads,
} from './data/thunks';
import messages from './messages';
import VideosPageProvider from './VideosPageProvider';
@@ -41,11 +41,12 @@ import {
ThumbnailColumn,
TranscriptColumn,
} from '../generic';
import TranscriptSettings from './transcript-settings';
import VideoThumbnail from './VideoThumbnail';
import { getFormattedDuration, resampleFile } from './data/utils';
import FILES_AND_UPLOAD_TYPE_FILTERS from '../generic/constants';
import TranscriptSettings from './transcript-settings';
import VideoInfoModalSidebar from './info-sidebar';
import VideoThumbnail from './VideoThumbnail';
import UploadModal from './upload-modal';
const VideosPage = ({
courseId,
@@ -58,11 +59,12 @@ const VideosPage = ({
openTranscriptSettings,
closeTranscriptSettings,
] = useToggle(false);
const [
isUploadTrackerOpen,
openUploadTracker,
closeUploadTracker,
] = useToggle(false);
const courseDetails = useModel('courseDetails', courseId);
document.title = getPageHeadTitle(
courseDetails?.name,
intl.formatMessage(messages.heading),
);
useEffect(() => {
dispatch(fetchVideos(courseId));
@@ -80,7 +82,7 @@ const VideosPage = ({
pageSettings,
} = useSelector((state) => state.videos);
const uploadingIdsRef = useRef([]);
const uploadingIdsRef = useRef({ uploadData: {}, uploadCount: 0 });
useEffect(() => {
window.onbeforeunload = () => {
@@ -90,6 +92,11 @@ const VideosPage = ({
}
return undefined;
};
if (addVideoStatus === RequestStatus.IN_PROGRESS) {
openUploadTracker();
} else {
closeUploadTracker();
}
}, [addVideoStatus]);
const {
@@ -103,11 +110,12 @@ const VideosPage = ({
const supportedFileFormats = {
'video/*': videoSupportedFileFormats || FILES_AND_UPLOAD_TYPE_FILTERS.video,
};
const handleUploadCancel = () => dispatch(cancelAllUploads(courseId, uploadingIdsRef.current.uploadData));
const handleErrorReset = (error) => dispatch(resetErrors(error));
const handleAddFile = (files) => {
handleErrorReset({ errorType: 'add' });
files.forEach((file) => dispatch(addVideoFile(courseId, file, videoIds, uploadingIdsRef)));
uploadingIdsRef.current.uploadCount = files.length;
dispatch(addVideoFile(courseId, files, videoIds, uploadingIdsRef));
};
const handleDeleteFile = (id) => dispatch(deleteVideoFile(courseId, id));
const handleDownloadFile = (selectedRows) => dispatch(fetchVideoDownload({ selectedRows, courseId }));
@@ -222,6 +230,9 @@ const VideosPage = ({
return (
<VideosPageProvider courseId={courseId}>
<Helmet>
<title>{getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.heading))}</title>
</Helmet>
<Container size="xl" className="p-4 pt-4.5">
<EditFileErrors
resetErrors={handleErrorReset}
@@ -231,11 +242,6 @@ 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} />
@@ -287,6 +293,13 @@ const VideosPage = ({
/>
</>
)}
<UploadModal
{...{
isUploadTrackerOpen,
currentUploadingIdsRef: uploadingIdsRef.current,
handleUploadCancel,
}}
/>
</Container>
</VideosPageProvider>
);

View File

@@ -10,7 +10,7 @@ import userEvent from '@testing-library/user-event';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getAuthenticatedHttpClient, getHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
@@ -30,7 +30,6 @@ import {
import {
fetchVideos,
addVideoFile,
deleteVideoFile,
getUsagePaths,
addVideoThumbnail,
@@ -43,6 +42,7 @@ import messages from '../generic/messages';
const { getVideosUrl, getCourseVideosApiUrl, getApiBaseUrl } = api;
let axiosMock;
let axiosUnauthenticateMock;
let store;
let file;
jest.mock('file-saver');
@@ -108,6 +108,7 @@ describe('Videos page', () => {
models: {},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosUnauthenticateMock = new MockAdapter(getHttpClient());
file = new File(['(⌐□_□)'], 'download.mp4', { type: 'video/mp4' });
});
@@ -136,21 +137,14 @@ describe('Videos page', () => {
it('should upload a single file', async () => {
await emptyMockStore(RequestStatus.SUCCESSFUL);
const dropzone = screen.getByTestId('files-dropzone');
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());
Object.defineProperty(dropzone, 'files', {
value: [file],
});
fireEvent.drop(dropzone);
await executeThunk(addVideoFile(courseId, file, [], { current: [] }), store.dispatch);
await waitFor(() => {
const addStatus = store.getState().videos.addingStatus;
expect(addStatus).toEqual(RequestStatus.SUCCESSFUL);
await act(async () => {
axiosMock.onPost(getCourseVideosApiUrl(courseId)).reply(204, generateNewVideoApiResponse());
axiosUnauthenticateMock.onPut('http://testing.org').reply(200);
axiosMock.onGet(getCourseVideosApiUrl(courseId)).reply(200, generateAddVideoApiResponse());
Object.defineProperty(dropzone, 'files', {
value: [file],
});
fireEvent.drop(dropzone);
});
expect(screen.queryByTestId('files-dropzone')).toBeNull();
@@ -170,6 +164,7 @@ describe('Videos page', () => {
});
store = initializeStore({ ...initialState });
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosUnauthenticateMock = new MockAdapter(getHttpClient());
file = new File(['(⌐□_□)'], 'download.png', { type: 'image/png' });
});
@@ -239,53 +234,93 @@ describe('Videos page', () => {
});
describe('table actions', () => {
it('should upload a single file', async () => {
await mockStore(RequestStatus.SUCCESSFUL);
const mockResponseData = { status: '200', ok: true, blob: () => 'Data' };
const mockFetchResponse = Promise.resolve(mockResponseData);
global.fetch = jest.fn().mockImplementation(() => mockFetchResponse);
describe('file upload', () => {
it('should upload a single file', async () => {
await mockStore(RequestStatus.SUCCESSFUL);
axiosMock.onPost(getCourseVideosApiUrl(courseId)).reply(204, generateNewVideoApiResponse());
axiosMock.onGet(getCourseVideosApiUrl(courseId)).reply(200, generateAddVideoApiResponse());
axiosMock.onPost(getCourseVideosApiUrl(courseId)).reply(204, generateNewVideoApiResponse());
axiosUnauthenticateMock.onPut('http://testing.org').reply(200);
axiosMock.onGet(getCourseVideosApiUrl(courseId)).reply(200, generateAddVideoApiResponse());
const addFilesButton = screen.getAllByLabelText('file-input')[3];
const { videoIds } = store.getState().videos;
userEvent.upload(addFilesButton, file);
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];
userEvent.upload(addFilesButton, file);
await waitFor(() => {
const addFilesButton = screen.getAllByLabelText('file-input')[3];
await act(async () => {
userEvent.upload(addFilesButton, file);
});
const addStatus = store.getState().videos.addingStatus;
expect(addStatus).toEqual(RequestStatus.IN_PROGRESS);
expect(uploadSpy).toHaveBeenCalled();
expect(screen.getByText(videoMessages.videoUploadAlertLabel.defaultMessage)).toBeVisible();
expect(addStatus).toEqual(RequestStatus.SUCCESSFUL);
});
act(() => {
window.dispatchEvent(new Event('beforeunload'));
it('when uploads are in progress, should show dialog and set them to failed on page leave', async () => {
await mockStore(RequestStatus.SUCCESSFUL);
axiosMock.onPost(getCourseVideosApiUrl(courseId)).reply(204, generateNewVideoApiResponse());
axiosUnauthenticateMock.onPut('http://testing.org').reply(200);
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];
await 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.videoUploadTrackerModalTitle.defaultMessage)).toBeVisible();
});
await act(async () => {
window.dispatchEvent(new Event('beforeunload'));
});
await waitFor(() => {
expect(setFailedSpy).toHaveBeenCalledWith(courseId, expect.any(String), expect.any(String), 'upload_failed');
});
uploadSpy.mockRestore();
setFailedSpy.mockRestore();
});
await waitFor(() => {
expect(setFailedSpy).toHaveBeenCalledWith(courseId, expect.any(String), expect.any(String), 'upload_failed');
it('should cancel all in-progress and set them to failed', async () => {
await mockStore(RequestStatus.SUCCESSFUL);
axiosMock.onPost(getCourseVideosApiUrl(courseId)).reply(204, generateNewVideoApiResponse());
axiosUnauthenticateMock.onPut('http://testing.org').reply(200);
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];
await 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.videoUploadTrackerModalTitle.defaultMessage)).toBeVisible();
});
await act(async () => {
const cancelButton = screen.getByText(videoMessages.videoUploadTrackerAlertCancelLabel.defaultMessage);
fireEvent.click(cancelButton);
});
await waitFor(() => {
const addStatus = store.getState().videos.addingStatus;
expect(setFailedSpy).toHaveBeenCalledWith(courseId, expect.any(String), expect.any(String), 'upload_failed');
expect(addStatus).toEqual(RequestStatus.FAILED);
expect(screen.getByText('Upload error')).toBeVisible();
});
uploadSpy.mockRestore();
setFailedSpy.mockRestore();
});
uploadSpy.mockRestore();
setFailedSpy.mockRestore();
});
it('should have disabled action buttons', async () => {
@@ -348,11 +383,8 @@ describe('Videos page', () => {
const downloadButton = screen.getByText(messages.downloadTitle.defaultMessage).closest('a');
expect(downloadButton).not.toHaveClass('disabled');
fireEvent.click(downloadButton);
await waitFor(() => {
const updateStatus = store.getState().videos.updatingStatus;
expect(updateStatus).toEqual(RequestStatus.SUCCESSFUL);
await act(async () => {
fireEvent.click(downloadButton);
});
});
@@ -574,27 +606,35 @@ describe('Videos page', () => {
const errorMessage = 'File download.png exceeds maximum size of 5 GB.';
await mockStore(RequestStatus.SUCCESSFUL);
axiosMock.onPost(getCourseVideosApiUrl(courseId)).reply(413, { error: errorMessage });
axiosMock.onGet(getCourseVideosApiUrl(courseId)).reply(200, generateAddVideoApiResponse());
const addFilesButton = screen.getAllByLabelText('file-input')[3];
userEvent.upload(addFilesButton, file);
await executeThunk(addVideoFile(courseId, file, undefined, { current: [] }), store.dispatch);
const addStatus = store.getState().videos.addingStatus;
expect(addStatus).toEqual(RequestStatus.FAILED);
await act(async () => {
userEvent.upload(addFilesButton, file);
});
await waitFor(() => {
const addStatus = store.getState().videos.addingStatus;
expect(addStatus).toEqual(RequestStatus.FAILED);
expect(screen.getByText('Error')).toBeVisible();
expect(screen.getByText('Upload error')).toBeVisible();
});
});
it('404 add file should show error', async () => {
await mockStore(RequestStatus.SUCCESSFUL);
axiosMock.onPost(getCourseVideosApiUrl(courseId)).reply(404);
axiosMock.onGet(getCourseVideosApiUrl(courseId)).reply(200, generateAddVideoApiResponse());
const addFilesButton = screen.getAllByLabelText('file-input')[3];
userEvent.upload(addFilesButton, file);
await executeThunk(addVideoFile(courseId, file, undefined, { current: [] }), store.dispatch);
const addStatus = store.getState().videos.addingStatus;
expect(addStatus).toEqual(RequestStatus.FAILED);
await act(async () => {
userEvent.upload(addFilesButton, file);
});
await waitFor(() => {
const addStatus = store.getState().videos.addingStatus;
expect(addStatus).toEqual(RequestStatus.FAILED);
expect(screen.getByText('Error')).toBeVisible();
expect(screen.getByText('Upload error')).toBeVisible();
});
});
it('404 add thumbnail should show error', async () => {
@@ -613,19 +653,19 @@ describe('Videos page', () => {
it('404 upload file to server should show error', async () => {
await mockStore(RequestStatus.SUCCESSFUL);
const mockResponseData = { status: '404', ok: false, blob: () => 'Data' };
const mockFetchResponse = Promise.reject(mockResponseData);
global.fetch = jest.fn().mockImplementation(() => mockFetchResponse);
axiosMock.onPost(getCourseVideosApiUrl(courseId)).reply(204, generateNewVideoApiResponse());
axiosUnauthenticateMock.onPut('http://testing.org').reply(404);
axiosMock.onGet(getCourseVideosApiUrl(courseId)).reply(200, generateAddVideoApiResponse());
const addFilesButton = screen.getAllByLabelText('file-input')[3];
userEvent.upload(addFilesButton, file);
await executeThunk(addVideoFile(courseId, file, undefined, { current: [] }), store.dispatch);
const addStatus = store.getState().videos.addingStatus;
expect(addStatus).toEqual(RequestStatus.FAILED);
await act(async () => {
userEvent.upload(addFilesButton, file);
});
await waitFor(() => {
const addStatus = store.getState().videos.addingStatus;
expect(addStatus).toEqual(RequestStatus.FAILED);
expect(screen.getByText('Error')).toBeVisible();
expect(screen.getByText('Upload error')).toBeVisible();
});
});
it('404 delete should show error', async () => {

View File

@@ -1,7 +1,7 @@
/* eslint-disable no-param-reassign */
import saveAs from 'file-saver';
import { camelCaseObject, ensureConfig, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getAuthenticatedHttpClient, getHttpClient } from '@edx/frontend-platform/auth';
import { isEmpty } from 'lodash';
ensureConfig([
@@ -176,11 +176,15 @@ export async function addThumbnail({ courseId, videoId, file }) {
* @param {blockId} courseId Course ID for the course to operate on
*/
export async function addVideo(courseId, file) {
export async function addVideo(courseId, file, controller) {
const postJson = {
files: [{ file_name: file.name, content_type: file.type }],
};
return getAuthenticatedHttpClient().post(getCourseVideosApiUrl(courseId), postJson);
return getAuthenticatedHttpClient().post(
getCourseVideosApiUrl(courseId),
postJson,
{ signal: controller?.signal },
);
}
export async function sendVideoUploadStatus(
@@ -200,16 +204,25 @@ export async function sendVideoUploadStatus(
export async function uploadVideo(
uploadUrl,
uploadFile,
uploadingIdsRef,
videoId,
controller,
) {
return fetch(uploadUrl, {
method: 'PUT',
const currentUpload = uploadingIdsRef.current.uploadData[videoId];
return getHttpClient().put(uploadUrl, uploadFile, {
headers: {
'Content-Disposition': `attachment; filename="${uploadFile.name}"`,
'Content-Type': uploadFile.type,
'Content-Length': uploadFile.size,
},
multipart: false,
body: uploadFile,
signal: controller?.signal,
onUploadProgress: ({ loaded, total }) => {
const progress = ((loaded / total) * 100).toFixed(2);
uploadingIdsRef.current.uploadData[videoId] = {
...currentUpload,
progress,
};
},
});
}

View File

@@ -1,7 +1,7 @@
import 'file-saver';
import MockAdapter from 'axios-mock-adapter';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getAuthenticatedHttpClient, getHttpClient } from '@edx/frontend-platform/auth';
import {
getDownload, getVideosUrl, getAllUsagePaths, getCourseVideosApiUrl, uploadVideo, sendVideoUploadStatus,
@@ -10,6 +10,7 @@ import {
jest.mock('file-saver');
let axiosMock;
let axiosUnauthenticatedMock;
describe('api.js', () => {
beforeEach(() => {
@@ -22,6 +23,7 @@ describe('api.js', () => {
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosUnauthenticatedMock = new MockAdapter(getHttpClient());
});
describe('getDownload', () => {
describe('selectedRows length is undefined or less than zero', () => {
@@ -110,10 +112,32 @@ describe('api.js', () => {
it('PUTs to the provided URL', async () => {
const mockUrl = 'mock.com';
const mockFile = { mock: 'file' };
const mockVideoId = 'id123';
const mockController = {};
const mockRef = {
current: {
uploadData: {
id123: {
progress: 0,
name: 'test',
status: 'failed',
},
},
},
};
const expectedResult = 'Something';
global.fetch = jest.fn().mockResolvedValue(expectedResult);
const actual = await uploadVideo(mockUrl, mockFile);
axiosUnauthenticatedMock.onPut(mockUrl).reply((config) => {
const total = 1024; // mocked file size
const progress = 0.4;
if (config.onUploadProgress) {
config.onUploadProgress({ loaded: total * progress, total });
}
return [200, expectedResult];
});
const { data: actual } = await uploadVideo(mockUrl, mockFile, mockRef, mockVideoId, mockController);
expect(actual).toEqual(expectedResult);
expect(mockRef.current.uploadData.id123.progress).toEqual('40.00');
});
});
describe('sendVideoUploadStatus', () => {

View File

@@ -92,7 +92,6 @@ const slice = createSlice({
const { fileName } = payload;
const currentErrorState = state.errors.add;
state.errors.add = [...currentErrorState, `Failed to add ${fileName}.`];
state.addingStatus = RequestStatus.FAILED;
},
},
});

View File

@@ -39,9 +39,42 @@ import {
failAddVideo,
} from './slice';
import { ServerError } from './errors';
import { updateFileValues } from './utils';
let controllers = [];
const updateVideoUploadStatus = async (courseId, edxVideoId, message, status) => {
await sendVideoUploadStatus(courseId, edxVideoId, message, status);
};
export function cancelAllUploads(courseId, uploadData) {
return async (dispatch) => {
if (controllers) {
controllers.forEach(control => {
control.abort();
});
Object.entries(uploadData).forEach(([key, value]) => {
if (value.status === RequestStatus.PENDING) {
updateVideoUploadStatus(
courseId,
key,
'Upload failed',
'upload_failed',
);
dispatch(
updateErrors({
error: 'add',
message: `Cancelled upload for ${value.name}.`,
}),
);
}
});
dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.FAILED }));
}
controllers = [];
};
}
export function fetchVideos(courseId) {
return async (dispatch) => {
dispatch(
@@ -143,9 +176,9 @@ export function deleteVideoFile(courseId, id) {
export function markVideoUploadsInProgressAsFailed({ uploadingIdsRef, courseId }) {
return (dispatch) => {
uploadingIdsRef.current.forEach((edxVideoId) => {
Object.keys(uploadingIdsRef.current.uploadData).forEach((edxVideoId) => {
try {
sendVideoUploadStatus(
updateVideoUploadStatus(
courseId,
edxVideoId || '',
'Upload failed',
@@ -155,18 +188,100 @@ export function markVideoUploadsInProgressAsFailed({ uploadingIdsRef, courseId }
// eslint-disable-next-line no-console
console.error(`Failed to send "Failed" upload status for ${edxVideoId} onbeforeunload`);
}
dispatch(
updateEditStatus({ editType: 'add', status: RequestStatus.FAILED }),
);
dispatch(updateEditStatus({ editType: 'add', status: '' }));
});
// eslint-disable-next-line no-param-reassign
uploadingIdsRef.current = [];
};
}
const addVideoToEdxVal = async (courseId, file, dispatch) => {
const currentController = new AbortController();
controllers.push(currentController);
try {
const createUrlResponse = await addVideo(courseId, file, currentController);
// eslint-disable-next-line
console.log(`Post Response: ${JSON.stringify(createUrlResponse)}`);
if (createUrlResponse.status < 200 || createUrlResponse.status >= 300) {
dispatch(failAddVideo({ fileName: file.name }));
}
const [{ uploadUrl, edxVideoId }] = camelCaseObject(
createUrlResponse.data,
).files;
return { uploadUrl, edxVideoId };
} catch (error) {
dispatch(failAddVideo({ fileName: file.name }));
return {};
}
};
const uploadToBucket = async ({
courseId,
uploadUrl,
file,
uploadingIdsRef,
edxVideoId,
dispatch,
}) => {
const currentController = new AbortController();
controllers.push(currentController);
const currentVideoData = uploadingIdsRef.current.uploadData[edxVideoId];
try {
const putToServerResponse = await uploadVideo(
uploadUrl,
file,
uploadingIdsRef,
edxVideoId,
currentController,
);
if (
putToServerResponse.status < 200
|| putToServerResponse.status >= 300
) {
throw new ServerError(
'Server responded with an error status',
putToServerResponse.status,
);
} else {
uploadingIdsRef.current.uploadData[edxVideoId] = {
...currentVideoData,
status: RequestStatus.SUCCESSFUL,
};
updateVideoUploadStatus(
courseId,
edxVideoId,
'Upload completed',
'upload_completed',
);
}
return false;
} 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}.`,
}),
);
uploadingIdsRef.current.uploadData[edxVideoId] = {
...currentVideoData,
status: RequestStatus.FAILED,
};
}
updateVideoUploadStatus(
courseId,
edxVideoId || '',
'Upload failed',
'upload_failed',
);
return true;
}
};
export function addVideoFile(
courseId,
file,
files,
videoIds,
uploadingIdsRef,
) {
@@ -174,74 +289,34 @@ export function addVideoFile(
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: ${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;
} 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,
);
let hasFailure = false;
await Promise.all(files.map(async (file, idx) => {
const name = file?.name || `Video ${idx + 1}`;
const { edxVideoId, uploadUrl } = await addVideoToEdxVal(courseId, file, dispatch);
if (uploadUrl && edxVideoId) {
uploadingIdsRef.current.uploadData = {
...uploadingIdsRef.current.uploadData,
[edxVideoId]: {
name,
status: RequestStatus.PENDING,
progress: 0,
},
};
hasFailure = await uploadToBucket({
courseId, uploadUrl, file, uploadingIdsRef, edxVideoId, dispatch,
});
} else {
await sendVideoUploadStatus(
courseId,
edxVideoId,
'Upload completed',
'upload_completed',
);
hasFailure = true;
uploadingIdsRef.current.uploadData = {
...uploadingIdsRef.current.uploadData,
[idx]: {
name,
status: RequestStatus.FAILED,
progress: 0,
},
};
}
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}.`,
}),
);
}
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(
@@ -257,14 +332,20 @@ export function addVideoFile(
);
// eslint-disable-next-line
console.error(`fetchVideoList failed with message: ${error.message}`);
hasFailure = true;
dispatch(
updateErrors({ error: 'add', message: 'Failed to load videos' }),
);
return;
}
dispatch(
updateEditStatus({ editType: 'add', status: RequestStatus.SUCCESSFUL }),
);
if (hasFailure) {
dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.FAILED }));
} else {
dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.SUCCESSFUL }));
}
uploadingIdsRef.current = {
uploadData: {},
uploadCount: 0,
};
};
}

View File

@@ -18,7 +18,7 @@ describe('addVideoFile', () => {
status: 404,
});
await addVideoFile(courseId, mockFile, undefined, { current: [] })(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, undefined, { current: [] })(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, undefined, { current: [] })(dispatch, getState);
await addVideoFile(courseId, [mockFile], undefined, { current: [] })(dispatch, getState);
expect(videoStatusMock).toHaveBeenCalledWith(courseId, mockEdxVideoId, 'Upload completed', 'upload_completed');
});
});

View File

@@ -1,7 +1,13 @@
import TranscriptSettings from './transcript-settings';
import UploadModal from './upload-modal';
import VideosPage from './VideosPage';
import VideoThumbnail from './VideoThumbnail';
import VideoInfoModalSidebar from './info-sidebar';
export default VideosPage;
export { TranscriptSettings, VideoThumbnail, VideoInfoModalSidebar };
export {
TranscriptSettings,
UploadModal,
VideoThumbnail,
VideoInfoModalSidebar,
};

View File

@@ -4,46 +4,87 @@ const messages = defineMessages({
heading: {
id: 'course-authoring.video-uploads.heading',
defaultMessage: 'Videos',
description: 'Title of the page',
},
transcriptSettingsButtonLabel: {
id: 'course-authoring.video-uploads.transcript-settings.button.toggle',
defaultMessage: 'Transcript settings',
description: 'Button text for transcript settings button',
},
thumbnailAltMessage: {
id: 'course-authoring.video-uploads.thumbnail.alt',
defaultMessage: '{displayName} video thumbnail',
description: 'Alternative text for video thumbnail image',
},
activeCheckboxLabel: {
id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.activeCheckbox.label',
defaultMessage: 'Active',
description: 'Checkbox label for Active checkbox in sort and filter modal',
},
inactiveCheckboxLabel: {
id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.inactiveCheckbox.label',
defaultMessage: 'Inactive',
description: 'Checkbox label for Inactive checkbox in sort and filter modal',
},
transcribedCheckboxLabel: {
id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.transcribedCheckbox.label',
defaultMessage: 'Transcribed',
description: 'Checkbox label for Transcribed checkbox in sort and filter modal',
},
notTranscribedCheckboxLabel: {
id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.notTranscribedCheckbox.label',
defaultMessage: 'Not transcribed',
description: 'Checkbox label for Not transcribed checkbox in sort and filter modal',
},
processingCheckboxLabel: {
id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.processingCheckbox.label',
defaultMessage: 'Processing',
description: 'Checkbox label for Processing checkbox in sort and filter modal',
},
failedCheckboxLabel: {
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:',
description: 'Checkbox label for Failed checkbox in sort and filter modal',
},
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.',
description: 'Message for video upload alert',
},
videoUploadTrackerModalTitle: {
id: 'course-authoring.files-and-videos.video-upload-tracker-modal.title',
defaultMessage: 'Upload in progress',
description: 'Title for the Upload Tracker Modal',
},
videoUploadTrackerAlertTitle: {
id: 'course-authoring.files-and-videos.video-upload-tracker-alert.title',
defaultMessage: 'Do not close or refresh this page or tab until uploads are complete',
description: 'Title for the Upload Tracker Alert',
},
videoUploadTrackerAlertBodyMessage: {
id: 'course-authoring.files-and-videos.video-upload-tracker-alert.body.message',
defaultMessage: 'Exiting now will delete all upload progress. This pop-up will close upon successful upload.',
description: 'Body text for the Upload Tracker Alert',
},
videoUploadTrackerAlertEditMessage: {
id: 'course-authoring.files-and-videos.video-upload-tracker-alert.edit.message',
defaultMessage: 'Want to coninue editing in Studio during this upload?',
description: 'Continue editing message for the Upload Tracker Alert',
},
videoUploadTrackerAlertEditHyperlinkLabel: {
id: 'course-authoring.files-and-videos.video-upload-tracker-alert.edit-hyperlink.message',
defaultMessage: 'Open new Studio tab',
description: 'Label for hyperlink to open a new tab',
},
videoUploadTrackerModalBody: {
id: 'course-authoring.files-and-videos.video-upload-tracker-modal.body.message',
defaultMessage: 'The following ({uploadCount}) {uploadCount, plural, one {video is} other {videos are}} being uploaded:',
description: 'Message for upload tracker modal body',
},
videoUploadTrackerAlertCancelLabel: {
id: 'course-authoring.files-and-videos.video-upload-tracker-alert.cancel-button.label',
defaultMessage: 'Cancel uploads',
description: 'Label for cancel button',
},
});

View File

@@ -0,0 +1,94 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
ActionRow,
Alert,
Button,
Hyperlink,
ModalDialog,
Scrollable,
} from '@openedx/paragon';
import { WarningFilled } from '@openedx/paragon/icons';
import messages from '../messages';
import UploadProgressList from './UploadProgressList';
const UploadModal = ({
isUploadTrackerOpen,
handleUploadCancel,
currentUploadingIdsRef,
}) => {
const intl = useIntl();
const videosPagePath = '';
const { uploadData, uploadCount } = currentUploadingIdsRef;
return (
<ModalDialog
title={intl.formatMessage(messages.videoUploadTrackerModalTitle)}
isOpen={isUploadTrackerOpen}
onClose={handleUploadCancel}
isBlocking
hasCloseButton={false}
size="lg"
>
<ModalDialog.Header>
<ModalDialog.Title className="mb-3">
{intl.formatMessage(messages.videoUploadTrackerModalTitle)}
</ModalDialog.Title>
<Alert
variant="warning"
icon={WarningFilled}
>
<Alert.Heading>
{intl.formatMessage(messages.videoUploadTrackerAlertTitle)}
</Alert.Heading>
{intl.formatMessage(messages.videoUploadTrackerAlertBodyMessage)}
<div className="mt-3">
<span className="font-weight-bold">
{intl.formatMessage(messages.videoUploadTrackerAlertEditMessage)}
</span>
<Hyperlink
className="ml-2"
destination={videosPagePath}
target="_blank"
content={intl.formatMessage(messages.videoUploadTrackerAlertEditHyperlinkLabel)}
/>
</div>
</Alert>
<div className="my-4 text-primary-500">
{intl.formatMessage(
messages.videoUploadTrackerModalBody,
{ uploadCount },
)}
</div>
</ModalDialog.Header>
<Scrollable>
<ModalDialog.Body>
<UploadProgressList videosList={Object.entries(uploadData)} />
</ModalDialog.Body>
</Scrollable>
<ModalDialog.Footer>
<ActionRow>
<Button onClick={handleUploadCancel}>
{intl.formatMessage(messages.videoUploadTrackerAlertCancelLabel)}
</Button>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
);
};
UploadModal.propTypes = {
isUploadTrackerOpen: PropTypes.bool.isRequired,
handleUploadCancel: PropTypes.func.isRequired,
currentUploadingIdsRef: PropTypes.shape({
uploadData: PropTypes.shape({
name: PropTypes.string,
status: PropTypes.string,
uploadPercentage: PropTypes.number,
}).isRequired,
uploadCount: PropTypes.number.isRequired,
}).isRequired,
};
export default UploadModal;

View File

@@ -0,0 +1,46 @@
import React from 'react';
import PropTypes from 'prop-types';
import { ProgressBar, Stack, Truncate } from '@openedx/paragon';
import UploadStatusIcon from './UploadStatusIcon';
import { RequestStatus } from '../../../data/constants';
const UploadProgressList = ({ videosList }) => (
<div role="list" className="text-primary-500">
{videosList.map(([id, video], index) => {
const bulletNumber = `${index + 1}. `;
return (
<Stack role="listitem" gap={2} direction="horizontal" className="mb-3 small" key={id}>
<span>{bulletNumber}</span>
<div className="col-5 pl-0">
<Truncate>
{video.name}
</Truncate>
</div>
<div className="col-6 p-0">
{video.status === RequestStatus.FAILED ? (
<span className="row m-0 justify-content-end font-weight-bold">
{video.status.toUpperCase()}
</span>
) : (
<ProgressBar now={video.progress} variant="info" />
)}
</div>
<UploadStatusIcon status={video.status} />
</Stack>
);
})}
</div>
);
UploadProgressList.propTypes = {
videosList: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string.isRequired,
status: PropTypes.string.isRequired,
uploadPercentage: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
]).isRequired,
})).isRequired,
};
export default UploadProgressList;

View File

@@ -0,0 +1,25 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Icon } from '@openedx/paragon';
import { Check, ErrorOutline } from '@openedx/paragon/icons';
import { RequestStatus } from '../../../data/constants';
const UploadStatusIcon = ({ status }) => {
switch (status) {
case RequestStatus.SUCCESSFUL:
return (<Icon src={Check} />);
case RequestStatus.FAILED:
return (<Icon src={ErrorOutline} />);
default:
return (<div style={{ width: '24px' }} />);
}
};
UploadStatusIcon.defaultProps = {
status: null,
};
UploadStatusIcon.propTypes = {
status: PropTypes.string,
};
export default UploadStatusIcon;

View File

@@ -0,0 +1,3 @@
import UploadModal from './UploadModal';
export default UploadModal;