diff --git a/.env.development b/.env.development index 279bbaedae..de0150810e 100644 --- a/.env.development +++ b/.env.development @@ -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='' diff --git a/src/files-and-videos/generic/messages.js b/src/files-and-videos/generic/messages.js index 7e452f5a35..d6f6d03e87 100644 --- a/src/files-and-videos/generic/messages.js +++ b/src/files-and-videos/generic/messages.js @@ -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; diff --git a/src/files-and-videos/generic/table-components/table-custom-columns/StatusColumn.jsx b/src/files-and-videos/generic/table-components/table-custom-columns/StatusColumn.jsx index c88bb65875..4d7195301c 100644 --- a/src/files-and-videos/generic/table-components/table-custom-columns/StatusColumn.jsx +++ b/src/files-and-videos/generic/table-components/table-custom-columns/StatusColumn.jsx @@ -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 ( - {status} + {isFailed ? failedText : status} ); }; diff --git a/src/files-and-videos/index.scss b/src/files-and-videos/index.scss index 2e419559af..0af5102913 100644 --- a/src/files-and-videos/index.scss +++ b/src/files-and-videos/index.scss @@ -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; +} diff --git a/src/files-and-videos/videos-page/VideoThumbnail.jsx b/src/files-and-videos/videos-page/VideoThumbnail.jsx index d1c65f3374..60543991dc 100644 --- a/src/files-and-videos/videos-page/VideoThumbnail.jsx +++ b/src/files-and-videos/videos-page/VideoThumbnail.jsx @@ -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 = ({
{!isUploaded && ( - {status} + {!isFailed ? status : failedMessage} )}
diff --git a/src/files-and-videos/videos-page/VideosPage.jsx b/src/files-and-videos/videos-page/VideosPage.jsx index 74cf61d320..354db5409e 100644 --- a/src/files-and-videos/videos-page/VideosPage.jsx +++ b/src/files-and-videos/videos-page/VideosPage.jsx @@ -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} /> + +
+

+
+
diff --git a/src/files-and-videos/videos-page/VideosPage.test.jsx b/src/files-and-videos/videos-page/VideosPage.test.jsx index 33fe2be9b7..cc1b1f52af 100644 --- a/src/files-and-videos/videos-page/VideosPage.test.jsx +++ b/src/files-and-videos/videos-page/VideosPage.test.jsx @@ -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 () => { diff --git a/src/files-and-videos/videos-page/data/constants.js b/src/files-and-videos/videos-page/data/constants.js index ab666b37aa..5b2ff7e4ee 100644 --- a/src/files-and-videos/videos-page/data/constants.js +++ b/src/files-and-videos/videos-page/data/constants.js @@ -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. diff --git a/src/files-and-videos/videos-page/data/thunks.js b/src/files-and-videos/videos-page/data/thunks.js index 4d1e4cabec..426fdfbd79 100644 --- a/src/files-and-videos/videos-page/data/thunks.js +++ b/src/files-and-videos/videos-page/data/thunks.js @@ -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, + }), + ); } }; } diff --git a/src/files-and-videos/videos-page/data/thunks.test.js b/src/files-and-videos/videos-page/data/thunks.test.js index 20c61d6ae1..7d0b3c7acc 100644 --- a/src/files-and-videos/videos-page/data/thunks.test.js +++ b/src/files-and-videos/videos-page/data/thunks.test.js @@ -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'); }); }); diff --git a/src/files-and-videos/videos-page/messages.js b/src/files-and-videos/videos-page/messages.js index 774dfe2f0b..6dd3f16b8c 100644 --- a/src/files-and-videos/videos-page/messages.js +++ b/src/files-and-videos/videos-page/messages.js @@ -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;