feat: Add slots for video and file upload components and alerts (#1523)

This change add plugin slots for the file and video upload components, and the alerts components on those pages.
This commit is contained in:
Kshitij Sobti
2025-10-25 03:09:50 +05:30
committed by GitHub
parent 157e2464aa
commit 15a728d0e7
23 changed files with 807 additions and 474 deletions

View File

@@ -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 {
Campaign as CampaignIcon,
InfoOutline as InfoOutlineIcon,
Warning as WarningIcon,
Error as ErrorIcon,
} from '@openedx/paragon/icons';
import {
Alert, Button, Hyperlink, Truncate,
} from '@openedx/paragon';
import {
Campaign as CampaignIcon,
Error as ErrorIcon,
InfoOutline as InfoOutlineIcon,
Warning as WarningIcon,
} from '@openedx/paragon/icons';
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()}
<CourseOutlinePageAlertsSlot />
</>
);
};

View File

@@ -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 (
<>
<FileTable
{...{
courseId,
data,
handleAddFile,
handleDeleteFile,
handleDownloadFile,
handleLockFile,
handleUsagePaths,
handleErrorReset,
handleFileOrder,
tableColumns,
maxFileSize,
thumbnailPreview,
infoModalSidebar,
files: assets,
}}
/>
<FileValidationModal {...{ handleFileOverwrite }} />
</>
);
};

View File

@@ -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));
const {
loadingStatus,
addingStatus: addAssetStatus,
deletingStatus: deleteAssetStatus,
updatingStatus: updateAssetStatus,
errors: errorMessages,
} = useSelector(state => state.assets);
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);
},
};
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 (loadingStatus === RequestStatus.DENIED) {
return (
@@ -188,31 +56,12 @@ const FilesPage = ({
updateFileStatus={updateAssetStatus}
loadingStatus={loadingStatus}
/>
<EditFileAlertsSlot />
<div className="h2">
<FormattedMessage {...messages.heading} />
{intl.formatMessage(messages.heading)}
</div>
{loadingStatus !== RequestStatus.FAILED && (
<>
<FileTable
{...{
courseId,
data,
handleAddFile,
handleDeleteFile,
handleDownloadFile,
handleLockFile,
handleUsagePaths,
handleErrorReset,
handleFileOrder,
tableColumns,
maxFileSize,
thumbnailPreview,
infoModalSidebar,
files: assets,
}}
/>
<FileValidationModal {...{ handleFileOverwrite }} />
</>
<CourseFilesSlot />
)}
</Container>
</FilesPageProvider>

View File

@@ -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(
<IntlProvider locale="en">
<AppProvider store={store}>
<FilesPage courseId={courseId} />
<AppProvider store={store} wrapWithRouter={false}>
<MemoryRouter initialEntries={[`/course/${courseId}/videos`]}>
<Routes>
<Route
path="/course/:courseId/*"
element={
<FilesPage courseId={courseId} />
}
/>
</Routes>
</MemoryRouter>
</AppProvider>
</IntlProvider>,
);

View File

@@ -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) => (
<VideoInfoModalSidebar video={video} activeTab={activeTab} setActiveTab={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 (
<>
<ActionRow>
<ActionRow.Spacer />
{isVideoTranscriptEnabled ? (
<Button
variant="link"
size="sm"
onClick={() => {
openTranscriptSettings();
handleErrorReset({ errorType: 'transcript' });
}}
>
{intl.formatMessage(messages.transcriptSettingsButtonLabel)}
</Button>
) : null}
</ActionRow>
{
loadingStatus !== RequestStatus.FAILED && (
<>
{isVideoTranscriptEnabled && (
<TranscriptSettings
{...{
isTranscriptSettingsOpen,
closeTranscriptSettings,
handleErrorReset,
errorMessages,
transcriptStatus,
courseId,
}}
/>
)}
<FileTable
{...{
courseId,
data,
handleAddFile,
handleDeleteFile,
handleDownloadFile,
handleUsagePaths,
handleErrorReset,
handleFileOrder,
tableColumns,
maxFileSize,
thumbnailPreview,
infoModalSidebar,
files: videos,
}}
/>
</>
)
}
<UploadModal
{...{
isUploadTrackerOpen,
currentUploadingIdsRef: uploadingIdsRef.current,
handleUploadCancel,
addVideoStatus,
}}
/>
</>
);
};

View File

@@ -1,247 +1,41 @@
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);
const {
loadingStatus,
addingStatus: addVideoStatus,
deletingStatus: deleteVideoStatus,
updatingStatus: updateVideoStatus,
errors: errorMessages,
} = useSelector((state) => state.videos);
const handleErrorReset = (error) => dispatch(resetErrors(error));
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) => (
<VideoInfoModalSidebar video={video} activeTab={activeTab} setActiveTab={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 },
];
if (loadingStatus === RequestStatus.DENIED) {
return (
<div data-testid="under-construction-placeholder" className="row justify-contnt-center m-6">
@@ -264,65 +58,9 @@ const VideosPage = ({
updateFileStatus={updateVideoStatus}
loadingStatus={loadingStatus}
/>
<ActionRow>
<div className="h2">
<FormattedMessage {...messages.heading} />
</div>
<ActionRow.Spacer />
{isVideoTranscriptEnabled ? (
<Button
variant="link"
size="sm"
onClick={() => {
openTranscriptSettings();
handleErrorReset({ errorType: 'transcript' });
}}
>
<FormattedMessage {...messages.transcriptSettingsButtonLabel} />
</Button>
) : null}
</ActionRow>
{loadingStatus !== RequestStatus.FAILED && (
<>
{isVideoTranscriptEnabled && (
<TranscriptSettings
{...{
isTranscriptSettingsOpen,
closeTranscriptSettings,
handleErrorReset,
errorMessages,
transcriptStatus,
courseId,
}}
/>
)}
<FileTable
{...{
courseId,
data,
handleAddFile,
handleDeleteFile,
handleDownloadFile,
handleUsagePaths,
handleErrorReset,
handleFileOrder,
tableColumns,
maxFileSize,
thumbnailPreview,
infoModalSidebar,
files: videos,
}}
/>
</>
)}
<UploadModal
{...{
isUploadTrackerOpen,
currentUploadingIdsRef: uploadingIdsRef.current,
handleUploadCancel,
addVideoStatus,
}}
/>
<EditVideoAlertsSlot />
<h2>{intl.formatMessage(messages.heading)}</h2>
<CourseVideosSlot />
</Container>
</VideosPageProvider>
);

View File

@@ -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(
<IntlProvider locale="en">
<AppProvider store={store}>
<VideosPage courseId={courseId} />
<AppProvider store={store} wrapWithRouter={false}>
<MemoryRouter initialEntries={[`/course/${courseId}/videos`]}>
<Routes>
<Route
path="/course/:courseId/*"
element={
<VideosPage courseId={courseId} />
}
/>
</Routes>
</MemoryRouter>
</AppProvider>
</IntlProvider>,
);

View File

@@ -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}) => (
<div style={{border:'thick dashed red'}}>
{component}
</div>
)
},
]
}
},
}
export default config;
```

View File

@@ -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 = () => (
<PluginSlot id="org.openedx.frontend.authoring.files_upload_page_table.v1">
<CourseFilesTable />
</PluginSlot>
);
export default CourseFilesSlot;

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

View File

@@ -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: () => (
<Alert variant="warning">
This is a test alert
</Alert>
)
}
},
]
}
},
}
export default config;
```

View File

@@ -0,0 +1,5 @@
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import React from 'react';
const CourseOutlinePageAlertsSlot = () => <PluginSlot id="org.openedx.frontend.authoring.course_outline_page_alerts.v1" />;
export default CourseOutlinePageAlertsSlot;

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

View File

@@ -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}) => (
<div style={{border:'thick dashed red'}}>
{component}
</div>
)
},
]
}
},
}
export default config;
```

View File

@@ -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 = () => (
<PluginSlot id="org.openedx.frontend.authoring.videos_upload_page_table.v1">
<CourseVideosTable />
</PluginSlot>
);
export default CourseVideosSlot;

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -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: () => (
<Alert variant="warning">
This is a test alert
</Alert>
)
}
},
]
}
},
}
export default config;
```

View File

@@ -0,0 +1,5 @@
import { PluginSlot } from '@openedx/frontend-plugin-framework';
const EditFileAlertsSlot = () => <PluginSlot id="org.openedx.frontend.authoring.edit_file_alerts.v1" />;
export default EditFileAlertsSlot;

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -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: () => (
<Alert variant="warning">
This is a test alert
</Alert>
)
}
},
]
}
},
}
export default config;
```

View File

@@ -0,0 +1,5 @@
import { PluginSlot } from '@openedx/frontend-plugin-framework';
const EditVideoAlertsSlot = () => <PluginSlot id="org.openedx.frontend.authoring.edit_video_alerts.v1" />;
export default EditVideoAlertsSlot;

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB