feat: add video page (#640)

This commit is contained in:
Kristin Aoki
2023-11-06 08:51:21 -05:00
committed by GitHub
parent 221fcf77dc
commit a28338df30
84 changed files with 6165 additions and 913 deletions

View File

@@ -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
View File

@@ -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",

View File

@@ -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"

View File

@@ -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/*"

View File

@@ -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);

View File

@@ -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);

View File

@@ -1 +0,0 @@
export { default } from './FilesAndUploads';

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View 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);

View 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);

View File

@@ -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;

View File

@@ -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);

View 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);

View 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;

View File

@@ -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;

View File

@@ -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}.` }));

View File

@@ -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`;
}

View File

@@ -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);
});
});
});

View File

@@ -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);

View 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);

View 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);

View File

@@ -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,

View File

@@ -1,4 +1,4 @@
import { RequestStatus } from '../../data/constants';
import { RequestStatus } from '../../../data/constants';
export const courseId = 'course-v1:edX+DemoX+Demo_Course';

View File

@@ -0,0 +1,3 @@
import FilesAndUploads from './FilesAndUploads';
export default FilesAndUploads;

View 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;

View 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';

View 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"

View File

@@ -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',

View 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;

View File

@@ -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);

View File

@@ -1,9 +1,7 @@
import GalleryCard from './GalleryCard';
import ListCard from './ListCard';
import TableActions from './TableActions';
export {
TableActions,
GalleryCard,
ListCard,
};

View File

@@ -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;
};

View File

@@ -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);

View File

@@ -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;

View 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);

View 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;
}

View 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);

View 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();
});
});
});
});

View 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;

View 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);
}

View 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);
});
});
});
});

View 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;

View 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;

View 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 }));
}
};
}

View 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;
};

View 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);
});
});
});

View 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;
}
};

View File

@@ -0,0 +1,6 @@
import TranscriptSettings from './transcript-settings';
import Videos from './Videos';
import VideoThumbnail from './VideoThumbnail';
export default Videos;
export { TranscriptSettings, VideoThumbnail };

View File

@@ -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);

View 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);

View 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);

View File

@@ -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();
});
});
});
});

View 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;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);

View File

@@ -0,0 +1,3 @@
import Transcript from './Transcript';
export default Transcript;

View File

@@ -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;

View 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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -0,0 +1,4 @@
.pgn__selectable_box:disabled,
.pgn__selectable_box[disabled] {
opacity: .5;
}

View File

@@ -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();
});
});
});
});

View File

@@ -0,0 +1,3 @@
import TranscriptSettings from './TranscriptSettings';
export default TranscriptSettings;

View 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;

View File

@@ -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': {

View File

@@ -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";

View File

@@ -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,
});