feat: add video page (#640)
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
"ignoreUnits": ["\\.5"]
|
||||
}],
|
||||
"property-no-vendor-prefix": [true, {
|
||||
"ignoreProperties": ["animation", "filter"]
|
||||
"ignoreProperties": ["animation", "filter", "transform", "transition"]
|
||||
}],
|
||||
"value-no-vendor-prefix": [true, {
|
||||
"ignoreValues": ["fill-available"]
|
||||
|
||||
32
package-lock.json
generated
32
package-lock.json
generated
@@ -63,6 +63,7 @@
|
||||
"enzyme-to-json": "^3.6.2",
|
||||
"glob": "7.2.0",
|
||||
"husky": "7.0.4",
|
||||
"jest-canvas-mock": "^2.5.2",
|
||||
"react-test-renderer": "17.0.2",
|
||||
"reactifex": "1.1.1",
|
||||
"ts-loader": "^9.5.0"
|
||||
@@ -11454,6 +11455,12 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/cssfontparser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/cssfontparser/-/cssfontparser-1.2.1.tgz",
|
||||
"integrity": "sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/cssnano": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cssnano/-/cssnano-6.0.1.tgz",
|
||||
@@ -15872,6 +15879,16 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-canvas-mock": {
|
||||
"version": "2.5.2",
|
||||
"resolved": "https://registry.npmjs.org/jest-canvas-mock/-/jest-canvas-mock-2.5.2.tgz",
|
||||
"integrity": "sha512-vgnpPupjOL6+L5oJXzxTxFrlGEIbHdZqFU+LFNdtLxZ3lRDCl17FlTMM7IatoRQkrcyOTMlDinjUguqmQ6bR2A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"cssfontparser": "^1.2.1",
|
||||
"moo-color": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-circus": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz",
|
||||
@@ -18854,6 +18871,21 @@
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/moo-color": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/moo-color/-/moo-color-1.0.3.tgz",
|
||||
"integrity": "sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"color-name": "^1.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/moo-color/node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/mpd-parser": {
|
||||
"version": "0.21.1",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"enzyme-to-json": "^3.6.2",
|
||||
"glob": "7.2.0",
|
||||
"husky": "7.0.4",
|
||||
"jest-canvas-mock": "^2.5.2",
|
||||
"react-test-renderer": "17.0.2",
|
||||
"reactifex": "1.1.1",
|
||||
"ts-loader": "^9.5.0"
|
||||
|
||||
@@ -8,7 +8,7 @@ import ProctoredExamSettings from './proctored-exam-settings/ProctoredExamSettin
|
||||
import EditorContainer from './editors/EditorContainer';
|
||||
import VideoSelectorContainer from './selectors/VideoSelectorContainer';
|
||||
import CustomPages from './custom-pages';
|
||||
import FilesAndUploads from './files-and-uploads';
|
||||
import { FilesPage, VideosPage } from './files-and-videos';
|
||||
import { AdvancedSettings } from './advanced-settings';
|
||||
import ScheduleAndDetails from './schedule-and-details';
|
||||
import { GradingSettings } from './grading-settings';
|
||||
@@ -49,11 +49,11 @@ const CourseAuthoringRoutes = () => {
|
||||
/>
|
||||
<Route
|
||||
path="assets"
|
||||
element={<PageWrap><FilesAndUploads courseId={courseId} /></PageWrap>}
|
||||
element={<PageWrap><FilesPage courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="videos"
|
||||
element={process.env.ENABLE_NEW_VIDEO_UPLOAD_PAGE === 'true' ? <PageWrap><Placeholder /></PageWrap> : null}
|
||||
element={process.env.ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true' ? <PageWrap><VideosPage courseId={courseId} /></PageWrap> : null}
|
||||
/>
|
||||
<Route
|
||||
path="pages-and-resources/*"
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {
|
||||
injectIntl,
|
||||
FormattedMessage,
|
||||
FormattedDate,
|
||||
intlShape,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ModalDialog,
|
||||
Stack,
|
||||
IconButton,
|
||||
ActionRow,
|
||||
Icon,
|
||||
Truncate,
|
||||
IconButtonWithTooltip,
|
||||
CheckboxControl,
|
||||
} from '@edx/paragon';
|
||||
import { ContentCopy, InfoOutline } from '@edx/paragon/icons';
|
||||
|
||||
import { getFileSizeToClosestByte } from './data/utils';
|
||||
import AssetThumbnail from './FileThumbnail';
|
||||
import messages from './messages';
|
||||
import UsageMetricsMessages from './UsageMetricsMessage';
|
||||
|
||||
const FileInfo = ({
|
||||
asset,
|
||||
isOpen,
|
||||
onClose,
|
||||
handleLockedAsset,
|
||||
usagePathStatus,
|
||||
error,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const [lockedState, setLockedState] = useState(asset?.locked);
|
||||
const handleLock = (e) => {
|
||||
const locked = e.target.checked;
|
||||
setLockedState(locked);
|
||||
handleLockedAsset(asset?.id, locked);
|
||||
};
|
||||
const fileSize = getFileSizeToClosestByte(asset?.fileSize);
|
||||
|
||||
return (
|
||||
<ModalDialog
|
||||
title={asset?.displayName}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
hasCloseButton
|
||||
data-testid="file-info-modal"
|
||||
>
|
||||
<ModalDialog.Header>
|
||||
<ModalDialog.Title>
|
||||
<div style={{ wordBreak: 'break-word' }}>
|
||||
<Truncate lines={2} className="font-weight-bold small mt-3">
|
||||
{asset?.displayName}
|
||||
</Truncate>
|
||||
</div>
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
<ModalDialog.Body className="pt-0 x-small">
|
||||
<hr />
|
||||
<div className="row flex-nowrap m-0 mt-4">
|
||||
<div className="col-8 mr-3">
|
||||
<AssetThumbnail
|
||||
thumbnail={asset?.thumbnail}
|
||||
externalUrl={asset?.externalUrl}
|
||||
displayName={asset?.displayName}
|
||||
wrapperType={asset?.wrapperType}
|
||||
/>
|
||||
</div>
|
||||
<Stack>
|
||||
<div className="font-weight-bold">
|
||||
<FormattedMessage {...messages.dateAddedTitle} />
|
||||
</div>
|
||||
<FormattedDate
|
||||
value={asset?.dateAdded}
|
||||
year="numeric"
|
||||
month="short"
|
||||
day="2-digit"
|
||||
hour="numeric"
|
||||
minute="numeric"
|
||||
/>
|
||||
<div className="font-weight-bold mt-3">
|
||||
<FormattedMessage {...messages.fileSizeTitle} />
|
||||
</div>
|
||||
{fileSize}
|
||||
<div className="font-weight-bold border-top mt-3 pt-3">
|
||||
<FormattedMessage {...messages.studioUrlTitle} />
|
||||
</div>
|
||||
<ActionRow>
|
||||
<div style={{ wordBreak: 'break-word' }}>
|
||||
<Truncate lines={1}>
|
||||
{asset?.portableUrl}
|
||||
</Truncate>
|
||||
</div>
|
||||
<ActionRow.Spacer />
|
||||
<IconButton
|
||||
src={ContentCopy}
|
||||
iconAs={Icon}
|
||||
alt={messages.copyStudioUrlTitle.defaultMessage}
|
||||
onClick={() => navigator.clipboard.writeText(asset?.portableUrl)}
|
||||
/>
|
||||
</ActionRow>
|
||||
<div className="font-weight-bold mt-3">
|
||||
<FormattedMessage {...messages.webUrlTitle} />
|
||||
</div>
|
||||
<ActionRow>
|
||||
<div style={{ wordBreak: 'break-word' }}>
|
||||
<Truncate lines={1}>
|
||||
{asset?.externalUrl}
|
||||
</Truncate>
|
||||
</div>
|
||||
<ActionRow.Spacer />
|
||||
<IconButton
|
||||
src={ContentCopy}
|
||||
iconAs={Icon}
|
||||
alt={messages.copyWebUrlTitle.defaultMessage}
|
||||
onClick={() => navigator.clipboard.writeText(asset?.externalUrl)}
|
||||
/>
|
||||
</ActionRow>
|
||||
<ActionRow className=" border-top mt-3 pt-3">
|
||||
<div className="font-weight-bold">
|
||||
<FormattedMessage {...messages.lockFileTitle} />
|
||||
</div>
|
||||
<IconButtonWithTooltip
|
||||
key="lock-file-info"
|
||||
tooltipPlacement="top"
|
||||
tooltipContent={intl.formatMessage(messages.lockFileTooltipContent)}
|
||||
src={InfoOutline}
|
||||
iconAs={Icon}
|
||||
alt="Info"
|
||||
size="inline"
|
||||
/>
|
||||
<ActionRow.Spacer />
|
||||
<CheckboxControl
|
||||
checked={lockedState}
|
||||
onChange={handleLock}
|
||||
aria-label="Checkbox"
|
||||
/>
|
||||
</ActionRow>
|
||||
</Stack>
|
||||
</div>
|
||||
<div className="row m-0 pt-3 font-weight-bold">
|
||||
<FormattedMessage {...messages.usageTitle} />
|
||||
</div>
|
||||
<UsageMetricsMessages {...{ usageLocations: asset?.usageLocations, usagePathStatus, error }} />
|
||||
</ModalDialog.Body>
|
||||
</ModalDialog>
|
||||
);
|
||||
};
|
||||
FileInfo.propTypes = {
|
||||
asset: PropTypes.shape({
|
||||
displayName: PropTypes.string.isRequired,
|
||||
wrapperType: PropTypes.string.isRequired,
|
||||
locked: PropTypes.bool.isRequired,
|
||||
externalUrl: PropTypes.string.isRequired,
|
||||
thumbnail: PropTypes.string,
|
||||
id: PropTypes.string.isRequired,
|
||||
portableUrl: PropTypes.string.isRequired,
|
||||
dateAdded: PropTypes.string.isRequired,
|
||||
fileSize: PropTypes.number.isRequired,
|
||||
usageLocations: PropTypes.arrayOf(PropTypes.string),
|
||||
}).isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
handleLockedAsset: PropTypes.func.isRequired,
|
||||
usagePathStatus: PropTypes.string.isRequired,
|
||||
error: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(FileInfo);
|
||||
@@ -1,376 +0,0 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
DataTable,
|
||||
TextFilter,
|
||||
CheckboxFilter,
|
||||
Dropzone,
|
||||
CardView,
|
||||
useToggle,
|
||||
AlertModal,
|
||||
ActionRow,
|
||||
Button,
|
||||
} from '@edx/paragon';
|
||||
import Placeholder, { ErrorAlert } from '@edx/frontend-lib-content-components';
|
||||
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import { useModels, useModel } from '../generic/model-store';
|
||||
import {
|
||||
addAssetFile,
|
||||
deleteAssetFile,
|
||||
fetchAssets,
|
||||
resetErrors,
|
||||
getUsagePaths,
|
||||
updateAssetLock,
|
||||
updateAssetOrder,
|
||||
fetchAssetDownload,
|
||||
} from './data/thunks';
|
||||
import { getFileSizeToClosestByte, sortFiles } from './data/utils';
|
||||
import messages from './messages';
|
||||
|
||||
import FileInfo from './FileInfo';
|
||||
import FileInput, { useFileInput } from './FileInput';
|
||||
import FilesAndUploadsProvider from './FilesAndUploadsProvider';
|
||||
import {
|
||||
GalleryCard,
|
||||
TableActions,
|
||||
} from './table-components';
|
||||
import { AccessColumn, MoreInfoColumn, ThumbnailColumn } from './table-components/table-custom-columns';
|
||||
import ApiStatusToast from './ApiStatusToast';
|
||||
import { clearErrors } from './data/slice';
|
||||
import getPageHeadTitle from '../generic/utils';
|
||||
import FilterStatus from './table-components/FilterStatus';
|
||||
|
||||
const FilesAndUploads = ({
|
||||
courseId,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const defaultVal = 'card';
|
||||
const columnSizes = {
|
||||
xs: 12,
|
||||
sm: 6,
|
||||
md: 4,
|
||||
lg: 2,
|
||||
};
|
||||
const [currentView, setCurrentView] = useState(defaultVal);
|
||||
const [isDeleteOpen, setDeleteOpen, setDeleteClose] = useToggle(false);
|
||||
const [isAssetInfoOpen, openAssetInfo, closeAssetinfo] = useToggle(false);
|
||||
const [isAddOpen, setAddOpen, setAddClose] = useToggle(false);
|
||||
const [selectedRows, setSelectedRows] = useState([]);
|
||||
const [isDeleteConfirmationOpen, openDeleteConfirmation, closeDeleteConfirmation] = useToggle(false);
|
||||
|
||||
const courseDetails = useModel('courseDetails', courseId);
|
||||
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.heading));
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchAssets(courseId));
|
||||
}, [courseId]);
|
||||
const {
|
||||
totalCount,
|
||||
assetIds,
|
||||
loadingStatus,
|
||||
addingStatus: addAssetStatus,
|
||||
deletingStatus: deleteAssetStatus,
|
||||
updatingStatus: updateAssetStatus,
|
||||
usageStatus: usagePathStatus,
|
||||
errors: errorMessages,
|
||||
} = useSelector(state => state.assets);
|
||||
const fileInputControl = useFileInput({
|
||||
onAddFile: (file) => dispatch(addAssetFile(courseId, file, totalCount)),
|
||||
setSelectedRows,
|
||||
setAddOpen,
|
||||
});
|
||||
const assets = useModels('assets', assetIds);
|
||||
const handleDropzoneAsset = ({ fileData, handleError }) => {
|
||||
try {
|
||||
const file = fileData.get('file');
|
||||
dispatch(addAssetFile(courseId, file, totalCount));
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSort = (sortType) => {
|
||||
const newAssetIdOrder = sortFiles(assets, sortType);
|
||||
dispatch(updateAssetOrder(courseId, newAssetIdOrder, sortType));
|
||||
};
|
||||
|
||||
const handleBulkDelete = () => {
|
||||
closeDeleteConfirmation();
|
||||
setDeleteOpen();
|
||||
dispatch(resetErrors({ errorType: 'delete' }));
|
||||
const assetIdsToDelete = selectedRows.map(row => row.original.id);
|
||||
assetIdsToDelete.forEach(id => dispatch(deleteAssetFile(courseId, id, totalCount)));
|
||||
};
|
||||
|
||||
const handleBulkDownload = useCallback(async (selectedFlatRows) => {
|
||||
dispatch(resetErrors({ errorType: 'download' }));
|
||||
dispatch(fetchAssetDownload({ selectedRows: selectedFlatRows, courseId }));
|
||||
}, []);
|
||||
|
||||
const handleLockedAsset = (assetId, locked) => {
|
||||
dispatch(clearErrors({ errorType: 'lock' }));
|
||||
dispatch(updateAssetLock({ courseId, assetId, locked }));
|
||||
};
|
||||
|
||||
const handleOpenDeleteConfirmation = (selectedFlatRows) => {
|
||||
setSelectedRows(selectedFlatRows);
|
||||
openDeleteConfirmation();
|
||||
};
|
||||
|
||||
const handleOpenAssetInfo = (original) => {
|
||||
dispatch(resetErrors({ errorType: 'usageMetrics' }));
|
||||
setSelectedRows([{ original }]);
|
||||
dispatch(getUsagePaths({ asset: original, courseId, setSelectedRows }));
|
||||
openAssetInfo();
|
||||
};
|
||||
|
||||
const headerActions = ({ selectedFlatRows }) => (
|
||||
<TableActions
|
||||
{...{
|
||||
selectedFlatRows,
|
||||
fileInputControl,
|
||||
handleSort,
|
||||
handleBulkDownload,
|
||||
handleOpenDeleteConfirmation,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const fileCard = ({ className, original }) => (
|
||||
<GalleryCard
|
||||
{...{
|
||||
handleLockedAsset,
|
||||
handleBulkDownload,
|
||||
handleOpenDeleteConfirmation,
|
||||
handleOpenAssetInfo,
|
||||
className,
|
||||
original,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const accessColumn = {
|
||||
id: 'locked',
|
||||
Header: 'Access',
|
||||
Cell: ({ row }) => AccessColumn({ row }),
|
||||
};
|
||||
const thumbnailColumn = {
|
||||
id: 'thumbnail',
|
||||
Header: '',
|
||||
Cell: ({ row }) => ThumbnailColumn({ row }),
|
||||
};
|
||||
const fileSizeColumn = {
|
||||
id: 'fileSize',
|
||||
Header: 'File size',
|
||||
Cell: ({ row }) => {
|
||||
const { fileSize } = row.original;
|
||||
return getFileSizeToClosestByte(fileSize);
|
||||
},
|
||||
};
|
||||
const moreInfoColumn = {
|
||||
id: 'moreInfo',
|
||||
Header: '',
|
||||
Cell: ({ row }) => MoreInfoColumn({
|
||||
row,
|
||||
handleLock: handleLockedAsset,
|
||||
handleBulkDownload,
|
||||
handleOpenAssetInfo,
|
||||
handleOpenDeleteConfirmation,
|
||||
}),
|
||||
};
|
||||
|
||||
const tableColumns = [
|
||||
{ ...thumbnailColumn },
|
||||
{
|
||||
Header: 'File name',
|
||||
accessor: 'displayName',
|
||||
},
|
||||
{ ...fileSizeColumn },
|
||||
{
|
||||
Header: 'Type',
|
||||
accessor: 'wrapperType',
|
||||
Filter: CheckboxFilter,
|
||||
filter: 'includesValue',
|
||||
filterChoices: [
|
||||
{
|
||||
name: 'Code',
|
||||
value: 'code',
|
||||
},
|
||||
{
|
||||
name: 'Images',
|
||||
value: 'image',
|
||||
},
|
||||
{
|
||||
name: 'Documents',
|
||||
value: 'document',
|
||||
},
|
||||
{
|
||||
name: 'Audio',
|
||||
value: 'audio',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ ...accessColumn },
|
||||
{ ...moreInfoColumn },
|
||||
];
|
||||
|
||||
if (loadingStatus === RequestStatus.DENIED) {
|
||||
return (
|
||||
<div data-testid="under-construction-placeholder" className="row justify-contnt-center m-6">
|
||||
<Placeholder />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FilesAndUploadsProvider courseId={courseId}>
|
||||
<main className="containerpt-5">
|
||||
<div className="p-4">
|
||||
<ErrorAlert
|
||||
hideHeading={false}
|
||||
isError={addAssetStatus === RequestStatus.FAILED}
|
||||
>
|
||||
<ul className="p-0">
|
||||
{errorMessages.add.map(message => (
|
||||
<li key={`add-error-${message}`} style={{ listStyle: 'none' }}>
|
||||
{intl.formatMessage(messages.errorAlertMessage, { message })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ErrorAlert>
|
||||
<ErrorAlert
|
||||
hideHeading={false}
|
||||
isError={deleteAssetStatus === RequestStatus.FAILED}
|
||||
>
|
||||
<ul className="p-0">
|
||||
{errorMessages.delete.map(message => (
|
||||
<li key={`delete-error-${message}`} style={{ listStyle: 'none' }}>
|
||||
{intl.formatMessage(messages.errorAlertMessage, { message })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ErrorAlert>
|
||||
<ErrorAlert
|
||||
hideHeading={false}
|
||||
isError={updateAssetStatus === RequestStatus.FAILED}
|
||||
>
|
||||
<ul className="p-0">
|
||||
{errorMessages.lock.map(message => (
|
||||
<li key={`lock-error-${message}`} style={{ listStyle: 'none' }}>
|
||||
{intl.formatMessage(messages.errorAlertMessage, { message })}
|
||||
</li>
|
||||
))}
|
||||
{errorMessages.download.map(message => (
|
||||
<li key={`download-error-${message}`} style={{ listStyle: 'none' }}>
|
||||
{intl.formatMessage(messages.errorAlertMessage, { message })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ErrorAlert>
|
||||
<div className="h2">
|
||||
<FormattedMessage {...messages.heading} />
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
isFilterable
|
||||
isLoading={loadingStatus === RequestStatus.IN_PROGRESS}
|
||||
isSortable
|
||||
isSelectable
|
||||
isPaginated
|
||||
defaultColumnValues={{ Filter: TextFilter }}
|
||||
dataViewToggleOptions={{
|
||||
isDataViewToggleEnabled: true,
|
||||
onDataViewToggle: val => setCurrentView(val),
|
||||
defaultActiveStateValue: defaultVal,
|
||||
togglePlacement: 'left',
|
||||
}}
|
||||
initialState={{
|
||||
pageSize: 50,
|
||||
}}
|
||||
tableActions={headerActions}
|
||||
bulkActions={headerActions}
|
||||
columns={tableColumns}
|
||||
itemCount={totalCount}
|
||||
pageCount={Math.ceil(totalCount / 50)}
|
||||
data={assets}
|
||||
FilterStatusComponent={FilterStatus}
|
||||
>
|
||||
{isEmpty(assets) && loadingStatus !== RequestStatus.IN_PROGRESS ? (
|
||||
<Dropzone
|
||||
data-testid="files-dropzone"
|
||||
onProcessUpload={handleDropzoneAsset}
|
||||
maxSize={20 * 1048576}
|
||||
errorMessages={{
|
||||
invalidSize: intl.formatMessage(messages.fileSizeError),
|
||||
multipleDragged: 'Dropzone can only upload a single file.',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div data-testid="files-data-table" className="mr-4 ml-3">
|
||||
<DataTable.TableControlBar />
|
||||
{ currentView === 'card' && <CardView CardComponent={fileCard} columnSizes={columnSizes} selectionPlacement="left" skeletonCardCount={6} /> }
|
||||
{ currentView === 'list' && <DataTable.Table /> }
|
||||
<DataTable.EmptyTable content={intl.formatMessage(messages.noResultsFoundMessage)} />
|
||||
<DataTable.TableFooter />
|
||||
<ApiStatusToast
|
||||
actionType={intl.formatMessage(messages.apiStatusDeletingAction)}
|
||||
selectedRowCount={selectedRows.length}
|
||||
isOpen={isDeleteOpen}
|
||||
setClose={setDeleteClose}
|
||||
setSelectedRows={setSelectedRows}
|
||||
/>
|
||||
<ApiStatusToast
|
||||
actionType={intl.formatMessage(messages.apiStatusAddingAction)}
|
||||
selectedRowCount={selectedRows.length}
|
||||
isOpen={isAddOpen}
|
||||
setClose={setAddClose}
|
||||
setSelectedRows={setSelectedRows}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DataTable>
|
||||
<FileInput fileInput={fileInputControl} />
|
||||
{!isEmpty(selectedRows) && (
|
||||
<FileInfo
|
||||
asset={selectedRows[0].original}
|
||||
onClose={closeAssetinfo}
|
||||
isOpen={isAssetInfoOpen}
|
||||
handleLockedAsset={handleLockedAsset}
|
||||
usagePathStatus={usagePathStatus}
|
||||
error={errorMessages.usageMetrics}
|
||||
/>
|
||||
)}
|
||||
<AlertModal
|
||||
title={intl.formatMessage(messages.deleteConfirmationTitle)}
|
||||
isOpen={isDeleteConfirmationOpen}
|
||||
onClose={closeDeleteConfirmation}
|
||||
footerNode={(
|
||||
<ActionRow>
|
||||
<Button variant="tertiary" onClick={closeDeleteConfirmation}>
|
||||
{intl.formatMessage(messages.cancelButtonLabel)}
|
||||
</Button>
|
||||
<Button onClick={handleBulkDelete}>
|
||||
{intl.formatMessage(messages.deleteFileButtonLabel)}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
)}
|
||||
>
|
||||
{intl.formatMessage(messages.deleteConfirmationMessage, { fileNumber: selectedRows.length })}
|
||||
</AlertModal>
|
||||
</main>
|
||||
</FilesAndUploadsProvider>
|
||||
);
|
||||
};
|
||||
|
||||
FilesAndUploads.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(FilesAndUploads);
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './FilesAndUploads';
|
||||
@@ -1,103 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
ActionRow,
|
||||
Icon,
|
||||
Card,
|
||||
Chip,
|
||||
Truncate,
|
||||
Image,
|
||||
} from '@edx/paragon';
|
||||
import {
|
||||
MoreVert,
|
||||
} from '@edx/paragon/icons';
|
||||
import FileMenu from '../FileMenu';
|
||||
import { getSrc } from '../data/utils';
|
||||
|
||||
const GalleryCard = ({
|
||||
className,
|
||||
original,
|
||||
handleBulkDownload,
|
||||
handleLockedAsset,
|
||||
handleOpenDeleteConfirmation,
|
||||
handleOpenAssetInfo,
|
||||
}) => {
|
||||
const lockAsset = () => {
|
||||
const { locked } = original;
|
||||
handleLockedAsset(original.id, !locked);
|
||||
};
|
||||
const src = getSrc({
|
||||
thumbnail: original.thumbnail,
|
||||
wrapperType: original.wrapperType,
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className={`${className} w-100 gallery-card`} data-testid={`grid-card-${original.id}`}>
|
||||
<Card.Header
|
||||
className="pr-0 pt-2 pb-2"
|
||||
actions={(
|
||||
<ActionRow>
|
||||
<FileMenu
|
||||
externalUrl={original.externalUrl}
|
||||
handleLock={lockAsset}
|
||||
locked={original.locked}
|
||||
openAssetInfo={() => handleOpenAssetInfo(original)}
|
||||
portableUrl={original.portableUrl}
|
||||
iconSrc={MoreVert}
|
||||
id={original.id}
|
||||
onDownload={() => handleBulkDownload(
|
||||
[{ original: { id: original.id, displayName: original.displayName } }],
|
||||
)}
|
||||
openDeleteConfirmation={() => handleOpenDeleteConfirmation([{ original }])}
|
||||
/>
|
||||
</ActionRow>
|
||||
)}
|
||||
/>
|
||||
<Card.Section className="pr-3 pl-3 pt-0 pb-0">
|
||||
<div
|
||||
className="row align-items-center justify-content-center m-0 thumbnail-container border rounded p-1"
|
||||
>
|
||||
{original.thumbnail ? (
|
||||
<Image src={src} className="w-auto mw-100 mh-100 thumbnail-image" />
|
||||
) : (
|
||||
<div className="row justify-content-center align-items-center m-0">
|
||||
<Icon src={src} style={{ height: '48px', width: '48px' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ wordBreak: 'break-word' }}>
|
||||
<Truncate lines={1} className="font-weight-bold mt-2 picture-title">
|
||||
{original.displayName}
|
||||
</Truncate>
|
||||
</div>
|
||||
</Card.Section>
|
||||
<Card.Footer className="p-3 pt-4">
|
||||
<Chip>
|
||||
{original.wrapperType}
|
||||
</Chip>
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
GalleryCard.defaultProps = {
|
||||
className: null,
|
||||
};
|
||||
GalleryCard.propTypes = {
|
||||
className: PropTypes.string,
|
||||
original: PropTypes.shape({
|
||||
displayName: PropTypes.string.isRequired,
|
||||
wrapperType: PropTypes.string.isRequired,
|
||||
locked: PropTypes.bool.isRequired,
|
||||
externalUrl: PropTypes.string.isRequired,
|
||||
thumbnail: PropTypes.string,
|
||||
id: PropTypes.string.isRequired,
|
||||
portableUrl: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
handleBulkDownload: PropTypes.func.isRequired,
|
||||
handleLockedAsset: PropTypes.func.isRequired,
|
||||
handleOpenDeleteConfirmation: PropTypes.func.isRequired,
|
||||
handleOpenAssetInfo: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default GalleryCard;
|
||||
@@ -1,102 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
ActionRow,
|
||||
Icon,
|
||||
Card,
|
||||
Chip,
|
||||
Truncate,
|
||||
Image,
|
||||
} from '@edx/paragon';
|
||||
import {
|
||||
MoreVert,
|
||||
} from '@edx/paragon/icons';
|
||||
import FileMenu from '../FileMenu';
|
||||
import { getSrc } from '../data/utils';
|
||||
|
||||
const ListCard = ({
|
||||
className,
|
||||
original,
|
||||
handleBulkDownload,
|
||||
handleLockedAsset,
|
||||
handleOpenDeleteConfirmation,
|
||||
handleOpenAssetInfo,
|
||||
}) => {
|
||||
const lockAsset = () => {
|
||||
const { locked } = original;
|
||||
handleLockedAsset(original.id, !locked);
|
||||
};
|
||||
const src = getSrc({
|
||||
thumbnail: original.thumbnail,
|
||||
wrapperType: original.wrapperType,
|
||||
});
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={className}
|
||||
orientation="horizontal"
|
||||
data-testid={`list-card-${original.id}`}
|
||||
>
|
||||
<div className="row align-items-center justify-content-center m-0 p-3">
|
||||
{original.thumbnail ? (
|
||||
<Image src={src} style={{ height: '76px', width: '135.71px' }} className="border rounded p-1" />
|
||||
) : (
|
||||
<div className="row border justify-content-center align-items-center rounded m-0" style={{ height: '76px', width: '135.71px' }}>
|
||||
<Icon src={src} style={{ height: '48px', width: '48px' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Card.Body>
|
||||
<Card.Section>
|
||||
<div style={{ wordBreak: 'break-word' }}>
|
||||
<Truncate lines={1} className="font-weight-bold small mt-3">
|
||||
{original.displayName}
|
||||
</Truncate>
|
||||
</div>
|
||||
<Chip className="mt-3">
|
||||
{original.wrapperType}
|
||||
</Chip>
|
||||
</Card.Section>
|
||||
</Card.Body>
|
||||
<Card.Footer>
|
||||
<ActionRow>
|
||||
<FileMenu
|
||||
externalUrl={original.externalUrl}
|
||||
handleLock={lockAsset}
|
||||
locked={original.locked}
|
||||
openAssetInfo={() => handleOpenAssetInfo(original)}
|
||||
portableUrl={original.portableUrl}
|
||||
iconSrc={MoreVert}
|
||||
id={original.id}
|
||||
onDownload={() => handleBulkDownload(
|
||||
[{ original: { id: original.id, displayName: original.displayName } }],
|
||||
)}
|
||||
openDeleteConfirmation={() => handleOpenDeleteConfirmation([{ original }])}
|
||||
/>
|
||||
</ActionRow>
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
ListCard.defaultProps = {
|
||||
className: null,
|
||||
};
|
||||
ListCard.propTypes = {
|
||||
className: PropTypes.string,
|
||||
original: PropTypes.shape({
|
||||
displayName: PropTypes.string.isRequired,
|
||||
wrapperType: PropTypes.string.isRequired,
|
||||
locked: PropTypes.bool.isRequired,
|
||||
externalUrl: PropTypes.string.isRequired,
|
||||
thumbnail: PropTypes.string,
|
||||
id: PropTypes.string.isRequired,
|
||||
portableUrl: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
handleBulkDownload: PropTypes.func.isRequired,
|
||||
handleLockedAsset: PropTypes.func.isRequired,
|
||||
handleOpenDeleteConfirmation: PropTypes.func.isRequired,
|
||||
handleOpenAssetInfo: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ListCard;
|
||||
@@ -1,47 +0,0 @@
|
||||
import React from 'react';
|
||||
import { PropTypes } from 'prop-types';
|
||||
import { Image, Icon } from '@edx/paragon';
|
||||
import { getSrc } from '../../data/utils';
|
||||
|
||||
const ThumbnailColumn = ({ row }) => {
|
||||
const {
|
||||
thumbnail,
|
||||
wrapperType,
|
||||
externalUrl,
|
||||
} = row.original;
|
||||
|
||||
const src = getSrc({ thumbnail, wrapperType, externalUrl });
|
||||
return (
|
||||
<div className="row align-items-center justify-content-center m-0 p-3">
|
||||
{thumbnail ? (
|
||||
<Image
|
||||
src={src}
|
||||
style={{
|
||||
height: '76px',
|
||||
width: '135.71px',
|
||||
objectFit: 'contain',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
}}
|
||||
className="border rounded p-1"
|
||||
/>
|
||||
) : (
|
||||
<div className="row border justify-content-center align-items-center rounded m-0" style={{ height: '76px', width: '135.71px' }}>
|
||||
<Icon src={src} style={{ height: '48px', width: '48px' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ThumbnailColumn.propTypes = {
|
||||
row: {
|
||||
original: {
|
||||
thumbnail: PropTypes.string,
|
||||
wrapperType: PropTypes.string.isRequired,
|
||||
externalUrl: PropTypes.string,
|
||||
}.isRequired,
|
||||
}.isRequired,
|
||||
};
|
||||
|
||||
export default ThumbnailColumn;
|
||||
87
src/files-and-videos/EditFileErrors.jsx
Normal file
87
src/files-and-videos/EditFileErrors.jsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { ErrorAlert } from '@edx/frontend-lib-content-components';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import messages from './messages';
|
||||
|
||||
const EditFileErrors = ({
|
||||
resetErrors,
|
||||
errorMessages,
|
||||
addFileStatus,
|
||||
deleteFileStatus,
|
||||
updateFileStatus,
|
||||
// injected
|
||||
intl,
|
||||
}) => (
|
||||
<>
|
||||
<ErrorAlert
|
||||
hideHeading={false}
|
||||
dismissError={() => resetErrors({ errorType: 'add' })}
|
||||
isError={addFileStatus === RequestStatus.FAILED}
|
||||
>
|
||||
<ul className="p-0">
|
||||
{errorMessages.add.map(message => (
|
||||
<li key={`add-error-${message}`} style={{ listStyle: 'none' }}>
|
||||
{intl.formatMessage(messages.errorAlertMessage, { message })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ErrorAlert>
|
||||
<ErrorAlert
|
||||
hideHeading={false}
|
||||
dismissError={() => resetErrors({ errorType: 'delete' })}
|
||||
isError={deleteFileStatus === RequestStatus.FAILED}
|
||||
>
|
||||
<ul className="p-0">
|
||||
{errorMessages.delete.map(message => (
|
||||
<li key={`delete-error-${message}`} style={{ listStyle: 'none' }}>
|
||||
{intl.formatMessage(messages.errorAlertMessage, { message })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ErrorAlert>
|
||||
<ErrorAlert
|
||||
hideHeading={false}
|
||||
dismissError={() => resetErrors({ errorType: 'update' })}
|
||||
isError={updateFileStatus === RequestStatus.FAILED}
|
||||
>
|
||||
<ul className="p-0">
|
||||
{errorMessages.lock?.map(message => (
|
||||
<li key={`lock-error-${message}`} style={{ listStyle: 'none' }}>
|
||||
{intl.formatMessage(messages.errorAlertMessage, { message })}
|
||||
</li>
|
||||
))}
|
||||
{errorMessages.download.map(message => (
|
||||
<li key={`download-error-${message}`} style={{ listStyle: 'none' }}>
|
||||
{intl.formatMessage(messages.errorAlertMessage, { message })}
|
||||
</li>
|
||||
))}
|
||||
{errorMessages.thumbnail?.map(message => (
|
||||
<li key={`add-thumbnail-error-${message}`} style={{ listStyle: 'none' }}>
|
||||
{intl.formatMessage(messages.errorAlertMessage, { message })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ErrorAlert>
|
||||
</>
|
||||
);
|
||||
|
||||
EditFileErrors.propTypes = {
|
||||
resetErrors: PropTypes.func.isRequired,
|
||||
errorMessages: PropTypes.shape({
|
||||
add: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
delete: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
lock: PropTypes.arrayOf(PropTypes.string),
|
||||
download: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
usageMetrics: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
thumbnail: PropTypes.arrayOf(PropTypes.string),
|
||||
}).isRequired,
|
||||
addFileStatus: PropTypes.string.isRequired,
|
||||
deleteFileStatus: PropTypes.string.isRequired,
|
||||
updateFileStatus: PropTypes.string.isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(EditFileErrors);
|
||||
102
src/files-and-videos/FileInfo.jsx
Normal file
102
src/files-and-videos/FileInfo.jsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {
|
||||
injectIntl,
|
||||
FormattedMessage,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ModalDialog,
|
||||
Truncate,
|
||||
} from '@edx/paragon';
|
||||
|
||||
import messages from './messages';
|
||||
import UsageMetricsMessages from './UsageMetricsMessage';
|
||||
import FileInfoAssetSidebar from './files-page/FileInfoAssetSidebar';
|
||||
import FileInfoVideoSidebar from './videos-page/info-sidebar/FileInfoVideoSidebar';
|
||||
import FileThumbnail from './FileThumbnail';
|
||||
|
||||
const FileInfo = ({
|
||||
file,
|
||||
isOpen,
|
||||
onClose,
|
||||
handleLockedFile,
|
||||
thumbnailPreview,
|
||||
usagePathStatus,
|
||||
error,
|
||||
}) => (
|
||||
<ModalDialog
|
||||
title={file?.displayName}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
hasCloseButton
|
||||
data-testid="file-info-modal"
|
||||
>
|
||||
<ModalDialog.Header>
|
||||
<ModalDialog.Title>
|
||||
<div style={{ wordBreak: 'break-word' }}>
|
||||
<Truncate lines={2} className="font-weight-bold small mt-3">
|
||||
{file?.displayName}
|
||||
</Truncate>
|
||||
</div>
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
<ModalDialog.Body className="pt-0 x-small">
|
||||
<hr />
|
||||
<div className="row flex-nowrap m-0 mt-4">
|
||||
<div className="col-7 mr-3">
|
||||
<FileThumbnail
|
||||
thumbnail={file?.thumbnail}
|
||||
externalUrl={file?.externalUrl}
|
||||
displayName={file?.displayName}
|
||||
wrapperType={file?.wrapperType}
|
||||
id={file?.id}
|
||||
status={file?.status}
|
||||
thumbnailPreview={thumbnailPreview}
|
||||
imageSize={{ width: '503px', height: '281px' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-5">
|
||||
{file?.wrapperType === 'video' ? (
|
||||
<FileInfoVideoSidebar video={file} />
|
||||
) : (
|
||||
<FileInfoAssetSidebar asset={file} handleLockedAsset={handleLockedFile} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="row m-0 pt-3 font-weight-bold">
|
||||
<FormattedMessage {...messages.usageTitle} />
|
||||
</div>
|
||||
<UsageMetricsMessages {...{ usageLocations: file?.usageLocations, usagePathStatus, error }} />
|
||||
</ModalDialog.Body>
|
||||
</ModalDialog>
|
||||
);
|
||||
|
||||
FileInfo.propTypes = {
|
||||
file: PropTypes.shape({
|
||||
displayName: PropTypes.string.isRequired,
|
||||
wrapperType: PropTypes.string.isRequired,
|
||||
locked: PropTypes.bool,
|
||||
externalUrl: PropTypes.string,
|
||||
thumbnail: PropTypes.string,
|
||||
id: PropTypes.string.isRequired,
|
||||
portableUrl: PropTypes.string,
|
||||
dateAdded: PropTypes.string.isRequired,
|
||||
fileSize: PropTypes.number.isRequired,
|
||||
usageLocations: PropTypes.arrayOf(PropTypes.string),
|
||||
status: PropTypes.string,
|
||||
}),
|
||||
onClose: PropTypes.func.isRequired,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
handleLockedFile: PropTypes.func.isRequired,
|
||||
usagePathStatus: PropTypes.string.isRequired,
|
||||
error: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
thumbnailPreview: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
FileInfo.defaultProps = {
|
||||
file: null,
|
||||
};
|
||||
|
||||
export default injectIntl(FileInfo);
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { getSupportedFormats } from './videos-page/data/utils';
|
||||
|
||||
export const useFileInput = ({
|
||||
onAddFile,
|
||||
@@ -23,14 +24,15 @@ export const useFileInput = ({
|
||||
};
|
||||
};
|
||||
|
||||
const FileInput = ({ fileInput: hook }) => (
|
||||
const FileInput = ({ fileInput: hook, supportedFileFormats, allowMultiple }) => (
|
||||
<input
|
||||
accept={getSupportedFormats(supportedFileFormats)}
|
||||
aria-label="file-input"
|
||||
className="upload d-none"
|
||||
onChange={hook.addFile}
|
||||
ref={hook.ref}
|
||||
type="file"
|
||||
multiple
|
||||
multiple={allowMultiple}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -44,6 +46,16 @@ FileInput.propTypes = {
|
||||
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
|
||||
]),
|
||||
}).isRequired,
|
||||
supportedFileFormats: PropTypes.oneOfType([
|
||||
PropTypes.shape({}),
|
||||
PropTypes.arrayOf(PropTypes.string),
|
||||
]),
|
||||
allowMultiple: PropTypes.bool,
|
||||
};
|
||||
|
||||
FileInput.defaultProps = {
|
||||
supportedFileFormats: null,
|
||||
allowMultiple: true,
|
||||
};
|
||||
|
||||
export default FileInput;
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
IconButton,
|
||||
Icon,
|
||||
} from '@edx/paragon';
|
||||
import { MoreHoriz } from '@edx/paragon/icons';
|
||||
import messages from './messages';
|
||||
|
||||
const FileMenu = ({
|
||||
@@ -16,8 +17,8 @@ const FileMenu = ({
|
||||
openAssetInfo,
|
||||
openDeleteConfirmation,
|
||||
portableUrl,
|
||||
iconSrc,
|
||||
id,
|
||||
wrapperType,
|
||||
// injected
|
||||
intl,
|
||||
}) => (
|
||||
@@ -25,28 +26,38 @@ const FileMenu = ({
|
||||
<Dropdown.Toggle
|
||||
id={`file-menu-dropdown-${id}`}
|
||||
as={IconButton}
|
||||
src={iconSrc}
|
||||
src={MoreHoriz}
|
||||
iconAs={Icon}
|
||||
variant="primary"
|
||||
alt="asset-menu-toggle"
|
||||
alt="file-menu-toggle"
|
||||
/>
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item
|
||||
onClick={() => navigator.clipboard.writeText(portableUrl)}
|
||||
>
|
||||
{intl.formatMessage(messages.copyStudioUrlTitle)}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={() => navigator.clipboard.writeText(externalUrl)}
|
||||
>
|
||||
{intl.formatMessage(messages.copyWebUrlTitle)}
|
||||
</Dropdown.Item>
|
||||
{wrapperType === 'video' ? (
|
||||
<Dropdown.Item
|
||||
onClick={() => navigator.clipboard.writeText(id)}
|
||||
>
|
||||
Copy video ID
|
||||
</Dropdown.Item>
|
||||
) : (
|
||||
<>
|
||||
<Dropdown.Item
|
||||
onClick={() => navigator.clipboard.writeText(portableUrl)}
|
||||
>
|
||||
{intl.formatMessage(messages.copyStudioUrlTitle)}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={() => navigator.clipboard.writeText(externalUrl)}
|
||||
>
|
||||
{intl.formatMessage(messages.copyWebUrlTitle)}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item onClick={handleLock}>
|
||||
{locked ? intl.formatMessage(messages.unlockMenuTitle) : intl.formatMessage(messages.lockMenuTitle)}
|
||||
</Dropdown.Item>
|
||||
</>
|
||||
)}
|
||||
<Dropdown.Item onClick={onDownload}>
|
||||
{intl.formatMessage(messages.downloadTitle)}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item onClick={handleLock}>
|
||||
{locked ? intl.formatMessage(messages.unlockMenuTitle) : intl.formatMessage(messages.lockMenuTitle)}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item onClick={openAssetInfo}>
|
||||
{intl.formatMessage(messages.infoTitle)}
|
||||
</Dropdown.Item>
|
||||
@@ -62,17 +73,24 @@ const FileMenu = ({
|
||||
);
|
||||
|
||||
FileMenu.propTypes = {
|
||||
externalUrl: PropTypes.string.isRequired,
|
||||
handleLock: PropTypes.func.isRequired,
|
||||
locked: PropTypes.bool.isRequired,
|
||||
externalUrl: PropTypes.string,
|
||||
handleLock: PropTypes.func,
|
||||
locked: PropTypes.bool,
|
||||
onDownload: PropTypes.func.isRequired,
|
||||
openAssetInfo: PropTypes.func.isRequired,
|
||||
openDeleteConfirmation: PropTypes.func.isRequired,
|
||||
portableUrl: PropTypes.string.isRequired,
|
||||
iconSrc: PropTypes.func.isRequired,
|
||||
portableUrl: PropTypes.string,
|
||||
id: PropTypes.string.isRequired,
|
||||
wrapperType: PropTypes.string.isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
FileMenu.defaultProps = {
|
||||
externalUrl: null,
|
||||
handleLock: null,
|
||||
locked: null,
|
||||
portableUrl: null,
|
||||
};
|
||||
|
||||
export default injectIntl(FileMenu);
|
||||
306
src/files-and-videos/FileTable.jsx
Normal file
306
src/files-and-videos/FileTable.jsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
DataTable,
|
||||
TextFilter,
|
||||
Dropzone,
|
||||
CardView,
|
||||
useToggle,
|
||||
AlertModal,
|
||||
ActionRow,
|
||||
Button,
|
||||
} from '@edx/paragon';
|
||||
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import { sortFiles } from './data/utils';
|
||||
import messages from './messages';
|
||||
|
||||
import FileInfo from './FileInfo';
|
||||
import FileInput, { useFileInput } from './FileInput';
|
||||
import {
|
||||
GalleryCard,
|
||||
TableActions,
|
||||
} from './table-components';
|
||||
import ApiStatusToast from './ApiStatusToast';
|
||||
import FilterStatus from './table-components/FilterStatus';
|
||||
import MoreInfoColumn from './table-components/table-custom-columns/MoreInfoColumn';
|
||||
|
||||
const FileTable = ({
|
||||
files,
|
||||
data,
|
||||
handleAddFile,
|
||||
handleLockFile,
|
||||
handleDeleteFile,
|
||||
handleDownloadFile,
|
||||
handleUsagePaths,
|
||||
handleErrorReset,
|
||||
handleFileOrder,
|
||||
tableColumns,
|
||||
maxFileSize,
|
||||
thumbnailPreview,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const defaultVal = 'card';
|
||||
const columnSizes = {
|
||||
xs: 12,
|
||||
sm: 6,
|
||||
md: 4,
|
||||
lg: 2,
|
||||
};
|
||||
const [currentView, setCurrentView] = useState(defaultVal);
|
||||
const [isDeleteOpen, setDeleteOpen, setDeleteClose] = useToggle(false);
|
||||
const [isAssetInfoOpen, openAssetInfo, closeAssetinfo] = useToggle(false);
|
||||
const [isAddOpen, setAddOpen, setAddClose] = useToggle(false);
|
||||
const [selectedRows, setSelectedRows] = useState([]);
|
||||
const [isDeleteConfirmationOpen, openDeleteConfirmation, closeDeleteConfirmation] = useToggle(false);
|
||||
|
||||
const {
|
||||
totalCount,
|
||||
loadingStatus,
|
||||
usagePathStatus,
|
||||
usageErrorMessages,
|
||||
encodingsDownloadUrl,
|
||||
supportedFileFormats,
|
||||
} = data;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEmpty(selectedRows) && Object.keys(selectedRows[0]).length > 0) {
|
||||
const updatedRows = [];
|
||||
selectedRows.forEach(row => {
|
||||
const currentFile = row.original;
|
||||
if (currentFile) {
|
||||
const [updatedFile] = files.filter(file => file.id === currentFile?.id);
|
||||
updatedRows.push({ original: updatedFile });
|
||||
}
|
||||
});
|
||||
setSelectedRows(updatedRows);
|
||||
}
|
||||
}, [files]);
|
||||
|
||||
const fileInputControl = useFileInput({
|
||||
onAddFile: (file) => handleAddFile(file),
|
||||
setSelectedRows,
|
||||
setAddOpen,
|
||||
});
|
||||
const handleDropzoneAsset = ({ fileData, handleError }) => {
|
||||
try {
|
||||
const file = fileData.get('file');
|
||||
handleAddFile(file);
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSort = (sortType) => {
|
||||
const newFileIdOrder = sortFiles(files, sortType);
|
||||
handleFileOrder({ newFileIdOrder, sortType });
|
||||
};
|
||||
|
||||
const handleBulkDelete = () => {
|
||||
closeDeleteConfirmation();
|
||||
setDeleteOpen();
|
||||
handleErrorReset({ errorType: 'delete' });
|
||||
const fileIdsToDelete = selectedRows.map(row => row.original.id);
|
||||
fileIdsToDelete.forEach(id => handleDeleteFile(id));
|
||||
};
|
||||
|
||||
const handleBulkDownload = useCallback(async (selectedFlatRows) => {
|
||||
handleErrorReset({ errorType: 'download' });
|
||||
handleDownloadFile(selectedFlatRows);
|
||||
}, []);
|
||||
|
||||
const handleLockedFile = (fileId, locked) => {
|
||||
handleErrorReset({ errorType: 'lock' });
|
||||
handleLockFile({ fileId, locked });
|
||||
};
|
||||
|
||||
const handleOpenDeleteConfirmation = (selectedFlatRows) => {
|
||||
setSelectedRows(selectedFlatRows);
|
||||
openDeleteConfirmation();
|
||||
};
|
||||
|
||||
const handleOpenFileInfo = (original) => {
|
||||
handleErrorReset({ errorType: 'usageMetrics' });
|
||||
setSelectedRows([{ original }]);
|
||||
handleUsagePaths(original);
|
||||
openAssetInfo();
|
||||
};
|
||||
|
||||
const headerActions = ({ selectedFlatRows }) => (
|
||||
<TableActions
|
||||
{...{
|
||||
selectedFlatRows,
|
||||
fileInputControl,
|
||||
encodingsDownloadUrl,
|
||||
handleSort,
|
||||
handleBulkDownload,
|
||||
handleOpenDeleteConfirmation,
|
||||
supportedFileFormats,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const fileCard = ({ className, original }) => (
|
||||
<GalleryCard
|
||||
{...{
|
||||
handleLockedFile,
|
||||
handleBulkDownload,
|
||||
handleOpenDeleteConfirmation,
|
||||
handleOpenFileInfo,
|
||||
thumbnailPreview,
|
||||
className,
|
||||
original,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const moreInfoColumn = {
|
||||
id: 'moreInfo',
|
||||
Header: '',
|
||||
Cell: ({ row }) => MoreInfoColumn({
|
||||
row,
|
||||
handleLock: handleLockedFile,
|
||||
handleBulkDownload,
|
||||
handleOpenFileInfo,
|
||||
handleOpenDeleteConfirmation,
|
||||
}),
|
||||
};
|
||||
|
||||
const hasMoreInfoColumn = tableColumns.filter(col => col.id === 'moreInfo').length === 1;
|
||||
if (!hasMoreInfoColumn) {
|
||||
tableColumns.push({ ...moreInfoColumn });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
isFilterable
|
||||
isLoading={loadingStatus === RequestStatus.IN_PROGRESS}
|
||||
isSortable
|
||||
isSelectable
|
||||
isPaginated
|
||||
defaultColumnValues={{ Filter: TextFilter }}
|
||||
dataViewToggleOptions={{
|
||||
isDataViewToggleEnabled: true,
|
||||
onDataViewToggle: val => setCurrentView(val),
|
||||
defaultActiveStateValue: defaultVal,
|
||||
togglePlacement: 'left',
|
||||
}}
|
||||
initialState={{
|
||||
pageSize: 50,
|
||||
}}
|
||||
tableActions={headerActions}
|
||||
bulkActions={headerActions}
|
||||
columns={tableColumns}
|
||||
itemCount={totalCount}
|
||||
pageCount={Math.ceil(totalCount / 50)}
|
||||
data={files}
|
||||
FilterStatusComponent={FilterStatus}
|
||||
>
|
||||
{isEmpty(files) && loadingStatus !== RequestStatus.IN_PROGRESS ? (
|
||||
<Dropzone
|
||||
data-testid="files-dropzone"
|
||||
accept={supportedFileFormats}
|
||||
onProcessUpload={handleDropzoneAsset}
|
||||
maxSize={maxFileSize}
|
||||
errorMessages={{
|
||||
invalidSize: intl.formatMessage(messages.fileSizeError),
|
||||
multipleDragged: 'Dropzone can only upload a single file.',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div data-testid="files-data-table" className="mr-4 ml-3">
|
||||
<DataTable.TableControlBar />
|
||||
{ currentView === 'card' && <CardView CardComponent={fileCard} columnSizes={columnSizes} selectionPlacement="left" skeletonCardCount={6} /> }
|
||||
{ currentView === 'list' && <DataTable.Table /> }
|
||||
<DataTable.EmptyTable content={intl.formatMessage(messages.noResultsFoundMessage)} />
|
||||
<DataTable.TableFooter />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ApiStatusToast
|
||||
actionType={intl.formatMessage(messages.apiStatusDeletingAction)}
|
||||
selectedRowCount={selectedRows.length}
|
||||
isOpen={isDeleteOpen}
|
||||
setClose={setDeleteClose}
|
||||
setSelectedRows={setSelectedRows}
|
||||
/>
|
||||
<ApiStatusToast
|
||||
actionType={intl.formatMessage(messages.apiStatusAddingAction)}
|
||||
selectedRowCount={selectedRows.length}
|
||||
isOpen={isAddOpen}
|
||||
setClose={setAddClose}
|
||||
setSelectedRows={setSelectedRows}
|
||||
/>
|
||||
</DataTable>
|
||||
<FileInput key="generic-file-upload" fileInput={fileInputControl} supportedFileFormats={supportedFileFormats} />
|
||||
{!isEmpty(selectedRows) && (
|
||||
<FileInfo
|
||||
file={selectedRows[0].original}
|
||||
onClose={closeAssetinfo}
|
||||
isOpen={isAssetInfoOpen}
|
||||
handleLockedFile={handleLockedFile}
|
||||
thumbnailPreview={thumbnailPreview}
|
||||
usagePathStatus={usagePathStatus}
|
||||
error={usageErrorMessages}
|
||||
/>
|
||||
)}
|
||||
<AlertModal
|
||||
title={intl.formatMessage(messages.deleteConfirmationTitle)}
|
||||
isOpen={isDeleteConfirmationOpen}
|
||||
onClose={closeDeleteConfirmation}
|
||||
footerNode={(
|
||||
<ActionRow>
|
||||
<Button variant="tertiary" onClick={closeDeleteConfirmation}>
|
||||
{intl.formatMessage(messages.cancelButtonLabel)}
|
||||
</Button>
|
||||
<Button onClick={handleBulkDelete}>
|
||||
{intl.formatMessage(messages.deleteFileButtonLabel)}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
)}
|
||||
>
|
||||
{intl.formatMessage(messages.deleteConfirmationMessage, { fileNumber: selectedRows.length })}
|
||||
</AlertModal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
FileTable.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
files: PropTypes.arrayOf(PropTypes.shape({})),
|
||||
data: PropTypes.shape({
|
||||
totalCount: PropTypes.number.isRequired,
|
||||
fileIds: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
loadingStatus: PropTypes.string.isRequired,
|
||||
usagePathStatus: PropTypes.string.isRequired,
|
||||
usageErrorMessages: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
encodingsDownloadUrl: PropTypes.string,
|
||||
supportedFileFormats: PropTypes.shape({}),
|
||||
}).isRequired,
|
||||
handleAddFile: PropTypes.func.isRequired,
|
||||
handleDeleteFile: PropTypes.func.isRequired,
|
||||
handleDownloadFile: PropTypes.func.isRequired,
|
||||
handleUsagePaths: PropTypes.func.isRequired,
|
||||
handleLockFile: PropTypes.func,
|
||||
handleErrorReset: PropTypes.func.isRequired,
|
||||
handleFileOrder: PropTypes.func.isRequired,
|
||||
tableColumns: PropTypes.arrayOf(PropTypes.shape({
|
||||
Header: PropTypes.string,
|
||||
accessor: PropTypes.string,
|
||||
})).isRequired,
|
||||
maxFileSize: PropTypes.number.isRequired,
|
||||
thumbnailPreview: PropTypes.func.isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
FileTable.defaultProps = {
|
||||
files: null,
|
||||
handleLockFile: () => {},
|
||||
};
|
||||
|
||||
export default injectIntl(FileTable);
|
||||
48
src/files-and-videos/FileThumbnail.jsx
Normal file
48
src/files-and-videos/FileThumbnail.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const FileThumbnail = ({
|
||||
thumbnail,
|
||||
wrapperType,
|
||||
externalUrl,
|
||||
displayName,
|
||||
imageSize,
|
||||
id,
|
||||
status,
|
||||
thumbnailPreview,
|
||||
}) => (
|
||||
<>
|
||||
{thumbnailPreview({
|
||||
thumbnail,
|
||||
wrapperType,
|
||||
externalUrl,
|
||||
displayName,
|
||||
imageSize,
|
||||
id,
|
||||
status,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
FileThumbnail.defaultProps = {
|
||||
thumbnail: null,
|
||||
wrapperType: null,
|
||||
externalUrl: null,
|
||||
displayName: null,
|
||||
id: null,
|
||||
status: null,
|
||||
};
|
||||
FileThumbnail.propTypes = {
|
||||
thumbnail: PropTypes.string,
|
||||
wrapperType: PropTypes.string,
|
||||
externalUrl: PropTypes.string,
|
||||
displayName: PropTypes.string,
|
||||
id: PropTypes.string,
|
||||
status: PropTypes.string,
|
||||
thumbnailPreview: PropTypes.func.isRequired,
|
||||
imageSize: PropTypes.shape({
|
||||
height: PropTypes.string.isRequired,
|
||||
width: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default FileThumbnail;
|
||||
@@ -33,6 +33,7 @@ const FILES_AND_UPLOAD_TYPE_FILTERS = {
|
||||
'application/java-vm', 'text/x-c++src', 'text/xml', 'text/x-scss', 'application/x-python-code',
|
||||
'application/java-archive', 'text/x-python-script', 'application/x-ruby', 'application/mathematica',
|
||||
'text/coffeescript', 'text/x-matlab', 'application/sql', 'text/php'],
|
||||
video: ['.mp4', '.mov'],
|
||||
};
|
||||
|
||||
export default FILES_AND_UPLOAD_TYPE_FILTERS;
|
||||
@@ -130,13 +130,19 @@ export function resetErrors({ errorType }) {
|
||||
return (dispatch) => { dispatch(clearErrors({ error: errorType })); };
|
||||
}
|
||||
|
||||
export function getUsagePaths({ asset, courseId, setSelectedRows }) {
|
||||
export function getUsagePaths({ asset, courseId }) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateEditStatus({ editType: 'usageMetrics', status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
const { usageLocations } = await getAssetUsagePaths({ assetId: asset.id, courseId });
|
||||
setSelectedRows([{ original: { ...asset, usageLocations } }]);
|
||||
dispatch(updateModel({
|
||||
modelType: 'assets',
|
||||
model: {
|
||||
id: asset.id,
|
||||
usageLocations,
|
||||
},
|
||||
}));
|
||||
dispatch(updateEditStatus({ editType: 'usageMetrics', status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
dispatch(updateErrors({ error: 'usageMetrics', message: `Failed to get usage metrics for ${asset.displayName}.` }));
|
||||
@@ -1,4 +1,8 @@
|
||||
import { InsertDriveFile, Terminal, AudioFile } from '@edx/paragon/icons';
|
||||
import {
|
||||
InsertDriveFile,
|
||||
Terminal,
|
||||
AudioFile,
|
||||
} from '@edx/paragon/icons';
|
||||
import { ensureConfig, getConfig } from '@edx/frontend-platform';
|
||||
import FILES_AND_UPLOAD_TYPE_FILTERS from './constant';
|
||||
|
||||
@@ -63,6 +67,8 @@ export const getFileSizeToClosestByte = (fileSize, numberOfDivides = 0) => {
|
||||
return `${fileSizeFixedDecimal} KB`;
|
||||
case 2:
|
||||
return `${fileSizeFixedDecimal} MB`;
|
||||
case 3:
|
||||
return `${fileSizeFixedDecimal} GB`;
|
||||
default:
|
||||
return `${fileSizeFixedDecimal} B`;
|
||||
}
|
||||
@@ -17,5 +17,10 @@ describe('FilesAndUploads utils', () => {
|
||||
const actualSize = getFileSizeToClosestByte(2190000);
|
||||
expect(expectedSize).toEqual(actualSize);
|
||||
});
|
||||
it('should return file size with GB for gigabytes', () => {
|
||||
const expectedSize = '2.03 GB';
|
||||
const actualSize = getFileSizeToClosestByte(2034190000);
|
||||
expect(expectedSize).toEqual(actualSize);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,42 +1,48 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Icon,
|
||||
Image,
|
||||
} from '@edx/paragon';
|
||||
import { getSrc } from './data/utils';
|
||||
import { getSrc } from '../data/utils';
|
||||
import messages from './messages';
|
||||
|
||||
const AssetThumbnail = ({
|
||||
thumbnail,
|
||||
wrapperType,
|
||||
externalUrl,
|
||||
displayName,
|
||||
imageSize,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const src = getSrc({
|
||||
thumbnail,
|
||||
externalUrl,
|
||||
wrapperType,
|
||||
});
|
||||
const { width, height } = imageSize;
|
||||
|
||||
return (
|
||||
<div className="row justify-content-center align-itmes-center">
|
||||
{thumbnail ? (
|
||||
<Image
|
||||
style={{
|
||||
width: '503px',
|
||||
height: '281px',
|
||||
width,
|
||||
height,
|
||||
objectFit: 'contain',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
}}
|
||||
className="border rounded p-1"
|
||||
src={src}
|
||||
alt={`Thumbnail of ${displayName}`}
|
||||
alt={intl.formatMessage(messages.thumbnailAltMessage, { displayName })}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="row border justify-content-center align-items-center rounded m-0"
|
||||
style={{ width: '503px', height: '281px' }}
|
||||
className="row justify-content-center align-items-center m-0 border rounded"
|
||||
style={imageSize}
|
||||
>
|
||||
<Icon src={src} style={{ height: '48px', width: '48px' }} />
|
||||
</div>
|
||||
@@ -46,12 +52,21 @@ const AssetThumbnail = ({
|
||||
};
|
||||
AssetThumbnail.defaultProps = {
|
||||
thumbnail: null,
|
||||
wrapperType: null,
|
||||
externalUrl: null,
|
||||
displayName: null,
|
||||
};
|
||||
AssetThumbnail.propTypes = {
|
||||
thumbnail: PropTypes.string,
|
||||
wrapperType: PropTypes.string.isRequired,
|
||||
externalUrl: PropTypes.string.isRequired,
|
||||
displayName: PropTypes.string.isRequired,
|
||||
wrapperType: PropTypes.string,
|
||||
externalUrl: PropTypes.string,
|
||||
displayName: PropTypes.string,
|
||||
imageSize: PropTypes.shape({
|
||||
width: PropTypes.string,
|
||||
height: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default AssetThumbnail;
|
||||
export default injectIntl(AssetThumbnail);
|
||||
130
src/files-and-videos/files-page/FileInfoAssetSidebar.jsx
Normal file
130
src/files-and-videos/files-page/FileInfoAssetSidebar.jsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {
|
||||
injectIntl,
|
||||
FormattedMessage,
|
||||
FormattedDate,
|
||||
intlShape,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Stack,
|
||||
IconButton,
|
||||
ActionRow,
|
||||
Icon,
|
||||
Truncate,
|
||||
IconButtonWithTooltip,
|
||||
CheckboxControl,
|
||||
} from '@edx/paragon';
|
||||
import { ContentCopy, InfoOutline } from '@edx/paragon/icons';
|
||||
|
||||
import { getFileSizeToClosestByte } from '../data/utils';
|
||||
import messages from './messages';
|
||||
|
||||
const FileInfoAssetSidebar = ({
|
||||
asset,
|
||||
handleLockedAsset,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const [lockedState, setLockedState] = useState(asset?.locked);
|
||||
const handleLock = (e) => {
|
||||
const locked = e.target.checked;
|
||||
setLockedState(locked);
|
||||
handleLockedAsset(asset?.id, locked);
|
||||
};
|
||||
const fileSize = getFileSizeToClosestByte(asset?.fileSize);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<div className="font-weight-bold">
|
||||
<FormattedMessage {...messages.dateAddedTitle} />
|
||||
</div>
|
||||
<FormattedDate
|
||||
value={asset?.dateAdded}
|
||||
year="numeric"
|
||||
month="short"
|
||||
day="2-digit"
|
||||
hour="numeric"
|
||||
minute="numeric"
|
||||
/>
|
||||
<div className="font-weight-bold mt-3">
|
||||
<FormattedMessage {...messages.fileSizeTitle} />
|
||||
</div>
|
||||
{fileSize}
|
||||
<div className="font-weight-bold border-top mt-3 pt-3">
|
||||
<FormattedMessage {...messages.studioUrlTitle} />
|
||||
</div>
|
||||
<ActionRow>
|
||||
<div style={{ wordBreak: 'break-word' }}>
|
||||
<Truncate lines={1}>
|
||||
{asset?.portableUrl}
|
||||
</Truncate>
|
||||
</div>
|
||||
<ActionRow.Spacer />
|
||||
<IconButton
|
||||
src={ContentCopy}
|
||||
iconAs={Icon}
|
||||
alt={messages.copyStudioUrlTitle.defaultMessage}
|
||||
onClick={() => navigator.clipboard.writeText(asset?.portableUrl)}
|
||||
/>
|
||||
</ActionRow>
|
||||
<div className="font-weight-bold mt-3">
|
||||
<FormattedMessage {...messages.webUrlTitle} />
|
||||
</div>
|
||||
<ActionRow>
|
||||
<div style={{ wordBreak: 'break-word' }}>
|
||||
<Truncate lines={1}>
|
||||
{asset?.externalUrl}
|
||||
</Truncate>
|
||||
</div>
|
||||
<ActionRow.Spacer />
|
||||
<IconButton
|
||||
src={ContentCopy}
|
||||
iconAs={Icon}
|
||||
alt={messages.copyWebUrlTitle.defaultMessage}
|
||||
onClick={() => navigator.clipboard.writeText(asset?.externalUrl)}
|
||||
/>
|
||||
</ActionRow>
|
||||
<ActionRow className=" border-top mt-3 pt-3">
|
||||
<div className="font-weight-bold">
|
||||
<FormattedMessage {...messages.lockFileTitle} />
|
||||
</div>
|
||||
<IconButtonWithTooltip
|
||||
key="lock-file-info"
|
||||
tooltipPlacement="top"
|
||||
tooltipContent={intl.formatMessage(messages.lockFileTooltipContent)}
|
||||
src={InfoOutline}
|
||||
iconAs={Icon}
|
||||
alt="Info"
|
||||
size="inline"
|
||||
/>
|
||||
<ActionRow.Spacer />
|
||||
<CheckboxControl
|
||||
checked={lockedState}
|
||||
onChange={handleLock}
|
||||
aria-label="Checkbox"
|
||||
/>
|
||||
</ActionRow>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
FileInfoAssetSidebar.propTypes = {
|
||||
asset: PropTypes.shape({
|
||||
displayName: PropTypes.string.isRequired,
|
||||
wrapperType: PropTypes.string.isRequired,
|
||||
locked: PropTypes.bool.isRequired,
|
||||
externalUrl: PropTypes.string.isRequired,
|
||||
thumbnail: PropTypes.string,
|
||||
id: PropTypes.string.isRequired,
|
||||
portableUrl: PropTypes.string.isRequired,
|
||||
dateAdded: PropTypes.string.isRequired,
|
||||
fileSize: PropTypes.number.isRequired,
|
||||
usageLocations: PropTypes.arrayOf(PropTypes.string),
|
||||
}).isRequired,
|
||||
handleLockedAsset: PropTypes.func.isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(FileInfoAssetSidebar);
|
||||
186
src/files-and-videos/files-page/FilesAndUploads.jsx
Normal file
186
src/files-and-videos/files-page/FilesAndUploads.jsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { CheckboxFilter } from '@edx/paragon';
|
||||
import Placeholder from '@edx/frontend-lib-content-components';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { useModels, useModel } from '../../generic/model-store';
|
||||
import {
|
||||
addAssetFile,
|
||||
deleteAssetFile,
|
||||
fetchAssets,
|
||||
updateAssetLock,
|
||||
fetchAssetDownload,
|
||||
getUsagePaths,
|
||||
resetErrors,
|
||||
updateAssetOrder,
|
||||
} from '../data/thunks';
|
||||
import messages from './messages';
|
||||
import FilesAndUploadsProvider from './FilesAndUploadsProvider';
|
||||
import getPageHeadTitle from '../../generic/utils';
|
||||
import FileTable from '../FileTable';
|
||||
import EditFileErrors from '../EditFileErrors';
|
||||
import { getFileSizeToClosestByte } from '../data/utils';
|
||||
import ThumbnailColumn from '../table-components/table-custom-columns/ThumbnailColumn';
|
||||
import ActiveColumn from '../table-components/table-custom-columns/ActiveColumn';
|
||||
import AccessColumn from '../table-components/table-custom-columns/AccessColumn';
|
||||
import AssetThumbnail from './AssetThumbnail';
|
||||
|
||||
const FilesAndUploads = ({
|
||||
courseId,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const courseDetails = useModel('courseDetails', courseId);
|
||||
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.heading));
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchAssets(courseId));
|
||||
}, [courseId]);
|
||||
|
||||
const {
|
||||
totalCount,
|
||||
assetIds,
|
||||
loadingStatus,
|
||||
addingStatus: addAssetStatus,
|
||||
deletingStatus: deleteAssetStatus,
|
||||
updatingStatus: updateAssetStatus,
|
||||
usageStatus: usagePathStatus,
|
||||
errors: errorMessages,
|
||||
} = useSelector(state => state.assets);
|
||||
|
||||
const handleAddFile = (file) => dispatch(addAssetFile(courseId, file, totalCount));
|
||||
const handleDeleteFile = (id) => dispatch(deleteAssetFile(courseId, id, totalCount));
|
||||
const handleDownloadFile = (selectedRows) => dispatch(fetchAssetDownload({ selectedRows, courseId }));
|
||||
const handleLockFile = ({ fileId, locked }) => dispatch(updateAssetLock({ courseId, assetId: fileId, locked }));
|
||||
const handleUsagePaths = (asset) => dispatch(getUsagePaths({ asset, courseId }));
|
||||
const handleErrorReset = (error) => dispatch(resetErrors(error));
|
||||
const handleFileOrder = ({ newFileIdOrder, sortType }) => {
|
||||
dispatch(updateAssetOrder(courseId, newFileIdOrder, sortType));
|
||||
};
|
||||
|
||||
const thumbnailPreview = (props) => AssetThumbnail(props);
|
||||
|
||||
const assets = useModels('assets', assetIds);
|
||||
const data = {
|
||||
totalCount,
|
||||
fileIds: assetIds,
|
||||
loadingStatus,
|
||||
usagePathStatus,
|
||||
usageErrorMessages: errorMessages.usageMetrics,
|
||||
};
|
||||
const maxFileSize = 20 * 1048576;
|
||||
|
||||
const activeColumn = {
|
||||
id: 'usageLocations',
|
||||
Header: 'Active',
|
||||
Cell: ({ row }) => ActiveColumn({ row }),
|
||||
};
|
||||
const accessColumn = {
|
||||
id: 'locked',
|
||||
Header: 'Access',
|
||||
Cell: ({ row }) => AccessColumn({ row }),
|
||||
};
|
||||
const thumbnailColumn = {
|
||||
id: 'thumbnail',
|
||||
Header: '',
|
||||
Cell: ({ row }) => ThumbnailColumn({ row, thumbnailPreview }),
|
||||
};
|
||||
const fileSizeColumn = {
|
||||
id: 'fileSize',
|
||||
Header: 'File size',
|
||||
Cell: ({ row }) => {
|
||||
const { fileSize } = row.original;
|
||||
return getFileSizeToClosestByte(fileSize);
|
||||
},
|
||||
};
|
||||
|
||||
const tableColumns = [
|
||||
{ ...thumbnailColumn },
|
||||
{
|
||||
Header: 'File name',
|
||||
accessor: 'displayName',
|
||||
},
|
||||
{ ...fileSizeColumn },
|
||||
{
|
||||
Header: 'Type',
|
||||
accessor: 'wrapperType',
|
||||
Filter: CheckboxFilter,
|
||||
filter: 'includesValue',
|
||||
filterChoices: [
|
||||
{
|
||||
name: 'Code',
|
||||
value: 'code',
|
||||
},
|
||||
{
|
||||
name: 'Images',
|
||||
value: 'image',
|
||||
},
|
||||
{
|
||||
name: 'Documents',
|
||||
value: 'document',
|
||||
},
|
||||
{
|
||||
name: 'Audio',
|
||||
value: 'audio',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ ...activeColumn },
|
||||
{ ...accessColumn },
|
||||
];
|
||||
|
||||
if (loadingStatus === RequestStatus.DENIED) {
|
||||
return (
|
||||
<div data-testid="under-construction-placeholder" className="row justify-contnt-center m-6">
|
||||
<Placeholder />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FilesAndUploadsProvider courseId={courseId}>
|
||||
<main>
|
||||
<div className="p-4">
|
||||
<EditFileErrors
|
||||
resetErrors={handleErrorReset}
|
||||
errorMessages={errorMessages}
|
||||
addFileStatus={addAssetStatus}
|
||||
deleteFileStatus={deleteAssetStatus}
|
||||
updateFileStatus={updateAssetStatus}
|
||||
/>
|
||||
<div className="h2">
|
||||
<FormattedMessage {...messages.heading} />
|
||||
</div>
|
||||
</div>
|
||||
<FileTable
|
||||
{...{
|
||||
courseId,
|
||||
data,
|
||||
handleAddFile,
|
||||
handleDeleteFile,
|
||||
handleDownloadFile,
|
||||
handleLockFile,
|
||||
handleUsagePaths,
|
||||
handleErrorReset,
|
||||
handleFileOrder,
|
||||
tableColumns,
|
||||
maxFileSize,
|
||||
thumbnailPreview,
|
||||
files: assets,
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
</FilesAndUploadsProvider>
|
||||
);
|
||||
};
|
||||
|
||||
FilesAndUploads.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(FilesAndUploads);
|
||||
@@ -16,9 +16,9 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import initializeStore from '../store';
|
||||
import { executeThunk } from '../utils';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import initializeStore from '../../store';
|
||||
import { executeThunk } from '../../utils';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import FilesAndUploads from './FilesAndUploads';
|
||||
import {
|
||||
generateFetchAssetApiResponse,
|
||||
@@ -35,9 +35,9 @@ import {
|
||||
deleteAssetFile,
|
||||
updateAssetLock,
|
||||
getUsagePaths,
|
||||
} from './data/thunks';
|
||||
import { getAssetsUrl } from './data/api';
|
||||
import messages from './messages';
|
||||
} from '../data/thunks';
|
||||
import { getAssetsUrl } from '../data/api';
|
||||
import messages from '../messages';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
@@ -333,7 +333,7 @@ describe('FilesAndUploads', () => {
|
||||
|
||||
axiosMock.onGet(`${getAssetsUrl(courseId)}mOckID1/usage`).reply(201, { usageLocations: ['subsection - unit / block'] });
|
||||
await waitFor(() => {
|
||||
fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle'));
|
||||
fireEvent.click(within(assetMenuButton).getByLabelText('file-menu-toggle'));
|
||||
fireEvent.click(screen.getByText('Info'));
|
||||
executeThunk(getUsagePaths({
|
||||
courseId,
|
||||
@@ -358,7 +358,7 @@ describe('FilesAndUploads', () => {
|
||||
axiosMock.onPut(`${getAssetsUrl(courseId)}mOckID1`).reply(201, { locked: false });
|
||||
axiosMock.onGet(`${getAssetsUrl(courseId)}mOckID1/usage`).reply(201, { usageLocations: [] });
|
||||
await waitFor(() => {
|
||||
fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle'));
|
||||
fireEvent.click(within(assetMenuButton).getByLabelText('file-menu-toggle'));
|
||||
fireEvent.click(screen.getByText('Info'));
|
||||
executeThunk(getUsagePaths({
|
||||
courseId,
|
||||
@@ -390,7 +390,7 @@ describe('FilesAndUploads', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
axiosMock.onPut(`${getAssetsUrl(courseId)}mOckID1`).reply(201, { locked: false });
|
||||
fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle'));
|
||||
fireEvent.click(within(assetMenuButton).getByLabelText('file-menu-toggle'));
|
||||
fireEvent.click(screen.getByText('Unlock'));
|
||||
executeThunk(updateAssetLock({
|
||||
courseId,
|
||||
@@ -412,7 +412,7 @@ describe('FilesAndUploads', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
axiosMock.onPut(`${getAssetsUrl(courseId)}mOckID3`).reply(201, { locked: true });
|
||||
fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle'));
|
||||
fireEvent.click(within(assetMenuButton).getByLabelText('file-menu-toggle'));
|
||||
fireEvent.click(screen.getByText('Lock'));
|
||||
executeThunk(updateAssetLock({
|
||||
courseId,
|
||||
@@ -433,7 +433,7 @@ describe('FilesAndUploads', () => {
|
||||
expect(assetMenuButton).toBeVisible();
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle'));
|
||||
fireEvent.click(within(assetMenuButton).getByLabelText('file-menu-toggle'));
|
||||
fireEvent.click(screen.getByText('Download'));
|
||||
});
|
||||
expect(saveAs).toHaveBeenCalled();
|
||||
@@ -449,7 +449,7 @@ describe('FilesAndUploads', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
axiosMock.onDelete(`${getAssetsUrl(courseId)}mOckID1`).reply(204);
|
||||
fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle'));
|
||||
fireEvent.click(within(assetMenuButton).getByLabelText('file-menu-toggle'));
|
||||
fireEvent.click(screen.getByTestId('open-delete-confirmation-button'));
|
||||
expect(screen.getByText(messages.deleteConfirmationTitle.defaultMessage)).toBeVisible();
|
||||
|
||||
@@ -507,7 +507,7 @@ describe('FilesAndUploads', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
axiosMock.onDelete(`${getAssetsUrl(courseId)}mOckID1`).reply(404);
|
||||
fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle'));
|
||||
fireEvent.click(within(assetMenuButton).getByLabelText('file-menu-toggle'));
|
||||
fireEvent.click(screen.getByTestId('open-delete-confirmation-button'));
|
||||
expect(screen.getByText(messages.deleteConfirmationTitle.defaultMessage)).toBeVisible();
|
||||
|
||||
@@ -534,7 +534,7 @@ describe('FilesAndUploads', () => {
|
||||
|
||||
axiosMock.onGet(`${getAssetsUrl(courseId)}mOckID3/usage`).reply(404);
|
||||
await waitFor(() => {
|
||||
fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle'));
|
||||
fireEvent.click(within(assetMenuButton).getByLabelText('file-menu-toggle'));
|
||||
fireEvent.click(screen.getByText('Info'));
|
||||
executeThunk(getUsagePaths({
|
||||
courseId,
|
||||
@@ -556,7 +556,7 @@ describe('FilesAndUploads', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
axiosMock.onPut(`${getAssetsUrl(courseId)}mOckID3`).reply(404);
|
||||
fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle'));
|
||||
fireEvent.click(within(assetMenuButton).getByLabelText('file-menu-toggle'));
|
||||
fireEvent.click(screen.getByText('Lock'));
|
||||
executeThunk(updateAssetLock({
|
||||
courseId,
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { RequestStatus } from '../../../data/constants';
|
||||
|
||||
export const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
|
||||
3
src/files-and-videos/files-page/index.js
Normal file
3
src/files-and-videos/files-page/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import FilesAndUploads from './FilesAndUploads';
|
||||
|
||||
export default FilesAndUploads;
|
||||
50
src/files-and-videos/files-page/messages.js
Normal file
50
src/files-and-videos/files-page/messages.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: {
|
||||
id: 'course-authoring.files-and-uploads.heading',
|
||||
defaultMessage: 'Files',
|
||||
},
|
||||
thumbnailAltMessage: {
|
||||
id: 'course-authoring.files-and-uploads.thumbnail.alt',
|
||||
defaultMessage: '{displayName} file preview',
|
||||
},
|
||||
copyStudioUrlTitle: {
|
||||
id: 'course-authoring.files-and-uploads.file-info.copyStudioUrl.title',
|
||||
defaultMessage: 'Copy Studio Url',
|
||||
},
|
||||
copyWebUrlTitle: {
|
||||
id: 'course-authoring.files-and-uploads.file-info.copyWebUrl.title',
|
||||
defaultMessage: 'Copy Web Url',
|
||||
},
|
||||
dateAddedTitle: {
|
||||
id: 'course-authoring.files-and-uploads.file-info.dateAdded.title',
|
||||
defaultMessage: 'Date added',
|
||||
},
|
||||
fileSizeTitle: {
|
||||
id: 'course-authoring.files-and-uploads.file-info.fileSize.title',
|
||||
defaultMessage: 'File size',
|
||||
},
|
||||
studioUrlTitle: {
|
||||
id: 'course-authoring.files-and-uploads.file-info.studioUrl.title',
|
||||
defaultMessage: 'Studio URL',
|
||||
},
|
||||
webUrlTitle: {
|
||||
id: 'course-authoring.files-and-uploads.file-info.webUrl.title',
|
||||
defaultMessage: 'Web URL',
|
||||
},
|
||||
lockFileTitle: {
|
||||
id: 'course-authoring.files-and-uploads.file-info.lockFile.title',
|
||||
defaultMessage: 'Lock file',
|
||||
},
|
||||
lockFileTooltipContent: {
|
||||
id: 'course-authoring.files-and-uploads.file-info.lockFile.tooltip.content',
|
||||
defaultMessage: `By default, anyone can access a file you upload if
|
||||
they know the web URL, even if they are not enrolled in your course.
|
||||
You can prevent outside access to a file by locking the file. When
|
||||
you lock a file, the web URL only allows learners who are enrolled
|
||||
in your course and signed in to access the file.`,
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
3
src/files-and-videos/index.js
Normal file
3
src/files-and-videos/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as FilesPage } from './files-page';
|
||||
export { default as VideosPage } from './videos-page';
|
||||
3
src/files-and-videos/index.scss
Normal file
3
src/files-and-videos/index.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
@import "files-and-videos/videos-page/transcript-settings/TranscriptSettings";
|
||||
@import "files-and-videos/videos-page/VideoThumbnail";
|
||||
@import "files-and-videos/table-components/GalleryCard"
|
||||
@@ -1,14 +1,6 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: {
|
||||
id: 'course-authoring.files-and-uploads.heading',
|
||||
defaultMessage: 'Files',
|
||||
},
|
||||
subheading: {
|
||||
id: 'course-authoring.files-and-uploads.subheading',
|
||||
defaultMessage: 'Content',
|
||||
},
|
||||
apiStatusToastMessage: {
|
||||
id: 'course-authoring.files-and-upload.apiStatus.message',
|
||||
defaultMessage: '{actionType} {selectedRowCount} file(s)',
|
||||
@@ -41,34 +33,6 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.files-and-upload.errorAlert.message',
|
||||
defaultMessage: '{message}',
|
||||
},
|
||||
dateAddedTitle: {
|
||||
id: 'course-authoring.files-and-uploads.file-info.dateAdded.title',
|
||||
defaultMessage: 'Date added',
|
||||
},
|
||||
fileSizeTitle: {
|
||||
id: 'course-authoring.files-and-uploads.file-info.fileSize.title',
|
||||
defaultMessage: 'File size',
|
||||
},
|
||||
studioUrlTitle: {
|
||||
id: 'course-authoring.files-and-uploads.file-info.studioUrl.title',
|
||||
defaultMessage: 'Studio URL',
|
||||
},
|
||||
webUrlTitle: {
|
||||
id: 'course-authoring.files-and-uploads.file-info.webUrl.title',
|
||||
defaultMessage: 'Web URL',
|
||||
},
|
||||
lockFileTitle: {
|
||||
id: 'course-authoring.files-and-uploads.file-info.lockFile.title',
|
||||
defaultMessage: 'Lock file',
|
||||
},
|
||||
lockFileTooltipContent: {
|
||||
id: 'course-authoring.files-and-uploads.file-info.lockFile.tooltip.content',
|
||||
defaultMessage: `By default, anyone can access a file you upload if
|
||||
they know the web URL, even if they are not enrolled in your course.
|
||||
You can prevent outside access to a file by locking the file. When
|
||||
you lock a file, the web URL only allows learners who are enrolled
|
||||
in your course and signed in to access the file.`,
|
||||
},
|
||||
usageTitle: {
|
||||
id: 'course-authoring.files-and-uploads.file-info.usage.title',
|
||||
defaultMessage: 'Usage',
|
||||
@@ -105,6 +69,10 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.files-and-uploads.cardMenu.infoTitle',
|
||||
defaultMessage: 'Info',
|
||||
},
|
||||
downloadEncodingsTitle: {
|
||||
id: 'course-authoring.files-and-uploads.cardMenu.downloadEncodingsTitle',
|
||||
defaultMessage: 'Download video list (.csv)',
|
||||
},
|
||||
deleteTitle: {
|
||||
id: 'course-authoring.files-and-uploads.cardMenu.deleteTitle',
|
||||
defaultMessage: 'Delete',
|
||||
108
src/files-and-videos/table-components/GalleryCard.jsx
Normal file
108
src/files-and-videos/table-components/GalleryCard.jsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
ActionRow,
|
||||
Icon,
|
||||
Card,
|
||||
Chip,
|
||||
Truncate,
|
||||
} from '@edx/paragon';
|
||||
import { ClosedCaption } from '@edx/paragon/icons';
|
||||
import FileMenu from '../FileMenu';
|
||||
import FileThumbnail from '../FileThumbnail';
|
||||
|
||||
const GalleryCard = ({
|
||||
className,
|
||||
original,
|
||||
handleBulkDownload,
|
||||
handleLockedFile,
|
||||
handleOpenDeleteConfirmation,
|
||||
handleOpenFileInfo,
|
||||
thumbnailPreview,
|
||||
}) => {
|
||||
const lockFile = () => {
|
||||
const { locked, id } = original;
|
||||
handleLockedFile(id, !locked);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={`${className} w-100 gallery-card`} data-testid={`grid-card-${original.id}`}>
|
||||
<Card.Header
|
||||
className="pr-0 pt-2 pb-2"
|
||||
actions={(
|
||||
<ActionRow>
|
||||
<FileMenu
|
||||
externalUrl={original.externalUrl}
|
||||
handleLock={lockFile}
|
||||
locked={original.locked}
|
||||
openAssetInfo={() => handleOpenFileInfo(original)}
|
||||
portableUrl={original.portableUrl}
|
||||
id={original.id}
|
||||
wrapperType={original.wrapperType}
|
||||
onDownload={() => handleBulkDownload([{
|
||||
original: {
|
||||
id: original.id,
|
||||
displayName:
|
||||
original.displayName,
|
||||
downloadLink: original?.downloadLink,
|
||||
},
|
||||
}])}
|
||||
openDeleteConfirmation={() => handleOpenDeleteConfirmation([{ original }])}
|
||||
/>
|
||||
</ActionRow>
|
||||
)}
|
||||
/>
|
||||
<Card.Section className="pr-3 pl-3 pt-0 pb-0">
|
||||
<div className="row align-items-center justify-content-center m-0">
|
||||
<FileThumbnail
|
||||
thumbnail={original.thumbnail}
|
||||
wrapperType={original.wrapperType}
|
||||
externalUrl={original.externalUrl}
|
||||
displayName={original.displayName}
|
||||
id={original.id}
|
||||
status={original.status}
|
||||
imageSize={{ height: '76px', width: '135.71px' }}
|
||||
thumbnailPreview={thumbnailPreview}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ wordBreak: 'break-word' }}>
|
||||
<Truncate lines={1} className="font-weight-bold mt-2 picture-title">
|
||||
{original.displayName}
|
||||
</Truncate>
|
||||
</div>
|
||||
</Card.Section>
|
||||
<Card.Footer className="p-3 pt-4 row m-0 flex-row-reverse justify-content-between align-items-center">
|
||||
<Chip>
|
||||
{original.wrapperType}
|
||||
</Chip>
|
||||
{original.transcripts?.length > 0 && <Icon size="lg" src={ClosedCaption} className="m-0 text-primary-500" />}
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
GalleryCard.defaultProps = {
|
||||
className: null,
|
||||
};
|
||||
GalleryCard.propTypes = {
|
||||
className: PropTypes.string,
|
||||
original: PropTypes.shape({
|
||||
displayName: PropTypes.string.isRequired,
|
||||
wrapperType: PropTypes.string.isRequired,
|
||||
locked: PropTypes.bool,
|
||||
externalUrl: PropTypes.string,
|
||||
thumbnail: PropTypes.string,
|
||||
id: PropTypes.string.isRequired,
|
||||
portableUrl: PropTypes.string,
|
||||
status: PropTypes.string,
|
||||
transcripts: PropTypes.arrayOf(PropTypes.string),
|
||||
downloadLink: PropTypes.string,
|
||||
}).isRequired,
|
||||
handleBulkDownload: PropTypes.func.isRequired,
|
||||
handleLockedFile: PropTypes.func.isRequired,
|
||||
handleOpenDeleteConfirmation: PropTypes.func.isRequired,
|
||||
handleOpenFileInfo: PropTypes.func.isRequired,
|
||||
thumbnailPreview: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default GalleryCard;
|
||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
||||
import _ from 'lodash';
|
||||
import { PropTypes } from 'prop-types';
|
||||
import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import {
|
||||
ActionRow,
|
||||
Button,
|
||||
@@ -19,6 +20,7 @@ const TableActions = ({
|
||||
handleSort,
|
||||
handleBulkDownload,
|
||||
handleOpenDeleteConfirmation,
|
||||
encodingsDownloadUrl,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
@@ -41,6 +43,14 @@ const TableActions = ({
|
||||
<FormattedMessage {...messages.actionsButtonLabel} />
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
{encodingsDownloadUrl ? (
|
||||
<Dropdown.Item
|
||||
download
|
||||
href={`${getConfig().STUDIO_BASE_URL}${encodingsDownloadUrl}`}
|
||||
>
|
||||
<FormattedMessage {...messages.downloadEncodingsTitle} />
|
||||
</Dropdown.Item>
|
||||
) : null}
|
||||
<Dropdown.Item
|
||||
onClick={() => handleBulkDownload(selectedFlatRows)}
|
||||
disabled={_.isEmpty(selectedFlatRows)}
|
||||
@@ -161,11 +171,11 @@ TableActions.propTypes = {
|
||||
original: PropTypes.shape({
|
||||
displayName: PropTypes.string.isRequired,
|
||||
wrapperType: PropTypes.string.isRequired,
|
||||
locked: PropTypes.bool.isRequired,
|
||||
externalUrl: PropTypes.string.isRequired,
|
||||
locked: PropTypes.bool,
|
||||
externalUrl: PropTypes.string,
|
||||
thumbnail: PropTypes.string,
|
||||
id: PropTypes.string.isRequired,
|
||||
portableUrl: PropTypes.string.isRequired,
|
||||
portableUrl: PropTypes.string,
|
||||
}).isRequired,
|
||||
}),
|
||||
),
|
||||
@@ -174,9 +184,14 @@ TableActions.propTypes = {
|
||||
}).isRequired,
|
||||
handleOpenDeleteConfirmation: PropTypes.func.isRequired,
|
||||
handleBulkDownload: PropTypes.func.isRequired,
|
||||
encodingsDownloadUrl: PropTypes.string,
|
||||
handleSort: PropTypes.func.isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
TableActions.defaultProps = {
|
||||
encodingsDownloadUrl: null,
|
||||
};
|
||||
|
||||
export default injectIntl(TableActions);
|
||||
@@ -1,9 +1,7 @@
|
||||
import GalleryCard from './GalleryCard';
|
||||
import ListCard from './ListCard';
|
||||
import TableActions from './TableActions';
|
||||
|
||||
export {
|
||||
TableActions,
|
||||
GalleryCard,
|
||||
ListCard,
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import { Check } from '@edx/paragon/icons';
|
||||
|
||||
const ActiveColumn = ({ row }) => {
|
||||
const { usageLocations } = row.original;
|
||||
const numOfUsageLocations = usageLocations.length;
|
||||
const numOfUsageLocations = usageLocations?.length;
|
||||
return numOfUsageLocations > 0 ? <Icon src={Check} /> : null;
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ const MoreInfoColumn = ({
|
||||
row,
|
||||
handleLock,
|
||||
handleBulkDownload,
|
||||
handleOpenAssetInfo,
|
||||
handleOpenFileInfo,
|
||||
handleOpenDeleteConfirmation,
|
||||
// injected
|
||||
intl,
|
||||
@@ -36,7 +36,13 @@ const MoreInfoColumn = ({
|
||||
} = row.original;
|
||||
return (
|
||||
<>
|
||||
<IconButton src={MoreHoriz} iconAs={Icon} onClick={toggle} ref={setTarget} />
|
||||
<IconButton
|
||||
src={MoreHoriz}
|
||||
iconAs={Icon}
|
||||
onClick={toggle}
|
||||
ref={setTarget}
|
||||
alt="More info icon button"
|
||||
/>
|
||||
<ModalPopup
|
||||
placement="left"
|
||||
positionRef={target}
|
||||
@@ -111,7 +117,7 @@ const MoreInfoColumn = ({
|
||||
as={Button}
|
||||
variant="tertiary"
|
||||
size="inline"
|
||||
onClick={() => handleOpenAssetInfo(row.original)}
|
||||
onClick={() => handleOpenFileInfo(row.original)}
|
||||
>
|
||||
{intl.formatMessage(messages.infoTitle)}
|
||||
</MenuItem>
|
||||
@@ -135,7 +141,7 @@ const MoreInfoColumn = ({
|
||||
};
|
||||
|
||||
MoreInfoColumn.propTypes = {
|
||||
row: {
|
||||
row: PropTypes.shape({
|
||||
original: {
|
||||
externalUrl: PropTypes.string,
|
||||
locked: PropTypes.bool,
|
||||
@@ -143,13 +149,17 @@ MoreInfoColumn.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
wrapperType: PropTypes.string,
|
||||
}.isRequired,
|
||||
}.isRequired,
|
||||
handleLock: PropTypes.func.isRequired,
|
||||
}).isRequired,
|
||||
handleLock: PropTypes.func,
|
||||
handleBulkDownload: PropTypes.func.isRequired,
|
||||
handleOpenAssetInfo: PropTypes.func.isRequired,
|
||||
handleOpenFileInfo: PropTypes.func.isRequired,
|
||||
handleOpenDeleteConfirmation: PropTypes.func.isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
MoreInfoColumn.defaultProps = {
|
||||
handleLock: null,
|
||||
};
|
||||
|
||||
export default injectIntl(MoreInfoColumn);
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { PropTypes } from 'prop-types';
|
||||
import FileThumbnail from '../../FileThumbnail';
|
||||
|
||||
const ThumbnailColumn = ({ row, thumbnailPreview }) => {
|
||||
const {
|
||||
thumbnail,
|
||||
wrapperType,
|
||||
externalUrl,
|
||||
displayName,
|
||||
id,
|
||||
status,
|
||||
} = row.original;
|
||||
return (
|
||||
<FileThumbnail
|
||||
{...{
|
||||
thumbnail,
|
||||
wrapperType,
|
||||
externalUrl,
|
||||
displayName,
|
||||
id,
|
||||
status,
|
||||
thumbnailPreview,
|
||||
imageSize: { width: '120px', height: '67.5px' },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
ThumbnailColumn.propTypes = {
|
||||
row: {
|
||||
original: {
|
||||
thumbnail: PropTypes.string,
|
||||
wrapperType: PropTypes.string.isRequired,
|
||||
}.isRequired,
|
||||
}.isRequired,
|
||||
thumbnailPreview: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ThumbnailColumn;
|
||||
132
src/files-and-videos/videos-page/VideoThumbnail.jsx
Normal file
132
src/files-and-videos/videos-page/VideoThumbnail.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { VideoFile } from '@edx/paragon/icons';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Icon,
|
||||
Image,
|
||||
} from '@edx/paragon';
|
||||
import FileInput, { useFileInput } from '../FileInput';
|
||||
import messages from './messages';
|
||||
|
||||
const VideoThumbnail = ({
|
||||
thumbnail,
|
||||
displayName,
|
||||
id,
|
||||
imageSize,
|
||||
handleAddThumbnail,
|
||||
videoImageSettings,
|
||||
status,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const fileInputControl = useFileInput({
|
||||
onAddFile: (file) => handleAddThumbnail(file, id),
|
||||
setSelectedRows: () => {},
|
||||
setAddOpen: () => false,
|
||||
});
|
||||
const [thumbnailError, setThumbnailError] = useState(false);
|
||||
const allowThumbnailUpload = videoImageSettings?.videoImageUploadEnabled;
|
||||
|
||||
let addThumbnailMessage = 'Add thumbnail';
|
||||
if (allowThumbnailUpload) {
|
||||
if (thumbnail) {
|
||||
addThumbnailMessage = 'Edit thumbnail';
|
||||
}
|
||||
}
|
||||
const supportedFiles = videoImageSettings?.supportedFileFormats
|
||||
? Object.values(videoImageSettings.supportedFileFormats) : null;
|
||||
let isUploaded = false;
|
||||
|
||||
switch (status) {
|
||||
case 'Ready':
|
||||
isUploaded = true;
|
||||
break;
|
||||
case 'Imported':
|
||||
isUploaded = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const showThumbnail = allowThumbnailUpload && thumbnail && isUploaded;
|
||||
|
||||
return (
|
||||
<div data-testid={`video-thumbnail-${id}`} className="video-thumbnail row justify-content-center align-itmes-center">
|
||||
{allowThumbnailUpload && <div className="thumbnail-overlay" />}
|
||||
{showThumbnail && !thumbnailError ? (
|
||||
<div className="border rounded">
|
||||
<Image
|
||||
style={imageSize}
|
||||
className="m-1 bg-light-300"
|
||||
src={thumbnail}
|
||||
alt={intl.formatMessage(messages.thumbnailAltMessage, { displayName })}
|
||||
onError={() => setThumbnailError(true)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="row justify-content-center align-items-center m-0 border rounded"
|
||||
style={imageSize}
|
||||
>
|
||||
<Icon src={VideoFile} style={{ height: '48px', width: '48px' }} />
|
||||
</div>
|
||||
<div className="status-badge">
|
||||
{!isUploaded && (
|
||||
<Badge variant="light">
|
||||
{status}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{allowThumbnailUpload && (
|
||||
<>
|
||||
<div className="add-thumbnail">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={fileInputControl.click}
|
||||
tabIndex="0"
|
||||
>
|
||||
{addThumbnailMessage}
|
||||
</Button>
|
||||
</div>
|
||||
<FileInput
|
||||
key="video-thumbnail-upload"
|
||||
fileInput={fileInputControl}
|
||||
supportedFileFormats={supportedFiles}
|
||||
allowMultiple={false}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
VideoThumbnail.propTypes = {
|
||||
thumbnail: PropTypes.string,
|
||||
displayName: PropTypes.string.isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
imageSize: PropTypes.shape({
|
||||
width: PropTypes.string,
|
||||
height: PropTypes.string,
|
||||
}).isRequired,
|
||||
handleAddThumbnail: PropTypes.func.isRequired,
|
||||
videoImageSettings: PropTypes.shape({
|
||||
videoImageUploadEnabled: PropTypes.bool.isRequired,
|
||||
supportedFileFormats: PropTypes.shape({}),
|
||||
}).isRequired,
|
||||
status: PropTypes.string.isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
VideoThumbnail.defaultProps = {
|
||||
thumbnail: null,
|
||||
};
|
||||
|
||||
export default injectIntl(VideoThumbnail);
|
||||
61
src/files-and-videos/videos-page/VideoThumbnail.scss
Normal file
61
src/files-and-videos/videos-page/VideoThumbnail.scss
Normal file
@@ -0,0 +1,61 @@
|
||||
.video-thumbnail {
|
||||
position: relative;
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
margin: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-thumbnail .thumbnail-overlay {
|
||||
background: rgba(0 0 0 / .7);
|
||||
position: absolute;
|
||||
height: 99%;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
opacity: 0;
|
||||
-webkit-transition: all .4s ease-in-out 0s;
|
||||
-moz-transition: all .4s ease-in-out 0s;
|
||||
transition: all .4s ease-in-out 0s;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
padding-left: 1em;
|
||||
padding-right: 1em;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
-webkit-transform: translate(-50%, -50%);
|
||||
-moz-transform: translate(-50%, -50%);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.video-thumbnail:hover .thumbnail-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.add-thumbnail {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
padding-left: 1em;
|
||||
padding-right: 1em;
|
||||
width: 100%;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
opacity: 0;
|
||||
-webkit-transform: translate(-50%, -50%);
|
||||
-moz-transform: translate(-50%, -50%);
|
||||
transform: translate(-50%, -50%);
|
||||
-webkit-transition: all .3s ease-in-out 0s;
|
||||
-moz-transition: all .3s ease-in-out 0s;
|
||||
transition: all .3s ease-in-out 0s;
|
||||
}
|
||||
|
||||
.video-thumbnail:hover .add-thumbnail {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
opacity: 1;
|
||||
}
|
||||
227
src/files-and-videos/videos-page/Videos.jsx
Normal file
227
src/files-and-videos/videos-page/Videos.jsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
injectIntl,
|
||||
FormattedMessage,
|
||||
intlShape,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
useToggle,
|
||||
ActionRow,
|
||||
Button,
|
||||
} from '@edx/paragon';
|
||||
import Placeholder from '@edx/frontend-lib-content-components';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { useModels, useModel } from '../../generic/model-store';
|
||||
import {
|
||||
addVideoFile,
|
||||
addVideoThumbnail,
|
||||
deleteVideoFile,
|
||||
fetchVideoDownload,
|
||||
fetchVideos,
|
||||
getUsagePaths,
|
||||
resetErrors,
|
||||
updateVideoOrder,
|
||||
} from './data/thunks';
|
||||
import messages from './messages';
|
||||
import VideosProvider from './VideosProvider';
|
||||
import getPageHeadTitle from '../../generic/utils';
|
||||
import FileTable from '../FileTable';
|
||||
import EditFileErrors from '../EditFileErrors';
|
||||
import ThumbnailColumn from '../table-components/table-custom-columns/ThumbnailColumn';
|
||||
import ActiveColumn from '../table-components/table-custom-columns/ActiveColumn';
|
||||
import StatusColumn from '../table-components/table-custom-columns/StatusColumn';
|
||||
import TranscriptSettings from './transcript-settings';
|
||||
import VideoThumbnail from './VideoThumbnail';
|
||||
import { getFormattedDuration, resampleFile } from './data/utils';
|
||||
import FILES_AND_UPLOAD_TYPE_FILTERS from '../data/constant';
|
||||
|
||||
const Videos = ({
|
||||
courseId,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const [isTranscriptSettingsOpen, openTranscriptSettings, closeTranscriptSettings] = useToggle(false);
|
||||
const courseDetails = useModel('courseDetails', courseId);
|
||||
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.heading));
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchVideos(courseId));
|
||||
}, [courseId]);
|
||||
|
||||
const {
|
||||
totalCount,
|
||||
videoIds,
|
||||
loadingStatus,
|
||||
transcriptStatus,
|
||||
addingStatus: addVideoStatus,
|
||||
deletingStatus: deleteVideoStatus,
|
||||
updatingStatus: updateVideoStatus,
|
||||
usageStatus: usagePathStatus,
|
||||
errors: errorMessages,
|
||||
pageSettings,
|
||||
} = useSelector(state => state.videos);
|
||||
|
||||
const {
|
||||
isVideoTranscriptEnabled,
|
||||
encodingsDownloadUrl,
|
||||
videoUploadMaxFileSize,
|
||||
videoSupportedFileFormats,
|
||||
videoImageSettings,
|
||||
} = pageSettings;
|
||||
|
||||
const supportedFileFormats = { 'video/*': videoSupportedFileFormats || FILES_AND_UPLOAD_TYPE_FILTERS.video };
|
||||
|
||||
const handleAddFile = (file) => dispatch(addVideoFile(courseId, file));
|
||||
const handleDeleteFile = (id) => dispatch(deleteVideoFile(courseId, id, totalCount));
|
||||
const handleDownloadFile = (selectedRows) => dispatch(fetchVideoDownload({ selectedRows }));
|
||||
const handleUsagePaths = (video) => dispatch(getUsagePaths({ video, courseId }));
|
||||
const handleErrorReset = (error) => dispatch(resetErrors(error));
|
||||
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,
|
||||
totalCount,
|
||||
fileIds: videoIds,
|
||||
loadingStatus,
|
||||
usagePathStatus,
|
||||
usageErrorMessages: errorMessages.usageMetrics,
|
||||
};
|
||||
const thumbnailPreview = (props) => VideoThumbnail({ ...props, handleAddThumbnail, videoImageSettings });
|
||||
const maxFileSize = videoUploadMaxFileSize * 1073741824;
|
||||
const transcriptColumn = {
|
||||
id: 'transcripts',
|
||||
Header: 'Transcript',
|
||||
Cell: ({ row }) => {
|
||||
const { transcripts } = row.original;
|
||||
const numOfTranscripts = transcripts?.length;
|
||||
return numOfTranscripts > 0 ? `(${numOfTranscripts}) available` : null;
|
||||
},
|
||||
};
|
||||
const activeColumn = {
|
||||
id: 'usageLocations',
|
||||
Header: 'Active',
|
||||
Cell: ({ row }) => ActiveColumn({ row }),
|
||||
};
|
||||
const durationColumn = {
|
||||
id: 'duration',
|
||||
Header: 'Video length',
|
||||
Cell: ({ row }) => {
|
||||
const { duration } = row.original;
|
||||
return getFormattedDuration(duration);
|
||||
},
|
||||
};
|
||||
const processingStatusColumn = {
|
||||
id: 'status',
|
||||
Header: '',
|
||||
Cell: ({ row }) => StatusColumn({ row }),
|
||||
};
|
||||
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">
|
||||
<Placeholder />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<VideosProvider courseId={courseId}>
|
||||
<main>
|
||||
<div className="p-4">
|
||||
<EditFileErrors
|
||||
resetErrors={handleErrorReset}
|
||||
errorMessages={errorMessages}
|
||||
addFileStatus={addVideoStatus}
|
||||
deleteFileStatus={deleteVideoStatus}
|
||||
updateFileStatus={updateVideoStatus}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
{isVideoTranscriptEnabled ? (
|
||||
<TranscriptSettings
|
||||
{...{
|
||||
isTranscriptSettingsOpen,
|
||||
closeTranscriptSettings,
|
||||
handleErrorReset,
|
||||
errorMessages,
|
||||
transcriptStatus,
|
||||
courseId,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<FileTable
|
||||
{...{
|
||||
courseId,
|
||||
data,
|
||||
handleAddFile,
|
||||
handleDeleteFile,
|
||||
handleDownloadFile,
|
||||
handleUsagePaths,
|
||||
handleErrorReset,
|
||||
handleFileOrder,
|
||||
tableColumns,
|
||||
maxFileSize,
|
||||
thumbnailPreview,
|
||||
files: videos,
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
</VideosProvider>
|
||||
);
|
||||
};
|
||||
|
||||
Videos.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(Videos);
|
||||
632
src/files-and-videos/videos-page/Videos.test.jsx
Normal file
632
src/files-and-videos/videos-page/Videos.test.jsx
Normal file
@@ -0,0 +1,632 @@
|
||||
import {
|
||||
render,
|
||||
act,
|
||||
fireEvent,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import { executeThunk } from '../../utils';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import Videos from './Videos';
|
||||
import {
|
||||
generateFetchVideosApiResponse,
|
||||
generateEmptyApiResponse,
|
||||
generateNewVideoApiResponse,
|
||||
generateAddVideoApiResponse,
|
||||
getStatusValue,
|
||||
courseId,
|
||||
initialState,
|
||||
} from './factories/mockApiResponses';
|
||||
|
||||
import {
|
||||
fetchVideos,
|
||||
addVideoFile,
|
||||
deleteVideoFile,
|
||||
getUsagePaths,
|
||||
addVideoThumbnail,
|
||||
fetchVideoDownload,
|
||||
} from './data/thunks';
|
||||
import { getVideosUrl, getCoursVideosApiUrl, getApiBaseUrl } from './data/api';
|
||||
import videoMessages from './messages';
|
||||
import messages from '../messages';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
let file;
|
||||
jest.mock('file-saver');
|
||||
|
||||
const renderComponent = () => {
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<Videos courseId={courseId} />
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
const mockStore = async (
|
||||
status,
|
||||
) => {
|
||||
const fetchVideosUrl = getVideosUrl(courseId);
|
||||
axiosMock.onGet(fetchVideosUrl).reply(getStatusValue(status), generateFetchVideosApiResponse());
|
||||
await executeThunk(fetchVideos(courseId), store.dispatch);
|
||||
};
|
||||
|
||||
const emptyMockStore = async (status) => {
|
||||
const fetchVideosUrl = getVideosUrl(courseId);
|
||||
axiosMock.onGet(fetchVideosUrl).reply(getStatusValue(status), generateEmptyApiResponse());
|
||||
await executeThunk(fetchVideos(courseId), store.dispatch);
|
||||
};
|
||||
|
||||
describe('FilesAndUploads', () => {
|
||||
describe('empty state', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore({
|
||||
...initialState,
|
||||
videos: {
|
||||
...initialState.videos,
|
||||
videoIds: [],
|
||||
},
|
||||
models: {},
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
file = new File(['(⌐□_□)'], 'download.mp4', { type: 'video/mp4' });
|
||||
});
|
||||
|
||||
it('should return placeholder component', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.DENIED);
|
||||
expect(screen.getByTestId('under-construction-placeholder')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should not render transcript settings button', async () => {
|
||||
renderComponent();
|
||||
await emptyMockStore(RequestStatus.SUCCESSFUL);
|
||||
expect(screen.queryByText(videoMessages.transcriptSettingsButtonLabel.defaultMessage));
|
||||
});
|
||||
|
||||
it('should have Video uploads title', async () => {
|
||||
renderComponent();
|
||||
await emptyMockStore(RequestStatus.SUCCESSFUL);
|
||||
expect(screen.getByText(videoMessages.heading.defaultMessage)).toBeVisible();
|
||||
});
|
||||
|
||||
it('should render dropzone', async () => {
|
||||
renderComponent();
|
||||
await emptyMockStore(RequestStatus.SUCCESSFUL);
|
||||
expect(screen.getByTestId('files-dropzone')).toBeVisible();
|
||||
|
||||
expect(screen.queryByTestId('files-data-table')).toBeNull();
|
||||
});
|
||||
|
||||
it('should upload a single file', async () => {
|
||||
renderComponent();
|
||||
await emptyMockStore(RequestStatus.SUCCESSFUL);
|
||||
const dropzone = screen.getByTestId('files-dropzone');
|
||||
await act(async () => {
|
||||
const mockResponseData = { status: '200', ok: true, blob: () => 'Data' };
|
||||
const mockFetchResponse = Promise.resolve(mockResponseData);
|
||||
global.fetch = jest.fn().mockImplementation(() => mockFetchResponse);
|
||||
|
||||
axiosMock.onPost(getCoursVideosApiUrl(courseId)).reply(204, generateNewVideoApiResponse());
|
||||
axiosMock.onGet(getCoursVideosApiUrl(courseId)).reply(200, generateAddVideoApiResponse());
|
||||
|
||||
Object.defineProperty(dropzone, 'files', {
|
||||
value: [file],
|
||||
});
|
||||
fireEvent.drop(dropzone);
|
||||
await executeThunk(addVideoFile(courseId, file), store.dispatch);
|
||||
});
|
||||
const addStatus = store.getState().videos.addingStatus;
|
||||
expect(addStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
|
||||
expect(screen.queryByTestId('files-dropzone')).toBeNull();
|
||||
|
||||
expect(screen.getByTestId('files-data-table')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('valid videos', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore(initialState);
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
file = new File(['(⌐□_□)'], 'download.png', { type: 'image/png' });
|
||||
});
|
||||
|
||||
describe('table view', () => {
|
||||
it('should render transcript settings button', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
const transcriptSettingsButton = screen.getByText(videoMessages.transcriptSettingsButtonLabel.defaultMessage);
|
||||
expect(transcriptSettingsButton).toBeVisible();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(transcriptSettingsButton);
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText('close settings')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should render table with gallery card', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
expect(screen.getByTestId('files-data-table')).toBeVisible();
|
||||
|
||||
expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should switch table to list view', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
expect(screen.getByTestId('files-data-table')).toBeVisible();
|
||||
|
||||
expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible();
|
||||
|
||||
expect(screen.queryByRole('table')).toBeNull();
|
||||
|
||||
const listButton = screen.getByLabelText('List');
|
||||
await act(async () => {
|
||||
fireEvent.click(listButton);
|
||||
});
|
||||
expect(screen.queryByTestId('grid-card-mOckID1')).toBeNull();
|
||||
|
||||
expect(screen.getByRole('table')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should update video thumbnail', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
axiosMock.onPost(`${getApiBaseUrl()}/video_images/${courseId}/mOckID1`).reply(200, { image_url: 'url' });
|
||||
const addThumbnailButton = screen.getByTestId('video-thumbnail-mOckID1');
|
||||
const thumbnail = new File(['test'], 'sOMEUrl.jpg', { type: 'image/jpg' });
|
||||
await act(async () => {
|
||||
fireEvent.click(addThumbnailButton);
|
||||
await executeThunk(addVideoThumbnail({ file: thumbnail, videoId: 'mOckID1', courseId }), store.dispatch);
|
||||
});
|
||||
const updateStatus = store.getState().videos.updatingStatus;
|
||||
expect(updateStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
});
|
||||
});
|
||||
|
||||
describe('table actions', () => {
|
||||
it('should upload a single file', async () => {
|
||||
renderComponent();
|
||||
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(getCoursVideosApiUrl(courseId)).reply(204, generateNewVideoApiResponse());
|
||||
axiosMock.onGet(getCoursVideosApiUrl(courseId)).reply(200, generateAddVideoApiResponse());
|
||||
|
||||
const addFilesButton = screen.getAllByLabelText('file-input')[3];
|
||||
await act(async () => {
|
||||
userEvent.upload(addFilesButton, file);
|
||||
await executeThunk(addVideoFile(courseId, file), store.dispatch);
|
||||
});
|
||||
const addStatus = store.getState().videos.addingStatus;
|
||||
expect(addStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
});
|
||||
|
||||
it('should have disabled action buttons', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage);
|
||||
expect(actionsButton).toBeVisible();
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(actionsButton);
|
||||
});
|
||||
expect(screen.getByText(messages.downloadTitle.defaultMessage).closest('a')).toHaveClass('disabled');
|
||||
|
||||
expect(screen.getByText(messages.deleteTitle.defaultMessage).closest('a')).toHaveClass('disabled');
|
||||
});
|
||||
|
||||
it('delete button should be enabled and delete selected file', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
const selectCardButton = screen.getAllByTestId('datatable-select-column-checkbox-cell')[0];
|
||||
fireEvent.click(selectCardButton);
|
||||
const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage);
|
||||
expect(actionsButton).toBeVisible();
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(actionsButton);
|
||||
});
|
||||
const deleteButton = screen.getByText(messages.deleteTitle.defaultMessage).closest('a');
|
||||
expect(deleteButton).not.toHaveClass('disabled');
|
||||
|
||||
axiosMock.onDelete(`${getCoursVideosApiUrl(courseId)}/mOckID1`).reply(204);
|
||||
|
||||
fireEvent.click(deleteButton);
|
||||
expect(screen.getByText(messages.deleteConfirmationTitle.defaultMessage)).toBeVisible();
|
||||
await act(async () => {
|
||||
userEvent.click(deleteButton);
|
||||
});
|
||||
|
||||
// Wait for the delete confirmation button to appear
|
||||
const confirmDeleteButton = await screen.findByRole('button', {
|
||||
name: messages.deleteFileButtonLabel.defaultMessage,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
userEvent.click(confirmDeleteButton);
|
||||
});
|
||||
|
||||
expect(screen.queryByText(messages.deleteConfirmationTitle.defaultMessage)).toBeNull();
|
||||
|
||||
// Check if the video is deleted in the store and UI
|
||||
const deleteStatus = store.getState().videos.deletingStatus;
|
||||
expect(deleteStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
expect(screen.queryByTestId('grid-card-mOckID1')).toBeNull();
|
||||
});
|
||||
|
||||
it('download button should be enabled and download single selected file', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
const selectCardButton = screen.getAllByTestId('datatable-select-column-checkbox-cell')[0];
|
||||
fireEvent.click(selectCardButton);
|
||||
const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage);
|
||||
expect(actionsButton).toBeVisible();
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(actionsButton);
|
||||
});
|
||||
const downloadButton = screen.getByText(messages.downloadTitle.defaultMessage).closest('a');
|
||||
expect(downloadButton).not.toHaveClass('disabled');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(downloadButton);
|
||||
});
|
||||
|
||||
const updateStatus = store.getState().videos.updatingStatus;
|
||||
expect(updateStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
});
|
||||
|
||||
it('download button should be enabled and download multiple selected files', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
const selectCardButtons = screen.getAllByTestId('datatable-select-column-checkbox-cell');
|
||||
fireEvent.click(selectCardButtons[0]);
|
||||
fireEvent.click(selectCardButtons[1]);
|
||||
const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage);
|
||||
expect(actionsButton).toBeVisible();
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(actionsButton);
|
||||
});
|
||||
axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1`).reply(200, { download_link: 'http://download.org' });
|
||||
axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID5`).reply(200, { download_link: 'http://download.org' });
|
||||
|
||||
const downloadButton = screen.getByText(messages.downloadTitle.defaultMessage).closest('a');
|
||||
expect(downloadButton).not.toHaveClass('disabled');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(downloadButton);
|
||||
});
|
||||
|
||||
const updateStatus = store.getState().videos.updatingStatus;
|
||||
expect(updateStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
});
|
||||
|
||||
it('sort button should be enabled and sort files by name', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
const sortsButton = screen.getByText(messages.sortButtonLabel.defaultMessage);
|
||||
expect(sortsButton).toBeVisible();
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(sortsButton);
|
||||
expect(screen.getByText(messages.sortModalTitleLabel.defaultMessage)).toBeVisible();
|
||||
});
|
||||
|
||||
const sortNameAscendingButton = screen.getByText(messages.sortByNameAscending.defaultMessage);
|
||||
fireEvent.click(sortNameAscendingButton);
|
||||
fireEvent.click(screen.getByText(messages.applySortButton.defaultMessage));
|
||||
expect(screen.queryByText(messages.sortModalTitleLabel.defaultMessage)).toBeNull();
|
||||
});
|
||||
|
||||
it('sort button should be enabled and sort files by file size', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
const sortsButton = screen.getByText(messages.sortButtonLabel.defaultMessage);
|
||||
expect(sortsButton).toBeVisible();
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(sortsButton);
|
||||
expect(screen.getByText(messages.sortModalTitleLabel.defaultMessage)).toBeVisible();
|
||||
});
|
||||
|
||||
const sortBySizeDescendingButton = screen.getByText(messages.sortBySizeDescending.defaultMessage);
|
||||
fireEvent.click(sortBySizeDescendingButton);
|
||||
fireEvent.click(screen.getByText(messages.applySortButton.defaultMessage));
|
||||
expect(screen.queryByText(messages.sortModalTitleLabel.defaultMessage)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('card menu actions', () => {
|
||||
it('should open video info', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible();
|
||||
|
||||
const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1');
|
||||
expect(videoMenuButton).toBeVisible();
|
||||
|
||||
axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1/usage`)
|
||||
.reply(201, { usageLocations: ['subsection - unit / block'] });
|
||||
await waitFor(() => {
|
||||
fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle'));
|
||||
fireEvent.click(screen.getByText('Info'));
|
||||
});
|
||||
|
||||
expect(screen.getByText(messages.infoTitle.defaultMessage)).toBeVisible();
|
||||
|
||||
const { usageStatus } = store.getState().videos;
|
||||
|
||||
expect(usageStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
|
||||
expect(screen.getByText('subsection - unit / block')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should open video info modal and show info tab', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible();
|
||||
const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1');
|
||||
expect(videoMenuButton).toBeVisible();
|
||||
|
||||
axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1/usage`).reply(201, { usageLocations: [] });
|
||||
await waitFor(() => {
|
||||
fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle'));
|
||||
fireEvent.click(screen.getByText('Info'));
|
||||
});
|
||||
|
||||
expect(screen.getByText(messages.usageNotInUseMessage.defaultMessage)).toBeVisible();
|
||||
|
||||
const infoTab = screen.getAllByRole('tab')[0];
|
||||
expect(infoTab).toBeVisible();
|
||||
|
||||
expect(infoTab).toHaveClass('active');
|
||||
});
|
||||
|
||||
it('should open video info modal and show transcript tab', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible();
|
||||
const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1');
|
||||
expect(videoMenuButton).toBeVisible();
|
||||
|
||||
axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1/usage`).reply(201, { usageLocations: [] });
|
||||
await waitFor(() => {
|
||||
fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle'));
|
||||
fireEvent.click(screen.getByText('Info'));
|
||||
});
|
||||
|
||||
expect(screen.getByText(messages.usageNotInUseMessage.defaultMessage)).toBeVisible();
|
||||
|
||||
const transcriptTab = screen.getAllByRole('tab')[1];
|
||||
await act(async () => {
|
||||
fireEvent.click(transcriptTab);
|
||||
});
|
||||
expect(transcriptTab).toBeVisible();
|
||||
|
||||
expect(transcriptTab).toHaveClass('active');
|
||||
});
|
||||
|
||||
it('download button should download file', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible();
|
||||
|
||||
const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1');
|
||||
expect(videoMenuButton).toBeVisible();
|
||||
|
||||
axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1`).reply(200, { download_link: 'test' });
|
||||
await waitFor(() => {
|
||||
fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle'));
|
||||
fireEvent.click(screen.getByText('Download'));
|
||||
});
|
||||
|
||||
const updateStatus = store.getState().videos.updatingStatus;
|
||||
|
||||
expect(updateStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
});
|
||||
|
||||
it('delete button should delete file', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible();
|
||||
|
||||
const fileMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1');
|
||||
expect(fileMenuButton).toBeVisible();
|
||||
|
||||
await waitFor(() => {
|
||||
axiosMock.onDelete(`${getCoursVideosApiUrl(courseId)}/mOckID1`).reply(204);
|
||||
fireEvent.click(within(fileMenuButton).getByLabelText('file-menu-toggle'));
|
||||
fireEvent.click(screen.getByTestId('open-delete-confirmation-button'));
|
||||
expect(screen.getByText(messages.deleteConfirmationTitle.defaultMessage)).toBeVisible();
|
||||
|
||||
fireEvent.click(screen.getByText(messages.deleteFileButtonLabel.defaultMessage));
|
||||
expect(screen.queryByText(messages.deleteConfirmationTitle.defaultMessage)).toBeNull();
|
||||
|
||||
executeThunk(deleteVideoFile(courseId, 'mOckID1', 5), store.dispatch);
|
||||
});
|
||||
const deleteStatus = store.getState().videos.deletingStatus;
|
||||
expect(deleteStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
|
||||
expect(screen.queryByTestId('grid-card-mOckID1')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('api errors', () => {
|
||||
it('invalid file size should show error', async () => {
|
||||
const errorMessage = 'File download.png exceeds maximum size of 5 GB.';
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
axiosMock.onPost(getCoursVideosApiUrl(courseId)).reply(413, { error: errorMessage });
|
||||
const addFilesButton = screen.getAllByLabelText('file-input')[3];
|
||||
await act(async () => {
|
||||
userEvent.upload(addFilesButton, file);
|
||||
await executeThunk(addVideoFile(courseId, file), store.dispatch);
|
||||
});
|
||||
const addStatus = store.getState().videos.addingStatus;
|
||||
expect(addStatus).toEqual(RequestStatus.FAILED);
|
||||
|
||||
expect(screen.getByText('Error')).toBeVisible();
|
||||
});
|
||||
|
||||
it('404 add file should show error', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
axiosMock.onPost(getCoursVideosApiUrl(courseId)).reply(404);
|
||||
const addFilesButton = screen.getAllByLabelText('file-input')[3];
|
||||
await act(async () => {
|
||||
userEvent.upload(addFilesButton, file);
|
||||
await executeThunk(addVideoFile(courseId, file), store.dispatch);
|
||||
});
|
||||
const addStatus = store.getState().videos.addingStatus;
|
||||
expect(addStatus).toEqual(RequestStatus.FAILED);
|
||||
|
||||
expect(screen.getByText('Error')).toBeVisible();
|
||||
});
|
||||
|
||||
it('404 add thumbnail should show error', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
axiosMock.onPost(`${getApiBaseUrl()}/video_images/${courseId}/mOckID1`).reply(404);
|
||||
const addThumbnailButton = screen.getByTestId('video-thumbnail-mOckID1');
|
||||
const thumbnail = new File(['test'], 'sOMEUrl.jpg', { type: 'image/jpg' });
|
||||
await act(async () => {
|
||||
fireEvent.click(addThumbnailButton);
|
||||
await executeThunk(addVideoThumbnail({ file: thumbnail, videoId: 'mOckID1', courseId }), store.dispatch);
|
||||
});
|
||||
const updateStatus = store.getState().videos.updatingStatus;
|
||||
expect(updateStatus).toEqual(RequestStatus.FAILED);
|
||||
|
||||
expect(screen.getByText('Error')).toBeVisible();
|
||||
});
|
||||
|
||||
it('404 upload file to server should show error', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
const mockResponseData = { status: '404', ok: false, blob: () => 'Data' };
|
||||
const mockFetchResponse = Promise.reject(mockResponseData);
|
||||
global.fetch = jest.fn().mockImplementation(() => mockFetchResponse);
|
||||
|
||||
axiosMock.onPost(getCoursVideosApiUrl(courseId)).reply(204, generateNewVideoApiResponse());
|
||||
axiosMock.onGet(getCoursVideosApiUrl(courseId)).reply(200, generateAddVideoApiResponse());
|
||||
const addFilesButton = screen.getAllByLabelText('file-input')[3];
|
||||
await act(async () => {
|
||||
userEvent.upload(addFilesButton, file);
|
||||
await executeThunk(addVideoFile(courseId, file), store.dispatch);
|
||||
});
|
||||
const addStatus = store.getState().videos.addingStatus;
|
||||
expect(addStatus).toEqual(RequestStatus.FAILED);
|
||||
|
||||
expect(screen.getByText('Error')).toBeVisible();
|
||||
});
|
||||
|
||||
it('404 delete should show error', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible();
|
||||
|
||||
const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1');
|
||||
expect(videoMenuButton).toBeVisible();
|
||||
|
||||
await waitFor(() => {
|
||||
axiosMock.onDelete(`${getCoursVideosApiUrl(courseId)}/mOckID1`).reply(404);
|
||||
fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle'));
|
||||
fireEvent.click(screen.getByTestId('open-delete-confirmation-button'));
|
||||
expect(screen.getByText(messages.deleteConfirmationTitle.defaultMessage)).toBeVisible();
|
||||
|
||||
fireEvent.click(screen.getByText(messages.deleteFileButtonLabel.defaultMessage));
|
||||
expect(screen.queryByText(messages.deleteConfirmationTitle.defaultMessage)).toBeNull();
|
||||
|
||||
executeThunk(deleteVideoFile(courseId, 'mOckID1', 5), store.dispatch);
|
||||
});
|
||||
const deleteStatus = store.getState().videos.deletingStatus;
|
||||
expect(deleteStatus).toEqual(RequestStatus.FAILED);
|
||||
|
||||
expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible();
|
||||
|
||||
expect(screen.getByText('Error')).toBeVisible();
|
||||
});
|
||||
|
||||
it('404 usage path fetch should show error', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
expect(screen.getByTestId('grid-card-mOckID3')).toBeVisible();
|
||||
|
||||
const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID3');
|
||||
expect(videoMenuButton).toBeVisible();
|
||||
|
||||
axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID3/usage`).reply(404);
|
||||
await waitFor(() => {
|
||||
fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle'));
|
||||
fireEvent.click(screen.getByText('Info'));
|
||||
executeThunk(getUsagePaths({
|
||||
courseId,
|
||||
video: { id: 'mOckID3', displayName: 'mOckID3' },
|
||||
}), store.dispatch);
|
||||
});
|
||||
const { usageStatus } = store.getState().videos;
|
||||
expect(usageStatus).toEqual(RequestStatus.FAILED);
|
||||
});
|
||||
|
||||
it('multiple video files fetch failure should show error', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
const selectCardButtons = screen.getAllByTestId('datatable-select-column-checkbox-cell');
|
||||
fireEvent.click(selectCardButtons[0]);
|
||||
fireEvent.click(selectCardButtons[2]);
|
||||
const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage);
|
||||
expect(actionsButton).toBeVisible();
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(actionsButton);
|
||||
});
|
||||
const downloadButton = screen.getByText(messages.downloadTitle.defaultMessage).closest('a');
|
||||
expect(downloadButton).not.toHaveClass('disabled');
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(downloadButton);
|
||||
executeThunk(fetchVideoDownload([{ original: { displayName: 'mOckID1', id: '2' } }]), store.dispatch);
|
||||
});
|
||||
|
||||
const updateStatus = store.getState().videos.updatingStatus;
|
||||
expect(updateStatus).toEqual(RequestStatus.FAILED);
|
||||
|
||||
expect(screen.getByText('Error')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
25
src/files-and-videos/videos-page/VideosProvider.jsx
Normal file
25
src/files-and-videos/videos-page/VideosProvider.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export const VideosContext = React.createContext({});
|
||||
|
||||
const VideosProvider = ({ courseId, children }) => {
|
||||
const contextValue = useMemo(() => ({
|
||||
courseId,
|
||||
path: `/course/${courseId}/videos`,
|
||||
}), []);
|
||||
return (
|
||||
<VideosContext.Provider
|
||||
value={contextValue}
|
||||
>
|
||||
{children}
|
||||
</VideosContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
VideosProvider.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export default VideosProvider;
|
||||
238
src/files-and-videos/videos-page/data/api.js
Normal file
238
src/files-and-videos/videos-page/data/api.js
Normal file
@@ -0,0 +1,238 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { camelCaseObject, ensureConfig, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import saveAs from 'file-saver';
|
||||
import { isEmpty } from 'lodash';
|
||||
|
||||
ensureConfig([
|
||||
'STUDIO_BASE_URL',
|
||||
], 'Course Apps API service');
|
||||
|
||||
export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
export const getVideosUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/videos/${courseId}`;
|
||||
export const getCoursVideosApiUrl = (courseId) => `${getApiBaseUrl()}/videos/${courseId}`;
|
||||
|
||||
/**
|
||||
* Fetches the course custom pages for provided course
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<[{}]>}
|
||||
*/
|
||||
export async function getVideos(courseId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getVideosUrl(courseId));
|
||||
const { video_transcript_settings: videoTranscriptSettings } = data;
|
||||
const { transcription_plans: transcriptionPlans } = videoTranscriptSettings;
|
||||
return {
|
||||
...camelCaseObject(data),
|
||||
videoTranscriptSettings: {
|
||||
...camelCaseObject(videoTranscriptSettings),
|
||||
transcriptionPlans,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the course custom pages for provided course
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<[{}]>}
|
||||
*/
|
||||
export async function fetchVideoList(courseId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCoursVideosApiUrl(courseId));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
export async function deleteTranscript({ videoId, language, apiUrl }) {
|
||||
await getAuthenticatedHttpClient()
|
||||
.delete(`${getApiBaseUrl()}${apiUrl}/${videoId}/${language}`);
|
||||
}
|
||||
|
||||
export async function downloadTranscript({
|
||||
videoId,
|
||||
language,
|
||||
apiUrl,
|
||||
filename,
|
||||
}) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(`${getApiBaseUrl()}${apiUrl}?edx_video_id=${videoId}&language_code=${language}`);
|
||||
const file = new Blob([data], { type: 'text/plain;charset=utf-8' });
|
||||
saveAs(file, filename);
|
||||
}
|
||||
|
||||
export async function uploadTranscript({
|
||||
videoId,
|
||||
newLanguage,
|
||||
apiUrl,
|
||||
file,
|
||||
language,
|
||||
}) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('edx_video_id', videoId);
|
||||
formData.append('language_code', language);
|
||||
formData.append('new_language_code', newLanguage);
|
||||
await getAuthenticatedHttpClient().post(`${getApiBaseUrl()}${apiUrl}`, formData);
|
||||
}
|
||||
|
||||
export async function getDownload(selectedRows) {
|
||||
const downloadErrors = [];
|
||||
if (selectedRows?.length > 0) {
|
||||
await Promise.allSettled(
|
||||
selectedRows.map(async row => {
|
||||
try {
|
||||
const video = row.original;
|
||||
const { downloadLink } = video;
|
||||
if (!isEmpty(downloadLink)) {
|
||||
saveAs(downloadLink, video.displayName);
|
||||
} else {
|
||||
downloadErrors.push(`Cannot find download file for ${video?.displayName}.`);
|
||||
}
|
||||
} catch (error) {
|
||||
downloadErrors.push('Failed to download video.');
|
||||
}
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
downloadErrors.push('No files were selected to download.');
|
||||
}
|
||||
return downloadErrors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch where a video is used in a course.
|
||||
* @param {blockId} courseId Course ID for the course to operate on
|
||||
|
||||
*/
|
||||
export async function getVideoUsagePaths({ courseId, videoId }) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(`${getVideosUrl(courseId)}/${videoId}/usage`);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete video from course.
|
||||
* @param {blockId} courseId Course ID for the course to operate on
|
||||
|
||||
*/
|
||||
export async function deleteVideo(courseId, videoId) {
|
||||
await getAuthenticatedHttpClient()
|
||||
.delete(`${getCoursVideosApiUrl(courseId)}/${videoId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add thumbnail to video.
|
||||
* @param {blockId} courseId Course ID for the course to operate on
|
||||
|
||||
*/
|
||||
export async function addThumbnail({ courseId, videoId, file }) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(`${getApiBaseUrl()}/video_images/${courseId}/${videoId}`, formData);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add video to course.
|
||||
* @param {blockId} courseId Course ID for the course to operate on
|
||||
|
||||
*/
|
||||
export async function addVideo(courseId, file) {
|
||||
const postJson = {
|
||||
files: [{ file_name: file.name, content_type: file.type }],
|
||||
};
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCoursVideosApiUrl(courseId), postJson);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
export async function uploadVideo(
|
||||
courseId,
|
||||
uploadUrl,
|
||||
uploadFile,
|
||||
edxVideoId,
|
||||
) {
|
||||
const formData = new FormData();
|
||||
formData.append('uploaded-file', uploadFile);
|
||||
const uploadErrors = [];
|
||||
await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: formData,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
.then(async () => {
|
||||
await getAuthenticatedHttpClient()
|
||||
.post(getCoursVideosApiUrl(courseId), [{
|
||||
edxVideoId,
|
||||
message: 'Upload completed',
|
||||
status: 'upload_completed',
|
||||
}]);
|
||||
})
|
||||
.catch(async () => {
|
||||
uploadErrors.push(`Failed to upload ${uploadFile.name} to server.`);
|
||||
await getAuthenticatedHttpClient()
|
||||
.post(getCoursVideosApiUrl(courseId), [{
|
||||
edxVideoId,
|
||||
message: 'Upload failed',
|
||||
status: 'upload_failed',
|
||||
}]);
|
||||
});
|
||||
return uploadErrors;
|
||||
}
|
||||
|
||||
export async function deleteTranscriptPreferences(courseId) {
|
||||
await getAuthenticatedHttpClient().delete(`${getApiBaseUrl()}/transcript_preferences/${courseId}`);
|
||||
}
|
||||
|
||||
export async function setTranscriptPreferences(courseId, preferences) {
|
||||
const {
|
||||
cielo24Fidelity,
|
||||
cielo24Turnaround,
|
||||
global,
|
||||
preferredLanguages,
|
||||
provider,
|
||||
threePlayTurnaround,
|
||||
videoSourceLanguage,
|
||||
} = preferences;
|
||||
const postJson = {
|
||||
cielo24_fideltiy: cielo24Fidelity?.toUpperCase(),
|
||||
cielo24_turnaround: cielo24Turnaround,
|
||||
global,
|
||||
preferred_languages: preferredLanguages,
|
||||
provider,
|
||||
video_source_language: videoSourceLanguage,
|
||||
three_play_turnaround: threePlayTurnaround,
|
||||
};
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(`${getApiBaseUrl()}/transcript_preferences/${courseId}`, postJson);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
export async function setTranscriptCredentials(courseId, formFields) {
|
||||
const {
|
||||
apiKey,
|
||||
global,
|
||||
provider,
|
||||
...otherFields
|
||||
} = formFields;
|
||||
const postJson = {
|
||||
api_key: apiKey,
|
||||
global,
|
||||
provider,
|
||||
};
|
||||
|
||||
if (provider === '3PlayMedia') {
|
||||
const { apiSecretKey } = otherFields;
|
||||
postJson.api_secret_key = apiSecretKey;
|
||||
} else {
|
||||
const { username } = otherFields;
|
||||
postJson.username = username;
|
||||
}
|
||||
await getAuthenticatedHttpClient()
|
||||
.post(`${getApiBaseUrl()}/transcript_credentials/${courseId}`, postJson);
|
||||
}
|
||||
55
src/files-and-videos/videos-page/data/api.test.js
Normal file
55
src/files-and-videos/videos-page/data/api.test.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { getDownload } from './api';
|
||||
import 'file-saver';
|
||||
|
||||
jest.mock('file-saver');
|
||||
|
||||
describe('api.js', () => {
|
||||
describe('getDownload', () => {
|
||||
describe('selectedRows length is undefined or less than zero', () => {
|
||||
it('should return with no files selected error if selectedRows is empty', async () => {
|
||||
const expected = ['No files were selected to download.'];
|
||||
const actual = await getDownload([], 'courseId');
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
it('should return with no files selected error if selectedRows is null', async () => {
|
||||
const expected = ['No files were selected to download.'];
|
||||
const actual = await getDownload(null, 'courseId');
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
describe('selectedRows length is greater than one', () => {
|
||||
it('should not throw error when blob returns null', async () => {
|
||||
const expected = [];
|
||||
const actual = await getDownload([
|
||||
{ original: { displayName: 'test1', downloadLink: 'test1.com' } },
|
||||
{ original: { displayName: 'test2', id: '2', downloadLink: 'test2.com' } },
|
||||
]);
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
it('should return error if row does not contain .original attribute', async () => {
|
||||
const expected = ['Failed to download video.'];
|
||||
const actual = await getDownload([
|
||||
{ asset: { displayName: 'test1', id: '1' } },
|
||||
{ original: { displayName: 'test2', id: '2', downloadLink: 'test1.com' } },
|
||||
]);
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
it('should return error if original does not contain .downloadLink attribute', async () => {
|
||||
const expected = ['Cannot find download file for test2.'];
|
||||
const actual = await getDownload([
|
||||
{ original: { displayName: 'test2', id: '2' } },
|
||||
]);
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
describe('selectedRows length equals one', () => {
|
||||
it('should return error if row does not contain .original ancestor', async () => {
|
||||
const expected = ['Failed to download video.'];
|
||||
const actual = await getDownload([
|
||||
{ asset: { displayName: 'test1', id: '1', download_link: 'test1.com' } },
|
||||
]);
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
8
src/files-and-videos/videos-page/data/constants.js
Normal file
8
src/files-and-videos/videos-page/data/constants.js
Normal file
@@ -0,0 +1,8 @@
|
||||
export const MAX_FILE_SIZE_MB = 2000000;
|
||||
export const MIN_FILE_SIZE_KB = 2000;
|
||||
export const MAX_WIDTH = 1280;
|
||||
export const MAX_HEIGHT = 720;
|
||||
export const MIN_WIDTH = 640;
|
||||
export const MIN_HEIGHT = 360;
|
||||
export const ASPECT_RATIO = 16 / 9;
|
||||
export const ASPECT_RATIO_ERROR_MARGIN = 0.1;
|
||||
109
src/files-and-videos/videos-page/data/slice.js
Normal file
109
src/files-and-videos/videos-page/data/slice.js
Normal file
@@ -0,0 +1,109 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import { RequestStatus } from '../../../data/constants';
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'videos',
|
||||
initialState: {
|
||||
videoIds: [],
|
||||
pageSettings: {},
|
||||
loadingStatus: RequestStatus.IN_PROGRESS,
|
||||
updatingStatus: '',
|
||||
addingStatus: '',
|
||||
deletingStatus: '',
|
||||
usageStatus: '',
|
||||
transcriptStatus: '',
|
||||
errors: {
|
||||
add: [],
|
||||
delete: [],
|
||||
thumbnail: [],
|
||||
download: [],
|
||||
usageMetrics: [],
|
||||
transcript: [],
|
||||
},
|
||||
totalCount: 0,
|
||||
},
|
||||
reducers: {
|
||||
setVideoIds: (state, { payload }) => {
|
||||
state.videoIds = payload.videoIds;
|
||||
},
|
||||
setPageSettings: (state, { payload }) => {
|
||||
state.pageSettings = payload;
|
||||
},
|
||||
setTotalCount: (state, { payload }) => {
|
||||
state.totalCount = payload.totalCount;
|
||||
},
|
||||
updateLoadingStatus: (state, { payload }) => {
|
||||
state.loadingStatus = payload.status;
|
||||
},
|
||||
updateEditStatus: (state, { payload }) => {
|
||||
const { editType, status } = payload;
|
||||
switch (editType) {
|
||||
case 'delete':
|
||||
state.deletingStatus = status;
|
||||
break;
|
||||
case 'add':
|
||||
state.addingStatus = status;
|
||||
break;
|
||||
case 'thumbnail':
|
||||
state.updatingStatus = status;
|
||||
break;
|
||||
case 'download':
|
||||
state.updatingStatus = status;
|
||||
break;
|
||||
case 'usageMetrics':
|
||||
state.usageStatus = status;
|
||||
break;
|
||||
case 'transcript':
|
||||
state.transcriptStatus = status;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
deleteVideoSuccess: (state, { payload }) => {
|
||||
state.videoIds = state.videoIds.filter(id => id !== payload.videoId);
|
||||
},
|
||||
addVideoSuccess: (state, { payload }) => {
|
||||
state.videoIds = [payload.videoId, ...state.videoIds];
|
||||
},
|
||||
updateTranscriptCredentialsSuccess: (state, { payload }) => {
|
||||
const { provider } = payload;
|
||||
state.pageSettings.transcriptCredentials = {
|
||||
...state.pageSettings.transcriptCredentials,
|
||||
[provider]: true,
|
||||
};
|
||||
},
|
||||
updateTranscriptPreferenceSuccess: (state, { payload }) => {
|
||||
state.pageSettings.activeTranscriptPreferences = payload;
|
||||
},
|
||||
updateErrors: (state, { payload }) => {
|
||||
const { error, message } = payload;
|
||||
const currentErrorState = state.errors[error];
|
||||
state.errors[error] = [...currentErrorState, message];
|
||||
},
|
||||
clearErrors: (state, { payload }) => {
|
||||
const { error } = payload;
|
||||
state.errors[error] = [];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setVideoIds,
|
||||
setPageSettings,
|
||||
setTotalCount,
|
||||
updateLoadingStatus,
|
||||
deleteVideoSuccess,
|
||||
addVideoSuccess,
|
||||
updateErrors,
|
||||
clearErrors,
|
||||
updateEditStatus,
|
||||
updateTranscriptCredentialsSuccess,
|
||||
updateTranscriptPreferenceSuccess,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
reducer,
|
||||
} = slice;
|
||||
352
src/files-and-videos/videos-page/data/thunks.js
Normal file
352
src/files-and-videos/videos-page/data/thunks.js
Normal file
@@ -0,0 +1,352 @@
|
||||
import { camelCase, isEmpty } from 'lodash';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { RequestStatus } from '../../../data/constants';
|
||||
import {
|
||||
addModels,
|
||||
removeModel,
|
||||
updateModel,
|
||||
updateModels,
|
||||
} from '../../../generic/model-store';
|
||||
import {
|
||||
addThumbnail,
|
||||
addVideo,
|
||||
deleteVideo,
|
||||
fetchVideoList,
|
||||
getVideos,
|
||||
uploadVideo,
|
||||
getDownload,
|
||||
deleteTranscript,
|
||||
downloadTranscript,
|
||||
uploadTranscript,
|
||||
getVideoUsagePaths,
|
||||
deleteTranscriptPreferences,
|
||||
setTranscriptCredentials,
|
||||
setTranscriptPreferences,
|
||||
} from './api';
|
||||
import {
|
||||
setVideoIds,
|
||||
setPageSettings,
|
||||
setTotalCount,
|
||||
updateLoadingStatus,
|
||||
deleteVideoSuccess,
|
||||
addVideoSuccess,
|
||||
updateErrors,
|
||||
clearErrors,
|
||||
updateEditStatus,
|
||||
updateTranscriptCredentialsSuccess,
|
||||
updateTranscriptPreferenceSuccess,
|
||||
} from './slice';
|
||||
|
||||
import { updateFileValues } from './utils';
|
||||
|
||||
export function fetchVideos(courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
const { previousUploads, ...data } = await getVideos(courseId);
|
||||
const parsedVideos = updateFileValues(previousUploads);
|
||||
dispatch(addModels({ modelType: 'videos', models: parsedVideos }));
|
||||
dispatch(setVideoIds({
|
||||
videoIds: parsedVideos.map(video => video.id),
|
||||
}));
|
||||
dispatch(setPageSettings({ ...data }));
|
||||
dispatch(setTotalCount({ totalCount: parsedVideos.length }));
|
||||
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
if (error.response && error.response.status === 403) {
|
||||
dispatch(updateLoadingStatus({ status: RequestStatus.DENIED }));
|
||||
} else {
|
||||
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.FAILED }));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function resetErrors({ errorType }) {
|
||||
return (dispatch) => { dispatch(clearErrors({ error: errorType })); };
|
||||
}
|
||||
|
||||
export function updateVideoOrder(courseId, videoIds) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.IN_PROGRESS }));
|
||||
dispatch(setVideoIds({ videoIds }));
|
||||
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.SUCCESSFUL }));
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteVideoFile(courseId, id, totalCount) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateEditStatus({ editType: 'delete', status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
await deleteVideo(courseId, id);
|
||||
dispatch(deleteVideoSuccess({ videoId: id }));
|
||||
dispatch(removeModel({ modelType: 'videos', id }));
|
||||
dispatch(setTotalCount({ totalCount: totalCount - 1 }));
|
||||
|
||||
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 }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function addVideoFile(courseId, file) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
const { files } = await addVideo(courseId, file);
|
||||
const { edxVideoId, uploadUrl } = files[0];
|
||||
const errors = await uploadVideo(
|
||||
courseId,
|
||||
uploadUrl,
|
||||
file,
|
||||
edxVideoId,
|
||||
);
|
||||
const { videos } = await fetchVideoList(courseId);
|
||||
const parsedVideos = updateFileValues(videos);
|
||||
dispatch(updateModels({
|
||||
modelType: 'videos',
|
||||
models: parsedVideos,
|
||||
}));
|
||||
dispatch(addVideoSuccess({
|
||||
videoId: edxVideoId,
|
||||
}));
|
||||
dispatch(setTotalCount({ totalCount: parsedVideos.length }));
|
||||
dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.SUCCESSFUL }));
|
||||
if (!isEmpty(errors)) {
|
||||
errors.forEach(error => {
|
||||
dispatch(updateErrors({ error: 'add', message: error }));
|
||||
});
|
||||
dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.FAILED }));
|
||||
}
|
||||
} 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 add ${file.name}.` }));
|
||||
}
|
||||
dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function addVideoThumbnail({ file, videoId, courseId }) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateEditStatus({ editType: 'thumbnail', status: RequestStatus.IN_PROGRESS }));
|
||||
dispatch(resetErrors({ errorType: 'thumbnail' }));
|
||||
try {
|
||||
const { imageUrl } = await addThumbnail({ courseId, videoId, file });
|
||||
let thumbnail = imageUrl;
|
||||
if (thumbnail.startsWith('/')) {
|
||||
thumbnail = `${getConfig().STUDIO_BASE_URL}${imageUrl}`;
|
||||
}
|
||||
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(updateEditStatus({ editType: 'thumbnail', status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteVideoTranscript({
|
||||
language,
|
||||
videoId,
|
||||
transcripts,
|
||||
apiUrl,
|
||||
}) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
await deleteTranscript({
|
||||
videoId,
|
||||
language,
|
||||
apiUrl,
|
||||
});
|
||||
const updatedTranscripts = transcripts.filter(transcript => transcript !== language);
|
||||
dispatch(updateModel({
|
||||
modelType: 'videos',
|
||||
model: {
|
||||
id: videoId,
|
||||
transcripts: updatedTranscripts,
|
||||
},
|
||||
}));
|
||||
|
||||
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 }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function downloadVideoTranscript({
|
||||
language,
|
||||
videoId,
|
||||
filename,
|
||||
apiUrl,
|
||||
}) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
await downloadTranscript({
|
||||
videoId,
|
||||
language,
|
||||
apiUrl,
|
||||
filename,
|
||||
});
|
||||
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 }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function uploadVideoTranscript({
|
||||
language,
|
||||
newLanguage,
|
||||
videoId,
|
||||
file,
|
||||
apiUrl,
|
||||
transcripts,
|
||||
}) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.IN_PROGRESS }));
|
||||
const isReplacement = !isEmpty(language);
|
||||
|
||||
try {
|
||||
await uploadTranscript({
|
||||
videoId,
|
||||
language,
|
||||
apiUrl,
|
||||
file,
|
||||
newLanguage,
|
||||
});
|
||||
let updatedTranscripts = transcripts;
|
||||
if (isReplacement) {
|
||||
const removeTranscript = transcripts.filter(transcript => transcript !== language);
|
||||
updatedTranscripts = [...removeTranscript, newLanguage];
|
||||
} else {
|
||||
updatedTranscripts = [...transcripts, newLanguage];
|
||||
}
|
||||
|
||||
dispatch(updateModel({
|
||||
modelType: 'videos',
|
||||
model: {
|
||||
id: videoId,
|
||||
transcripts: updatedTranscripts,
|
||||
},
|
||||
}));
|
||||
|
||||
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}.`;
|
||||
dispatch(updateErrors({ error: 'transcript', message }));
|
||||
}
|
||||
dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getUsagePaths({ video, courseId }) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateEditStatus({ editType: 'usageMetrics', status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
const { usageLocations } = await getVideoUsagePaths({ videoId: video.id, courseId });
|
||||
dispatch(updateModel({
|
||||
modelType: 'videos',
|
||||
model: {
|
||||
id: video.id,
|
||||
usageLocations,
|
||||
},
|
||||
}));
|
||||
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 }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchVideoDownload({ selectedRows }) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.IN_PROGRESS }));
|
||||
const errors = await getDownload(selectedRows);
|
||||
if (isEmpty(errors)) {
|
||||
dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.SUCCESSFUL }));
|
||||
} else {
|
||||
errors.forEach(error => {
|
||||
dispatch(updateErrors({ error: 'download', message: error }));
|
||||
});
|
||||
dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function clearAutomatedTranscript({ courseId }) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
await deleteTranscriptPreferences(courseId);
|
||||
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 }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function updateTranscriptCredentials({ courseId, data }) {
|
||||
return async (dispatch) => {
|
||||
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 }));
|
||||
} catch (error) {
|
||||
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 }));
|
||||
|
||||
try {
|
||||
const preferences = await setTranscriptPreferences(courseId, data);
|
||||
dispatch(updateTranscriptPreferenceSuccess(preferences));
|
||||
dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
dispatch(updateErrors({ error: 'transcript', message: `Failed to update ${data.provider} transcripts settings.` }));
|
||||
dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
252
src/files-and-videos/videos-page/data/utils.js
Normal file
252
src/files-and-videos/videos-page/data/utils.js
Normal file
@@ -0,0 +1,252 @@
|
||||
import { ensureConfig, getConfig } from '@edx/frontend-platform';
|
||||
import { isArray, isEmpty } from 'lodash';
|
||||
import {
|
||||
ASPECT_RATIO,
|
||||
ASPECT_RATIO_ERROR_MARGIN,
|
||||
MAX_HEIGHT,
|
||||
MAX_WIDTH,
|
||||
MIN_HEIGHT,
|
||||
MIN_WIDTH,
|
||||
} from './constants';
|
||||
|
||||
ensureConfig([
|
||||
'STUDIO_BASE_URL',
|
||||
], 'Course Apps API service');
|
||||
|
||||
export const updateFileValues = (files) => {
|
||||
const updatedFiles = [];
|
||||
files.forEach(file => {
|
||||
const {
|
||||
edxVideoId,
|
||||
clientVideoId,
|
||||
created,
|
||||
courseVideoImageUrl,
|
||||
} = file;
|
||||
const wrapperType = 'video';
|
||||
|
||||
let thumbnail = courseVideoImageUrl;
|
||||
if (thumbnail && thumbnail.startsWith('/')) {
|
||||
thumbnail = `${getConfig().STUDIO_BASE_URL}${thumbnail}`;
|
||||
}
|
||||
|
||||
updatedFiles.push({
|
||||
...file,
|
||||
displayName: clientVideoId,
|
||||
id: edxVideoId,
|
||||
wrapperType,
|
||||
dateAdded: created.toString(),
|
||||
usageLocations: [],
|
||||
thumbnail,
|
||||
});
|
||||
});
|
||||
|
||||
return updatedFiles;
|
||||
};
|
||||
|
||||
export const getFormattedDuration = (value) => {
|
||||
if (!value || typeof value !== 'number' || value <= 0) {
|
||||
return '00:00:00';
|
||||
}
|
||||
const seconds = Math.floor(value % 60);
|
||||
const minutes = Math.floor((value / 60) % 60);
|
||||
const hours = Math.floor((value / 360) % 60);
|
||||
const zeroPad = (num) => String(num).padStart(2, '0');
|
||||
return [hours, minutes, seconds].map(zeroPad).join(':');
|
||||
};
|
||||
|
||||
export const getLanguages = (availableLanguages) => {
|
||||
const languages = {};
|
||||
availableLanguages?.forEach(language => {
|
||||
const { languageCode, languageText } = language;
|
||||
languages[languageCode] = languageText;
|
||||
});
|
||||
return languages;
|
||||
};
|
||||
|
||||
export const getSupportedFormats = (supportedFileFormats) => {
|
||||
if (isEmpty(supportedFileFormats)) {
|
||||
return null;
|
||||
}
|
||||
if (isArray(supportedFileFormats)) {
|
||||
return supportedFileFormats;
|
||||
}
|
||||
const supportedFormats = [];
|
||||
Object.entries(supportedFileFormats).forEach(([key, value]) => {
|
||||
let format;
|
||||
if (isArray(value)) {
|
||||
value.forEach(val => {
|
||||
format = key.replace('*', val.substring(1));
|
||||
supportedFormats.push(format);
|
||||
});
|
||||
} else {
|
||||
format = key.replace('*', value?.substring(1));
|
||||
supportedFormats.push(format);
|
||||
}
|
||||
});
|
||||
return supportedFormats;
|
||||
};
|
||||
|
||||
/** createResampledFile({ canvasUrl, filename, mimeType })
|
||||
* createResampledFile takes a canvasUrl, filename, and a valid mimeType. The
|
||||
* canvasUrl is parsed and written to an 8-bit array of unsigned integers. The
|
||||
* new array is saved to a new file with the same filename as the original image.
|
||||
* @param {string} canvasUrl - string of base64 URL for new image canvas
|
||||
* @param {string} filename - string of the original image's filename
|
||||
* @param {string} mimeType - string of mimeType for the canvas
|
||||
* @return {File} new File object
|
||||
*/
|
||||
export const createResampledFile = ({ canvasUrl, filename, mimeType }) => {
|
||||
const arr = canvasUrl.split(',');
|
||||
const bstr = atob(arr[1]);
|
||||
let n = bstr.length;
|
||||
const u8arr = new Uint8Array(n);
|
||||
while (n--) {
|
||||
u8arr[n] = bstr.charCodeAt(n);
|
||||
}
|
||||
return new File([u8arr], filename, { type: mimeType });
|
||||
};
|
||||
|
||||
/** resampleImage({ image, filename })
|
||||
* resampledImage takes a canvasUrl, filename, and a valid mimeType. The
|
||||
* canvasUrl is parsed and written to an 8-bit array of unsigned integers. The
|
||||
* new array is saved to a new file with the same filename as the original image.
|
||||
* @param {File} canvasUrl - string of base64 URL for new image canvas
|
||||
* @param {string} filename - string of the image's filename
|
||||
* @return {array} array containing the base64 URL for the resampled image and the file containing the resampled image
|
||||
*/
|
||||
export const resampleImage = ({ image, filename }) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Determine new dimensions for image
|
||||
if (image.naturalWidth > MAX_WIDTH) {
|
||||
// Set dimensions to the maximum size
|
||||
canvas.width = MAX_WIDTH;
|
||||
canvas.height = MAX_HEIGHT;
|
||||
} else if (image.naturalWidth < MIN_WIDTH) {
|
||||
// Set dimensions to the minimum size
|
||||
canvas.width = MIN_WIDTH;
|
||||
canvas.height = MIN_HEIGHT;
|
||||
} else {
|
||||
// Set dimensions to the closest 16:9 ratio
|
||||
const heightRatio = 9 / 16;
|
||||
canvas.width = image.naturalWidth;
|
||||
canvas.height = image.naturalWidth * heightRatio;
|
||||
}
|
||||
const cropLeft = (image.naturalWidth - canvas.width) / 2;
|
||||
const cropTop = (image.naturalHeight - canvas.height) / 2;
|
||||
|
||||
ctx.drawImage(image, cropLeft, cropTop, canvas.width, canvas.height, 0, 0, canvas.width, canvas.height);
|
||||
const resampledFile = createResampledFile({ canvasUrl: canvas.toDataURL(), filename, mimeType: 'image/png' });
|
||||
return resampledFile;
|
||||
};
|
||||
|
||||
export const hasValidDimensions = ({ width, height }) => {
|
||||
const imageAspectRatio = Math.abs((width / height) - ASPECT_RATIO);
|
||||
|
||||
if (width < MIN_WIDTH || height < MIN_HEIGHT) {
|
||||
return false;
|
||||
}
|
||||
if (imageAspectRatio >= ASPECT_RATIO_ERROR_MARGIN) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export const resampleFile = ({
|
||||
file,
|
||||
dispatch,
|
||||
videoId,
|
||||
courseId,
|
||||
addVideoThumbnail,
|
||||
}) => {
|
||||
const reader = new FileReader();
|
||||
const image = new Image();
|
||||
reader.onload = () => {
|
||||
image.src = reader.result;
|
||||
image.onload = () => {
|
||||
const width = image.naturalWidth;
|
||||
const height = image.naturalHeight;
|
||||
if (!hasValidDimensions({ width, height })) {
|
||||
const resampledFile = resampleImage({ image, filename: file.name });
|
||||
dispatch(addVideoThumbnail({ courseId, videoId, file: resampledFile }));
|
||||
} else {
|
||||
dispatch(addVideoThumbnail({ courseId, videoId, file }));
|
||||
}
|
||||
};
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
export const getLanguageOptions = (keys, languages) => {
|
||||
const options = {};
|
||||
if (keys) {
|
||||
keys.forEach(key => {
|
||||
options[key] = languages[key];
|
||||
});
|
||||
}
|
||||
return options;
|
||||
};
|
||||
|
||||
export const getFidelityOptions = (fidelities) => {
|
||||
const options = {};
|
||||
Object.entries(fidelities).forEach(([key, value]) => {
|
||||
const { display_name: displayName } = value;
|
||||
options[key] = displayName;
|
||||
});
|
||||
return options;
|
||||
};
|
||||
|
||||
export const checkCredentials = (transcriptCredentials) => {
|
||||
const cieloHasCredentials = transcriptCredentials.cielo24;
|
||||
const threePlayHasCredentials = transcriptCredentials['3PlayMedia'];
|
||||
return [cieloHasCredentials, threePlayHasCredentials];
|
||||
};
|
||||
|
||||
export const checkTranscriptionPlans = (transcriptionPlans) => {
|
||||
let cieloIsValid = !isEmpty(transcriptionPlans.Cielo24);
|
||||
let threePlayIsValid = !isEmpty(transcriptionPlans['3PlayMedia']);
|
||||
|
||||
if (cieloIsValid) {
|
||||
const { fidelity, turnaround } = transcriptionPlans.Cielo24;
|
||||
cieloIsValid = !isEmpty(fidelity) && !isEmpty(turnaround);
|
||||
}
|
||||
|
||||
if (threePlayIsValid) {
|
||||
const { languages, turnaround, translations } = transcriptionPlans['3PlayMedia'];
|
||||
threePlayIsValid = !isEmpty(turnaround) && !isEmpty(languages) && !isEmpty(translations);
|
||||
}
|
||||
|
||||
return [cieloIsValid, threePlayIsValid];
|
||||
};
|
||||
|
||||
export const validateForm = (cieloHasCredentials, threePlayHasCredentials, provider, data) => {
|
||||
const {
|
||||
apiKey,
|
||||
apiSecretKey,
|
||||
username,
|
||||
cielo24Fidelity,
|
||||
cielo24Turnaround,
|
||||
preferredLanguages,
|
||||
threePlayTurnaround,
|
||||
videoSourceLanguage,
|
||||
} = data;
|
||||
switch (provider) {
|
||||
case 'Cielo24':
|
||||
if (cieloHasCredentials) {
|
||||
return !isEmpty(cielo24Fidelity) && !isEmpty(cielo24Turnaround)
|
||||
&& !isEmpty(preferredLanguages) && !isEmpty(videoSourceLanguage);
|
||||
}
|
||||
return !isEmpty(apiKey) && !isEmpty(username);
|
||||
case '3PlayMedia':
|
||||
if (threePlayHasCredentials) {
|
||||
return !isEmpty(threePlayTurnaround) && !isEmpty(preferredLanguages) && !isEmpty(videoSourceLanguage);
|
||||
}
|
||||
return !isEmpty(apiKey) && !isEmpty(apiSecretKey);
|
||||
case 'order':
|
||||
return true;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
259
src/files-and-videos/videos-page/data/utils.test.js
Normal file
259
src/files-and-videos/videos-page/data/utils.test.js
Normal file
@@ -0,0 +1,259 @@
|
||||
import 'jest-canvas-mock';
|
||||
import {
|
||||
hasValidDimensions,
|
||||
getSupportedFormats,
|
||||
resampleImage,
|
||||
createResampledFile,
|
||||
validateForm,
|
||||
checkTranscriptionPlans,
|
||||
} from './utils';
|
||||
|
||||
describe('getSupportedFormats', () => {
|
||||
it('should return null', () => {
|
||||
const supportedFileFormats = getSupportedFormats('');
|
||||
expect(supportedFileFormats).toBeNull();
|
||||
});
|
||||
it('should return provided supportedFileFormats', () => {
|
||||
const expected = ['image/png', 'video/mp4'];
|
||||
const actual = getSupportedFormats(expected);
|
||||
expect(expected).toEqual(actual);
|
||||
});
|
||||
it('should return array of valid file types', () => {
|
||||
const expected = ['image/png'];
|
||||
const actual = getSupportedFormats({ 'image/*': '.png' });
|
||||
expect(expected).toEqual(actual);
|
||||
});
|
||||
it('should return array of valid file types', () => {
|
||||
const expected = ['video/mp4', 'video/mov'];
|
||||
const actual = getSupportedFormats({ 'video/*': ['.mp4', '.mov'] });
|
||||
expect(expected).toEqual(actual);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createResampledFile', () => {
|
||||
it('should return resampled file object', () => {
|
||||
const expected = new File([{ name: 'imageName', size: 20000 }], 'testVALUEVALIDIMAGE');
|
||||
const actual = createResampledFile({
|
||||
canvasUrl: 'data:MimETYpe,sOMEUrl',
|
||||
filename: 'imageName',
|
||||
mimeType: 'sOmEuiMAge',
|
||||
});
|
||||
|
||||
expect(expected).toEqual(actual);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resampleImage', () => {
|
||||
it('should return filename and file', () => {
|
||||
const resampledFile = new File([{ name: 'testVALUEVALIDIMAGE', size: 20000 }], 'testVALUEVALIDIMAGE');
|
||||
const image = document.createElement('img');
|
||||
image.height = '800';
|
||||
image.width = '800';
|
||||
const actualImage = resampleImage({ image, filename: 'testVALUEVALIDIMAGE' });
|
||||
|
||||
expect(actualImage).toEqual(resampledFile);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkValidDimensions', () => {
|
||||
it('returns false for images less than min width and min height', () => {
|
||||
const image = { width: 500, height: 281 };
|
||||
const actual = hasValidDimensions(image);
|
||||
expect(actual).toBeFalsy();
|
||||
});
|
||||
it('returns false for images that do not have a 16:9 aspect ratio', () => {
|
||||
const image = { width: 800, height: 800 };
|
||||
const actual = hasValidDimensions(image);
|
||||
expect(actual).toBeFalsy();
|
||||
});
|
||||
it('returns true for images that have a 16:9 aspect ratio and larger than min width/height', () => {
|
||||
const image = { width: 1280, height: 720 };
|
||||
const actual = hasValidDimensions(image);
|
||||
expect(actual).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateForm', () => {
|
||||
describe('provider equals Cielo24', () => {
|
||||
describe('with credentials', () => {
|
||||
it('should return false', () => {
|
||||
const isValid = validateForm(
|
||||
true,
|
||||
false,
|
||||
'Cielo24',
|
||||
{
|
||||
cielo24Fidelity: 'test-fidelity',
|
||||
cielo24Turnaround: 'test-turnaround',
|
||||
preferredLanguages: [],
|
||||
videoSourceLanguage: 'test-source',
|
||||
},
|
||||
);
|
||||
expect(isValid).toBeFalsy();
|
||||
});
|
||||
it('should return true', () => {
|
||||
const isValid = validateForm(
|
||||
true,
|
||||
false,
|
||||
'Cielo24',
|
||||
{
|
||||
cielo24Fidelity: 'test-fidelity',
|
||||
cielo24Turnaround: 'test-turnaround',
|
||||
preferredLanguages: ['test-language'],
|
||||
videoSourceLanguage: 'test-source',
|
||||
},
|
||||
);
|
||||
expect(isValid).toBeTruthy();
|
||||
});
|
||||
});
|
||||
describe('with no credentials', () => {
|
||||
it('should return false', () => {
|
||||
const isValid = validateForm(
|
||||
false,
|
||||
false,
|
||||
'Cielo24',
|
||||
{
|
||||
apiKey: 'test-key',
|
||||
username: '',
|
||||
},
|
||||
);
|
||||
expect(isValid).toBeFalsy();
|
||||
});
|
||||
it('should return true', () => {
|
||||
const isValid = validateForm(
|
||||
false,
|
||||
false,
|
||||
'Cielo24',
|
||||
{
|
||||
apiKey: 'test-key',
|
||||
username: 'test-username',
|
||||
},
|
||||
);
|
||||
expect(isValid).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('provider equals 3PlayMedia', () => {
|
||||
describe('with credentials', () => {
|
||||
it('should return false', () => {
|
||||
const isValid = validateForm(
|
||||
false,
|
||||
true,
|
||||
'3PlayMedia',
|
||||
{
|
||||
threePlayTurnaround: 'test-turnaround',
|
||||
preferredLanguages: ['test-language'],
|
||||
videoSourceLanguage: '',
|
||||
},
|
||||
);
|
||||
expect(isValid).toBeFalsy();
|
||||
});
|
||||
it('should return true', () => {
|
||||
const isValid = validateForm(
|
||||
true,
|
||||
true,
|
||||
'3PlayMedia',
|
||||
{
|
||||
threePlayTurnaround: 'test-turnaround',
|
||||
preferredLanguages: ['test-language'],
|
||||
videoSourceLanguage: 'test-source',
|
||||
},
|
||||
);
|
||||
expect(isValid).toBeTruthy();
|
||||
});
|
||||
});
|
||||
describe('with no credentials', () => {
|
||||
it('should return false', () => {
|
||||
const isValid = validateForm(
|
||||
true,
|
||||
false,
|
||||
'3PlayMedia',
|
||||
{
|
||||
apiKey: 'test-key',
|
||||
username: '',
|
||||
},
|
||||
);
|
||||
expect(isValid).toBeFalsy();
|
||||
});
|
||||
it('should return true', () => {
|
||||
const isValid = validateForm(
|
||||
false,
|
||||
false,
|
||||
'3PlayMedia',
|
||||
{
|
||||
apiKey: 'test-key',
|
||||
apiSecretKey: 'test-username',
|
||||
},
|
||||
);
|
||||
expect(isValid).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('provider equals order', () => {
|
||||
it('should return true', () => {
|
||||
const isValid = validateForm(
|
||||
false,
|
||||
false,
|
||||
'order',
|
||||
{},
|
||||
);
|
||||
expect(isValid).toBeTruthy();
|
||||
});
|
||||
});
|
||||
describe('provider equals null', () => {
|
||||
it('should return false', () => {
|
||||
const isValid = validateForm(
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
{},
|
||||
);
|
||||
expect(isValid).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkTranscriptionPlans', () => {
|
||||
describe('invalid Cielo24 plan', () => {
|
||||
it('Cielo24 is empty should return [false, false]', () => {
|
||||
const expected = [false, false];
|
||||
const actual = checkTranscriptionPlans({ '3PlayMedia': {} });
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
it('Cielo24 is missing required atrribute fidelity should return [false, true]', () => {
|
||||
const expected = [false, true];
|
||||
const actual = checkTranscriptionPlans({
|
||||
'3PlayMedia': {
|
||||
languages: ['en'],
|
||||
turnaround: 'test',
|
||||
translations: { en: 'English' },
|
||||
},
|
||||
Cielo24: {
|
||||
turnaround: ['tomorrow'],
|
||||
},
|
||||
});
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
describe('invalid 3PlayMedia plan', () => {
|
||||
it('3PlayMedia is empty should return [false, false]', () => {
|
||||
const expected = [false, false];
|
||||
const actual = checkTranscriptionPlans({ Cielo24: {} });
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
it('3PlayMedia atrribute languages is empty should return [true, false]', () => {
|
||||
const expected = [true, false];
|
||||
const actual = checkTranscriptionPlans({
|
||||
Cielo24: {
|
||||
turnaround: ['tomorrow'],
|
||||
fidelity: 'test',
|
||||
},
|
||||
'3PlayMedia': {
|
||||
languages: [],
|
||||
turnaround: 'test',
|
||||
translations: { en: 'English' },
|
||||
},
|
||||
});
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
244
src/files-and-videos/videos-page/factories/mockApiResponses.jsx
Normal file
244
src/files-and-videos/videos-page/factories/mockApiResponses.jsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import { RequestStatus } from '../../../data/constants';
|
||||
|
||||
export const courseId = 'course';
|
||||
|
||||
export const initialState = {
|
||||
courseDetail: {
|
||||
courseId,
|
||||
status: 'sucessful',
|
||||
},
|
||||
videos: {
|
||||
videoIds: ['mOckID0'],
|
||||
pageSettings: {
|
||||
transcriptAvailableLanguages: [
|
||||
{ languageCode: 'ar', languageText: 'Arabic' },
|
||||
{ languageCode: 'en', languageText: 'English' },
|
||||
{ languageCode: 'fr', languageText: 'French' },
|
||||
],
|
||||
videoImageSettings: {
|
||||
videoImageUploadEnabled: false,
|
||||
maxSize: 2097152,
|
||||
minSize: 2048,
|
||||
maxWidth: 1280,
|
||||
maxHeight: 720,
|
||||
supportedFileFormats: {
|
||||
'.bmp': 'image/bmp',
|
||||
'.bmp2': 'image/x-ms-bmp',
|
||||
'.gif': 'image/gif',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
},
|
||||
},
|
||||
isVideoTranscriptEnabled: false,
|
||||
activeTranscriptPreferences: null,
|
||||
videoTranscriptSettings: {
|
||||
transcriptDownloadHandlerUrl: '/transcript_download/',
|
||||
transcriptUploadHandlerUrl: '/transcript_upload/',
|
||||
transcriptDeleteHandlerUrl: `/transcript_delete/${courseId}`,
|
||||
transcriptionPlans: {
|
||||
Cielo24: {
|
||||
turnaround: { PRIORITY: 'Priority (24 hours)' },
|
||||
fidelity: {
|
||||
PREMIUM: { display_name: 'Premium (95% accuracy)', languages: { en: 'English' } },
|
||||
PROFESSIONAL: {
|
||||
display_name: 'Professional (99% accuracy)',
|
||||
languages: {
|
||||
ar: 'Arabic',
|
||||
en: 'English',
|
||||
fr: 'French',
|
||||
es: 'Spanish',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'3PlayMedia': {
|
||||
turnaround: { two_hour: '2 hours' },
|
||||
translations: {
|
||||
es: ['en'],
|
||||
en: ['ar', 'en', 'es', 'fr'],
|
||||
},
|
||||
languages: {
|
||||
ar: 'Arabic',
|
||||
en: 'English',
|
||||
fr: 'French',
|
||||
es: 'Spanish',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
transcriptCredentials: { cielo24: false, '3PlayMedia': false },
|
||||
},
|
||||
loadingStatus: RequestStatus.SUCCESSFUL,
|
||||
updatingStatus: '',
|
||||
addingStatus: '',
|
||||
deletingStatus: '',
|
||||
usageStatus: '',
|
||||
transcriptStatus: '',
|
||||
errors: {
|
||||
add: [],
|
||||
delete: [],
|
||||
thumbnail: [],
|
||||
download: [],
|
||||
usageMetrics: [],
|
||||
transcript: [],
|
||||
},
|
||||
totalCount: 0,
|
||||
},
|
||||
models: {
|
||||
videos: {
|
||||
mOckID0: {
|
||||
id: 'mOckID0',
|
||||
displayName: 'mOckID0.mp4',
|
||||
wrapperType: 'video',
|
||||
dateAdded: '',
|
||||
thumbnail: '/video',
|
||||
fileSize: null,
|
||||
edx_video_id: 'mOckID0',
|
||||
clientVideoId: 'mOckID0.mp4',
|
||||
created: '',
|
||||
courseVideoImageUrl: '/video',
|
||||
transcripts: [],
|
||||
status: 'Imported',
|
||||
downloadLink: 'http://mOckID0.mp4',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const generateFetchVideosApiResponse = () => ({
|
||||
image_upload_url: '/video_images/course',
|
||||
video_handler_url: '/videos/course',
|
||||
encodings_download_url: '/video_encodings_download/course',
|
||||
default_video_image_url: '/static/studio/images/video-images/default_video_image.png',
|
||||
previous_uploads: [
|
||||
{
|
||||
edx_video_id: 'mOckID1',
|
||||
clientVideoId: 'mOckID1.mp4',
|
||||
created: '',
|
||||
courseVideoImageUrl: '/video',
|
||||
transcripts: [],
|
||||
status: 'Imported',
|
||||
duration: 12333,
|
||||
downloadLink: 'http://mOckID1.mp4',
|
||||
},
|
||||
{
|
||||
edx_video_id: 'mOckID5',
|
||||
clientVideoId: 'mOckID5.mp4',
|
||||
created: '',
|
||||
courseVideoImageUrl: 'http:/video',
|
||||
transcripts: ['en'],
|
||||
status: 'Failed',
|
||||
duration: 12,
|
||||
downloadLink: 'http://mOckID5.mp4',
|
||||
},
|
||||
{
|
||||
edx_video_id: 'mOckID3',
|
||||
clientVideoId: 'mOckID3.mp4',
|
||||
created: '',
|
||||
courseVideoImageUrl: null,
|
||||
transcripts: ['en'],
|
||||
status: 'Ready',
|
||||
duration: null,
|
||||
downloadLink: '',
|
||||
},
|
||||
],
|
||||
concurrent_upload_limit: 4,
|
||||
video_supported_file_formats: ['.mp4', '.mov'],
|
||||
video_upload_max_file_size: '5',
|
||||
video_image_settings: {
|
||||
video_image_upload_enabled: true,
|
||||
max_size: 2097152,
|
||||
min_size: 2048,
|
||||
max_width: 1280,
|
||||
max_height: 720,
|
||||
supported_file_formats: {
|
||||
'.bmp': 'image/bmp',
|
||||
'.bmp2': 'image/x-ms-bmp',
|
||||
'.gif': 'image/gif',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
},
|
||||
},
|
||||
is_video_transcript_enabled: true,
|
||||
active_transcript_preferences: null,
|
||||
transcript_credentials: {},
|
||||
transcript_available_languages: [{ language_code: 'ab', language_text: 'Abkhazian' }],
|
||||
video_transcript_settings: {
|
||||
transcript_download_handler_url: '/transcript_download/',
|
||||
transcript_upload_handler_url: '/transcript_upload/',
|
||||
transcript_delete_handler_url: '/transcript_delete/course',
|
||||
trancript_download_file_format: 'srt',
|
||||
transcript_preferences_handler_url: '/transcript_preferences/course',
|
||||
transcript_credentials_handler_url: '/transcript_credentials/course',
|
||||
transcription_plans: {
|
||||
Cielo24: {
|
||||
display_name: 'Cielo24',
|
||||
turnaround: { PRIORITY: 'Priority (24 hours)', STANDARD: 'Standard (48 hours)' },
|
||||
fidelity: {
|
||||
MECHANICAL: {
|
||||
display_name: 'Mechanical (75% accuracy)',
|
||||
languages: { nl: 'Dutch', en: 'English', fr: 'French' },
|
||||
},
|
||||
PREMIUM: { display_name: 'Premium (95% accuracy)', languages: { en: 'English' } },
|
||||
PROFESSIONAL: {
|
||||
display_name: 'Professional (99% accuracy)',
|
||||
languages: { ar: 'Arabic', 'zh-tw': 'Chinese - Mandarin (Traditional)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'3PlayMedia': {
|
||||
display_name: '3Play Media',
|
||||
turnaround: {
|
||||
two_hour: '2 hours',
|
||||
same_day: 'Same day',
|
||||
rush: '24 hours (rush)',
|
||||
expedited: '2 days (expedited)',
|
||||
standard: '4 days (standard)',
|
||||
extended: '10 days (extended)',
|
||||
},
|
||||
languages: { en: 'English', el: 'Greek', zh: 'Chinese' },
|
||||
translations: {
|
||||
es: ['en'],
|
||||
en: ['el', 'en', 'zh'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
pagination_context: {},
|
||||
});
|
||||
|
||||
export const generateAddVideoApiResponse = () => ({
|
||||
videos: [
|
||||
{
|
||||
edx_video_id: 'mOckID4',
|
||||
clientVideoId: 'mOckID4.mov',
|
||||
created: '',
|
||||
courseVideoImageUrl: null,
|
||||
transcripts: ['en'],
|
||||
status: 'Uploaded',
|
||||
duration: 168.001,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const generateEmptyApiResponse = () => ([{
|
||||
previousUploads: [],
|
||||
}]);
|
||||
|
||||
export const generateNewVideoApiResponse = () => ({
|
||||
files: [{
|
||||
edx_video_id: 'mOckID4',
|
||||
upload_url: 'http://testing.org',
|
||||
}],
|
||||
});
|
||||
|
||||
export const getStatusValue = (status) => {
|
||||
switch (status) {
|
||||
case RequestStatus.DENIED:
|
||||
return 403;
|
||||
default:
|
||||
return 200;
|
||||
}
|
||||
};
|
||||
6
src/files-and-videos/videos-page/index.js
Normal file
6
src/files-and-videos/videos-page/index.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import TranscriptSettings from './transcript-settings';
|
||||
import Videos from './Videos';
|
||||
import VideoThumbnail from './VideoThumbnail';
|
||||
|
||||
export default Videos;
|
||||
export { TranscriptSettings, VideoThumbnail };
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Tabs,
|
||||
Tab,
|
||||
} from '@edx/paragon';
|
||||
import InfoTab from './InfoTab';
|
||||
import TranscriptTab from './TranscriptTab';
|
||||
import messages from './messages';
|
||||
|
||||
const FileInfoVideoSidebar = ({
|
||||
video,
|
||||
// injected
|
||||
intl,
|
||||
}) => (
|
||||
<Tabs>
|
||||
<Tab eventKey="fileInfo" title={intl.formatMessage(messages.infoTabTitle)}>
|
||||
<InfoTab {...{ video }} />
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="fileTranscripts"
|
||||
title={intl.formatMessage(
|
||||
messages.transcriptTabTitle,
|
||||
{ transcriptCount: video.transcripts.length },
|
||||
)}
|
||||
>
|
||||
<TranscriptTab {...{ video }} />
|
||||
</Tab>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
FileInfoVideoSidebar.propTypes = {
|
||||
video: PropTypes.shape({
|
||||
displayName: PropTypes.string.isRequired,
|
||||
wrapperType: PropTypes.string.isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
dateAdded: PropTypes.string.isRequired,
|
||||
fileSize: PropTypes.number.isRequired,
|
||||
transcripts: PropTypes.arrayOf(PropTypes.string),
|
||||
}),
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
FileInfoVideoSidebar.defaultProps = {
|
||||
video: null,
|
||||
};
|
||||
|
||||
export default injectIntl(FileInfoVideoSidebar);
|
||||
50
src/files-and-videos/videos-page/info-sidebar/InfoTab.jsx
Normal file
50
src/files-and-videos/videos-page/info-sidebar/InfoTab.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Stack } from '@edx/paragon';
|
||||
import { injectIntl, FormattedDate, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { getFileSizeToClosestByte } from '../../data/utils';
|
||||
import { getFormattedDuration } from '../data/utils';
|
||||
import messages from './messages';
|
||||
|
||||
const InfoTab = ({ video }) => {
|
||||
const fileSize = getFileSizeToClosestByte(video?.fileSize);
|
||||
const duration = getFormattedDuration(video?.duration);
|
||||
|
||||
return (
|
||||
<Stack className="mt-3">
|
||||
<div className="font-weight-bold">
|
||||
<FormattedMessage {...messages.dateAddedTitle} />
|
||||
</div>
|
||||
<FormattedDate
|
||||
value={video?.dateAdded}
|
||||
year="numeric"
|
||||
month="short"
|
||||
day="2-digit"
|
||||
hour="numeric"
|
||||
minute="numeric"
|
||||
/>
|
||||
<div className="font-weight-bold mt-3">
|
||||
<FormattedMessage {...messages.fileSizeTitle} />
|
||||
</div>
|
||||
{fileSize}
|
||||
<div className="font-weight-bold mt-3">
|
||||
<FormattedMessage {...messages.videoLengthTitle} />
|
||||
</div>
|
||||
{duration}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
InfoTab.propTypes = {
|
||||
video: PropTypes.shape({
|
||||
duration: PropTypes.number.isRequired,
|
||||
dateAdded: PropTypes.string.isRequired,
|
||||
fileSize: PropTypes.number.isRequired,
|
||||
}),
|
||||
};
|
||||
|
||||
InfoTab.defaultProps = {
|
||||
video: {},
|
||||
};
|
||||
|
||||
export default injectIntl(InfoTab);
|
||||
136
src/files-and-videos/videos-page/info-sidebar/TranscriptTab.jsx
Normal file
136
src/files-and-videos/videos-page/info-sidebar/TranscriptTab.jsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { ErrorAlert } from '@edx/frontend-lib-content-components';
|
||||
import { Button, Stack } from '@edx/paragon';
|
||||
import { Add } from '@edx/paragon/icons';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { getLanguages } from '../data/utils';
|
||||
import Transcript from './transcript-item';
|
||||
import {
|
||||
deleteVideoTranscript,
|
||||
downloadVideoTranscript,
|
||||
resetErrors,
|
||||
uploadVideoTranscript,
|
||||
} from '../data/thunks';
|
||||
import { RequestStatus } from '../../../data/constants';
|
||||
import messages from './messages';
|
||||
|
||||
const TranscriptTab = ({
|
||||
video,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const { transcriptStatus, errors } = useSelector(state => state.videos);
|
||||
const {
|
||||
transcriptAvailableLanguages,
|
||||
videoTranscriptSettings,
|
||||
} = useSelector(state => state.videos.pageSettings);
|
||||
const {
|
||||
transcriptDeleteHandlerUrl,
|
||||
transcriptUploadHandlerUrl,
|
||||
transcriptDownloadHandlerUrl,
|
||||
} = videoTranscriptSettings;
|
||||
const { transcripts, id, displayName } = video;
|
||||
const languages = getLanguages(transcriptAvailableLanguages);
|
||||
|
||||
const [previousSelection, setPreviousSelection] = useState(transcripts);
|
||||
useEffect(() => {
|
||||
dispatch(resetErrors({ errorType: 'transcript' }));
|
||||
setPreviousSelection(transcripts);
|
||||
}, [transcripts]);
|
||||
|
||||
const handleTranscript = (data, actionType) => {
|
||||
const {
|
||||
language,
|
||||
newLanguage,
|
||||
file,
|
||||
} = data;
|
||||
dispatch(resetErrors({ errorType: 'transcript' }));
|
||||
switch (actionType) {
|
||||
case 'delete':
|
||||
if (isEmpty(language)) {
|
||||
const updatedSelection = previousSelection.filter(selection => selection !== '');
|
||||
setPreviousSelection(updatedSelection);
|
||||
} else {
|
||||
dispatch(deleteVideoTranscript({
|
||||
language,
|
||||
videoId: id,
|
||||
apiUrl: transcriptDeleteHandlerUrl,
|
||||
transcripts,
|
||||
}));
|
||||
}
|
||||
break;
|
||||
case 'download':
|
||||
dispatch(downloadVideoTranscript({
|
||||
filename: `${displayName}-${language}.srt`,
|
||||
language,
|
||||
videoId: id,
|
||||
apiUrl: transcriptDownloadHandlerUrl,
|
||||
}));
|
||||
break;
|
||||
case 'upload':
|
||||
dispatch(uploadVideoTranscript({
|
||||
language,
|
||||
videoId: id,
|
||||
apiUrl: transcriptUploadHandlerUrl,
|
||||
newLanguage,
|
||||
file,
|
||||
transcripts,
|
||||
}));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={3} className="mt-3">
|
||||
<ErrorAlert
|
||||
hideHeading={false}
|
||||
isError={transcriptStatus === RequestStatus.FAILED && !isEmpty(errors.transcript)}
|
||||
>
|
||||
<ul className="p-0">
|
||||
{errors.transcript.map(message => (
|
||||
<li key={`transcript-error-${message}`} style={{ listStyle: 'none' }}>
|
||||
{intl.formatMessage(messages.errorAlertMessage, { message })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ErrorAlert>
|
||||
{previousSelection.map(transcript => (
|
||||
<Transcript
|
||||
{...{
|
||||
languages,
|
||||
transcript,
|
||||
previousSelection,
|
||||
handleTranscript,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
variant="link"
|
||||
iconBefore={Add}
|
||||
size="sm"
|
||||
className="text-primary-500 justify-content-start pl-0"
|
||||
onClick={() => setPreviousSelection([...previousSelection, ''])}
|
||||
>
|
||||
{intl.formatMessage(messages.uploadButtonLabel)}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
TranscriptTab.propTypes = {
|
||||
video: PropTypes.shape({
|
||||
transcripts: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
displayName: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(TranscriptTab);
|
||||
@@ -0,0 +1,336 @@
|
||||
import {
|
||||
render,
|
||||
act,
|
||||
fireEvent,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import {
|
||||
initializeMockApp,
|
||||
} from '@edx/frontend-platform';
|
||||
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 initializeStore from '../../../store';
|
||||
import { executeThunk } from '../../../utils';
|
||||
import { RequestStatus } from '../../../data/constants';
|
||||
import TranscriptTab from './TranscriptTab';
|
||||
import {
|
||||
courseId,
|
||||
initialState,
|
||||
} from '../factories/mockApiResponses';
|
||||
|
||||
import { getApiBaseUrl } from '../data/api';
|
||||
import messages from './messages';
|
||||
import transcriptRowMessages from './transcript-item/messages';
|
||||
import VideosProvider from '../VideosProvider';
|
||||
import { deleteVideoTranscript } from '../data/thunks';
|
||||
|
||||
ReactDOM.createPortal = jest.fn(node => node);
|
||||
|
||||
const defaultProps = {
|
||||
id: 'mOckID0',
|
||||
displayName: 'mOckID0.mp4',
|
||||
wrapperType: 'video',
|
||||
dateAdded: '',
|
||||
thumbnail: '/video',
|
||||
fileSize: null,
|
||||
edx_video_id: 'mOckID0',
|
||||
clientVideoId: 'mOckID0.mp4',
|
||||
created: '',
|
||||
courseVideoImageUrl: '/video',
|
||||
transcripts: [],
|
||||
status: 'Imported',
|
||||
};
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
jest.mock('file-saver');
|
||||
|
||||
const renderComponent = (props) => {
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<VideosProvider courseId={courseId}>
|
||||
<TranscriptTab video={props} />
|
||||
</VideosProvider>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('TranscriptTab', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore(initialState);
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
describe('with no transcripts preloaded', () => {
|
||||
it('should have add transcript button', async () => {
|
||||
renderComponent(defaultProps);
|
||||
const addButton = screen.getByText(messages.uploadButtonLabel.defaultMessage);
|
||||
const transcriptRow = screen.queryByTestId('transcript', { exact: false });
|
||||
expect(addButton).toBeInTheDocument();
|
||||
expect(transcriptRow).toBeNull();
|
||||
});
|
||||
|
||||
it('should delete empty transcript row', async () => {
|
||||
renderComponent(defaultProps);
|
||||
const addButton = screen.getByText(messages.uploadButtonLabel.defaultMessage);
|
||||
await act(async () => { fireEvent.click(addButton); });
|
||||
|
||||
const deleteButton = screen.getByLabelText('delete empty transcript');
|
||||
await act(async () => { fireEvent.click(deleteButton); });
|
||||
|
||||
expect(screen.getByText(transcriptRowMessages.deleteConfirmationHeader.defaultMessage)).toBeVisible();
|
||||
|
||||
const confirmButton = screen.getByText(transcriptRowMessages.confirmDeleteLabel.defaultMessage);
|
||||
await act(async () => { fireEvent.click(confirmButton); });
|
||||
|
||||
expect(screen.queryByTestId('transcript-')).toBeNull();
|
||||
});
|
||||
|
||||
describe('uploadVideoTranscript as add function', () => {
|
||||
let addButton;
|
||||
const file = new File(['(⌐□_□)'], 'download.srt', { type: 'text/srt' });
|
||||
beforeEach(async () => {
|
||||
renderComponent(defaultProps);
|
||||
addButton = screen.getByText(messages.uploadButtonLabel.defaultMessage);
|
||||
|
||||
await act(async () => { fireEvent.click(addButton); });
|
||||
});
|
||||
|
||||
it('should upload new transcript', async () => {
|
||||
axiosMock.onPost(`${getApiBaseUrl()}/transcript_upload/`).reply(204);
|
||||
await act(async () => {
|
||||
const addFileInput = screen.getByLabelText('file-input');
|
||||
expect(addFileInput).toBeInTheDocument();
|
||||
|
||||
userEvent.upload(addFileInput, file);
|
||||
});
|
||||
const addStatus = store.getState().videos.transcriptStatus;
|
||||
|
||||
expect(addStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
});
|
||||
|
||||
it('should show default error message', async () => {
|
||||
axiosMock.onPost(`${getApiBaseUrl()}/transcript_upload/`).reply(404);
|
||||
await act(async () => {
|
||||
const addFileInput = screen.getByLabelText('file-input');
|
||||
userEvent.upload(addFileInput, file);
|
||||
});
|
||||
const addStatus = store.getState().videos.transcriptStatus;
|
||||
|
||||
expect(addStatus).toEqual(RequestStatus.FAILED);
|
||||
|
||||
expect(screen.getAllByText('Failed to add .')[0]).toBeVisible();
|
||||
});
|
||||
|
||||
it('should show api provided error message', async () => {
|
||||
axiosMock.onPost(`${getApiBaseUrl()}/transcript_upload/`).reply(404, { error: 'api error' });
|
||||
await act(async () => {
|
||||
const addFileInput = screen.getByLabelText('file-input');
|
||||
userEvent.upload(addFileInput, file);
|
||||
});
|
||||
const addStatus = store.getState().videos.transcriptStatus;
|
||||
|
||||
expect(addStatus).toEqual(RequestStatus.FAILED);
|
||||
|
||||
expect(screen.getAllByText('api error')[0]).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with one transcripts preloaded', () => {
|
||||
const updatedProps = { ...defaultProps, transcripts: ['ar'] };
|
||||
beforeEach(() => {
|
||||
renderComponent(updatedProps);
|
||||
});
|
||||
|
||||
it('should contain transcript row', () => {
|
||||
const addButton = screen.getByText(messages.uploadButtonLabel.defaultMessage);
|
||||
const transcriptRow = screen.getByTestId('transcript-ar');
|
||||
expect(addButton).toBeInTheDocument();
|
||||
expect(transcriptRow).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('deleteVideoTranscript', () => {
|
||||
beforeEach(async () => {
|
||||
const menuButton = screen.getByTestId('ar-transcript-menu');
|
||||
await waitFor(() => {
|
||||
fireEvent.click(menuButton);
|
||||
});
|
||||
|
||||
const deleteButton = screen.getByText(transcriptRowMessages.deleteTranscript.defaultMessage).closest('a');
|
||||
fireEvent.click(deleteButton);
|
||||
});
|
||||
|
||||
it('should open delete confirmation modal and cancel delete', async () => {
|
||||
const cancelButton = screen.getByText(transcriptRowMessages.cancelDeleteLabel.defaultMessage);
|
||||
await waitFor(() => {
|
||||
fireEvent.click(cancelButton);
|
||||
});
|
||||
|
||||
expect(screen.queryByText(transcriptRowMessages.deleteConfirmationHeader.defaultMessage)).toBeNull();
|
||||
});
|
||||
|
||||
it('should open delete confirmation modal and handle delete', async () => {
|
||||
const confirmButton = screen.getByText(transcriptRowMessages.confirmDeleteLabel.defaultMessage);
|
||||
axiosMock.onDelete(`${getApiBaseUrl()}/transcript_delete/${courseId}/mOckID0/ar`).reply(204);
|
||||
await act(async () => {
|
||||
fireEvent.click(confirmButton);
|
||||
executeThunk(deleteVideoTranscript({
|
||||
language: 'ar',
|
||||
videoId: updatedProps.id,
|
||||
transcripts: updatedProps.transcripts,
|
||||
apiUrl: `/transcript_delete/${courseId}`,
|
||||
}), store.dispatch);
|
||||
});
|
||||
const deleteStatus = store.getState().videos.transcriptStatus;
|
||||
|
||||
expect(deleteStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
|
||||
expect(screen.queryByText(transcriptRowMessages.deleteConfirmationHeader.defaultMessage)).toBeNull();
|
||||
});
|
||||
|
||||
it('should show error message', async () => {
|
||||
const confirmButton = screen.getByText(transcriptRowMessages.confirmDeleteLabel.defaultMessage);
|
||||
axiosMock.onDelete(`${getApiBaseUrl()}/transcript_delete/${courseId}/mOckID0/ar`).reply(404);
|
||||
await act(async () => {
|
||||
fireEvent.click(confirmButton);
|
||||
executeThunk(deleteVideoTranscript({
|
||||
language: 'ar',
|
||||
videoId: updatedProps.id,
|
||||
transcripts: updatedProps.transcripts,
|
||||
apiUrl: `/transcript_delete/${courseId}`,
|
||||
}), store.dispatch);
|
||||
});
|
||||
const deleteStatus = store.getState().videos.transcriptStatus;
|
||||
|
||||
expect(deleteStatus).toEqual(RequestStatus.FAILED);
|
||||
|
||||
expect(screen.queryByText(transcriptRowMessages.deleteConfirmationHeader.defaultMessage)).toBeNull();
|
||||
|
||||
expect(screen.getAllByText('Failed to delete ar transcript.')[0]).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadVideoTranscript', () => {
|
||||
let downloadButton;
|
||||
beforeEach(async () => {
|
||||
const menuButton = screen.getByTestId('ar-transcript-menu');
|
||||
await waitFor(() => {
|
||||
fireEvent.click(menuButton);
|
||||
});
|
||||
downloadButton = screen.getByText(
|
||||
transcriptRowMessages.downloadTranscript.defaultMessage,
|
||||
).closest('a');
|
||||
});
|
||||
|
||||
it('should download transcript', async () => {
|
||||
axiosMock.onGet(
|
||||
`${getApiBaseUrl()}/transcript_download/?edx_video_id=${updatedProps.id}&language_code=ar`,
|
||||
).reply(200, 'string of transcript');
|
||||
await act(async () => {
|
||||
fireEvent.click(downloadButton);
|
||||
});
|
||||
const downloadStatus = store.getState().videos.transcriptStatus;
|
||||
|
||||
expect(downloadStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
});
|
||||
|
||||
it('should show error message', async () => {
|
||||
const filename = 'mOckID0.mp4-ar.srt';
|
||||
axiosMock.onGet(
|
||||
`${getApiBaseUrl()}/transcript_download/?edx_video_id=${updatedProps.id}&language_code=ar`,
|
||||
).reply(404);
|
||||
await act(async () => {
|
||||
fireEvent.click(downloadButton);
|
||||
});
|
||||
const downloadStatus = store.getState().videos.transcriptStatus;
|
||||
|
||||
expect(downloadStatus).toEqual(RequestStatus.FAILED);
|
||||
|
||||
expect(screen.getAllByText(`Failed to download ${filename}.`)[0]).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with multiple transcripts preloaded', () => {
|
||||
describe('uploadVideoTranscript as replace function', () => {
|
||||
const file = new File(['(⌐□_□)'], 'download.srt', { type: 'text/srt' });
|
||||
beforeEach(async () => {
|
||||
const updatedProps = { ...defaultProps, transcripts: ['fr', 'ar'] };
|
||||
renderComponent(updatedProps);
|
||||
const dropdownButton = screen.getAllByTestId('language-select-dropdown')[0];
|
||||
await waitFor(() => {
|
||||
fireEvent.click(dropdownButton);
|
||||
});
|
||||
|
||||
const englishOption = screen.getByText('English');
|
||||
const arabicOption = screen.getAllByRole('button', { name: 'Arabic' })[0];
|
||||
await act(async () => {
|
||||
expect(arabicOption).toHaveClass('disabled');
|
||||
fireEvent.click(englishOption);
|
||||
});
|
||||
|
||||
const menuButton = screen.getByTestId('fr-transcript-menu');
|
||||
await waitFor(() => {
|
||||
fireEvent.click(menuButton);
|
||||
});
|
||||
const replaceButton = screen.getByText(
|
||||
transcriptRowMessages.replaceTranscript.defaultMessage,
|
||||
).closest('a');
|
||||
fireEvent.click(replaceButton);
|
||||
});
|
||||
|
||||
it('should replace transcript', async () => {
|
||||
axiosMock.onPost(`${getApiBaseUrl()}/transcript_upload/`).reply(204);
|
||||
|
||||
await act(async () => {
|
||||
const addFileInput = screen.getAllByLabelText('file-input')[0];
|
||||
expect(addFileInput).toBeInTheDocument();
|
||||
|
||||
userEvent.upload(addFileInput, file);
|
||||
});
|
||||
const addStatus = store.getState().videos.transcriptStatus;
|
||||
|
||||
expect(addStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
|
||||
const updatedTranscripts = store.getState().models.videos[defaultProps.id].transcripts;
|
||||
|
||||
expect(updatedTranscripts).toEqual(['ar', 'en']);
|
||||
});
|
||||
|
||||
it('should show error message', async () => {
|
||||
axiosMock.onPost(`${getApiBaseUrl()}/transcript_upload/`).reply(404);
|
||||
|
||||
await act(async () => {
|
||||
const addFileInput = screen.getAllByLabelText('file-input')[0];
|
||||
expect(addFileInput).toBeInTheDocument();
|
||||
|
||||
userEvent.upload(addFileInput, file);
|
||||
});
|
||||
|
||||
const addStatus = store.getState().videos.transcriptStatus;
|
||||
|
||||
expect(addStatus).toEqual(RequestStatus.FAILED);
|
||||
|
||||
expect(screen.getAllByText('Failed to replace fr with en.')[0]).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
40
src/files-and-videos/videos-page/info-sidebar/messages.js
Normal file
40
src/files-and-videos/videos-page/info-sidebar/messages.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
infoTabTitle: {
|
||||
id: 'course-authoring.video-uploads.file-info.infoTab.title',
|
||||
defaultMessage: 'Info',
|
||||
description: 'Title for info tab',
|
||||
},
|
||||
transcriptTabTitle: {
|
||||
id: 'course-authoring.video-uploads.file-info.transcriptTab.title',
|
||||
defaultMessage: 'Transcript ({transcriptCount})',
|
||||
description: 'Title for info tab',
|
||||
},
|
||||
dateAddedTitle: {
|
||||
id: 'course-authoring.video-uploads.file-info.infoTab.dateAdded.title',
|
||||
defaultMessage: 'Date added',
|
||||
description: 'Title for date added section',
|
||||
},
|
||||
fileSizeTitle: {
|
||||
id: 'course-authoring.video-uploads.file-info.infoTab.fileSize.title',
|
||||
defaultMessage: 'File size',
|
||||
description: 'Title for file size section',
|
||||
},
|
||||
videoLengthTitle: {
|
||||
id: 'course-authoring.video-uploads.file-info.infoTab.videoLength.title',
|
||||
defaultMessage: 'Video length',
|
||||
description: 'Title for video length section',
|
||||
},
|
||||
errorAlertMessage: {
|
||||
id: 'course-authoring.files-and-upload.file-info.transcriptTab.errorAlert.message',
|
||||
defaultMessage: '{message}',
|
||||
},
|
||||
uploadButtonLabel: {
|
||||
id: 'course-authoriong.video-uploads.file-info.transcriptTab.upload.label',
|
||||
defaultMessage: 'Add a transcript',
|
||||
description: 'Label for upload button',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Dropdown, Icon } from '@edx/paragon';
|
||||
import { Check } from '@edx/paragon/icons';
|
||||
import { isEmpty } from 'lodash';
|
||||
|
||||
const LanguageSelect = ({
|
||||
value,
|
||||
previousSelection,
|
||||
options,
|
||||
handleSelect,
|
||||
placeholderText,
|
||||
}) => {
|
||||
const currentSelection = isEmpty(value) ? placeholderText : options[value];
|
||||
return (
|
||||
<Dropdown>
|
||||
<Dropdown.Toggle
|
||||
variant="teritary"
|
||||
className="border border-gray-700 justify-content-between"
|
||||
style={{ minWidth: '100%' }}
|
||||
id={`language-select-dropdown-${currentSelection}`}
|
||||
data-testid="language-select-dropdown"
|
||||
>
|
||||
{currentSelection}
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu className="m-0" style={{ height: '300px', overflowY: 'scroll' }}>
|
||||
{Object.entries(options).map(([valueKey, text]) => {
|
||||
if (valueKey === value) {
|
||||
return (
|
||||
<Dropdown.Item key={`${valueKey}-item`}>
|
||||
<Icon size="inline" src={Check} className="m-n2" /><span className="pl-3">{text}</span>
|
||||
</Dropdown.Item>
|
||||
);
|
||||
}
|
||||
if (!previousSelection.includes(valueKey)) {
|
||||
return (
|
||||
<Dropdown.Item onClick={() => handleSelect(valueKey)} key={`${valueKey}-item`}>
|
||||
<span className="pl-3">{text}</span>
|
||||
</Dropdown.Item>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Dropdown.Item disabled key={`${valueKey}-item`}>
|
||||
<span className="pl-3">{text}</span>
|
||||
</Dropdown.Item>
|
||||
);
|
||||
})}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
LanguageSelect.propTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
options: PropTypes.shape({}).isRequired,
|
||||
handleSelect: PropTypes.func.isRequired,
|
||||
placeholderText: PropTypes.string.isRequired,
|
||||
previousSelection: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
};
|
||||
|
||||
export default LanguageSelect;
|
||||
@@ -0,0 +1,125 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Icon,
|
||||
IconButton,
|
||||
useToggle,
|
||||
} from '@edx/paragon';
|
||||
import { DeleteOutline } from '@edx/paragon/icons';
|
||||
import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { isEmpty } from 'lodash';
|
||||
import LanguageSelect from './LanguageSelect';
|
||||
import TranscriptMenu from './TranscriptMenu';
|
||||
import messages from './messages';
|
||||
import FileInput, { useFileInput } from '../../../FileInput';
|
||||
|
||||
const Transcript = ({
|
||||
languages,
|
||||
transcript,
|
||||
previousSelection,
|
||||
handleTranscript,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const [isConfirmationOpen, openConfirmation, closeConfirmation] = useToggle();
|
||||
const [newLanguage, setNewLanguage] = useState(transcript);
|
||||
const language = transcript;
|
||||
|
||||
const input = useFileInput({
|
||||
onAddFile: (file) => {
|
||||
handleTranscript({
|
||||
file,
|
||||
language,
|
||||
newLanguage,
|
||||
}, 'upload');
|
||||
},
|
||||
setSelectedRows: () => {},
|
||||
setAddOpen: () => {},
|
||||
});
|
||||
|
||||
const updateLangauge = (selected) => {
|
||||
setNewLanguage(selected);
|
||||
if (isEmpty(language)) {
|
||||
input.click();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isConfirmationOpen ? (
|
||||
<Card className="mb-2">
|
||||
<Card.Header title={(<FormattedMessage {...messages.deleteConfirmationHeader} />)} />
|
||||
<Card.Body>
|
||||
<Card.Section>
|
||||
<FormattedMessage {...messages.deleteConfirmationMessage} />
|
||||
</Card.Section>
|
||||
<Card.Footer>
|
||||
<Button variant="tertiary" className="mb-2 mb-sm-0" onClick={closeConfirmation}>
|
||||
<FormattedMessage {...messages.cancelDeleteLabel} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
className="mb-2 mb-sm-0"
|
||||
onClick={() => {
|
||||
handleTranscript({ language: transcript }, 'delete');
|
||||
closeConfirmation();
|
||||
}}
|
||||
>
|
||||
<FormattedMessage {...messages.confirmDeleteLabel} />
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : (
|
||||
<div
|
||||
className="row m-0 align-items-center justify-content-between"
|
||||
key={`transcript-${language}`}
|
||||
data-testid={`transcript-${language}`}
|
||||
>
|
||||
<div className="col-10 p-0">
|
||||
<LanguageSelect
|
||||
options={languages}
|
||||
value={newLanguage}
|
||||
placeholderText={intl.formatMessage(messages.languageSelectPlaceholder)}
|
||||
previousSelection={previousSelection}
|
||||
handleSelect={updateLangauge}
|
||||
/>
|
||||
</div>
|
||||
{ transcript === '' ? (
|
||||
<IconButton
|
||||
iconAs={Icon}
|
||||
src={DeleteOutline}
|
||||
onClick={openConfirmation}
|
||||
alt="delete empty transcript"
|
||||
/>
|
||||
) : (
|
||||
<TranscriptMenu
|
||||
{...{
|
||||
language,
|
||||
newLanguage,
|
||||
setNewLanguage,
|
||||
handleTranscript,
|
||||
input,
|
||||
launchDeleteConfirmation: openConfirmation,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<FileInput key="transcript-input" fileInput={input} supportedFileFormats={['.srt']} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Transcript.propTypes = {
|
||||
languages: PropTypes.shape({}).isRequired,
|
||||
transcript: PropTypes.string.isRequired,
|
||||
previousSelection: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
handleTranscript: PropTypes.func.isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(Transcript);
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Dropdown, Icon, IconButton } from '@edx/paragon';
|
||||
import { MoreHoriz } from '@edx/paragon/icons';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
export const TranscriptActionMenu = ({
|
||||
language,
|
||||
launchDeleteConfirmation,
|
||||
handleTranscript,
|
||||
input,
|
||||
}) => (
|
||||
<Dropdown>
|
||||
<Dropdown.Toggle
|
||||
id="dropdown-toggle-with-iconbutton-video-transcript-widget"
|
||||
as={IconButton}
|
||||
src={MoreHoriz}
|
||||
iconAs={Icon}
|
||||
variant="primary"
|
||||
alt="Actions dropdown"
|
||||
data-testid={`${language}-transcript-menu`}
|
||||
/>
|
||||
<Dropdown.Menu className="video_transcript Action Menu">
|
||||
<Dropdown.Item
|
||||
key={`transcript-actions-${language}-replace`}
|
||||
onClick={input.click}
|
||||
>
|
||||
<FormattedMessage {...messages.replaceTranscript} />
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
key={`transcript-actions-${language}-download`}
|
||||
onClick={() => handleTranscript({ language }, 'download')}
|
||||
>
|
||||
<FormattedMessage {...messages.downloadTranscript} />
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item key={`transcript-actions-${language}-delete`} onClick={launchDeleteConfirmation}>
|
||||
<FormattedMessage {...messages.deleteTranscript} />
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
TranscriptActionMenu.propTypes = {
|
||||
language: PropTypes.string.isRequired,
|
||||
handleTranscript: PropTypes.func.isRequired,
|
||||
launchDeleteConfirmation: PropTypes.func.isRequired,
|
||||
input: PropTypes.shape({
|
||||
click: PropTypes.func.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(TranscriptActionMenu);
|
||||
@@ -0,0 +1,3 @@
|
||||
import Transcript from './Transcript';
|
||||
|
||||
export default Transcript;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
fileSizeError: {
|
||||
id: 'course-authoriong.video-uploads.file-info.transcript.error.fileSizeError',
|
||||
defaultMessage: 'Transcript file size exeeds the maximum. Please try again.',
|
||||
description: 'Message presented to user when transcript file size is too large',
|
||||
},
|
||||
deleteTranscript: {
|
||||
id: 'course-authoriong.video-uploads.file-info.transcript.deleteTranscript',
|
||||
defaultMessage: 'Delete',
|
||||
description: 'Message Presented To user for action to delete transcript',
|
||||
},
|
||||
replaceTranscript: {
|
||||
id: 'course-authoriong.video-uploads.file-info.transcript.replaceTranscript',
|
||||
defaultMessage: 'Replace',
|
||||
description: 'Message Presented To user for action to replace transcript',
|
||||
},
|
||||
downloadTranscript: {
|
||||
id: 'course-authoriong.video-uploads.file-info.transcript.downloadTranscript',
|
||||
defaultMessage: 'Download',
|
||||
description: 'Message Presented To user for action to download transcript',
|
||||
},
|
||||
languageSelectPlaceholder: {
|
||||
id: 'course-authoriong.video-uploads.file-info.transcripts.languageSelectPlaceholder',
|
||||
defaultMessage: 'Select language',
|
||||
description: 'Placeholder For Dropdown, which allows users to set the language associtated with a transcript',
|
||||
},
|
||||
cancelDeleteLabel: {
|
||||
id: 'course-authoriong.video-uploads.file-info.transcripts.cancelDeleteLabel',
|
||||
defaultMessage: 'Cancel',
|
||||
description: 'Label For Button, which allows users to stop the process of deleting a transcript',
|
||||
},
|
||||
confirmDeleteLabel: {
|
||||
id: 'course-authoriong.video-uploads.file-info.transcripts.confirmDeleteLabel',
|
||||
defaultMessage: 'Delete',
|
||||
description: 'Label For Button, which allows users to confirm the process of deleting a transcript',
|
||||
},
|
||||
deleteConfirmationMessage: {
|
||||
id: 'course-authoriong.video-uploads.file-info.transcripts.deleteConfirmationMessage',
|
||||
defaultMessage: 'Are you sure you want to delete this transcript?',
|
||||
description: 'Warning which allows users to select next step in the process of deleting a transcript',
|
||||
},
|
||||
deleteConfirmationHeader: {
|
||||
id: 'course-authoriong.video-uploads.file-info.transcripts.deleteConfirmationTitle',
|
||||
defaultMessage: 'Delete this transcript?',
|
||||
description: 'Title for Warning which allows users to select next step in the process of deleting a transcript',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
18
src/files-and-videos/videos-page/messages.js
Normal file
18
src/files-and-videos/videos-page/messages.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: {
|
||||
id: 'course-authoring.video-uploads.heading',
|
||||
defaultMessage: 'Videos',
|
||||
},
|
||||
transcriptSettingsButtonLabel: {
|
||||
id: 'course-authoring.video-uploads.transcript-settings.button.toggle',
|
||||
defaultMessage: 'Transcript settings',
|
||||
},
|
||||
thumbnailAltMessage: {
|
||||
id: 'course-authoring.video-uploads.thumbnail.alt',
|
||||
defaultMessage: '{displayName} video thumbnail',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -0,0 +1,125 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Form, Stack, TransitionReplace } from '@edx/paragon';
|
||||
import FormDropdown from './FormDropdown';
|
||||
import { getFidelityOptions } from '../data/utils';
|
||||
import messages from './messages';
|
||||
|
||||
const Cielo24Form = ({
|
||||
hasTranscriptCredentials,
|
||||
data,
|
||||
setData,
|
||||
transcriptionPlan,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
if (hasTranscriptCredentials) {
|
||||
const { fidelity } = transcriptionPlan;
|
||||
const selectedLanguage = data.preferredLanguages ? data.preferredLanguages : '';
|
||||
const turnaroundOptions = transcriptionPlan.turnaround;
|
||||
const fidelityOptions = getFidelityOptions(fidelity);
|
||||
const sourceLanguageOptions = data.cielo24Fidelity ? fidelity[data.cielo24Fidelity]?.languages : {};
|
||||
const languages = data.cielo24Fidelity === 'PROFESSIONAL' ? sourceLanguageOptions : {
|
||||
[data.videoSourceLanguage]: sourceLanguageOptions[data.videoSourceLanguage],
|
||||
};
|
||||
return (
|
||||
<Stack gap={1}>
|
||||
<Form.Group size="sm">
|
||||
<Form.Label className="h5">
|
||||
<FormattedMessage {...messages.cieloTurnaroundLabel} />
|
||||
</Form.Label>
|
||||
<FormDropdown
|
||||
value={data.cielo24Turnaround}
|
||||
options={turnaroundOptions}
|
||||
handleSelect={(value) => setData({ ...data, cielo24Turnaround: value })}
|
||||
placeholderText={intl.formatMessage(messages.cieloTurnaroundPlaceholder)}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group size="sm">
|
||||
<Form.Label className="h5">
|
||||
<FormattedMessage {...messages.cieloFidelityLabel} />
|
||||
</Form.Label>
|
||||
<FormDropdown
|
||||
value={data.cielo24Fidelity}
|
||||
options={fidelityOptions}
|
||||
handleSelect={(value) => setData({ ...data, cielo24Fidelity: value, videoSourceLanguage: '' })}
|
||||
placeholderText={intl.formatMessage(messages.cieloFidelityPlaceholder)}
|
||||
/>
|
||||
</Form.Group>
|
||||
<TransitionReplace>
|
||||
{isEmpty(data.cielo24Fidelity) ? null : (
|
||||
<Form.Group size="sm">
|
||||
<Form.Label className="h5">
|
||||
<FormattedMessage {...messages.cieloSourceLanguageLabel} />
|
||||
</Form.Label>
|
||||
<FormDropdown
|
||||
value={data.videoSourceLanguage}
|
||||
options={sourceLanguageOptions}
|
||||
handleSelect={(value) => setData({ ...data, videoSourceLanguage: value, preferredLanguages: [] })}
|
||||
placeholderText={intl.formatMessage(messages.cieloSourceLanguagePlaceholder)}
|
||||
/>
|
||||
</Form.Group>
|
||||
)}
|
||||
</TransitionReplace>
|
||||
<TransitionReplace>
|
||||
{isEmpty(data.videoSourceLanguage) ? null : (
|
||||
<Form.Group size="sm">
|
||||
<Form.Label className="h5">
|
||||
<FormattedMessage {...messages.cieloTranscriptLanguageLabel} />
|
||||
</Form.Label>
|
||||
<FormDropdown
|
||||
value={selectedLanguage}
|
||||
options={languages}
|
||||
handleSelect={(value) => setData({ ...data, preferredLanguages: [value] })}
|
||||
placeholderText={intl.formatMessage(messages.cieloTranscriptLanguagePlaceholder)}
|
||||
/>
|
||||
</Form.Group>
|
||||
)}
|
||||
</TransitionReplace>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={1}>
|
||||
<div className="small" data-testid="cieloCredentialMessage">
|
||||
<FormattedMessage {...messages.cieloCredentialMessage} />
|
||||
</div>
|
||||
<Form.Group size="sm">
|
||||
<Form.Label className="h5">
|
||||
<FormattedMessage {...messages.cieloApiKeyLabel} />
|
||||
</Form.Label>
|
||||
<Form.Control onBlur={(e) => setData({ ...data, apiKey: e.target.value })} />
|
||||
</Form.Group>
|
||||
<Form.Group size="sm">
|
||||
<Form.Label className="h5">
|
||||
<FormattedMessage {...messages.cieloUsernameLabel} />
|
||||
</Form.Label>
|
||||
<Form.Control onBlur={(e) => setData({ ...data, username: e.target.value })} />
|
||||
</Form.Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
Cielo24Form.propTypes = {
|
||||
hasTranscriptCredentials: PropTypes.bool.isRequired,
|
||||
data: PropTypes.shape({
|
||||
apiKey: PropTypes.string,
|
||||
apiSecretKey: PropTypes.string,
|
||||
cielo24Turnaround: PropTypes.string,
|
||||
cielo24Fidelity: PropTypes.string,
|
||||
preferredLanguages: PropTypes.arrayOf(PropTypes.string),
|
||||
videoSourceLanguage: PropTypes.string,
|
||||
}).isRequired,
|
||||
setData: PropTypes.func.isRequired,
|
||||
transcriptionPlan: PropTypes.shape({
|
||||
turnaround: PropTypes.shape({}),
|
||||
fidelity: PropTypes.shape({}),
|
||||
}).isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(Cielo24Form);
|
||||
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import { Dropdown, Form, Icon } from '@edx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Check } from '@edx/paragon/icons';
|
||||
import { isArray, isEmpty } from 'lodash';
|
||||
|
||||
const FormDropdown = ({
|
||||
value,
|
||||
allowMultiple,
|
||||
options,
|
||||
handleSelect,
|
||||
placeholderText,
|
||||
}) => {
|
||||
let currentSelection;
|
||||
if (isEmpty(value)) {
|
||||
currentSelection = placeholderText;
|
||||
} else {
|
||||
currentSelection = isArray(value) && value.length > 1 ? 'Multiple' : options[value];
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
autoClose={allowMultiple ? 'outside' : true}
|
||||
>
|
||||
<Dropdown.Toggle
|
||||
variant="teritary"
|
||||
className="border border-gray-700 justify-content-between w-100"
|
||||
id="transcript-form-dropdown"
|
||||
>
|
||||
<span className="mw-100" style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{currentSelection}
|
||||
</span>
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu className="m-0" style={{ maxHeight: '300px', overflowY: 'scroll' }}>
|
||||
{Object.entries(options).map(([valueKey, text]) => {
|
||||
if (allowMultiple) {
|
||||
return (
|
||||
<Dropdown.Item as={Form.Checkbox} checked={value.includes(valueKey)} onChange={(e) => handleSelect([valueKey, e.target.checked])} key={`${valueKey}-item`}>
|
||||
{text}
|
||||
</Dropdown.Item>
|
||||
);
|
||||
}
|
||||
if (valueKey === value) {
|
||||
return (
|
||||
<Dropdown.Item key={`${valueKey}-item`}>
|
||||
<Icon size="inline" src={Check} className="m-n2" /><span className="pl-3">{text}</span>
|
||||
</Dropdown.Item>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Dropdown.Item onClick={() => handleSelect(valueKey)} key={`${valueKey}-item`}>
|
||||
<span className="pl-3">{text}</span>
|
||||
</Dropdown.Item>
|
||||
);
|
||||
})}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
FormDropdown.propTypes = {
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]).isRequired,
|
||||
allowMultiple: PropTypes.bool,
|
||||
options: PropTypes.shape({}).isRequired,
|
||||
handleSelect: PropTypes.func.isRequired,
|
||||
placeholderText: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
FormDropdown.defaultProps = {
|
||||
allowMultiple: false,
|
||||
};
|
||||
|
||||
export default FormDropdown;
|
||||
@@ -0,0 +1,181 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button, SelectableBox, Stack } from '@edx/paragon';
|
||||
import { ErrorAlert } from '@edx/frontend-lib-content-components';
|
||||
import Cielo24Form from './Cielo24Form';
|
||||
import ThreePlayMediaForm from './ThreePlayMediaForm';
|
||||
import { RequestStatus } from '../../../data/constants';
|
||||
import messages from './messages';
|
||||
import { checkCredentials, checkTranscriptionPlans, validateForm } from '../data/utils';
|
||||
|
||||
const OrderTranscriptForm = ({
|
||||
setTranscriptType,
|
||||
activeTranscriptPreferences,
|
||||
transcriptType,
|
||||
transcriptCredentials,
|
||||
closeTranscriptSettings,
|
||||
handleOrderTranscripts,
|
||||
transcriptionPlans,
|
||||
errorMessages,
|
||||
transcriptStatus,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const [data, setData] = useState(activeTranscriptPreferences || { videoSourceLanguage: '' });
|
||||
|
||||
const [validCieloTranscriptionPlan, validThreePlayTranscriptionPlan] = checkTranscriptionPlans(transcriptionPlans);
|
||||
|
||||
let [cieloHasCredentials, threePlayHasCredentials] = checkCredentials(transcriptCredentials);
|
||||
useEffect(() => {
|
||||
[cieloHasCredentials, threePlayHasCredentials] = checkCredentials(transcriptCredentials);
|
||||
}, [transcriptCredentials]);
|
||||
|
||||
let isFormValid = validateForm(cieloHasCredentials, threePlayHasCredentials, transcriptType, data);
|
||||
useEffect(() => {
|
||||
isFormValid = validateForm(cieloHasCredentials, threePlayHasCredentials, transcriptType, data);
|
||||
}, [data]);
|
||||
|
||||
const handleDiscard = () => {
|
||||
setTranscriptType(activeTranscriptPreferences);
|
||||
closeTranscriptSettings();
|
||||
};
|
||||
|
||||
const handleUpdate = () => handleOrderTranscripts(data, transcriptType);
|
||||
|
||||
let form;
|
||||
switch (transcriptType) {
|
||||
case 'Cielo24':
|
||||
form = (
|
||||
<Cielo24Form
|
||||
{...{
|
||||
hasTranscriptCredentials: cieloHasCredentials,
|
||||
data,
|
||||
setData,
|
||||
transcriptionPlan: transcriptionPlans.Cielo24,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case '3PlayMedia':
|
||||
form = (
|
||||
<ThreePlayMediaForm
|
||||
{...{
|
||||
hasTranscriptCredentials: threePlayHasCredentials,
|
||||
data,
|
||||
setData,
|
||||
transcriptionPlan: transcriptionPlans['3PlayMedia'],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<ErrorAlert
|
||||
hideHeading={false}
|
||||
isError={!validCieloTranscriptionPlan && cieloHasCredentials}
|
||||
>
|
||||
<FormattedMessage {...messages.invalidCielo24TranscriptionPlanMessage} />
|
||||
</ErrorAlert>
|
||||
<ErrorAlert
|
||||
hideHeading={false}
|
||||
isError={!validThreePlayTranscriptionPlan && threePlayHasCredentials}
|
||||
>
|
||||
<FormattedMessage {...messages.invalid3PlayMediaTranscriptionPlanMessage} />
|
||||
</ErrorAlert>
|
||||
<ErrorAlert
|
||||
hideHeading={false}
|
||||
isError={transcriptStatus === RequestStatus.FAILED}
|
||||
>
|
||||
<ul className="p-0">
|
||||
{errorMessages.transcript.map(message => (
|
||||
<li key={`order-transcript-error-${message}`} style={{ listStyle: 'none' }}>
|
||||
{intl.formatMessage(messages.errorAlertMessage, { message })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ErrorAlert>
|
||||
<SelectableBox.Set
|
||||
columns={1}
|
||||
value={transcriptType}
|
||||
name="transcriptProviders"
|
||||
ariaLabel="provider selection"
|
||||
className="my-3"
|
||||
onChange={(e) => {
|
||||
setTranscriptType(e.target.value);
|
||||
}}
|
||||
>
|
||||
<SelectableBox
|
||||
value="order"
|
||||
aria-label="none radio"
|
||||
className="text-center"
|
||||
>
|
||||
<FormattedMessage {...messages.noneLabel} />
|
||||
</SelectableBox>
|
||||
<SelectableBox
|
||||
value="Cielo24"
|
||||
aria-label="Cielo24 radio"
|
||||
className="text-center"
|
||||
disabled={!validCieloTranscriptionPlan && cieloHasCredentials}
|
||||
>
|
||||
<FormattedMessage {...messages.cieloLabel} />
|
||||
</SelectableBox>
|
||||
<SelectableBox
|
||||
value="3PlayMedia"
|
||||
aria-label="3PlayMedia radio"
|
||||
className="text-center"
|
||||
disabled={!validThreePlayTranscriptionPlan && threePlayHasCredentials}
|
||||
>
|
||||
<FormattedMessage {...messages.threePlayMediaLabel} />
|
||||
</SelectableBox>
|
||||
</SelectableBox.Set>
|
||||
{form}
|
||||
<Stack gap={3} className="mt-4">
|
||||
<Button onClick={handleUpdate} disabled={!isFormValid}>
|
||||
<FormattedMessage {...messages.updateSettingsLabel} />
|
||||
</Button>
|
||||
<Button variant="tertiary" onClick={handleDiscard}>
|
||||
<FormattedMessage {...messages.discardSettingsLabel} />
|
||||
</Button>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
OrderTranscriptForm.propTypes = {
|
||||
setTranscriptType: PropTypes.func.isRequired,
|
||||
activeTranscriptPreferences: PropTypes.shape({}),
|
||||
transcriptType: PropTypes.string.isRequired,
|
||||
transcriptCredentials: PropTypes.shape({
|
||||
cielo24: PropTypes.bool.isRequired,
|
||||
'3PlayMedia': PropTypes.bool.isRequired,
|
||||
}).isRequired,
|
||||
closeTranscriptSettings: PropTypes.func.isRequired,
|
||||
transcriptStatus: PropTypes.string.isRequired,
|
||||
errorMessages: PropTypes.shape({
|
||||
transcript: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
}).isRequired,
|
||||
handleOrderTranscripts: PropTypes.func.isRequired,
|
||||
transcriptionPlans: PropTypes.shape({
|
||||
Cielo24: PropTypes.shape({
|
||||
turnaround: PropTypes.shape({}),
|
||||
fidelity: PropTypes.shape({}),
|
||||
}).isRequired,
|
||||
'3PlayMedia': PropTypes.shape({
|
||||
turnaround: PropTypes.shape({}),
|
||||
translations: PropTypes.shape({}),
|
||||
languages: PropTypes.shape({}),
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
OrderTranscriptForm.defaultProps = {
|
||||
activeTranscriptPreferences: null,
|
||||
};
|
||||
|
||||
export default injectIntl(OrderTranscriptForm);
|
||||
@@ -0,0 +1,140 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Form,
|
||||
Icon,
|
||||
Stack,
|
||||
TransitionReplace,
|
||||
} from '@edx/paragon';
|
||||
import { Check } from '@edx/paragon/icons';
|
||||
import FormDropdown from './FormDropdown';
|
||||
import { getLanguageOptions } from '../data/utils';
|
||||
import messages from './messages';
|
||||
|
||||
const ThreePlayMediaForm = ({
|
||||
hasTranscriptCredentials,
|
||||
data,
|
||||
setData,
|
||||
transcriptionPlan,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
if (hasTranscriptCredentials) {
|
||||
const selectedLanguages = data.preferredLanguages ? data.preferredLanguages : [];
|
||||
const turnaroundOptions = transcriptionPlan.turnaround;
|
||||
const sourceLangaugeOptions = getLanguageOptions(
|
||||
Object.keys(transcriptionPlan.translations),
|
||||
transcriptionPlan.languages,
|
||||
);
|
||||
const languages = getLanguageOptions(
|
||||
transcriptionPlan.translations[data.videoSourceLanguage],
|
||||
transcriptionPlan.languages,
|
||||
);
|
||||
const allowMultiple = Object.keys(languages).length > 1;
|
||||
return (
|
||||
<Stack gap={1}>
|
||||
<Form.Group size="sm">
|
||||
<Form.Label className="h5">
|
||||
<FormattedMessage {...messages.threePlayMediaTurnaroundLabel} />
|
||||
</Form.Label>
|
||||
<FormDropdown
|
||||
value={data.threePlayTurnaround}
|
||||
options={turnaroundOptions}
|
||||
handleSelect={(value) => setData({ ...data, threePlayTurnaround: value })}
|
||||
placeholderText={intl.formatMessage(messages.threePlayMediaTurnaroundPlaceholder)}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group size="sm">
|
||||
<Form.Label className="h5">
|
||||
<FormattedMessage {...messages.threePlayMediaSourceLanguageLabel} />
|
||||
</Form.Label>
|
||||
<FormDropdown
|
||||
value={data.videoSourceLanguage}
|
||||
options={sourceLangaugeOptions}
|
||||
handleSelect={(value) => setData({ ...data, videoSourceLanguage: value, preferredLanguages: [] })}
|
||||
placeholderText={intl.formatMessage(messages.threePlayMediaSourceLanguagePlaceholder)}
|
||||
/>
|
||||
</Form.Group>
|
||||
<TransitionReplace>
|
||||
{!isEmpty(data.videoSourceLanguage) ? (
|
||||
<Form.Group size="sm">
|
||||
<Form.Label className="h5">
|
||||
<FormattedMessage {...messages.threePlayMediaTranscriptLanguageLabel} />
|
||||
</Form.Label>
|
||||
<FormDropdown
|
||||
value={selectedLanguages}
|
||||
options={languages}
|
||||
allowMultiple={allowMultiple}
|
||||
handleSelect={(value) => {
|
||||
if (!allowMultiple) {
|
||||
setData({ ...data, preferredLanguages: [value] });
|
||||
} else {
|
||||
const [lang, checked] = value;
|
||||
if (checked) {
|
||||
setData({ ...data, preferredLanguages: [...selectedLanguages, lang] });
|
||||
} else {
|
||||
const updatedLangList = selectedLanguages.filter((selected) => selected !== lang);
|
||||
setData({ ...data, preferredLanguages: updatedLangList });
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholderText={intl.formatMessage(messages.threePlayMediaTranscriptLanguagePlaceholder)}
|
||||
/>
|
||||
<Form.Control.Feedback>
|
||||
<ul className="m-0 p-0">
|
||||
{selectedLanguages.map(language => (
|
||||
<li className="row align-items-center m-0 pt-2" key={language}>
|
||||
<Icon src={Check} size="xs" /> <span>{languages[language]}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Form.Control.Feedback>
|
||||
</Form.Group>
|
||||
) : null }
|
||||
</TransitionReplace>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Stack gap={1}>
|
||||
<div className="small" data-testid="threePlayMediaCredentialMessage">
|
||||
<FormattedMessage {...messages.threePlayMediaCredentialMessage} />
|
||||
</div>
|
||||
<Form.Group size="sm">
|
||||
<Form.Label className="h5">
|
||||
<FormattedMessage {...messages.threePlayMediaApiKeyLabel} />
|
||||
</Form.Label>
|
||||
<Form.Control onBlur={(e) => setData({ ...data, apiKey: e.target.value })} />
|
||||
</Form.Group>
|
||||
<Form.Group size="sm">
|
||||
<Form.Label className="h5">
|
||||
<FormattedMessage {...messages.threePlayMediaApiSecretLabel} />
|
||||
</Form.Label>
|
||||
<Form.Control onBlur={(e) => setData({ ...data, apiSecretKey: e.target.value })} />
|
||||
</Form.Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
ThreePlayMediaForm.propTypes = {
|
||||
hasTranscriptCredentials: PropTypes.bool.isRequired,
|
||||
data: PropTypes.shape({
|
||||
apiKey: PropTypes.string,
|
||||
apiSecretKey: PropTypes.string,
|
||||
threePlayTurnaround: PropTypes.string,
|
||||
preferredLanguages: PropTypes.arrayOf(PropTypes.string),
|
||||
videoSourceLanguage: PropTypes.string,
|
||||
}).isRequired,
|
||||
setData: PropTypes.func.isRequired,
|
||||
transcriptionPlan: PropTypes.shape({
|
||||
turnaround: PropTypes.shape({}),
|
||||
translations: PropTypes.shape({}),
|
||||
languages: PropTypes.shape({}),
|
||||
}).isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ThreePlayMediaForm);
|
||||
@@ -0,0 +1,121 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow,
|
||||
Collapsible,
|
||||
Icon, IconButton,
|
||||
Sheet,
|
||||
TransitionReplace,
|
||||
} from '@edx/paragon';
|
||||
import { ChevronLeft, ChevronRight, Close } from '@edx/paragon/icons';
|
||||
import OrderTranscriptForm from './OrderTranscriptForm';
|
||||
import messages from './messages';
|
||||
import {
|
||||
clearAutomatedTranscript,
|
||||
resetErrors,
|
||||
updateTranscriptCredentials,
|
||||
updateTranscriptPreference,
|
||||
} from '../data/thunks';
|
||||
|
||||
const TranscriptSettings = ({
|
||||
isTranscriptSettingsOpen,
|
||||
closeTranscriptSettings,
|
||||
courseId,
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const { errors: errorMessages, pageSettings, transcriptStatus } = useSelector(state => state.videos);
|
||||
const {
|
||||
activeTranscriptPreferences,
|
||||
transcriptCredentials,
|
||||
videoTranscriptSettings,
|
||||
} = pageSettings;
|
||||
const { transcriptionPlans } = videoTranscriptSettings || {};
|
||||
const [transcriptType, setTranscriptType] = useState(activeTranscriptPreferences);
|
||||
|
||||
const handleOrderTranscripts = (data, provider) => {
|
||||
const noCredentials = isEmpty(transcriptCredentials) || data.apiKey;
|
||||
dispatch(resetErrors({ errorType: 'transcript' }));
|
||||
if (provider === 'order') {
|
||||
dispatch(clearAutomatedTranscript({ courseId }));
|
||||
} else if (noCredentials) {
|
||||
dispatch(updateTranscriptCredentials({ courseId, data: { ...data, provider, global: false } }));
|
||||
} else {
|
||||
dispatch(updateTranscriptPreference({ courseId, data: { ...data, provider, global: false } }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
position="right"
|
||||
blocking
|
||||
show={isTranscriptSettingsOpen}
|
||||
onClose={closeTranscriptSettings}
|
||||
>
|
||||
<div style={{ width: '225px' }}>
|
||||
<ActionRow>
|
||||
<TransitionReplace>
|
||||
{transcriptType ? (
|
||||
<IconButton
|
||||
key="back-button"
|
||||
size="sm"
|
||||
iconAs={Icon}
|
||||
src={ChevronLeft}
|
||||
onClick={() => setTranscriptType(null)}
|
||||
alt="back button to main transcript settings view"
|
||||
/>
|
||||
) : (
|
||||
<div key="title" className="h3">
|
||||
<FormattedMessage {...messages.transcriptSettingsTitle} />
|
||||
</div>
|
||||
)}
|
||||
</TransitionReplace>
|
||||
<ActionRow.Spacer />
|
||||
<IconButton size="sm" iconAs={Icon} src={Close} onClick={closeTranscriptSettings} alt="close settings" />
|
||||
</ActionRow>
|
||||
<TransitionReplace>
|
||||
{transcriptType ? (
|
||||
<div key="transcript-settings">
|
||||
<OrderTranscriptForm
|
||||
{...{
|
||||
setTranscriptType,
|
||||
transcriptType,
|
||||
activeTranscriptPreferences,
|
||||
transcriptCredentials,
|
||||
closeTranscriptSettings,
|
||||
handleOrderTranscripts,
|
||||
transcriptionPlans,
|
||||
errorMessages,
|
||||
transcriptStatus,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div key="transcript-type-selection" className="mt-3">
|
||||
<Collapsible.Advanced
|
||||
onOpen={() => setTranscriptType('order')}
|
||||
>
|
||||
<Collapsible.Trigger
|
||||
className="row m-0 justify-content-between align-items-center"
|
||||
>
|
||||
<FormattedMessage {...messages.orderTranscriptsTitle} />
|
||||
<Icon src={ChevronRight} />
|
||||
</Collapsible.Trigger>
|
||||
</Collapsible.Advanced>
|
||||
</div>
|
||||
)}
|
||||
</TransitionReplace>
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
TranscriptSettings.propTypes = {
|
||||
closeTranscriptSettings: PropTypes.func.isRequired,
|
||||
isTranscriptSettingsOpen: PropTypes.bool.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(TranscriptSettings);
|
||||
@@ -0,0 +1,4 @@
|
||||
.pgn__selectable_box:disabled,
|
||||
.pgn__selectable_box[disabled] {
|
||||
opacity: .5;
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
import {
|
||||
render,
|
||||
act,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {
|
||||
initializeMockApp,
|
||||
} from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import initializeStore from '../../../store';
|
||||
import { RequestStatus } from '../../../data/constants';
|
||||
import TranscriptSettings from './TranscriptSettings';
|
||||
import {
|
||||
courseId,
|
||||
initialState,
|
||||
} from '../factories/mockApiResponses';
|
||||
import { getApiBaseUrl } from '../data/api';
|
||||
import messages from './messages';
|
||||
import VideosProvider from '../VideosProvider';
|
||||
|
||||
const defaultProps = {
|
||||
isTranscriptSettingsOpen: true,
|
||||
closeTranscriptSettings: jest.fn(),
|
||||
courseId,
|
||||
};
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
const renderComponent = () => {
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<VideosProvider courseId={courseId}>
|
||||
<TranscriptSettings {...defaultProps} />
|
||||
</VideosProvider>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('TranscriptSettings', () => {
|
||||
describe('default behaviors', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore(initialState);
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
it('should have Transcript settings title', async () => {
|
||||
renderComponent();
|
||||
const header = screen.getByText(messages.transcriptSettingsTitle.defaultMessage);
|
||||
|
||||
expect(header).toBeVisible();
|
||||
});
|
||||
|
||||
it('should change view to order form', async () => {
|
||||
renderComponent(defaultProps);
|
||||
const orderButton = screen.getByText(messages.orderTranscriptsTitle.defaultMessage);
|
||||
await act(async () => {
|
||||
userEvent.click(orderButton);
|
||||
});
|
||||
const selectableButtons = screen.getAllByLabelText('none radio')[0];
|
||||
|
||||
expect(selectableButtons).toBeVisible();
|
||||
});
|
||||
|
||||
it('should return to order transcript collapsible', async () => {
|
||||
renderComponent(defaultProps);
|
||||
const orderButton = screen.getByText(messages.orderTranscriptsTitle.defaultMessage);
|
||||
await act(async () => {
|
||||
userEvent.click(orderButton);
|
||||
});
|
||||
const selectableButtons = screen.getAllByLabelText('none radio')[0];
|
||||
|
||||
expect(selectableButtons).toBeVisible();
|
||||
|
||||
const backButton = screen.getByLabelText('back button to main transcript settings view');
|
||||
await waitFor(() => {
|
||||
userEvent.click(backButton);
|
||||
|
||||
expect(screen.queryByLabelText('back button to main transcript settings view')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('discard changes should call closeTranscriptSettings', async () => {
|
||||
renderComponent(defaultProps);
|
||||
const orderButton = screen.getByText(messages.orderTranscriptsTitle.defaultMessage);
|
||||
await act(async () => {
|
||||
userEvent.click(orderButton);
|
||||
});
|
||||
const discardButton = screen.getByText(messages.discardSettingsLabel.defaultMessage);
|
||||
await act(async () => {
|
||||
userEvent.click(discardButton);
|
||||
});
|
||||
|
||||
expect(defaultProps.closeTranscriptSettings).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete transcript preferences', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore(initialState);
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
renderComponent(defaultProps);
|
||||
const orderButton = screen.getByText(messages.orderTranscriptsTitle.defaultMessage);
|
||||
await act(async () => {
|
||||
userEvent.click(orderButton);
|
||||
});
|
||||
const cielo24Button = screen.getAllByLabelText('Cielo24 radio')[0];
|
||||
await act(async () => {
|
||||
userEvent.click(cielo24Button);
|
||||
});
|
||||
const noneButton = screen.getAllByLabelText('none radio')[0];
|
||||
await act(async () => {
|
||||
userEvent.click(noneButton);
|
||||
});
|
||||
});
|
||||
|
||||
it('api should succeed', async () => {
|
||||
const updateButton = screen.getByText(messages.updateSettingsLabel.defaultMessage);
|
||||
|
||||
axiosMock.onDelete(`${getApiBaseUrl()}/transcript_preferences/${courseId}`).reply(204);
|
||||
await waitFor(() => {
|
||||
userEvent.click(updateButton);
|
||||
});
|
||||
const { transcriptStatus } = store.getState().videos;
|
||||
|
||||
expect(transcriptStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
});
|
||||
|
||||
it('should show error alert', async () => {
|
||||
const updateButton = screen.getByText(messages.updateSettingsLabel.defaultMessage);
|
||||
|
||||
axiosMock.onDelete(`${getApiBaseUrl()}/transcript_preferences/${courseId}`).reply(404);
|
||||
await waitFor(() => {
|
||||
userEvent.click(updateButton);
|
||||
});
|
||||
const { transcriptStatus } = store.getState().videos;
|
||||
|
||||
expect(transcriptStatus).toEqual(RequestStatus.FAILED);
|
||||
|
||||
expect(screen.getByText('Failed to update order transcripts settings.')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with no credentials set', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore(initialState);
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
renderComponent(defaultProps);
|
||||
const orderButton = screen.getByText(messages.orderTranscriptsTitle.defaultMessage);
|
||||
await act(async () => {
|
||||
userEvent.click(orderButton);
|
||||
});
|
||||
});
|
||||
|
||||
it('should ask for Cielo24 or 3Play Media credentials', async () => {
|
||||
const cielo24Button = screen.getAllByLabelText('Cielo24 radio')[0];
|
||||
await act(async () => {
|
||||
userEvent.click(cielo24Button);
|
||||
});
|
||||
const cieloCredentialMessage = screen.getByTestId('cieloCredentialMessage');
|
||||
|
||||
expect(cieloCredentialMessage).toBeVisible();
|
||||
|
||||
const threePlayMediaButton = screen.getAllByLabelText('3PlayMedia radio')[0];
|
||||
await act(async () => {
|
||||
userEvent.click(threePlayMediaButton);
|
||||
});
|
||||
const threePlayMediaCredentialMessage = screen.getByTestId('threePlayMediaCredentialMessage');
|
||||
|
||||
expect(threePlayMediaCredentialMessage).toBeVisible();
|
||||
});
|
||||
|
||||
describe('api succeeds', () => {
|
||||
it('should update cielo24 credentials ', async () => {
|
||||
const cielo24Button = screen.getAllByLabelText('Cielo24 radio')[0];
|
||||
await act(async () => {
|
||||
userEvent.click(cielo24Button);
|
||||
});
|
||||
|
||||
const firstInput = screen.getByLabelText(messages.cieloApiKeyLabel.defaultMessage);
|
||||
const secondInput = screen.getByLabelText(messages.cieloUsernameLabel.defaultMessage);
|
||||
const updateButton = screen.getByText(messages.updateSettingsLabel.defaultMessage);
|
||||
|
||||
await waitFor(() => {
|
||||
userEvent.type(firstInput, 'apiKey');
|
||||
userEvent.type(secondInput, 'username');
|
||||
|
||||
expect(updateButton).not.toHaveAttribute('disabled');
|
||||
});
|
||||
|
||||
axiosMock.onPost(`${getApiBaseUrl()}/transcript_credentials/${courseId}`).reply(200);
|
||||
await waitFor(() => {
|
||||
userEvent.click(updateButton);
|
||||
});
|
||||
|
||||
const { transcriptStatus } = store.getState().videos;
|
||||
|
||||
expect(transcriptStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
|
||||
expect(screen.queryByTestId('cieloCredentialMessage')).toBeNull();
|
||||
|
||||
expect(screen.getByText(messages.cieloFidelityLabel.defaultMessage)).toBeVisible();
|
||||
});
|
||||
|
||||
it('should update 3Play Media credentials', async () => {
|
||||
const threePlayButton = screen.getAllByLabelText('3PlayMedia radio')[0];
|
||||
await act(async () => {
|
||||
userEvent.click(threePlayButton);
|
||||
});
|
||||
|
||||
const updateButton = screen.getByText(messages.updateSettingsLabel.defaultMessage);
|
||||
const firstInput = screen.getByLabelText(messages.threePlayMediaApiKeyLabel.defaultMessage);
|
||||
const secondInput = screen.getByLabelText(messages.threePlayMediaApiSecretLabel.defaultMessage);
|
||||
|
||||
await waitFor(() => {
|
||||
userEvent.type(firstInput, 'apiKey');
|
||||
userEvent.type(secondInput, 'secretKey');
|
||||
|
||||
expect(updateButton).not.toHaveAttribute('disabled');
|
||||
});
|
||||
|
||||
axiosMock.onPost(`${getApiBaseUrl()}/transcript_credentials/${courseId}`).reply(200);
|
||||
await waitFor(() => {
|
||||
userEvent.click(updateButton);
|
||||
});
|
||||
const { transcriptStatus } = store.getState().videos;
|
||||
|
||||
expect(transcriptStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
|
||||
expect(screen.queryByTestId('threePlayCredentialMessage')).toBeNull();
|
||||
|
||||
expect(screen.getByText(messages.threePlayMediaTurnaroundLabel.defaultMessage)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('api fails', () => {
|
||||
it('should show error alert on Cielo24 credentials update', async () => {
|
||||
const cielo24Button = screen.getAllByLabelText('Cielo24 radio')[0];
|
||||
await act(async () => {
|
||||
userEvent.click(cielo24Button);
|
||||
});
|
||||
|
||||
const firstInput = screen.getByLabelText(messages.cieloApiKeyLabel.defaultMessage);
|
||||
const secondInput = screen.getByLabelText(messages.cieloUsernameLabel.defaultMessage);
|
||||
const updateButton = screen.getByText(messages.updateSettingsLabel.defaultMessage);
|
||||
|
||||
await waitFor(() => {
|
||||
userEvent.type(firstInput, 'apiKey');
|
||||
userEvent.type(secondInput, 'username');
|
||||
|
||||
expect(updateButton).not.toHaveAttribute('disabled');
|
||||
});
|
||||
|
||||
axiosMock.onPost(`${getApiBaseUrl()}/transcript_preferences/${courseId}`).reply(503);
|
||||
await waitFor(() => {
|
||||
userEvent.click(updateButton);
|
||||
});
|
||||
const { transcriptStatus } = store.getState().videos;
|
||||
|
||||
expect(transcriptStatus).toEqual(RequestStatus.FAILED);
|
||||
|
||||
expect(screen.getByText('Failed to update Cielo24 credentials.')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should show error alert on 3PlayMedia credentials update', async () => {
|
||||
const threePlayButton = screen.getAllByLabelText('3PlayMedia radio')[0];
|
||||
await act(async () => {
|
||||
userEvent.click(threePlayButton);
|
||||
});
|
||||
|
||||
const updateButton = screen.getByText(messages.updateSettingsLabel.defaultMessage);
|
||||
const firstInput = screen.getByLabelText(messages.threePlayMediaApiKeyLabel.defaultMessage);
|
||||
const secondInput = screen.getByLabelText(messages.threePlayMediaApiSecretLabel.defaultMessage);
|
||||
|
||||
await waitFor(() => {
|
||||
userEvent.type(firstInput, 'apiKey');
|
||||
userEvent.type(secondInput, 'secretKey');
|
||||
|
||||
expect(updateButton).not.toHaveAttribute('disabled');
|
||||
});
|
||||
|
||||
axiosMock.onPost(`${getApiBaseUrl()}/transcript_preferences/${courseId}`).reply(404);
|
||||
await waitFor(() => {
|
||||
userEvent.click(updateButton);
|
||||
});
|
||||
const { transcriptStatus } = store.getState().videos;
|
||||
|
||||
expect(transcriptStatus).toEqual(RequestStatus.FAILED);
|
||||
|
||||
expect(screen.getByText('Failed to update 3PlayMedia credentials.')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with credentials set', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore({
|
||||
...initialState,
|
||||
videos: {
|
||||
...initialState.videos,
|
||||
pageSettings: {
|
||||
...initialState.videos.pageSettings,
|
||||
transcriptCredentials: {
|
||||
cielo24: true,
|
||||
'3PlayMedia': true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
renderComponent(defaultProps);
|
||||
const orderButton = screen.getByText(messages.orderTranscriptsTitle.defaultMessage);
|
||||
await act(async () => {
|
||||
userEvent.click(orderButton);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not show credentials request for Cielo24 and 3Play Media', async () => {
|
||||
const cielo24Button = screen.getAllByLabelText('Cielo24 radio')[0];
|
||||
await act(async () => {
|
||||
userEvent.click(cielo24Button);
|
||||
});
|
||||
const cieloCredentialMessage = screen.queryByTestId('cieloCredentialMessage');
|
||||
|
||||
expect(cieloCredentialMessage).toBeNull();
|
||||
|
||||
const threePlayMediaButton = screen.getAllByLabelText('3PlayMedia radio')[0];
|
||||
await act(async () => {
|
||||
userEvent.click(threePlayMediaButton);
|
||||
});
|
||||
const threePlayMediaCredentialMessage = screen.queryByTestId('threePlayMediaCredentialMessage');
|
||||
|
||||
expect(threePlayMediaCredentialMessage).toBeNull();
|
||||
});
|
||||
|
||||
describe('api succeeds', () => {
|
||||
it('should update cielo24 preferences', async () => {
|
||||
const apiResponse = {
|
||||
videoSourceLanguage: 'en',
|
||||
cielo24Turnaround: 'PRIORITY',
|
||||
cielo24FidelityTypee: 'PREMIUM',
|
||||
preferredLanguages: ['en'],
|
||||
provider: 'cielo24',
|
||||
global: false,
|
||||
};
|
||||
|
||||
const cielo24Button = screen.getAllByLabelText('Cielo24 radio')[0];
|
||||
await act(async () => {
|
||||
userEvent.click(cielo24Button);
|
||||
});
|
||||
const updateButton = screen.getByText(messages.updateSettingsLabel.defaultMessage);
|
||||
const turnaround = screen.getByText(messages.cieloTurnaroundPlaceholder.defaultMessage);
|
||||
const fidelity = screen.getByText(messages.cieloFidelityPlaceholder.defaultMessage);
|
||||
|
||||
await waitFor(() => {
|
||||
userEvent.click(turnaround);
|
||||
userEvent.click(screen.getByText('Priority (24 hours)'));
|
||||
|
||||
userEvent.click(fidelity);
|
||||
userEvent.click(screen.getByText('Premium (95% accuracy)'));
|
||||
|
||||
const source = screen.getAllByText(messages.cieloSourceLanguagePlaceholder.defaultMessage)[0];
|
||||
userEvent.click(source);
|
||||
userEvent.click(screen.getByText('English'));
|
||||
|
||||
const language = screen.getByText(messages.cieloTranscriptLanguagePlaceholder.defaultMessage);
|
||||
userEvent.click(language);
|
||||
userEvent.click(screen.getAllByText('English')[2]);
|
||||
});
|
||||
|
||||
expect(updateButton).not.toHaveAttribute('disabled');
|
||||
|
||||
axiosMock.onPost(`${getApiBaseUrl()}/transcript_preferences/${courseId}`).reply(200, apiResponse);
|
||||
await waitFor(() => {
|
||||
userEvent.click(updateButton);
|
||||
});
|
||||
const { transcriptStatus } = store.getState().videos;
|
||||
|
||||
expect(transcriptStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
|
||||
expect(screen.getByText(messages.cieloFidelityLabel.defaultMessage)).toBeVisible();
|
||||
});
|
||||
|
||||
it('should update 3Play Media preferences with english as source language', async () => {
|
||||
const apiResponse = {
|
||||
videoSourceLanguage: 'en',
|
||||
threePlayTurnaround: 'two_hour',
|
||||
preferredLanguages: ['ar', 'fr'],
|
||||
provider: '3PlayMedia',
|
||||
global: false,
|
||||
};
|
||||
const threePlayButton = screen.getAllByLabelText('3PlayMedia radio')[0];
|
||||
await act(async () => {
|
||||
userEvent.click(threePlayButton);
|
||||
});
|
||||
const updateButton = screen.getByText(messages.updateSettingsLabel.defaultMessage);
|
||||
const turnaround = screen.getByText(messages.threePlayMediaTurnaroundPlaceholder.defaultMessage);
|
||||
const source = screen.getByText(messages.threePlayMediaSourceLanguagePlaceholder.defaultMessage);
|
||||
|
||||
await waitFor(() => {
|
||||
userEvent.click(turnaround);
|
||||
userEvent.click(screen.getByText('2 hours'));
|
||||
|
||||
userEvent.click(source);
|
||||
userEvent.click(screen.getByText('English'));
|
||||
|
||||
const language = screen.getByText(messages.threePlayMediaTranscriptLanguagePlaceholder.defaultMessage);
|
||||
userEvent.click(language);
|
||||
userEvent.click(screen.getByText('Arabic'));
|
||||
userEvent.click(screen.getByText('French'));
|
||||
userEvent.click(screen.getAllByText('Arabic')[0]);
|
||||
|
||||
expect(updateButton).not.toHaveAttribute('disabled');
|
||||
});
|
||||
|
||||
axiosMock.onPost(`${getApiBaseUrl()}/transcript_preferences/${courseId}`).reply(200, apiResponse);
|
||||
await waitFor(() => {
|
||||
userEvent.click(updateButton);
|
||||
});
|
||||
const { transcriptStatus } = store.getState().videos;
|
||||
|
||||
expect(transcriptStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
});
|
||||
|
||||
it('should update 3Play Media preferences with spanish as source language', async () => {
|
||||
const apiResponse = {
|
||||
videoSourceLanguage: 'en',
|
||||
threePlayTurnaround: 'two_hour',
|
||||
preferredLanguages: ['ar', 'fr'],
|
||||
provider: '3PlayMedia',
|
||||
global: false,
|
||||
};
|
||||
const threePlayButton = screen.getAllByLabelText('3PlayMedia radio')[0];
|
||||
await act(async () => {
|
||||
userEvent.click(threePlayButton);
|
||||
});
|
||||
const updateButton = screen.getByText(messages.updateSettingsLabel.defaultMessage);
|
||||
const turnaround = screen.getByText(messages.threePlayMediaTurnaroundPlaceholder.defaultMessage);
|
||||
const source = screen.getByText(messages.threePlayMediaSourceLanguagePlaceholder.defaultMessage);
|
||||
|
||||
await waitFor(() => {
|
||||
userEvent.click(turnaround);
|
||||
userEvent.click(screen.getByText('2 hours'));
|
||||
|
||||
userEvent.click(source);
|
||||
userEvent.click(screen.getByText('Spanish'));
|
||||
|
||||
const language = screen.getByText(messages.threePlayMediaTranscriptLanguagePlaceholder.defaultMessage);
|
||||
userEvent.click(language);
|
||||
userEvent.click(screen.getAllByText('English')[1]);
|
||||
});
|
||||
expect(updateButton).not.toHaveAttribute('disabled');
|
||||
|
||||
axiosMock.onPost(`${getApiBaseUrl()}/transcript_preferences/${courseId}`).reply(200, apiResponse);
|
||||
await waitFor(() => {
|
||||
userEvent.click(updateButton);
|
||||
});
|
||||
const { transcriptStatus } = store.getState().videos;
|
||||
|
||||
expect(transcriptStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
});
|
||||
});
|
||||
|
||||
describe('api fails', () => {
|
||||
it('should show error alert on Cielo24 preferences update', async () => {
|
||||
const cielo24Button = screen.getAllByLabelText('Cielo24 radio')[0];
|
||||
await act(async () => {
|
||||
userEvent.click(cielo24Button);
|
||||
});
|
||||
const updateButton = screen.getByText(messages.updateSettingsLabel.defaultMessage);
|
||||
const turnaround = screen.getByText(messages.cieloTurnaroundPlaceholder.defaultMessage);
|
||||
const fidelity = screen.getByText(messages.cieloFidelityPlaceholder.defaultMessage);
|
||||
|
||||
await waitFor(() => {
|
||||
userEvent.click(turnaround);
|
||||
userEvent.click(screen.getByText('Priority (24 hours)'));
|
||||
|
||||
userEvent.click(fidelity);
|
||||
userEvent.click(screen.getByText('Premium (95% accuracy)'));
|
||||
|
||||
const source = screen.getAllByText(messages.cieloSourceLanguagePlaceholder.defaultMessage)[0];
|
||||
userEvent.click(source);
|
||||
userEvent.click(screen.getByText('English'));
|
||||
|
||||
const language = screen.getByText(messages.cieloTranscriptLanguagePlaceholder.defaultMessage);
|
||||
userEvent.click(language);
|
||||
userEvent.click(screen.getAllByText('English')[2]);
|
||||
});
|
||||
|
||||
expect(updateButton).not.toHaveAttribute('disabled');
|
||||
|
||||
axiosMock.onPost(`${getApiBaseUrl()}/transcript_preferences/${courseId}`).reply(503);
|
||||
await waitFor(() => {
|
||||
userEvent.click(updateButton);
|
||||
});
|
||||
const { transcriptStatus } = store.getState().videos;
|
||||
|
||||
expect(transcriptStatus).toEqual(RequestStatus.FAILED);
|
||||
|
||||
expect(screen.getByText('Failed to update Cielo24 transcripts settings.')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should show error alert on 3PlayMedia preferences update', async () => {
|
||||
const threePlayButton = screen.getAllByLabelText('3PlayMedia radio')[0];
|
||||
await act(async () => {
|
||||
userEvent.click(threePlayButton);
|
||||
});
|
||||
const updateButton = screen.getByText(messages.updateSettingsLabel.defaultMessage);
|
||||
const turnaround = screen.getByText(messages.threePlayMediaTurnaroundPlaceholder.defaultMessage);
|
||||
const source = screen.getByText(messages.threePlayMediaSourceLanguagePlaceholder.defaultMessage);
|
||||
|
||||
await waitFor(() => {
|
||||
userEvent.click(turnaround);
|
||||
userEvent.click(screen.getByText('2 hours'));
|
||||
|
||||
userEvent.click(source);
|
||||
userEvent.click(screen.getByText('Spanish'));
|
||||
|
||||
const language = screen.getByText(messages.threePlayMediaTranscriptLanguagePlaceholder.defaultMessage);
|
||||
userEvent.click(language);
|
||||
userEvent.click(screen.getAllByText('English')[1]);
|
||||
});
|
||||
expect(updateButton).not.toHaveAttribute('disabled');
|
||||
|
||||
axiosMock.onPost(`${getApiBaseUrl()}/transcript_preferences/${courseId}`).reply(404);
|
||||
await waitFor(() => {
|
||||
userEvent.click(updateButton);
|
||||
});
|
||||
const { transcriptStatus } = store.getState().videos;
|
||||
|
||||
expect(transcriptStatus).toEqual(RequestStatus.FAILED);
|
||||
|
||||
expect(screen.getByText('Failed to update 3PlayMedia transcripts settings.')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import TranscriptSettings from './TranscriptSettings';
|
||||
|
||||
export default TranscriptSettings;
|
||||
153
src/files-and-videos/videos-page/transcript-settings/messages.js
Normal file
153
src/files-and-videos/videos-page/transcript-settings/messages.js
Normal file
@@ -0,0 +1,153 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
transcriptSettingsTitle: {
|
||||
id: 'course-authoring.video-uploads.transcriptSettings.title',
|
||||
defaultMessage: 'Transcript settings',
|
||||
description: 'Title for transcript settings sheet',
|
||||
},
|
||||
invalidCielo24TranscriptionPlanMessage: {
|
||||
id: 'course-authoring.video-uploads.transcriptSettings.cielo24.errorAlert.message',
|
||||
defaultMessage: 'No transcription plans found for Cielo24.',
|
||||
},
|
||||
invalid3PlayMediaTranscriptionPlanMessage: {
|
||||
id: 'course-authoring.video-uploads.transcriptSettings.3PlayMedia.errorAlert.message',
|
||||
defaultMessage: 'No transcription plans found for 3PlayMedia.',
|
||||
},
|
||||
errorAlertMessage: {
|
||||
id: 'course-authoring.video-uploads.transcriptSettings.errorAlert.message',
|
||||
defaultMessage: '{message}',
|
||||
},
|
||||
orderTranscriptsTitle: {
|
||||
id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.title',
|
||||
defaultMessage: 'Order transcripts',
|
||||
description: 'Title for order transcript collapsible',
|
||||
},
|
||||
noneLabel: {
|
||||
id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.none.label',
|
||||
defaultMessage: 'None',
|
||||
description: 'Label for order transcript None option',
|
||||
},
|
||||
cieloLabel: {
|
||||
id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.cielo24.label',
|
||||
defaultMessage: 'Cielo24',
|
||||
description: 'Label for order transcript Cieol24 option',
|
||||
},
|
||||
threePlayMediaLabel: {
|
||||
id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.3PlayMedia.label',
|
||||
defaultMessage: '3Play Media',
|
||||
description: 'Label for order transcript 3Play Media option',
|
||||
},
|
||||
updateSettingsLabel: {
|
||||
id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.updateSettings.label',
|
||||
defaultMessage: 'Update settings',
|
||||
description: 'Label for order transcript update settings button',
|
||||
},
|
||||
discardSettingsLabel: {
|
||||
id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.discardSettings.label',
|
||||
defaultMessage: 'Discard settings',
|
||||
description: 'Label for order transcript discard settings button',
|
||||
},
|
||||
threePlayMediaTurnaroundLabel: {
|
||||
id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.3PlayMedia.turnaround.label',
|
||||
defaultMessage: 'Transcript turnaround',
|
||||
description: 'Label for 3Play Media transcript turnaround dropdown',
|
||||
},
|
||||
threePlayMediaTurnaroundPlaceholder: {
|
||||
id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.3PlayMedia.turnaround.dropdown.placeholder',
|
||||
defaultMessage: 'Select turnaround',
|
||||
description: 'Label for 3Play Media transcript turnaround dropdown placeholder',
|
||||
},
|
||||
threePlayMediaSourceLanguageLabel: {
|
||||
id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.3PlayMedia.sourceLanguage.label',
|
||||
defaultMessage: 'Video source language',
|
||||
description: 'Label for 3Play Media video source language dropdown',
|
||||
},
|
||||
threePlayMediaSourceLanguagePlaceholder: {
|
||||
id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.3PlayMedia.sourceLanguage.dropdown.placeholder',
|
||||
defaultMessage: 'Select language',
|
||||
description: 'Label for 3Play Media video source language dropdown placeholder',
|
||||
},
|
||||
threePlayMediaTranscriptLanguageLabel: {
|
||||
id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.3PlayMedia.transcriptLanguage.label',
|
||||
defaultMessage: 'Transcript language',
|
||||
description: 'Label for 3Play Media video source language dropdown',
|
||||
},
|
||||
threePlayMediaTranscriptLanguagePlaceholder: {
|
||||
id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.3PlayMedia.transcriptLanguage.dropdown.placeholder',
|
||||
defaultMessage: 'Select language(s)',
|
||||
description: 'Label for 3Play Media transcript language dropdown placeholder',
|
||||
},
|
||||
threePlayMediaCredentialMessage: {
|
||||
id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.3PlayMedia.credential.message',
|
||||
defaultMessage: 'Enter the account information for your organization.',
|
||||
description: 'Message for 3Play Media credential view',
|
||||
},
|
||||
threePlayMediaApiKeyLabel: {
|
||||
id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.3PlayMedia.apiKey.label',
|
||||
defaultMessage: 'API key',
|
||||
description: 'Label for 3Play Media API key input',
|
||||
},
|
||||
threePlayMediaApiSecretLabel: {
|
||||
id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.3PlayMedia.apiSecret.label',
|
||||
defaultMessage: 'API secret',
|
||||
description: 'Label for 3Play Media API secret input',
|
||||
},
|
||||
cieloTurnaroundLabel: {
|
||||
id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.cielo24.turnaround.label',
|
||||
defaultMessage: 'Transcript turnaround',
|
||||
description: 'Label for Cielo24 transcript turnaround dropdown',
|
||||
},
|
||||
cieloTurnaroundPlaceholder: {
|
||||
id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.cielo24.turnaround.dropdown.placeholder',
|
||||
defaultMessage: 'Select turnaround',
|
||||
description: 'Label for Cielo24 transcript turnaround dropdown placeholder',
|
||||
},
|
||||
cieloFidelityLabel: {
|
||||
id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.cielo24.fidelity.label',
|
||||
defaultMessage: 'Transcript fidelity',
|
||||
description: 'Label for Cielo24 transcript fidelity dropdown',
|
||||
},
|
||||
cieloFidelityPlaceholder: {
|
||||
id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.cielo24.fidelity.dropdown.placeholder',
|
||||
defaultMessage: 'Select fidelity',
|
||||
description: 'Label for Cielo24 transcript fidelity dropdown placeholder',
|
||||
},
|
||||
cieloSourceLanguageLabel: {
|
||||
id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.cielo24.sourceLanguage.label',
|
||||
defaultMessage: 'Video source language',
|
||||
description: 'Label for Cielo24 video source language dropdown',
|
||||
},
|
||||
cieloSourceLanguagePlaceholder: {
|
||||
id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.cielo24.sourceLanguage.dropdown.placeholder',
|
||||
defaultMessage: 'Select language',
|
||||
description: 'Label for Cielo24 video source language dropdown placeholder',
|
||||
},
|
||||
cieloTranscriptLanguageLabel: {
|
||||
id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.cielo24.transcriptLanguage.label',
|
||||
defaultMessage: 'Transcript language',
|
||||
description: 'Label for Cielo24 video source language dropdown',
|
||||
},
|
||||
cieloTranscriptLanguagePlaceholder: {
|
||||
id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.cielo24.transcriptLanguage.dropdown.placeholder',
|
||||
defaultMessage: 'Select language',
|
||||
description: 'Label for Cielo24 transcript language dropdown placeholder',
|
||||
},
|
||||
cieloCredentialMessage: {
|
||||
id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.cielo24.credential.message',
|
||||
defaultMessage: 'Enter the account information for your organization.',
|
||||
description: 'Message for Cielo24 credential view',
|
||||
},
|
||||
cieloApiKeyLabel: {
|
||||
id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.cielo24.apiKey.label',
|
||||
defaultMessage: 'API key',
|
||||
description: 'Label for Cielo24 API key input',
|
||||
},
|
||||
cieloUsernameLabel: {
|
||||
id: 'course-authoring.video-uploads.transcriptSettings.orderTranscripts.cielo24.username.label',
|
||||
defaultMessage: 'Username',
|
||||
description: 'Label for Cielo24 username input',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -43,7 +43,7 @@ const messages = defineMessages({
|
||||
},
|
||||
'header.links.videoUploads': {
|
||||
id: 'header.links.videoUploads',
|
||||
defaultMessage: 'Video Uploads',
|
||||
defaultMessage: 'Videos',
|
||||
description: 'Link to Studio Video Uploads page',
|
||||
},
|
||||
'header.links.scheduleAndDetails': {
|
||||
|
||||
@@ -19,4 +19,4 @@
|
||||
@import "course-updates/CourseUpdates";
|
||||
@import "export-page/CourseExportPage";
|
||||
@import "import-page/CourseImportPage";
|
||||
@import "files-and-uploads/table-components/GalleryCard";
|
||||
@import "files-and-videos";
|
||||
|
||||
@@ -10,7 +10,7 @@ import { reducer as gradingSettingsReducer } from './grading-settings/data/slice
|
||||
import { reducer as studioHomeReducer } from './studio-home/data/slice';
|
||||
import { reducer as scheduleAndDetailsReducer } from './schedule-and-details/data/slice';
|
||||
import { reducer as liveReducer } from './pages-and-resources/live/data/slice';
|
||||
import { reducer as filesReducer } from './files-and-uploads/data/slice';
|
||||
import { reducer as filesReducer } from './files-and-videos/data/slice';
|
||||
import { reducer as courseTeamReducer } from './course-team/data/slice';
|
||||
import { reducer as CourseUpdatesReducer } from './course-updates/data/slice';
|
||||
import { reducer as processingNotificationReducer } from './generic/processing-notification/data/slice';
|
||||
@@ -18,6 +18,7 @@ import { reducer as helpUrlsReducer } from './help-urls/data/slice';
|
||||
import { reducer as courseExportReducer } from './export-page/data/slice';
|
||||
import { reducer as genericReducer } from './generic/data/slice';
|
||||
import { reducer as courseImportReducer } from './import-page/data/slice';
|
||||
import { reducer as videosReducer } from './files-and-videos/videos-page/data/slice';
|
||||
|
||||
export default function initializeStore(preloadedState = undefined) {
|
||||
return configureStore({
|
||||
@@ -40,6 +41,7 @@ export default function initializeStore(preloadedState = undefined) {
|
||||
courseExport: courseExportReducer,
|
||||
generic: genericReducer,
|
||||
courseImport: courseImportReducer,
|
||||
videos: videosReducer,
|
||||
},
|
||||
preloadedState,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user