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:
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
3
src/files-and-videos/videos-page/upload-modal/index.js
Normal file
3
src/files-and-videos/videos-page/upload-modal/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import UploadModal from './UploadModal';
|
||||
|
||||
export default UploadModal;
|
||||
Reference in New Issue
Block a user