diff --git a/src/course-outline/page-alerts/PageAlerts.jsx b/src/course-outline/page-alerts/PageAlerts.jsx index f71a9c368b..ce430d6e47 100644 --- a/src/course-outline/page-alerts/PageAlerts.jsx +++ b/src/course-outline/page-alerts/PageAlerts.jsx @@ -1,30 +1,31 @@ -import React, { useState } from 'react'; -import PropTypes from 'prop-types'; -import { uniqBy } from 'lodash'; import { getConfig } from '@edx/frontend-platform'; -import { useDispatch, useSelector } from 'react-redux'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { + Alert, Button, Hyperlink, Truncate, +} from '@openedx/paragon'; import { Campaign as CampaignIcon, + Error as ErrorIcon, InfoOutline as InfoOutlineIcon, Warning as WarningIcon, - Error as ErrorIcon, } from '@openedx/paragon/icons'; -import { - Alert, Button, Hyperlink, Truncate, -} from '@openedx/paragon'; +import { uniqBy } from 'lodash'; +import PropTypes from 'prop-types'; +import React, { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { Link, useNavigate } from 'react-router-dom'; +import CourseOutlinePageAlertsSlot from '../../plugin-slots/CourseOutlinePageAlertsSlot'; +import advancedSettingsMessages from '../../advanced-settings/messages'; +import { OutOfSyncAlert } from '../../course-libraries/OutOfSyncAlert'; +import { RequestStatus } from '../../data/constants'; import ErrorAlert from '../../editors/sharedComponents/ErrorAlerts/ErrorAlert'; -import { RequestStatus } from '../../data/constants'; import AlertMessage from '../../generic/alert-message'; import AlertProctoringError from '../../generic/AlertProctoringError'; -import messages from './messages'; -import advancedSettingsMessages from '../../advanced-settings/messages'; +import { API_ERROR_TYPES } from '../constants'; import { getPasteFileNotices } from '../data/selectors'; import { dismissError, removePasteFileNotices } from '../data/slice'; -import { API_ERROR_TYPES } from '../constants'; -import { OutOfSyncAlert } from '../../course-libraries/OutOfSyncAlert'; +import messages from './messages'; const PageAlerts = ({ courseId, @@ -437,6 +438,7 @@ const PageAlerts = ({ {conflictingFilesPasteAlert()} {newFilesPasteAlert()} {renderOutOfSyncAlert()} + ); }; diff --git a/src/files-and-videos/files-page/CourseFilesTable.tsx b/src/files-and-videos/files-page/CourseFilesTable.tsx new file mode 100644 index 0000000000..98cb58157e --- /dev/null +++ b/src/files-and-videos/files-page/CourseFilesTable.tsx @@ -0,0 +1,183 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { CheckboxFilter } from '@openedx/paragon'; +import { + addAssetFile, + deleteAssetFile, + fetchAssetDownload, + getUsagePaths, + resetErrors, + updateAssetLock, + updateAssetOrder, + validateAssetFiles, +} from '@src/files-and-videos/files-page/data/thunks'; +import FileInfoModalSidebar from '@src/files-and-videos/files-page/FileInfoModalSidebar'; +import FileThumbnail from '@src/files-and-videos/files-page/FileThumbnail'; +import FileValidationModal from '@src/files-and-videos/files-page/FileValidationModal'; +import messages from '@src/files-and-videos/files-page/messages'; +import { + AccessColumn, + ActiveColumn, + FileTable, + ThumbnailColumn, +} from '@src/files-and-videos/generic'; +import { useModels } from '@src/generic/model-store'; +import { DeprecatedReduxState } from '@src/store'; +import { getFileSizeToClosestByte } from '@src/utils'; +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useParams } from 'react-router-dom'; + +export const CourseFilesTable = () => { + const intl = useIntl(); + const { courseId } = useParams() as { courseId: string }; + const dispatch = useDispatch(); + const { + assetIds, + loadingStatus, + usageStatus: usagePathStatus, + errors: errorMessages, + } = useSelector((state: DeprecatedReduxState) => state.assets); + + const handleErrorReset = (error) => dispatch(resetErrors(error)); + const handleDeleteFile = (id) => dispatch(deleteAssetFile(courseId, id)); + const handleDownloadFile = (selectedRows) => dispatch(fetchAssetDownload({ + selectedRows, + courseId, + })); + const handleAddFile = (files) => { + handleErrorReset({ errorType: 'add' }); + dispatch(validateAssetFiles(courseId, files)); + }; + const handleFileOverwrite = (close, files) => { + Object.values(files).forEach(file => dispatch(addAssetFile(courseId, file, true))); + close(); + }; + const handleLockFile = (fileId, locked) => { + handleErrorReset({ errorType: 'lock' }); + dispatch(updateAssetLock({ courseId, assetId: fileId, locked })); + }; + const handleUsagePaths = (asset) => dispatch(getUsagePaths({ asset, courseId })); + const handleFileOrder = ({ newFileIdOrder }) => { + dispatch(updateAssetOrder(courseId, newFileIdOrder)); + }; + + const thumbnailPreview = (props) => FileThumbnail(props); + const infoModalSidebar = (asset) => FileInfoModalSidebar({ + asset, + handleLockedAsset: handleLockFile, + }); + + const assets = useModels('assets', assetIds); + const data = { + fileIds: assetIds, + loadingStatus, + usagePathStatus, + usageErrorMessages: errorMessages.usageMetrics, + fileType: 'file', + }; + const maxFileSize = 20 * 1048576; + + const activeColumn = { + id: 'activeStatus', + Header: intl.formatMessage(messages.fileActiveColumn), + accessor: 'activeStatus', + Cell: ({ row }) => ActiveColumn({ row, pageLoadStatus: loadingStatus }), + Filter: CheckboxFilter, + filter: 'exactTextCase', + filterChoices: [ + { name: intl.formatMessage(messages.activeCheckboxLabel), value: 'active' }, + { name: intl.formatMessage(messages.inactiveCheckboxLabel), value: 'inactive' }, + ], + }; + const accessColumn = { + id: 'lockStatus', + Header: intl.formatMessage(messages.fileAccessColumn), + accessor: 'lockStatus', + Cell: ({ row }) => AccessColumn({ row }), + Filter: CheckboxFilter, + filterChoices: [ + { name: intl.formatMessage(messages.lockedCheckboxLabel), value: 'locked' }, + { name: intl.formatMessage(messages.publicCheckboxLabel), value: 'public' }, + ], + }; + const thumbnailColumn = { + id: 'thumbnail', + Header: '', + Cell: ({ row }) => ThumbnailColumn({ row, thumbnailPreview }), + }; + const fileSizeColumn = { + id: 'fileSize', + Header: intl.formatMessage(messages.fileSizeColumn), + accessor: 'fileSize', + Cell: ({ row }) => { + const { fileSize } = row.original; + return getFileSizeToClosestByte(fileSize); + }, + }; + + const tableColumns = [ + { ...thumbnailColumn }, + { + Header: intl.formatMessage(messages.fileNameColumn), + accessor: 'displayName', + }, + { ...fileSizeColumn }, + { + Header: intl.formatMessage(messages.fileTypeColumn), + accessor: 'wrapperType', + Filter: CheckboxFilter, + filter: 'includesValue', + filterChoices: [ + { + name: intl.formatMessage(messages.codeCheckboxLabel), + value: 'code', + }, + { + name: intl.formatMessage(messages.imageCheckboxLabel), + value: 'image', + }, + { + name: intl.formatMessage(messages.documentCheckboxLabel), + value: 'document', + }, + { + name: intl.formatMessage(messages.audioCheckboxLabel), + value: 'audio', + }, + { + name: intl.formatMessage(messages.otherCheckboxLabel), + value: 'other', + }, + ], + }, + { ...activeColumn }, + { ...accessColumn }, + ]; + + if (!courseId) { + return null; + } + return ( + <> + + + + ); +}; diff --git a/src/files-and-videos/files-page/FilesPage.jsx b/src/files-and-videos/files-page/FilesPage.jsx index 0976cc9bb6..71476ce0ba 100644 --- a/src/files-and-videos/files-page/FilesPage.jsx +++ b/src/files-and-videos/files-page/FilesPage.jsx @@ -1,37 +1,19 @@ -import React, { useEffect } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Container } from '@openedx/paragon'; import PropTypes from 'prop-types'; +import React, { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; -import { CheckboxFilter, Container } from '@openedx/paragon'; +import CourseFilesSlot from '../../plugin-slots/CourseFilesSlot'; import Placeholder from '../../editors/Placeholder'; import { RequestStatus } from '../../data/constants'; -import { useModels, useModel } from '../../generic/model-store'; -import { - addAssetFile, - deleteAssetFile, - fetchAssets, - updateAssetLock, - fetchAssetDownload, - getUsagePaths, - resetErrors, - updateAssetOrder, - validateAssetFiles, -} from './data/thunks'; -import messages from './messages'; -import FilesPageProvider from './FilesPageProvider'; +import { useModel } from '../../generic/model-store'; import getPageHeadTitle from '../../generic/utils'; -import { - AccessColumn, - ActiveColumn, - EditFileErrors, - FileTable, - ThumbnailColumn, -} from '../generic'; -import { getFileSizeToClosestByte } from '../../utils'; -import FileThumbnail from './FileThumbnail'; -import FileInfoModalSidebar from './FileInfoModalSidebar'; -import FileValidationModal from './FileValidationModal'; +import EditFileAlertsSlot from '../../plugin-slots/EditFileAlertsSlot'; +import { EditFileErrors } from '../generic'; +import { fetchAssets, resetErrors } from './data/thunks'; +import FilesPageProvider from './FilesPageProvider'; +import messages from './messages'; import './FilesPage.scss'; const FilesPage = ({ @@ -41,133 +23,19 @@ const FilesPage = ({ const dispatch = useDispatch(); const courseDetails = useModel('courseDetails', courseId); document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.heading)); - - useEffect(() => { - dispatch(fetchAssets(courseId)); - }, [courseId]); - const { - assetIds, loadingStatus, addingStatus: addAssetStatus, deletingStatus: deleteAssetStatus, updatingStatus: updateAssetStatus, - usageStatus: usagePathStatus, errors: errorMessages, } = useSelector(state => state.assets); - const handleErrorReset = (error) => dispatch(resetErrors(error)); - const handleDeleteFile = (id) => dispatch(deleteAssetFile(courseId, id)); - const handleDownloadFile = (selectedRows) => dispatch(fetchAssetDownload({ selectedRows, courseId })); - const handleAddFile = (files) => { - handleErrorReset({ errorType: 'add' }); - dispatch(validateAssetFiles(courseId, files)); - }; - const handleFileOverwrite = (close, files) => { - Object.values(files).forEach(file => dispatch(addAssetFile(courseId, file, true))); - close(); - }; - const handleLockFile = (fileId, locked) => { - handleErrorReset({ errorType: 'lock' }); - dispatch(updateAssetLock({ courseId, assetId: fileId, locked })); - }; - const handleUsagePaths = (asset) => dispatch(getUsagePaths({ asset, courseId })); - const handleFileOrder = ({ newFileIdOrder, sortType }) => { - dispatch(updateAssetOrder(courseId, newFileIdOrder, sortType)); - }; - - const thumbnailPreview = (props) => FileThumbnail(props); - const infoModalSidebar = (asset) => FileInfoModalSidebar({ - asset, - handleLockedAsset: handleLockFile, - }); - - const assets = useModels('assets', assetIds); - const data = { - fileIds: assetIds, - loadingStatus, - usagePathStatus, - usageErrorMessages: errorMessages.usageMetrics, - fileType: 'file', - }; - const maxFileSize = 20 * 1048576; - - const activeColumn = { - id: 'activeStatus', - Header: intl.formatMessage(messages.fileActiveColumn), - accessor: 'activeStatus', - Cell: ({ row }) => ActiveColumn({ row, pageLoadStatus: loadingStatus }), - Filter: CheckboxFilter, - filter: 'exactTextCase', - filterChoices: [ - { name: intl.formatMessage(messages.activeCheckboxLabel), value: 'active' }, - { name: intl.formatMessage(messages.inactiveCheckboxLabel), value: 'inactive' }, - ], - }; - const accessColumn = { - id: 'lockStatus', - Header: intl.formatMessage(messages.fileAccessColumn), - accessor: 'lockStatus', - Cell: ({ row }) => AccessColumn({ row }), - Filter: CheckboxFilter, - filterChoices: [ - { name: intl.formatMessage(messages.lockedCheckboxLabel), value: 'locked' }, - { name: intl.formatMessage(messages.publicCheckboxLabel), value: 'public' }, - ], - }; - const thumbnailColumn = { - id: 'thumbnail', - Header: '', - Cell: ({ row }) => ThumbnailColumn({ row, thumbnailPreview }), - }; - const fileSizeColumn = { - id: 'fileSize', - Header: intl.formatMessage(messages.fileSizeColumn), - accessor: 'fileSize', - Cell: ({ row }) => { - const { fileSize } = row.original; - return getFileSizeToClosestByte(fileSize); - }, - }; + useEffect(() => { + dispatch(fetchAssets(courseId)); + }, [courseId]); - const tableColumns = [ - { ...thumbnailColumn }, - { - Header: intl.formatMessage(messages.fileNameColumn), - accessor: 'displayName', - }, - { ...fileSizeColumn }, - { - Header: intl.formatMessage(messages.fileTypeColumn), - accessor: 'wrapperType', - Filter: CheckboxFilter, - filter: 'includesValue', - filterChoices: [ - { - name: intl.formatMessage(messages.codeCheckboxLabel), - value: 'code', - }, - { - name: intl.formatMessage(messages.imageCheckboxLabel), - value: 'image', - }, - { - name: intl.formatMessage(messages.documentCheckboxLabel), - value: 'document', - }, - { - name: intl.formatMessage(messages.audioCheckboxLabel), - value: 'audio', - }, - { - name: intl.formatMessage(messages.otherCheckboxLabel), - value: 'other', - }, - ], - }, - { ...activeColumn }, - { ...accessColumn }, - ]; + const handleErrorReset = (error) => dispatch(resetErrors(error)); if (loadingStatus === RequestStatus.DENIED) { return ( @@ -188,31 +56,12 @@ const FilesPage = ({ updateFileStatus={updateAssetStatus} loadingStatus={loadingStatus} /> +
- + {intl.formatMessage(messages.heading)}
{loadingStatus !== RequestStatus.FAILED && ( - <> - - - + )} diff --git a/src/files-and-videos/files-page/FilesPage.test.jsx b/src/files-and-videos/files-page/FilesPage.test.jsx index 2e2d1ada3e..0e5ab24eea 100644 --- a/src/files-and-videos/files-page/FilesPage.test.jsx +++ b/src/files-and-videos/files-page/FilesPage.test.jsx @@ -14,6 +14,7 @@ import MockAdapter from 'axios-mock-adapter'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { AppProvider } from '@edx/frontend-platform/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; import initializeStore from '../../store'; import { executeThunk } from '../../utils'; @@ -51,8 +52,17 @@ jest.mock('file-saver'); const renderComponent = () => { render( - - + + + + + } + /> + + , ); diff --git a/src/files-and-videos/videos-page/CourseVideosTable.tsx b/src/files-and-videos/videos-page/CourseVideosTable.tsx new file mode 100644 index 0000000000..d1667ef13f --- /dev/null +++ b/src/files-and-videos/videos-page/CourseVideosTable.tsx @@ -0,0 +1,288 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + ActionRow, Button, CheckboxFilter, useToggle, +} from '@openedx/paragon'; +import { RequestStatus } from '@src/data/constants'; +import { + ActiveColumn, + FileTable, + StatusColumn, + ThumbnailColumn, + TranscriptColumn, +} from '@src/files-and-videos/generic'; +import FILES_AND_UPLOAD_TYPE_FILTERS from '@src/files-and-videos/generic/constants'; +import { + addVideoFile, + addVideoThumbnail, + cancelAllUploads, + deleteVideoFile, + fetchVideoDownload, + getUsagePaths, + markVideoUploadsInProgressAsFailed, + newUploadData, + resetErrors, + updateVideoOrder, +} from '@src/files-and-videos/videos-page/data/thunks'; +import { getFormattedDuration, resampleFile } from '@src/files-and-videos/videos-page/data/utils'; +import VideoInfoModalSidebar from '@src/files-and-videos/videos-page/info-sidebar'; +import messages from '@src/files-and-videos/videos-page/messages'; +import TranscriptSettings from '@src/files-and-videos/videos-page/transcript-settings'; +import UploadModal from '@src/files-and-videos/videos-page/upload-modal'; +import VideoThumbnail from '@src/files-and-videos/videos-page/VideoThumbnail'; +import { useModels } from '@src/generic/model-store'; +import { DeprecatedReduxState } from '@src/store'; +import React, { useEffect, useRef } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useParams } from 'react-router-dom'; + +export const CourseVideosTable = () => { + const intl = useIntl(); + const { courseId } = useParams() as { courseId: string }; + const dispatch = useDispatch(); + const [ + isTranscriptSettingsOpen, + openTranscriptSettings, + closeTranscriptSettings, + ] = useToggle(false); + const [ + isUploadTrackerOpen, + openUploadTracker, + closeUploadTracker, + ] = useToggle(false); + + const { + videoIds, + loadingStatus, + transcriptStatus, + addingStatus: addVideoStatus, + usageStatus: usagePathStatus, + errors: errorMessages, + pageSettings, + } = useSelector((state: DeprecatedReduxState) => state.videos); + + const uploadingIdsRef = useRef({ uploadData: {}, uploadCount: 0 }); + + useEffect(() => { + window.onbeforeunload = () => { + dispatch(markVideoUploadsInProgressAsFailed({ uploadingIdsRef, courseId })); + if (addVideoStatus === RequestStatus.IN_PROGRESS) { + return ''; + } + return undefined; + }; + switch (addVideoStatus) { + case RequestStatus.IN_PROGRESS: + openUploadTracker(); + break; + case RequestStatus.SUCCESSFUL: + setTimeout(() => closeUploadTracker(), 500); + break; + case RequestStatus.FAILED: + setTimeout(() => closeUploadTracker(), 500); + break; + default: + closeUploadTracker(); + break; + } + }, [addVideoStatus]); + + const { + isVideoTranscriptEnabled, + encodingsDownloadUrl, + videoUploadMaxFileSize, + videoSupportedFileFormats, + videoImageSettings, + } = pageSettings; + + 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' }); + uploadingIdsRef.current.uploadCount = files.length; + + files.forEach((file, idx) => { + const name = file?.name || `Video ${idx + 1}`; + const progress = 0; + + newUploadData({ + status: RequestStatus.PENDING, + currentData: uploadingIdsRef.current.uploadData, + originalValue: { name, progress }, + key: `video_${idx}`, + edxVideoId: undefined, + }); + }); + dispatch(addVideoFile(courseId, files, videoIds, uploadingIdsRef)); + }; + const handleDeleteFile = (id) => dispatch(deleteVideoFile(courseId, id)); + const handleDownloadFile = (selectedRows) => dispatch(fetchVideoDownload({ + selectedRows, + courseId, + })); + const handleUsagePaths = (video) => dispatch(getUsagePaths({ video, courseId })); + const handleFileOrder = ({ newFileIdOrder }) => { + dispatch(updateVideoOrder(courseId, newFileIdOrder)); + }; + const handleAddThumbnail = (file, videoId) => resampleFile({ + file, + dispatch, + courseId, + videoId, + addVideoThumbnail, + }); + + const videos = useModels('videos', videoIds); + + const data = { + supportedFileFormats, + encodingsDownloadUrl, + fileIds: videoIds, + loadingStatus, + usagePathStatus, + usageErrorMessages: errorMessages.usageMetrics, + fileType: 'video', + }; + const thumbnailPreview = (props) => VideoThumbnail({ + ...props, + pageLoadStatus: loadingStatus, + handleAddThumbnail, + videoImageSettings, + }); + const infoModalSidebar = (video, activeTab, setActiveTab) => ( + + ); + const maxFileSize = videoUploadMaxFileSize * 1073741824; + const transcriptColumn = { + id: 'transcriptStatus', + Header: 'Transcript', + accessor: 'transcriptStatus', + Cell: ({ row }) => TranscriptColumn({ row }), + Filter: CheckboxFilter, + filter: 'exactTextCase', + filterChoices: [ + { + name: intl.formatMessage(messages.transcribedCheckboxLabel), + value: 'transcribed', + }, + { + name: intl.formatMessage(messages.notTranscribedCheckboxLabel), + value: 'notTranscribed', + }, + ], + }; + const activeColumn = { + id: 'activeStatus', + Header: 'Active', + accessor: 'activeStatus', + Cell: ({ row }) => ActiveColumn({ row, pageLoadStatus: loadingStatus }), + Filter: CheckboxFilter, + filter: 'exactTextCase', + filterChoices: [ + { name: intl.formatMessage(messages.activeCheckboxLabel), value: 'active' }, + { name: intl.formatMessage(messages.inactiveCheckboxLabel), value: 'inactive' }, + ], + }; + const durationColumn = { + id: 'duration', + Header: 'Video length', + accessor: 'duration', + Cell: ({ row }) => { + const { duration } = row.original; + return getFormattedDuration(duration); + }, + }; + const processingStatusColumn = { + id: 'status', + Header: 'Status', + accessor: 'status', + Cell: ({ row }) => StatusColumn({ row }), + Filter: CheckboxFilter, + filterChoices: [ + { name: intl.formatMessage(messages.processingCheckboxLabel), value: 'Processing' }, + + { name: intl.formatMessage(messages.failedCheckboxLabel), value: 'Failed' }, + ], + }; + const videoThumbnailColumn = { + id: 'courseVideoImageUrl', + Header: '', + Cell: ({ row }) => ThumbnailColumn({ row, thumbnailPreview }), + }; + const tableColumns = [ + { ...videoThumbnailColumn }, + { + Header: 'File name', + accessor: 'clientVideoId', + }, + { ...durationColumn }, + { ...transcriptColumn }, + { ...activeColumn }, + { ...processingStatusColumn }, + ]; + + return ( + <> + + + {isVideoTranscriptEnabled ? ( + + ) : null} + + { + loadingStatus !== RequestStatus.FAILED && ( + <> + {isVideoTranscriptEnabled && ( + + )} + + + ) + } + + + ); +}; diff --git a/src/files-and-videos/videos-page/VideosPage.jsx b/src/files-and-videos/videos-page/VideosPage.jsx index 18ed079826..1b164afdfd 100644 --- a/src/files-and-videos/videos-page/VideosPage.jsx +++ b/src/files-and-videos/videos-page/VideosPage.jsx @@ -1,246 +1,40 @@ -import React, { useEffect, useRef } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Container } from '@openedx/paragon'; import PropTypes from 'prop-types'; +import React, { useEffect } from 'react'; import { Helmet } from 'react-helmet'; import { useDispatch, useSelector } from 'react-redux'; -import { - useIntl, - FormattedMessage, -} from '@edx/frontend-platform/i18n'; -import { - ActionRow, - Button, - CheckboxFilter, - Container, - useToggle, -} from '@openedx/paragon'; +import CourseVideosSlot from '../../plugin-slots/CourseVideosSlot'; +import { RequestStatus } from '../../data/constants'; import Placeholder from '../../editors/Placeholder'; -import { RequestStatus } from '../../data/constants'; -import { useModels, useModel } from '../../generic/model-store'; -import { - addVideoFile, - addVideoThumbnail, - deleteVideoFile, - fetchVideoDownload, - fetchVideos, - getUsagePaths, - markVideoUploadsInProgressAsFailed, - resetErrors, - updateVideoOrder, - cancelAllUploads, - newUploadData, -} from './data/thunks'; +import { useModel } from '../../generic/model-store'; +import getPageHeadTitle from '../../generic/utils'; +import EditVideoAlertsSlot from '../../plugin-slots/EditVideoAlertsSlot'; +import { EditFileErrors } from '../generic'; +import { fetchVideos, resetErrors } from './data/thunks'; import messages from './messages'; import VideosPageProvider from './VideosPageProvider'; -import getPageHeadTitle from '../../generic/utils'; -import { - ActiveColumn, - EditFileErrors, - FileTable, - StatusColumn, - ThumbnailColumn, - TranscriptColumn, -} from '../generic'; -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, }) => { const intl = useIntl(); const dispatch = useDispatch(); - const [ - isTranscriptSettingsOpen, - openTranscriptSettings, - closeTranscriptSettings, - ] = useToggle(false); - const [ - isUploadTrackerOpen, - openUploadTracker, - closeUploadTracker, - ] = useToggle(false); const courseDetails = useModel('courseDetails', courseId); - - useEffect(() => { - dispatch(fetchVideos(courseId)); - }, [courseId]); - const { - videoIds, loadingStatus, - transcriptStatus, addingStatus: addVideoStatus, deletingStatus: deleteVideoStatus, updatingStatus: updateVideoStatus, - usageStatus: usagePathStatus, errors: errorMessages, - pageSettings, } = useSelector((state) => state.videos); - const uploadingIdsRef = useRef({ uploadData: {}, uploadCount: 0 }); - - useEffect(() => { - window.onbeforeunload = () => { - dispatch(markVideoUploadsInProgressAsFailed({ uploadingIdsRef, courseId })); - if (addVideoStatus === RequestStatus.IN_PROGRESS) { - return ''; - } - return undefined; - }; - switch (addVideoStatus) { - case RequestStatus.IN_PROGRESS: - openUploadTracker(); - break; - case RequestStatus.SUCCESSFUL: - setTimeout(() => closeUploadTracker(), 500); - break; - case RequestStatus.FAILED: - setTimeout(() => closeUploadTracker(), 500); - break; - default: - closeUploadTracker(); - break; - } - }, [addVideoStatus]); - - const { - isVideoTranscriptEnabled, - encodingsDownloadUrl, - videoUploadMaxFileSize, - videoSupportedFileFormats, - videoImageSettings, - } = pageSettings; - - 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' }); - uploadingIdsRef.current.uploadCount = files.length; - - files.forEach((file, idx) => { - const name = file?.name || `Video ${idx + 1}`; - const progress = 0; - - newUploadData({ - status: RequestStatus.PENDING, - currentData: uploadingIdsRef.current.uploadData, - originalValue: { name, progress }, - key: `video_${idx}`, - }); - }); - - dispatch(addVideoFile(courseId, files, videoIds, uploadingIdsRef)); - }; - const handleDeleteFile = (id) => dispatch(deleteVideoFile(courseId, id)); - const handleDownloadFile = (selectedRows) => dispatch(fetchVideoDownload({ selectedRows, courseId })); - const handleUsagePaths = (video) => dispatch(getUsagePaths({ video, courseId })); - const handleFileOrder = ({ newFileIdOrder, sortType }) => { - dispatch(updateVideoOrder(courseId, newFileIdOrder, sortType)); - }; - const handleAddThumbnail = (file, videoId) => resampleFile({ - file, - dispatch, - courseId, - videoId, - addVideoThumbnail, - }); - - const videos = useModels('videos', videoIds); - - const data = { - supportedFileFormats, - encodingsDownloadUrl, - fileIds: videoIds, - loadingStatus, - usagePathStatus, - usageErrorMessages: errorMessages.usageMetrics, - fileType: 'video', - }; - const thumbnailPreview = (props) => VideoThumbnail({ - ...props, - pageLoadStatus: loadingStatus, - handleAddThumbnail, - videoImageSettings, - }); - const infoModalSidebar = (video, activeTab, setActiveTab) => ( - - ); - const maxFileSize = videoUploadMaxFileSize * 1073741824; - const transcriptColumn = { - id: 'transcriptStatus', - Header: 'Transcript', - accessor: 'transcriptStatus', - Cell: ({ row }) => TranscriptColumn({ row }), - Filter: CheckboxFilter, - filter: 'exactTextCase', - filterChoices: [ - { - name: intl.formatMessage(messages.transcribedCheckboxLabel), - value: 'transcribed', - }, - { - name: intl.formatMessage(messages.notTranscribedCheckboxLabel), - value: 'notTranscribed', - }, - ], - }; - const activeColumn = { - id: 'activeStatus', - Header: 'Active', - accessor: 'activeStatus', - Cell: ({ row }) => ActiveColumn({ row, pageLoadStatus: loadingStatus }), - Filter: CheckboxFilter, - filter: 'exactTextCase', - filterChoices: [ - { name: intl.formatMessage(messages.activeCheckboxLabel), value: 'active' }, - { name: intl.formatMessage(messages.inactiveCheckboxLabel), value: 'inactive' }, - ], - }; - const durationColumn = { - id: 'duration', - Header: 'Video length', - accessor: 'duration', - Cell: ({ row }) => { - const { duration } = row.original; - return getFormattedDuration(duration); - }, - }; - const processingStatusColumn = { - id: 'status', - Header: 'Status', - accessor: 'status', - Cell: ({ row }) => StatusColumn({ row }), - Filter: CheckboxFilter, - filterChoices: [ - { name: intl.formatMessage(messages.processingCheckboxLabel), value: 'Processing' }, - - { name: intl.formatMessage(messages.failedCheckboxLabel), value: 'Failed' }, - ], - }; - const videoThumbnailColumn = { - id: 'courseVideoImageUrl', - Header: '', - Cell: ({ row }) => ThumbnailColumn({ row, thumbnailPreview }), - }; - const tableColumns = [ - { ...videoThumbnailColumn }, - { - Header: 'File name', - accessor: 'clientVideoId', - }, - { ...durationColumn }, - { ...transcriptColumn }, - { ...activeColumn }, - { ...processingStatusColumn }, - ]; + useEffect(() => { + dispatch(fetchVideos(courseId)); + }, [courseId]); if (loadingStatus === RequestStatus.DENIED) { return ( @@ -264,65 +58,9 @@ const VideosPage = ({ updateFileStatus={updateVideoStatus} loadingStatus={loadingStatus} /> - -
- -
- - {isVideoTranscriptEnabled ? ( - - ) : null} -
- {loadingStatus !== RequestStatus.FAILED && ( - <> - {isVideoTranscriptEnabled && ( - - )} - - - )} - + +

{intl.formatMessage(messages.heading)}

+ ); diff --git a/src/files-and-videos/videos-page/VideosPage.test.jsx b/src/files-and-videos/videos-page/VideosPage.test.jsx index c7aa3c7ebf..7ae5aa70b7 100644 --- a/src/files-and-videos/videos-page/VideosPage.test.jsx +++ b/src/files-and-videos/videos-page/VideosPage.test.jsx @@ -13,6 +13,8 @@ import MockAdapter from 'axios-mock-adapter'; import { getAuthenticatedHttpClient, getHttpClient } from '@edx/frontend-platform/auth'; import { AppProvider } from '@edx/frontend-platform/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; +import React from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; import initializeStore from '../../store'; import { executeThunk } from '../../utils'; @@ -50,8 +52,17 @@ jest.mock('file-saver'); const renderComponent = () => { render( - - + + + + + } + /> + + , ); diff --git a/src/plugin-slots/CourseFilesSlot/README.md b/src/plugin-slots/CourseFilesSlot/README.md new file mode 100644 index 0000000000..691dc19e49 --- /dev/null +++ b/src/plugin-slots/CourseFilesSlot/README.md @@ -0,0 +1,41 @@ +# Course Files Slot + +### Slot ID: `org.openedx.frontend.authoring.files_upload_page_table.v1` + +## Description + +This slot is used to replace/modify/hide the course file table UI. + +## Example + +### Wrapped with a div with dashed border + +![Red Border around Files UI](./screenshot_files_border_wrap.png) + +The following `env.config.jsx` will wrap the files component with a div that has a large dashed +red border. + +```js +import { PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework'; + +const config = { + pluginSlots: { + 'org.openedx.frontend.authoring.files_upload_page_table.v1': { + keepDefault: true, + plugins: [ + { + op: PLUGIN_OPERATIONS.Wrap, + widgetId: 'default_contents', + wrapper: ({component}) => ( +
+ {component} +
+ ) + }, + ] + } + }, +} + +export default config; +``` diff --git a/src/plugin-slots/CourseFilesSlot/index.tsx b/src/plugin-slots/CourseFilesSlot/index.tsx new file mode 100644 index 0000000000..3ee657aff7 --- /dev/null +++ b/src/plugin-slots/CourseFilesSlot/index.tsx @@ -0,0 +1,11 @@ +import { PluginSlot } from '@openedx/frontend-plugin-framework'; +import { CourseFilesTable } from '@src/files-and-videos/files-page/CourseFilesTable'; +import React from 'react'; + +const CourseFilesSlot = () => ( + + + +); + +export default CourseFilesSlot; diff --git a/src/plugin-slots/CourseFilesSlot/screenshot_files_border_wrap.png b/src/plugin-slots/CourseFilesSlot/screenshot_files_border_wrap.png new file mode 100644 index 0000000000..67cf99952d Binary files /dev/null and b/src/plugin-slots/CourseFilesSlot/screenshot_files_border_wrap.png differ diff --git a/src/plugin-slots/CourseOutlinePageAlertsSlot/README.md b/src/plugin-slots/CourseOutlinePageAlertsSlot/README.md new file mode 100644 index 0000000000..3de9376571 --- /dev/null +++ b/src/plugin-slots/CourseOutlinePageAlertsSlot/README.md @@ -0,0 +1,44 @@ +# Course Outline Page Alerts Slot + +### Slot ID: `org.openedx.frontend.authoring.course_outline_page_alerts.v1` + +## Description + +This slot is used to add alerts to the course outline page. + +## Example + +### Additional Alert + +![Additional Alerts in Outline Page](./screenshot_outline_alert_added.png) + +The following `env.config.jsx` display a custom additional alert on the course outline page. + +```js +import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework'; +import { Alert } from '@openedx/paragon'; + +const config = { + pluginSlots: { + 'org.openedx.frontend.authoring.course_outline_page_alerts.v1': { + keepDefault: true, + plugins: [ + { + op: PLUGIN_OPERATIONS.Insert, + widget: { + id: 'test-alert', + type: DIRECT_PLUGIN, + RenderWidget: () => ( + + This is a test alert + + ) + } + }, + ] + } + }, +} + +export default config; +``` diff --git a/src/plugin-slots/CourseOutlinePageAlertsSlot/index.tsx b/src/plugin-slots/CourseOutlinePageAlertsSlot/index.tsx new file mode 100644 index 0000000000..5dbf44c50c --- /dev/null +++ b/src/plugin-slots/CourseOutlinePageAlertsSlot/index.tsx @@ -0,0 +1,5 @@ +import { PluginSlot } from '@openedx/frontend-plugin-framework'; +import React from 'react'; + +const CourseOutlinePageAlertsSlot = () => ; +export default CourseOutlinePageAlertsSlot; diff --git a/src/plugin-slots/CourseOutlinePageAlertsSlot/screenshot_outline_alert_added.png b/src/plugin-slots/CourseOutlinePageAlertsSlot/screenshot_outline_alert_added.png new file mode 100644 index 0000000000..e859dcada1 Binary files /dev/null and b/src/plugin-slots/CourseOutlinePageAlertsSlot/screenshot_outline_alert_added.png differ diff --git a/src/plugin-slots/CourseVideosSlot/README.md b/src/plugin-slots/CourseVideosSlot/README.md new file mode 100644 index 0000000000..8580654e35 --- /dev/null +++ b/src/plugin-slots/CourseVideosSlot/README.md @@ -0,0 +1,42 @@ +# Course Video Upload Page Slot + +### Slot ID: `org.openedx.frontend.authoring.videos_upload_page_table.v1` + +## Description + +This slot is used to replace/modify/hide the course video upload page UI. + +## Example + +### Wrapped with a div with dashed border + +![Red Border around Videos UI on course without videos showing upload UI](./screenshot_upload_videos_border_wrap.png) + +![Red Border around Videos UI on course with videos list](./screenshot_list_videos_border_wrap.png) + +The following `env.config.jsx` will wrap the videos UI with a div that has a large dashed red border. + +```js +import { PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework'; + +const config = { + pluginSlots: { + 'org.openedx.frontend.authoring.videos_upload_page_table.v1': { + keepDefault: true, + plugins: [ + { + op: PLUGIN_OPERATIONS.Wrap, + widgetId: 'default_contents', + wrapper: ({component}) => ( +
+ {component} +
+ ) + }, + ] + } + }, +} + +export default config; +``` diff --git a/src/plugin-slots/CourseVideosSlot/index.tsx b/src/plugin-slots/CourseVideosSlot/index.tsx new file mode 100644 index 0000000000..276988eb11 --- /dev/null +++ b/src/plugin-slots/CourseVideosSlot/index.tsx @@ -0,0 +1,11 @@ +import { PluginSlot } from '@openedx/frontend-plugin-framework'; +import { CourseVideosTable } from '@src/files-and-videos/videos-page/CourseVideosTable'; +import React from 'react'; + +const CourseVideosSlot = () => ( + + + +); + +export default CourseVideosSlot; diff --git a/src/plugin-slots/CourseVideosSlot/screenshot_list_videos_border_wrap.png b/src/plugin-slots/CourseVideosSlot/screenshot_list_videos_border_wrap.png new file mode 100644 index 0000000000..ac940f1ece Binary files /dev/null and b/src/plugin-slots/CourseVideosSlot/screenshot_list_videos_border_wrap.png differ diff --git a/src/plugin-slots/CourseVideosSlot/screenshot_upload_videos_border_wrap.png b/src/plugin-slots/CourseVideosSlot/screenshot_upload_videos_border_wrap.png new file mode 100644 index 0000000000..3c36ba98aa Binary files /dev/null and b/src/plugin-slots/CourseVideosSlot/screenshot_upload_videos_border_wrap.png differ diff --git a/src/plugin-slots/EditFileAlertsSlot/README.md b/src/plugin-slots/EditFileAlertsSlot/README.md new file mode 100644 index 0000000000..92c2c4ffa4 --- /dev/null +++ b/src/plugin-slots/EditFileAlertsSlot/README.md @@ -0,0 +1,44 @@ +# Files Page Alerts Slot + +### Slot ID: `org.openedx.frontend.authoring.edit_file_alerts.v1` + +## Description + +This slot is used to add alerts to the course file edit page. + +## Example + +### Additional Alert on Files Page + +![Additional alert displayed in alerts slot on files page](./screenshot_files_alert_added.png) + +The following `env.config.jsx` will display an additional custom alert on the files and uploads page. + +```js +import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework'; +import { Alert } from '@openedx/paragon'; + +const config = { + pluginSlots: { + 'org.openedx.frontend.authoring.edit_file_alerts.v1': { + keepDefault: true, + plugins: [ + { + op: PLUGIN_OPERATIONS.Insert, + widget: { + id: 'test-alert', + type: DIRECT_PLUGIN, + RenderWidget: () => ( + + This is a test alert + + ) + } + }, + ] + } + }, +} + +export default config; +``` diff --git a/src/plugin-slots/EditFileAlertsSlot/index.tsx b/src/plugin-slots/EditFileAlertsSlot/index.tsx new file mode 100644 index 0000000000..43f6b66537 --- /dev/null +++ b/src/plugin-slots/EditFileAlertsSlot/index.tsx @@ -0,0 +1,5 @@ +import { PluginSlot } from '@openedx/frontend-plugin-framework'; + +const EditFileAlertsSlot = () => ; + +export default EditFileAlertsSlot; diff --git a/src/plugin-slots/EditFileAlertsSlot/screenshot_files_alert_added.png b/src/plugin-slots/EditFileAlertsSlot/screenshot_files_alert_added.png new file mode 100644 index 0000000000..3b02ee55d7 Binary files /dev/null and b/src/plugin-slots/EditFileAlertsSlot/screenshot_files_alert_added.png differ diff --git a/src/plugin-slots/EditVideoAlertsSlot/README.md b/src/plugin-slots/EditVideoAlertsSlot/README.md new file mode 100644 index 0000000000..92a6b478d9 --- /dev/null +++ b/src/plugin-slots/EditVideoAlertsSlot/README.md @@ -0,0 +1,44 @@ +# Videos Page Alerts Slot + +### Slot ID: `org.openedx.frontend.authoring.edit_video_alerts.v1` + +## Description + +This slot is used to add alerts to the course video edit page. + +## Example + +### Additional Alert on Videos Page + +![Additional alert displayed in alerts slot on videos page](./screenshot_videos_alert_added.png) + +The following `env.config.jsx` will display an additional custom alert on the videos page. + +```js +import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework'; +import { Alert } from '@openedx/paragon'; + +const config = { + pluginSlots: { + 'org.openedx.frontend.authoring.edit_video_alerts.v1': { + keepDefault: true, + plugins: [ + { + op: PLUGIN_OPERATIONS.Insert, + widget: { + id: 'test-alert', + type: DIRECT_PLUGIN, + RenderWidget: () => ( + + This is a test alert + + ) + } + }, + ] + } + }, +} + +export default config; +``` diff --git a/src/plugin-slots/EditVideoAlertsSlot/index.tsx b/src/plugin-slots/EditVideoAlertsSlot/index.tsx new file mode 100644 index 0000000000..47ebbcc83d --- /dev/null +++ b/src/plugin-slots/EditVideoAlertsSlot/index.tsx @@ -0,0 +1,5 @@ +import { PluginSlot } from '@openedx/frontend-plugin-framework'; + +const EditVideoAlertsSlot = () => ; + +export default EditVideoAlertsSlot; diff --git a/src/plugin-slots/EditVideoAlertsSlot/screenshot_videos_alert_added.png b/src/plugin-slots/EditVideoAlertsSlot/screenshot_videos_alert_added.png new file mode 100644 index 0000000000..58e6f79787 Binary files /dev/null and b/src/plugin-slots/EditVideoAlertsSlot/screenshot_videos_alert_added.png differ