From 8fb2598ceea80fc692f535fed61d1edd23615608 Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Wed, 4 Oct 2023 13:22:15 -0400 Subject: [PATCH 01/46] feat: add video empty state view --- .env.development | 2 +- src/CourseAuthoringRoutes.jsx | 6 +- src/files-and-uploads/EditFileErrors.jsx | 76 ++++ src/files-and-uploads/FileTable.jsx | 278 ++++++++++++++ src/files-and-uploads/FilesAndUploads.jsx | 347 ++++-------------- src/files-and-uploads/files/messages.js | 14 + src/files-and-uploads/videos/Videos.jsx | 161 ++++++++ .../videos/VideosProvider.jsx | 25 ++ src/files-and-uploads/videos/data/api.js | 121 ++++++ src/files-and-uploads/videos/data/slice.js | 87 +++++ src/files-and-uploads/videos/data/thunks.js | 157 ++++++++ src/files-and-uploads/videos/messages.js | 18 + src/store.js | 2 + 13 files changed, 1008 insertions(+), 286 deletions(-) create mode 100644 src/files-and-uploads/EditFileErrors.jsx create mode 100644 src/files-and-uploads/FileTable.jsx create mode 100644 src/files-and-uploads/files/messages.js create mode 100644 src/files-and-uploads/videos/Videos.jsx create mode 100644 src/files-and-uploads/videos/VideosProvider.jsx create mode 100644 src/files-and-uploads/videos/data/api.js create mode 100644 src/files-and-uploads/videos/data/slice.js create mode 100644 src/files-and-uploads/videos/data/thunks.js create mode 100644 src/files-and-uploads/videos/messages.js diff --git a/.env.development b/.env.development index 79af5244ad..70b3365a8d 100644 --- a/.env.development +++ b/.env.development @@ -36,7 +36,7 @@ ENABLE_NEW_EDITOR_PAGES=true ENABLE_NEW_COURSE_OUTLINE_PAGE = false ENABLE_NEW_VIDEO_UPLOAD_PAGE = false ENABLE_UNIT_PAGE = false -ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = false +ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = true BBB_LEARN_MORE_URL='' HOTJAR_APP_ID='' HOTJAR_VERSION=6 diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index 05ddec53dc..37b3f98626 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -17,6 +17,7 @@ import CourseTeam from './course-team/CourseTeam'; import { CourseUpdates } from './course-updates'; import CourseExportPage from './export-page/CourseExportPage'; import CourseImportPage from './import-page/CourseImportPage'; +import Videos from './files-and-uploads/videos/Videos'; /** * As of this writing, these routes are mounted at a path prefixed with the following: @@ -52,10 +53,7 @@ const CourseAuthoringRoutes = ({ courseId }) => { - {process.env.ENABLE_NEW_VIDEO_UPLOAD_PAGE === 'true' - && ( - - )} + diff --git a/src/files-and-uploads/EditFileErrors.jsx b/src/files-and-uploads/EditFileErrors.jsx new file mode 100644 index 0000000000..e8d1d91c38 --- /dev/null +++ b/src/files-and-uploads/EditFileErrors.jsx @@ -0,0 +1,76 @@ +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 = ({ + errorMessages, + addFileStatus, + deleteFileStatus, + updateFileStatus, + // injected + intl, +}) => ( + <> + +
    + {errorMessages.add.map(message => ( +
  • + {intl.formatMessage(messages.errorAlertMessage, { message })} +
  • + ))} +
+
+ +
    + {errorMessages.delete.map(message => ( +
  • + {intl.formatMessage(messages.errorAlertMessage, { message })} +
  • + ))} +
+
+ +
    + {errorMessages.lock.map(message => ( +
  • + {intl.formatMessage(messages.errorAlertMessage, { message })} +
  • + ))} + {errorMessages.download.map(message => ( +
  • + {intl.formatMessage(messages.errorAlertMessage, { message })} +
  • + ))} +
+
+ +); + +EditFileErrors.propTypes = { + errorMessages: PropTypes.shape({ + add: PropTypes.arrayOf(PropTypes.string).isRequired, + delete: PropTypes.arrayOf(PropTypes.string).isRequired, + lock: PropTypes.arrayOf(PropTypes.string).isRequired, + download: PropTypes.arrayOf(PropTypes.string).isRequired, + usageMetrics: PropTypes.arrayOf(PropTypes.string).isRequired, + }).isRequired, + addFileStatus: PropTypes.string.isRequired, + deleteFileStatus: PropTypes.string.isRequired, + updateFileStatus: PropTypes.string.isRequired, + // injected + intl: intlShape.isRequired, +}; + +export default injectIntl(EditFileErrors); diff --git a/src/files-and-uploads/FileTable.jsx b/src/files-and-uploads/FileTable.jsx new file mode 100644 index 0000000000..ed10954120 --- /dev/null +++ b/src/files-and-uploads/FileTable.jsx @@ -0,0 +1,278 @@ +import React, { useCallback, useState } from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch } from 'react-redux'; +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 { + resetErrors, + getUsagePaths, + updateAssetOrder, +} from './data/thunks'; +import { sortFiles } from './data/utils'; +import messages from './messages'; + +import FileInfo from './FileInfo'; +import FileInput, { useFileInput } from './FileInput'; +import { + GalleryCard, + ListCard, + TableActions, +} from './table-components'; +import ApiStatusToast from './ApiStatusToast'; +import { clearErrors } from './data/slice'; + +const FileTable = ({ + courseId, + files, + data, + handleAddFile, + handleLockFile, + handleDeleteFile, + handleDownloadFile, + tableColumns, + maxFileSize, + // 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 { + totalCount, + loadingStatus, + usagePathStatus, + usageErrorMessages, + } = data; + 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 newAssetIdOrder = sortFiles(files, sortType); + dispatch(updateAssetOrder(courseId, newAssetIdOrder, sortType)); + }; + + const handleBulkDelete = () => { + closeDeleteConfirmation(); + setDeleteOpen(); + dispatch(resetErrors({ errorType: 'delete' })); + const fileIdsToDelete = selectedRows.map(row => row.original.id); + fileIdsToDelete.forEach(id => handleDeleteFile(id)); + }; + + const handleBulkDownload = useCallback(async (selectedFlatRows) => { + dispatch(resetErrors({ errorType: 'download' })); + handleDownloadFile(selectedFlatRows); + }, []); + + const handleLockedAsset = (fileId, locked) => { + dispatch(clearErrors({ errorType: 'lock' })); + handleLockFile({ fileId, 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 }) => ( + + ); + + const fileCard = ({ className, original }) => { + if (currentView === defaultVal) { + return ( + + ); + } + return ( + + ); + }; + + return ( + <> + setCurrentView(val), + defaultActiveStateValue: defaultVal, + togglePlacement: 'left', + }} + initialState={{ + pageSize: 50, + }} + tableActions={headerActions} + bulkActions={headerActions} + columns={tableColumns} + itemCount={totalCount} + pageCount={Math.ceil(totalCount / 50)} + data={files} + > + {isEmpty(files) && loadingStatus !== RequestStatus.IN_PROGRESS ? ( + + ) : ( +
+ + { currentView === 'card' && } + { currentView === 'list' && } + + + + +
+ )} +
+ + {!isEmpty(selectedRows) && ( + + )} + + + + + )} + > + {intl.formatMessage(messages.deleteConfirmationMessage, { fileNumber: selectedRows.length })} + + + ); +}; + +FileTable.propTypes = { + courseId: PropTypes.string.isRequired, + files: PropTypes.arrayOf(PropTypes.shape({})), + data: PropTypes.arrayOf(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, + })).isRequired, + handleAddFile: PropTypes.func.isRequired, + handleDeleteFile: PropTypes.func.isRequired, + handleDownloadFile: PropTypes.func.isRequired, + handleLockFile: PropTypes.func.isRequired, + tableColumns: PropTypes.arrayOf(PropTypes.shape({ + Header: PropTypes.string, + accessor: PropTypes.string, + })).isRequired, + maxFileSize: PropTypes.number.isRequired, + // injected + intl: intlShape.isRequired, +}; + +FileTable.defaultProps = { + files: null, +}; + +export default injectIntl(FileTable); diff --git a/src/files-and-uploads/FilesAndUploads.jsx b/src/files-and-uploads/FilesAndUploads.jsx index d2a203de0a..545f041d87 100644 --- a/src/files-and-uploads/FilesAndUploads.jsx +++ b/src/files-and-uploads/FilesAndUploads.jsx @@ -1,20 +1,11 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useEffect } 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 Placeholder from '@edx/frontend-lib-content-components'; import { RequestStatus } from '../data/constants'; import { useModels, useModel } from '../generic/model-store'; @@ -22,26 +13,14 @@ import { addAssetFile, deleteAssetFile, fetchAssets, - resetErrors, - getUsagePaths, updateAssetLock, - updateAssetOrder, fetchAssetDownload, } from './data/thunks'; -import { sortFiles } from './data/utils'; import messages from './messages'; - -import FileInfo from './FileInfo'; -import FileInput, { useFileInput } from './FileInput'; import FilesAndUploadsProvider from './FilesAndUploadsProvider'; -import { - GalleryCard, - ListCard, - TableActions, -} from './table-components'; -import ApiStatusToast from './ApiStatusToast'; -import { clearErrors } from './data/slice'; import getPageHeadTitle from '../generic/utils'; +import FileTable from './FileTable'; +import EditFileErrors from './EditFileErrors'; const FilesAndUploads = ({ courseId, @@ -49,26 +28,13 @@ const FilesAndUploads = ({ 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, @@ -79,96 +45,51 @@ const FilesAndUploads = ({ 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 }) => ( - - ); + 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 fileCard = ({ className, original }) => { - if (currentView === defaultVal) { - return ( - - ); - } - return ( - - ); + const assets = useModels('assets', assetIds); + const data = { + totalCount, + fileIds: assetIds, + loadingStatus, + usagePathStatus, + usageErrorMessages: errorMessages.usageMetrics, }; + const maxFileSize = 20 * 1048576; + const tableColumns = [ + { + Header: 'Name', + accessor: 'displayName', + }, + { + 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', + }, + ], + }, + ]; if (loadingStatus === RequestStatus.DENIED) { return ( @@ -179,167 +100,31 @@ const FilesAndUploads = ({ } return ( -
+
- -
    - {errorMessages.add.map(message => ( -
  • - {intl.formatMessage(messages.errorAlertMessage, { message })} -
  • - ))} -
-
- -
    - {errorMessages.delete.map(message => ( -
  • - {intl.formatMessage(messages.errorAlertMessage, { message })} -
  • - ))} -
-
- -
    - {errorMessages.lock.map(message => ( -
  • - {intl.formatMessage(messages.errorAlertMessage, { message })} -
  • - ))} - {errorMessages.download.map(message => ( -
  • - {intl.formatMessage(messages.errorAlertMessage, { message })} -
  • - ))} -
-
+
- setCurrentView(val), - defaultActiveStateValue: defaultVal, - togglePlacement: 'left', - }} - initialState={{ - pageSize: 50, + - {isEmpty(assets) && loadingStatus !== RequestStatus.IN_PROGRESS ? ( - - ) : ( -
- - { currentView === 'card' && } - { currentView === 'list' && } - - - - -
- )} -
- - {!isEmpty(selectedRows) && ( - - )} - - - - - )} - > - {intl.formatMessage(messages.deleteConfirmationMessage, { fileNumber: selectedRows.length })} - + />
); diff --git a/src/files-and-uploads/files/messages.js b/src/files-and-uploads/files/messages.js new file mode 100644 index 0000000000..d5c7bcf249 --- /dev/null +++ b/src/files-and-uploads/files/messages.js @@ -0,0 +1,14 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + heading: { + id: 'course-authoring.files-and-uploads.heading', + defaultMessage: 'Files and uploads', + }, + subheading: { + id: 'course-authoring.files-and-uploads.subheading', + defaultMessage: 'Content', + }, +}); + +export default messages; diff --git a/src/files-and-uploads/videos/Videos.jsx b/src/files-and-uploads/videos/Videos.jsx new file mode 100644 index 0000000000..792a8b06bf --- /dev/null +++ b/src/files-and-uploads/videos/Videos.jsx @@ -0,0 +1,161 @@ +/* eslint-disable no-console */ +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, + Sheet, +} from '@edx/paragon'; +import Placeholder from '@edx/frontend-lib-content-components'; + +import { RequestStatus } from '../../data/constants'; +import { useModels, useModel } from '../../generic/model-store'; +import { + fetchVideos, +} from './data/thunks'; +import messages from './messages'; +import VideosProvider from './VideosProvider'; +import getPageHeadTitle from '../../generic/utils'; +import FileTable from '../FileTable'; +import EditFileErrors from '../EditFileErrors'; + +const Videos = ({ + courseId, + // injected + intl, +}) => { + const dispatch = useDispatch(); + const [isTranscriptSettngsOpen, openTranscriptSettngs, closeTranscriptSettngs] = 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, + addingStatus: addVideoStatus, + deletingStatus: deleteVideoStatus, + updatingStatus: updateVideoStatus, + usageStatus: usagePathStatus, + errors: errorMessages, + } = useSelector(state => state.videos); + + // 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 handleAddFile = (file) => console.log(file); + const handleDeleteFile = (id) => console.log(id); + const handleDownloadFile = (selectedRows) => console.log(selectedRows); + const handleLockFile = ({ fileId, locked }) => console.log({ fileId, locked }); + + const videos = useModels('videos', videoIds); + const data = { + totalCount, + fileIds: videoIds, + loadingStatus, + usagePathStatus, + usageErrorMessages: errorMessages.usageMetrics, + }; + const maxFileSize = 5 * 1073741824; + const tableColumns = [ + // { + // Header: 'Name', + // accessor: 'displayName', + // }, + // { + // 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', + // }, + // ], + // }, + ]; + + if (loadingStatus === RequestStatus.DENIED) { + return ( +
+ +
+ ); + } + return ( + +
+
+ + +
+ +
+ + +
+
+ + temp! + + +
+
+ ); +}; + +Videos.propTypes = { + courseId: PropTypes.string.isRequired, + // injected + intl: intlShape.isRequired, +}; + +export default injectIntl(Videos); diff --git a/src/files-and-uploads/videos/VideosProvider.jsx b/src/files-and-uploads/videos/VideosProvider.jsx new file mode 100644 index 0000000000..d196168093 --- /dev/null +++ b/src/files-and-uploads/videos/VideosProvider.jsx @@ -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 ( + + {children} + + ); +}; + +VideosProvider.propTypes = { + courseId: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, +}; + +export default VideosProvider; diff --git a/src/files-and-uploads/videos/data/api.js b/src/files-and-uploads/videos/data/api.js new file mode 100644 index 0000000000..f6f6fa6665 --- /dev/null +++ b/src/files-and-uploads/videos/data/api.js @@ -0,0 +1,121 @@ +/* eslint-disable import/prefer-default-export */ +import { camelCaseObject, ensureConfig, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +// import JSZip from 'jszip'; +// import saveAs from 'file-saver'; + +ensureConfig([ + 'STUDIO_BASE_URL', +], 'Course Apps API service'); + +export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; +export const getVideosUrl = (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)); + return camelCaseObject(data); +} + +// /** +// * Fetch asset file. +// * @param {blockId} courseId Course ID for the course to operate on + +// */ +// export async function getDownload(selectedRows, courseId) { +// const downloadErrors = []; +// if (selectedRows?.length > 1) { +// const zip = new JSZip(); +// const date = new Date().toString(); +// const folder = zip.folder(`${courseId}-assets-${date}`); +// const assetNames = []; +// const assetFetcher = await Promise.allSettled( +// selectedRows.map(async (row) => { +// const asset = row?.original; +// try { +// assetNames.push(asset.displayName); +// const res = await fetch(`${getApiBaseUrl()}/${asset.id}`); +// if (!res.ok) { +// throw new Error(); +// } +// return res.blob(); +// } catch (error) { +// downloadErrors.push(`Failed to download ${asset?.displayName}.`); +// return null; +// } +// }), +// ); +// const definedAssets = assetFetcher.filter(asset => asset.value !== null); +// if (definedAssets.length > 0) { +// definedAssets.forEach((assetBlob, index) => { +// folder.file(assetNames[index], assetBlob.value, { blob: true }); +// }); +// zip.generateAsync({ type: 'blob' }).then(content => { +// saveAs(content, `${courseId}-assets-${date}.zip`); +// }); +// } +// } else if (selectedRows?.length === 1) { +// const asset = selectedRows[0].original; +// try { +// saveAs(`${getApiBaseUrl()}/${asset.id}`, asset.displayName); +// } catch (error) { +// downloadErrors.push(`Failed to download ${asset?.displayName}.`); +// } +// } else { +// downloadErrors.push('No files were selected to download'); +// } +// return downloadErrors; +// } + +// /** +// * Fetch where asset is used in a course. +// * @param {blockId} courseId Course ID for the course to operate on + +// */ +// export async function getAssetUsagePaths({ courseId, assetId }) { +// const { data } = await getAuthenticatedHttpClient() +// .get(`${getAssetsUrl(courseId)}${assetId}/usage`); +// return camelCaseObject(data); +// } + +// /** +// * Delete asset to course. +// * @param {blockId} courseId Course ID for the course to operate on + +// */ +// export async function deleteAsset(courseId, assetId) { +// await getAuthenticatedHttpClient() +// .delete(`${getAssetsUrl(courseId)}${assetId}`); +// } + +// /** +// * Add asset to course. +// * @param {blockId} courseId Course ID for the course to operate on + +// */ +// export async function addAsset(courseId, file) { +// const formData = new FormData(); +// formData.append('file', file); +// const { data } = await getAuthenticatedHttpClient() +// .post(getAssetsUrl(courseId), formData); +// return camelCaseObject(data); +// } + +// /** +// * Update locked attribute for provided asset. +// * @param {blockId} courseId Course ID for the course to operate on + +// */ +// export async function updateLockStatus({ assetId, courseId, locked }) { +// const { data } = await getAuthenticatedHttpClient() +// .put(`${getAssetsUrl(courseId)}${assetId}`, { +// locked, +// }); +// return camelCaseObject(data); +// } diff --git a/src/files-and-uploads/videos/data/slice.js b/src/files-and-uploads/videos/data/slice.js new file mode 100644 index 0000000000..5a433ad719 --- /dev/null +++ b/src/files-and-uploads/videos/data/slice.js @@ -0,0 +1,87 @@ +/* eslint-disable no-param-reassign */ +import { createSlice } from '@reduxjs/toolkit'; + +import { RequestStatus } from '../../../data/constants'; + +const slice = createSlice({ + name: 'videos', + initialState: { + videoIds: [], + loadingStatus: RequestStatus.IN_PROGRESS, + updatingStatus: '', + addingStatus: '', + deletingStatus: '', + usageStatus: '', + errors: { + add: [], + delete: [], + lock: [], + download: [], + usageMetrics: [], + }, + totalCount: 0, + }, + reducers: { + setVideoIds: (state, { payload }) => { + state.assetIds = payload.videoIds; + }, + 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 'lock': + state.updatingStatus = status; + break; + case 'download': + state.updatingStatus = status; + break; + case 'usageMetrics': + state.usageStatus = status; + break; + default: + break; + } + }, + deleteAssetSuccess: (state, { payload }) => { + state.videoIds = state.videoIds.filter(id => id !== payload.videoId); + }, + addAssetSuccess: (state, { payload }) => { + state.videoIds = [payload.assetId, ...state.videoIds]; + }, + 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, + setTotalCount, + updateLoadingStatus, + deleteAssetSuccess, + addAssetSuccess, + updateErrors, + clearErrors, + updateEditStatus, +} = slice.actions; + +export const { + reducer, +} = slice; diff --git a/src/files-and-uploads/videos/data/thunks.js b/src/files-and-uploads/videos/data/thunks.js new file mode 100644 index 0000000000..1993af3392 --- /dev/null +++ b/src/files-and-uploads/videos/data/thunks.js @@ -0,0 +1,157 @@ +// import { isEmpty } from 'lodash'; +import { RequestStatus } from '../../../data/constants'; +import { + // addModel, + addModels, + // removeModel, + // updateModel, +} from '../../../generic/model-store'; +import { + getVideos, +} from './api'; +import { + setVideoIds, + setTotalCount, + updateLoadingStatus, + // deleteAssetSuccess, + // addAssetSuccess, + // updateErrors, + // clearErrors, + // updateEditStatus, +} from './slice'; + +// import { updateFileValues } from './utils'; + +export function fetchVideos(courseId) { + return async (dispatch) => { + dispatch(updateLoadingStatus({ courseId, status: RequestStatus.IN_PROGRESS })); + + try { + const { videos } = await getVideos(courseId); + // const parsedAssests = updateFileValues(assets); + dispatch(addModels({ modelType: 'videos', models: videos })); + dispatch(setVideoIds({ + videoIds: videos.map(video => video.id), + })); + dispatch(setTotalCount({ totalCount: videos.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 updateAssetOrder(courseId, videoIds) { + return async (dispatch) => { + dispatch(updateLoadingStatus({ courseId, status: RequestStatus.IN_PROGRESS })); + dispatch(setVideoIds({ videoIds })); + dispatch(updateLoadingStatus({ courseId, status: RequestStatus.SUCCESSFUL })); + }; +} + +// export function deleteAssetFile(courseId, id, totalCount) { +// return async (dispatch) => { +// dispatch(updateEditStatus({ editType: 'delete', status: RequestStatus.IN_PROGRESS })); + +// try { +// await deleteAsset(courseId, id); +// dispatch(deleteAssetSuccess({ 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 addAssetFile(courseId, file, totalCount) { +// return async (dispatch) => { +// dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.IN_PROGRESS })); + +// try { +// const { asset } = await addAsset(courseId, file); +// // const [parsedAssest] = updateFileValues([asset]); +// dispatch(addModel({ +// modelType: 'videos', +// model: { ...parsedAssest }, +// })); +// dispatch(addAssetSuccess({ +// assetId: asset.id, +// })); +// dispatch(setTotalCount({ totalCount: totalCount + 1 })); +// dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.SUCCESSFUL })); +// } 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 updateAssetLock({ assetId, courseId, locked }) { +// return async (dispatch) => { +// dispatch(updateEditStatus({ editType: 'lock', status: RequestStatus.IN_PROGRESS })); + +// try { +// await updateLockStatus({ assetId, courseId, locked }); +// dispatch(updateModel({ +// modelType: 'assets', +// model: { +// id: assetId, +// locked, +// }, +// })); +// dispatch(updateEditStatus({ editType: 'lock', status: RequestStatus.SUCCESSFUL })); +// } catch (error) { +// const lockStatus = locked ? 'lock' : 'unlock'; +// dispatch(updateErrors({ error: 'lock', message: `Failed to ${lockStatus} file id ${assetId}.` })); +// dispatch(updateEditStatus({ editType: 'lock', status: RequestStatus.FAILED })); +// } +// }; +// } + +// export function resetErrors({ errorType }) { +// return (dispatch) => { dispatch(clearErrors({ error: errorType })); }; +// } + +// export function getUsagePaths({ asset, courseId, setSelectedRows }) { +// 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(updateEditStatus({ editType: 'usageMetrics', status: RequestStatus.SUCCESSFUL })); +// } catch (error) { +// dispatch(updateErrors({ +// error: 'usageMetrics', +// message: `Failed to get usage metrics for ${asset.displayName}.` })); +// dispatch(updateEditStatus({ editType: 'usageMetrics', status: RequestStatus.FAILED })); +// } +// }; +// } + +// export function fetchAssetDownload({ selectedRows, courseId }) { +// return async (dispatch) => { +// dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.IN_PROGRESS })); +// const errors = await getDownload(selectedRows, courseId); +// 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 })); +// } +// }; +// } diff --git a/src/files-and-uploads/videos/messages.js b/src/files-and-uploads/videos/messages.js new file mode 100644 index 0000000000..39e731597e --- /dev/null +++ b/src/files-and-uploads/videos/messages.js @@ -0,0 +1,18 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + heading: { + id: 'course-authoring.video-uploads.heading', + defaultMessage: 'Video uploads', + }, + subheading: { + id: 'course-authoring.video-uploads.subheading', + defaultMessage: 'Content', + }, + transcriptSettingsButtonLabel: { + id: 'course-authoring.video-uploads.transcript-settings.button.toggle', + defaultMessage: 'Transcript settings', + }, +}); + +export default messages; diff --git a/src/store.js b/src/store.js index 4b88a64353..c21cad7f5f 100644 --- a/src/store.js +++ b/src/store.js @@ -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-uploads/videos/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, }); From f3d4cbb4bbecc4d6b54988dc661945e6c80d7c6f Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Thu, 5 Oct 2023 10:31:38 -0400 Subject: [PATCH 02/46] feat: add initial api fetch --- src/files-and-uploads/data/utils.js | 11 ++- src/files-and-uploads/videos/Videos.jsx | 40 +++------ src/files-and-uploads/videos/data/api.js | 3 +- src/files-and-uploads/videos/data/slice.js | 2 +- src/files-and-uploads/videos/data/thunks.js | 12 +-- src/files-and-uploads/videos/data/utils.js | 92 +++++++++++++++++++++ 6 files changed, 123 insertions(+), 37 deletions(-) create mode 100644 src/files-and-uploads/videos/data/utils.js diff --git a/src/files-and-uploads/data/utils.js b/src/files-and-uploads/data/utils.js index 75ace4caff..67ff620a51 100644 --- a/src/files-and-uploads/data/utils.js +++ b/src/files-and-uploads/data/utils.js @@ -1,4 +1,9 @@ -import { InsertDriveFile, Terminal, AudioFile } from '@edx/paragon/icons'; +import { + InsertDriveFile, + Terminal, + AudioFile, + VideoFile, +} from '@edx/paragon/icons'; import { ensureConfig, getConfig } from '@edx/frontend-platform'; import FILES_AND_UPLOAD_TYPE_FILTERS from './constant'; @@ -46,6 +51,8 @@ export const getSrc = ({ thumbnail, wrapperType, externalUrl }) => { return Terminal; case 'audio': return AudioFile; + case 'video': + return VideoFile; default: return InsertDriveFile; } @@ -63,6 +70,8 @@ export const getFileSizeToClosestByte = (fileSize, numberOfDivides = 0) => { return `${fileSizeFixedDecimal} KB`; case 2: return `${fileSizeFixedDecimal} MB`; + case 3: + return `${fileSizeFixedDecimal} GB`; default: return `${fileSizeFixedDecimal} B`; } diff --git a/src/files-and-uploads/videos/Videos.jsx b/src/files-and-uploads/videos/Videos.jsx index 792a8b06bf..448149a1ec 100644 --- a/src/files-and-uploads/videos/Videos.jsx +++ b/src/files-and-uploads/videos/Videos.jsx @@ -70,34 +70,18 @@ const Videos = ({ }; const maxFileSize = 5 * 1073741824; const tableColumns = [ - // { - // Header: 'Name', - // accessor: 'displayName', - // }, - // { - // 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', - // }, - // ], - // }, + { + Header: 'Name', + accessor: 'clientVideoId', + }, + { + Header: 'Video length', + accessor: 'duration', + }, + { + Header: 'Transcripts', + accessor: 'transcripts', + }, ]; if (loadingStatus === RequestStatus.DENIED) { diff --git a/src/files-and-uploads/videos/data/api.js b/src/files-and-uploads/videos/data/api.js index f6f6fa6665..1bf7065eaf 100644 --- a/src/files-and-uploads/videos/data/api.js +++ b/src/files-and-uploads/videos/data/api.js @@ -10,7 +10,8 @@ ensureConfig([ ], 'Course Apps API service'); export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; -export const getVideosUrl = (courseId) => `${getApiBaseUrl()}/videos/${courseId}`; +export const getVideosUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/videos/${courseId}`; +// export const getCourseTeamApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/course_team/${courseId}`; /** * Fetches the course custom pages for provided course diff --git a/src/files-and-uploads/videos/data/slice.js b/src/files-and-uploads/videos/data/slice.js index 5a433ad719..949934eb02 100644 --- a/src/files-and-uploads/videos/data/slice.js +++ b/src/files-and-uploads/videos/data/slice.js @@ -23,7 +23,7 @@ const slice = createSlice({ }, reducers: { setVideoIds: (state, { payload }) => { - state.assetIds = payload.videoIds; + state.videoIds = payload.videoIds; }, setTotalCount: (state, { payload }) => { state.totalCount = payload.totalCount; diff --git a/src/files-and-uploads/videos/data/thunks.js b/src/files-and-uploads/videos/data/thunks.js index 1993af3392..a7a9e82925 100644 --- a/src/files-and-uploads/videos/data/thunks.js +++ b/src/files-and-uploads/videos/data/thunks.js @@ -20,20 +20,20 @@ import { // updateEditStatus, } from './slice'; -// import { updateFileValues } from './utils'; +import { updateFileValues } from './utils'; export function fetchVideos(courseId) { return async (dispatch) => { dispatch(updateLoadingStatus({ courseId, status: RequestStatus.IN_PROGRESS })); try { - const { videos } = await getVideos(courseId); - // const parsedAssests = updateFileValues(assets); - dispatch(addModels({ modelType: 'videos', models: videos })); + const { previousUploads } = await getVideos(courseId); + const parsedVideos = updateFileValues(previousUploads); + dispatch(addModels({ modelType: 'videos', models: parsedVideos })); dispatch(setVideoIds({ - videoIds: videos.map(video => video.id), + videoIds: parsedVideos.map(video => video.id), })); - dispatch(setTotalCount({ totalCount: videos.length })); + dispatch(setTotalCount({ totalCount: parsedVideos.length })); dispatch(updateLoadingStatus({ courseId, status: RequestStatus.SUCCESSFUL })); } catch (error) { if (error.response && error.response.status === 403) { diff --git a/src/files-and-uploads/videos/data/utils.js b/src/files-and-uploads/videos/data/utils.js new file mode 100644 index 0000000000..f4bde520e4 --- /dev/null +++ b/src/files-and-uploads/videos/data/utils.js @@ -0,0 +1,92 @@ +import { InsertDriveFile, Terminal, AudioFile } from '@edx/paragon/icons'; +import { ensureConfig, getConfig } from '@edx/frontend-platform'; +// import FILES_AND_UPLOAD_TYPE_FILTERS from './constant'; + +ensureConfig([ + 'STUDIO_BASE_URL', +], 'Course Apps API service'); + +export const updateFileValues = (files) => { + const updatedFiles = []; + files.forEach(file => { + const { edxVideoId, clientVideoId, created } = file; + const wrapperType = 'video'; + + updatedFiles.push({ + ...file, + displayName: clientVideoId, + id: edxVideoId, + wrapperType, + dateAdded: created.toString(), + usageLocations: [], + }); + }); + + return updatedFiles; +}; + +export const getSrc = ({ thumbnail, wrapperType, externalUrl }) => { + if (thumbnail) { + return externalUrl || `${getConfig().STUDIO_BASE_URL}${thumbnail}`; + } + switch (wrapperType) { + case 'document': + return InsertDriveFile; + case 'code': + return Terminal; + case 'audio': + return AudioFile; + default: + return InsertDriveFile; + } +}; + +export const getFileSizeToClosestByte = (fileSize, numberOfDivides = 0) => { + if (fileSize > 1000) { + const updatedSize = fileSize / 1000; + const incrementNumberOfDivides = numberOfDivides + 1; + return getFileSizeToClosestByte(updatedSize, incrementNumberOfDivides); + } + const fileSizeFixedDecimal = Number.parseFloat(fileSize).toFixed(2); + switch (numberOfDivides) { + case 1: + return `${fileSizeFixedDecimal} KB`; + case 2: + return `${fileSizeFixedDecimal} MB`; + default: + return `${fileSizeFixedDecimal} B`; + } +}; + +export const sortFiles = (files, sortType) => { + const [sort, direction] = sortType.split(','); + let sortedFiles; + if (sort === 'displayName') { + sortedFiles = files.sort((f1, f2) => { + const lowerCaseF1 = f1[sort].toLowerCase(); + const lowerCaseF2 = f2[sort].toLowerCase(); + if (lowerCaseF1 < lowerCaseF2) { + return 1; + } + if (lowerCaseF1 > lowerCaseF2) { + return -1; + } + return 0; + }); + } else { + sortedFiles = files.sort((f1, f2) => { + if (f1[sort] < f2[sort]) { + return 1; + } + if (f1[sort] > f2[sort]) { + return -1; + } + return 0; + }); + } + const sortedIds = sortedFiles.map(file => file.id); + if (direction === 'asc') { + return sortedIds.reverse(); + } + return sortedIds; +}; From 88190e3add477b274bb898694d1774e887d7b7a5 Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Thu, 5 Oct 2023 14:11:17 -0400 Subject: [PATCH 03/46] feat: add list view --- src/files-and-uploads/FileMenu.jsx | 43 +++++++++------ src/files-and-uploads/FileTable.jsx | 21 +++++++- src/files-and-uploads/FilesAndUploads.jsx | 39 ++++++++++++-- .../table-components/GalleryCard.jsx | 5 +- .../table-components/ListCard.jsx | 5 +- .../table-custom-columns/AccessColumn.jsx | 42 +++++++++++++++ .../table-custom-columns/ActiveColumn.jsx | 20 +++++++ .../table-custom-columns/MoreInfoColumn.jsx | 53 +++++++++++++++++++ .../table-custom-columns/StatusColumn.jsx | 22 ++++++++ .../table-custom-columns/ThumbnailColumn.jsx | 29 ++++++++++ src/files-and-uploads/videos/Videos.jsx | 50 +++++++++++++---- src/files-and-uploads/videos/data/utils.js | 9 +++- 12 files changed, 298 insertions(+), 40 deletions(-) create mode 100644 src/files-and-uploads/table-components/table-custom-columns/AccessColumn.jsx create mode 100644 src/files-and-uploads/table-components/table-custom-columns/ActiveColumn.jsx create mode 100644 src/files-and-uploads/table-components/table-custom-columns/MoreInfoColumn.jsx create mode 100644 src/files-and-uploads/table-components/table-custom-columns/StatusColumn.jsx create mode 100644 src/files-and-uploads/table-components/table-custom-columns/ThumbnailColumn.jsx diff --git a/src/files-and-uploads/FileMenu.jsx b/src/files-and-uploads/FileMenu.jsx index 7d9ac47cdc..6bad4e22fa 100644 --- a/src/files-and-uploads/FileMenu.jsx +++ b/src/files-and-uploads/FileMenu.jsx @@ -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 = ({ - navigator.clipboard.writeText(portableUrl)} - > - {intl.formatMessage(messages.copyStudioUrlTitle)} - - navigator.clipboard.writeText(externalUrl)} - > - {intl.formatMessage(messages.copyWebUrlTitle)} - + {wrapperType === 'video' ? ( + navigator.clipboard.writeText(id)} + > + Copy video ID + + ) : ( + <> + navigator.clipboard.writeText(portableUrl)} + > + {intl.formatMessage(messages.copyStudioUrlTitle)} + + navigator.clipboard.writeText(externalUrl)} + > + {intl.formatMessage(messages.copyWebUrlTitle)} + + + {locked ? intl.formatMessage(messages.unlockMenuTitle) : intl.formatMessage(messages.lockMenuTitle)} + + + )} {intl.formatMessage(messages.downloadTitle)} - - {locked ? intl.formatMessage(messages.unlockMenuTitle) : intl.formatMessage(messages.lockMenuTitle)} - {intl.formatMessage(messages.infoTitle)} @@ -69,8 +80,8 @@ FileMenu.propTypes = { openAssetInfo: PropTypes.func.isRequired, openDeleteConfirmation: PropTypes.func.isRequired, portableUrl: PropTypes.string.isRequired, - iconSrc: PropTypes.func.isRequired, id: PropTypes.string.isRequired, + wrapperType: PropTypes.string.isRequired, // injected intl: intlShape.isRequired, }; diff --git a/src/files-and-uploads/FileTable.jsx b/src/files-and-uploads/FileTable.jsx index ed10954120..82b8610823 100644 --- a/src/files-and-uploads/FileTable.jsx +++ b/src/files-and-uploads/FileTable.jsx @@ -32,6 +32,7 @@ import { } from './table-components'; import ApiStatusToast from './ApiStatusToast'; import { clearErrors } from './data/slice'; +import MoreInfoColumn from './table-components/table-custom-columns/MoreInfoColumn'; const FileTable = ({ courseId, @@ -129,7 +130,7 @@ const FileTable = ({ ); const fileCard = ({ className, original }) => { - if (currentView === defaultVal) { + if (currentView === 'card') { return ( ); }; + const moreInfoColumn = { + id: 'moreInfo', + Header: '', + Cell: ({ row }) => MoreInfoColumn({ + row, + handleLock: handleLockedAsset, + onDownload: handleBulkDownload, + openAssetInfo: handleOpenAssetInfo, + openDeleteConfirmation: handleOpenDeleteConfirmation, + }), + }; + + const hasMoreInfoColumn = tableColumns.filter(col => col.id === 'moreInfo').length === 1; + if (!hasMoreInfoColumn) { + tableColumns.push({ ...moreInfoColumn }); + } return ( <> @@ -196,7 +213,7 @@ const FileTable = ({
{ currentView === 'card' && } - { currentView === 'list' && } + { currentView === 'list' && } ActiveColumn({ row }), + }; + 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 tableColumns = [ + { ...thumbnailColumn }, { - Header: 'Name', + Header: 'File name', accessor: 'displayName', }, + { ...fileSizeColumn }, { Header: 'Type', accessor: 'wrapperType', @@ -89,6 +118,8 @@ const FilesAndUploads = ({ }, ], }, + { ...activeColumn }, + { ...accessColumn }, ]; if (loadingStatus === RequestStatus.DENIED) { diff --git a/src/files-and-uploads/table-components/GalleryCard.jsx b/src/files-and-uploads/table-components/GalleryCard.jsx index 5728f2b3d6..ec2d19eb4c 100644 --- a/src/files-and-uploads/table-components/GalleryCard.jsx +++ b/src/files-and-uploads/table-components/GalleryCard.jsx @@ -8,9 +8,6 @@ import { Truncate, Image, } from '@edx/paragon'; -import { - MoreVert, -} from '@edx/paragon/icons'; import FileMenu from '../FileMenu'; import { getSrc } from '../data/utils'; @@ -42,8 +39,8 @@ const GalleryCard = ({ locked={original.locked} openAssetInfo={() => handleOpenAssetInfo(original)} portableUrl={original.portableUrl} - iconSrc={MoreVert} id={original.id} + wrapperType={original.wrapperType} onDownload={() => handleBulkDownload( [{ original: { id: original.id, displayName: original.displayName } }], )} diff --git a/src/files-and-uploads/table-components/ListCard.jsx b/src/files-and-uploads/table-components/ListCard.jsx index 9a1d38b1bb..45285a261e 100644 --- a/src/files-and-uploads/table-components/ListCard.jsx +++ b/src/files-and-uploads/table-components/ListCard.jsx @@ -8,9 +8,6 @@ import { Truncate, Image, } from '@edx/paragon'; -import { - MoreVert, -} from '@edx/paragon/icons'; import FileMenu from '../FileMenu'; import { getSrc } from '../data/utils'; @@ -66,8 +63,8 @@ const ListCard = ({ locked={original.locked} openAssetInfo={() => handleOpenAssetInfo(original)} portableUrl={original.portableUrl} - iconSrc={MoreVert} id={original.id} + wrapperType={original.wrapperType} onDownload={() => handleBulkDownload( [{ original: { id: original.id, displayName: original.displayName } }], )} diff --git a/src/files-and-uploads/table-components/table-custom-columns/AccessColumn.jsx b/src/files-and-uploads/table-components/table-custom-columns/AccessColumn.jsx new file mode 100644 index 0000000000..b1cde1e03d --- /dev/null +++ b/src/files-and-uploads/table-components/table-custom-columns/AccessColumn.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { PropTypes } from 'prop-types'; +import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon'; +import { Locked, LockOpen } from '@edx/paragon/icons'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import messages from '../../messages'; + +const AccessColumn = ({ + row, + // injected + intl, +}) => { + const { locked } = row.original; + return ( + + {intl.formatMessage(messages.lockFileTooltipContent)} + + )} + > + {locked ? ( + + ) : ( + + )} + + ); +}; + +AccessColumn.propTypes = { + row: { + original: { + locked: PropTypes.bool.isRequired, + }.isRequired, + }.isRequired, + // injected + intl: intlShape.isRequired, +}; + +export default injectIntl(AccessColumn); diff --git a/src/files-and-uploads/table-components/table-custom-columns/ActiveColumn.jsx b/src/files-and-uploads/table-components/table-custom-columns/ActiveColumn.jsx new file mode 100644 index 0000000000..a604dfe348 --- /dev/null +++ b/src/files-and-uploads/table-components/table-custom-columns/ActiveColumn.jsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { PropTypes } from 'prop-types'; +import { Icon } from '@edx/paragon'; +import { Check } from '@edx/paragon/icons'; + +const ActiveColumn = ({ row }) => { + const { usageLocations } = row.original; + const numOfUsageLocations = usageLocations.length; + return numOfUsageLocations > 0 ? : null; +}; + +ActiveColumn.propTypes = { + row: { + original: { + usageLocations: PropTypes.arrayOf(PropTypes.string).isRequired, + }.isRequired, + }.isRequired, +}; + +export default ActiveColumn; diff --git a/src/files-and-uploads/table-components/table-custom-columns/MoreInfoColumn.jsx b/src/files-and-uploads/table-components/table-custom-columns/MoreInfoColumn.jsx new file mode 100644 index 0000000000..b47262c4a3 --- /dev/null +++ b/src/files-and-uploads/table-components/table-custom-columns/MoreInfoColumn.jsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { PropTypes } from 'prop-types'; +import FileMenu from '../../FileMenu'; + +const MoreInfoColumn = ({ + row, + handleLock, + onDownload, + openAssetInfo, + openDeleteConfirmation, +}) => { + const { + externalUrl, + locked, + portableUrl, + id, + wrapperType, + } = row.original; + + return ( + + ); +}; + +MoreInfoColumn.propTypes = { + row: { + original: { + externalUrl: PropTypes.string, + locked: PropTypes.bool, + portableUrl: PropTypes.string, + id: PropTypes.string.isRequired, + wrapperType: PropTypes.string, + }.isRequired, + }.isRequired, + handleLock: PropTypes.func.isRequired, + onDownload: PropTypes.func.isRequired, + openAssetInfo: PropTypes.func.isRequired, + openDeleteConfirmation: PropTypes.func.isRequired, +}; + +export default MoreInfoColumn; diff --git a/src/files-and-uploads/table-components/table-custom-columns/StatusColumn.jsx b/src/files-and-uploads/table-components/table-custom-columns/StatusColumn.jsx new file mode 100644 index 0000000000..8773042e10 --- /dev/null +++ b/src/files-and-uploads/table-components/table-custom-columns/StatusColumn.jsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { PropTypes } from 'prop-types'; +import { Badge } from '@edx/paragon'; + +const StatusColumn = ({ row }) => { + const { status } = row.original; + return ( + + {status} + + ); +}; + +StatusColumn.propTypes = { + row: { + original: { + status: PropTypes.string.isRequired, + }.isRequired, + }.isRequired, +}; + +export default StatusColumn; diff --git a/src/files-and-uploads/table-components/table-custom-columns/ThumbnailColumn.jsx b/src/files-and-uploads/table-components/table-custom-columns/ThumbnailColumn.jsx new file mode 100644 index 0000000000..0740a5ca18 --- /dev/null +++ b/src/files-and-uploads/table-components/table-custom-columns/ThumbnailColumn.jsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { PropTypes } from 'prop-types'; +import { Icon, Image } from '@edx/paragon'; +import { getSrc } from '../../data/utils'; + +const ThumbnailColumn = ({ row }) => { + const { thumbnail, wrapperType } = row.original; + const src = getSrc({ thumbnail, wrapperType }); + return ( + thumbnail ? ( + + ) : ( +
+ +
+ ) + ); +}; + +ThumbnailColumn.propTypes = { + row: { + original: { + thumbnail: PropTypes.string, + wrapperType: PropTypes.string.isRequired, + }.isRequired, + }.isRequired, +}; + +export default ThumbnailColumn; diff --git a/src/files-and-uploads/videos/Videos.jsx b/src/files-and-uploads/videos/Videos.jsx index 448149a1ec..96486f5405 100644 --- a/src/files-and-uploads/videos/Videos.jsx +++ b/src/files-and-uploads/videos/Videos.jsx @@ -25,6 +25,9 @@ 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'; const Videos = ({ courseId, @@ -69,19 +72,48 @@ const Videos = ({ usageErrorMessages: errorMessages.usageMetrics, }; const maxFileSize = 5 * 1073741824; - const tableColumns = [ - { - Header: 'Name', - accessor: 'clientVideoId', + const transcriptColumn = { + id: 'transcripts', + Header: 'Transcript', + Cell: ({ row }) => { + const { transcripts } = row.original; + const numOfTranscripts = transcripts.length; + return numOfTranscripts > 0 ? `(${numOfTranscripts}) available` : null; }, - { - Header: 'Video length', - accessor: 'duration', + }; + const activeColumn = { + id: 'usageLocations', + Header: 'Active', + Cell: ({ row }) => ActiveColumn({ row }), + }; + const durationColumn = { + id: 'duration', + Header: 'Video length', + Cell: ({ row }) => { + const { duration } = row.original; + return duration; }, + }; + const processingStatusColumn = { + id: 'status', + Header: '', + Cell: ({ row }) => StatusColumn({ row }), + }; + const videoThumbnailColumn = { + id: 'courseVideoImageUrl', + Header: '', + Cell: ({ row }) => ThumbnailColumn({ row }), + }; + const tableColumns = [ + { ...videoThumbnailColumn }, { - Header: 'Transcripts', - accessor: 'transcripts', + Header: 'File name', + accessor: 'clientVideoId', }, + { ...durationColumn }, + { ...transcriptColumn }, + { ...activeColumn }, + { ...processingStatusColumn }, ]; if (loadingStatus === RequestStatus.DENIED) { diff --git a/src/files-and-uploads/videos/data/utils.js b/src/files-and-uploads/videos/data/utils.js index f4bde520e4..6e6f5a2af1 100644 --- a/src/files-and-uploads/videos/data/utils.js +++ b/src/files-and-uploads/videos/data/utils.js @@ -9,7 +9,12 @@ ensureConfig([ export const updateFileValues = (files) => { const updatedFiles = []; files.forEach(file => { - const { edxVideoId, clientVideoId, created } = file; + const { + edxVideoId, + clientVideoId, + created, + courseVideoImageUrl, + } = file; const wrapperType = 'video'; updatedFiles.push({ @@ -19,6 +24,8 @@ export const updateFileValues = (files) => { wrapperType, dateAdded: created.toString(), usageLocations: [], + fileSize: null, + thumbnail: courseVideoImageUrl, }); }); From ce7d23d6212cee9eb9a77ed2478af178bc0c3613 Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Fri, 6 Oct 2023 09:53:50 -0400 Subject: [PATCH 04/46] feat: add delete functionality --- src/files-and-uploads/videos/Videos.jsx | 45 +++++++++++++-------- src/files-and-uploads/videos/data/api.js | 18 ++++----- src/files-and-uploads/videos/data/slice.js | 13 ++++-- src/files-and-uploads/videos/data/thunks.js | 44 +++++++++++--------- 4 files changed, 71 insertions(+), 49 deletions(-) diff --git a/src/files-and-uploads/videos/Videos.jsx b/src/files-and-uploads/videos/Videos.jsx index 96486f5405..8dd7798efc 100644 --- a/src/files-and-uploads/videos/Videos.jsx +++ b/src/files-and-uploads/videos/Videos.jsx @@ -11,13 +11,13 @@ import { useToggle, ActionRow, Button, - Sheet, } from '@edx/paragon'; import Placeholder from '@edx/frontend-lib-content-components'; import { RequestStatus } from '../../data/constants'; import { useModels, useModel } from '../../generic/model-store'; import { + deleteVideoFile, fetchVideos, } from './data/thunks'; import messages from './messages'; @@ -28,6 +28,7 @@ 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'; const Videos = ({ courseId, @@ -52,16 +53,23 @@ const Videos = ({ updatingStatus: updateVideoStatus, usageStatus: usagePathStatus, errors: errorMessages, + pageSettings, } = useSelector(state => state.videos); + const { + isVideoTranscriptEnabled, + activeTranscriptPreferences, + transcriptAvailableLanguages, + transcriptCredentials, + } = pageSettings; + // const handleAddFile = (file) => dispatch(addAssetFile(courseId, file, totalCount)); - // const handleDeleteFile = (id) => dispatch(deleteAssetFile(courseId, id, totalCount)); + const handleDeleteFile = (id) => dispatch(deleteVideoFile(courseId, id, totalCount)); // const handleDownloadFile = (selectedRows) => dispatch(fetchAssetDownload({ selectedRows, courseId })); - // const handleLockFile = ({ fileId, locked }) => dispatch(updateAssetLock({ courseId, assetId: fileId, locked })); const handleAddFile = (file) => console.log(file); - const handleDeleteFile = (id) => console.log(id); const handleDownloadFile = (selectedRows) => console.log(selectedRows); - const handleLockFile = ({ fileId, locked }) => console.log({ fileId, locked }); + // const handleTranscriptCredentials = ({data, global, provider}) => { + // dispatch(addTranscriptCredentials({data, global, provider}))} const videos = useModels('videos', videoIds); const data = { @@ -138,18 +146,24 @@ const Videos = ({
- + {isVideoTranscriptEnabled ? ( + + ) : null} - - temp! - + {isVideoTranscriptEnabled ? ( + + ) : null} getConfig().STUDIO_BASE_URL; export const getVideosUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/videos/${courseId}`; -// export const getCourseTeamApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/course_team/${courseId}`; +export const getCoursVideosApiUrl = (courseId) => `${getApiBaseUrl()}/videos/${courseId}/`; /** * Fetches the course custom pages for provided course @@ -85,15 +85,15 @@ export async function getVideos(courseId) { // return camelCaseObject(data); // } -// /** -// * Delete asset to course. -// * @param {blockId} courseId Course ID for the course to operate on +/** + * Delete video from course. + * @param {blockId} courseId Course ID for the course to operate on -// */ -// export async function deleteAsset(courseId, assetId) { -// await getAuthenticatedHttpClient() -// .delete(`${getAssetsUrl(courseId)}${assetId}`); -// } + */ +export async function deleteVideo(courseId, videoId) { + await getAuthenticatedHttpClient() + .delete(`${getCoursVideosApiUrl(courseId)}${videoId}`); +} // /** // * Add asset to course. diff --git a/src/files-and-uploads/videos/data/slice.js b/src/files-and-uploads/videos/data/slice.js index 949934eb02..0d1409eec2 100644 --- a/src/files-and-uploads/videos/data/slice.js +++ b/src/files-and-uploads/videos/data/slice.js @@ -7,6 +7,7 @@ const slice = createSlice({ name: 'videos', initialState: { videoIds: [], + pageSettings: {}, loadingStatus: RequestStatus.IN_PROGRESS, updatingStatus: '', addingStatus: '', @@ -25,6 +26,9 @@ const slice = createSlice({ setVideoIds: (state, { payload }) => { state.videoIds = payload.videoIds; }, + setPageSettings: (state, { payload }) => { + state.pageSettings = payload; + }, setTotalCount: (state, { payload }) => { state.totalCount = payload.totalCount; }, @@ -53,10 +57,10 @@ const slice = createSlice({ break; } }, - deleteAssetSuccess: (state, { payload }) => { + deleteVideoSuccess: (state, { payload }) => { state.videoIds = state.videoIds.filter(id => id !== payload.videoId); }, - addAssetSuccess: (state, { payload }) => { + addVideoSuccess: (state, { payload }) => { state.videoIds = [payload.assetId, ...state.videoIds]; }, updateErrors: (state, { payload }) => { @@ -73,10 +77,11 @@ const slice = createSlice({ export const { setVideoIds, + setPageSettings, setTotalCount, updateLoadingStatus, - deleteAssetSuccess, - addAssetSuccess, + deleteVideoSuccess, + addVideoSuccess, updateErrors, clearErrors, updateEditStatus, diff --git a/src/files-and-uploads/videos/data/thunks.js b/src/files-and-uploads/videos/data/thunks.js index a7a9e82925..7127e1c3c8 100644 --- a/src/files-and-uploads/videos/data/thunks.js +++ b/src/files-and-uploads/videos/data/thunks.js @@ -3,21 +3,23 @@ import { RequestStatus } from '../../../data/constants'; import { // addModel, addModels, - // removeModel, + removeModel, // updateModel, } from '../../../generic/model-store'; import { getVideos, + deleteVideo, } from './api'; import { setVideoIds, + setPageSettings, setTotalCount, updateLoadingStatus, - // deleteAssetSuccess, + deleteVideoSuccess, // addAssetSuccess, - // updateErrors, + updateErrors, // clearErrors, - // updateEditStatus, + updateEditStatus, } from './slice'; import { updateFileValues } from './utils'; @@ -27,12 +29,13 @@ export function fetchVideos(courseId) { dispatch(updateLoadingStatus({ courseId, status: RequestStatus.IN_PROGRESS })); try { - const { previousUploads } = await getVideos(courseId); + 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) { @@ -53,22 +56,23 @@ export function updateAssetOrder(courseId, videoIds) { }; } -// export function deleteAssetFile(courseId, id, totalCount) { -// return async (dispatch) => { -// dispatch(updateEditStatus({ editType: 'delete', status: RequestStatus.IN_PROGRESS })); +export function deleteVideoFile(courseId, id, totalCount) { + return async (dispatch) => { + dispatch(updateEditStatus({ editType: 'delete', status: RequestStatus.IN_PROGRESS })); -// try { -// await deleteAsset(courseId, id); -// dispatch(deleteAssetSuccess({ 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 })); -// } -// }; -// } + 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 addAssetFile(courseId, file, totalCount) { // return async (dispatch) => { From 0248930057f0122fbcdffcd22a0ecd0da2422bde Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Fri, 6 Oct 2023 10:21:00 -0400 Subject: [PATCH 05/46] feat: add encodings download --- src/files-and-uploads/FileTable.jsx | 12 ++++++++---- .../table-components/TableActions.jsx | 16 ++++++++++++++++ src/files-and-uploads/videos/Videos.jsx | 2 ++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/files-and-uploads/FileTable.jsx b/src/files-and-uploads/FileTable.jsx index 82b8610823..6e8de8e11d 100644 --- a/src/files-and-uploads/FileTable.jsx +++ b/src/files-and-uploads/FileTable.jsx @@ -67,6 +67,7 @@ const FileTable = ({ loadingStatus, usagePathStatus, usageErrorMessages, + encodingsDownloadUrl, } = data; const fileInputControl = useFileInput({ onAddFile: (file) => handleAddFile(file), @@ -122,6 +123,7 @@ const FileTable = ({ {...{ selectedFlatRows, fileInputControl, + encodingsDownloadUrl, handleSort, handleBulkDownload, handleOpenDeleteConfirmation, @@ -236,7 +238,7 @@ const FileTable = ({ {!isEmpty(selectedRows) && ( {}, }; export default injectIntl(FileTable); diff --git a/src/files-and-uploads/table-components/TableActions.jsx b/src/files-and-uploads/table-components/TableActions.jsx index e395ca301e..1d8e36eca3 100644 --- a/src/files-and-uploads/table-components/TableActions.jsx +++ b/src/files-and-uploads/table-components/TableActions.jsx @@ -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,15 @@ const TableActions = ({
+ {encodingsDownloadUrl ? ( + + + + ) : null} handleBulkDownload(selectedFlatRows)} disabled={_.isEmpty(selectedFlatRows)} @@ -174,9 +185,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); diff --git a/src/files-and-uploads/videos/Videos.jsx b/src/files-and-uploads/videos/Videos.jsx index 8dd7798efc..f095e71cf0 100644 --- a/src/files-and-uploads/videos/Videos.jsx +++ b/src/files-and-uploads/videos/Videos.jsx @@ -61,6 +61,7 @@ const Videos = ({ activeTranscriptPreferences, transcriptAvailableLanguages, transcriptCredentials, + encodingsDownloadUrl, } = pageSettings; // const handleAddFile = (file) => dispatch(addAssetFile(courseId, file, totalCount)); @@ -73,6 +74,7 @@ const Videos = ({ const videos = useModels('videos', videoIds); const data = { + encodingsDownloadUrl, totalCount, fileIds: videoIds, loadingStatus, From d3a4d8aec7b79233d6fed0fd09f5e9b87baeaa6f Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Fri, 6 Oct 2023 16:39:04 -0400 Subject: [PATCH 06/46] feat: add video functionality --- src/files-and-uploads/EditFileErrors.jsx | 2 +- src/files-and-uploads/FileInput.jsx | 9 ++- src/files-and-uploads/FileTable.jsx | 7 +- src/files-and-uploads/videos/Videos.jsx | 12 ++- src/files-and-uploads/videos/data/api.js | 84 +++++++++++++++------ src/files-and-uploads/videos/data/slice.js | 2 +- src/files-and-uploads/videos/data/thunks.js | 79 +++++++++++-------- src/files-and-uploads/videos/data/utils.js | 21 ++++++ 8 files changed, 152 insertions(+), 64 deletions(-) diff --git a/src/files-and-uploads/EditFileErrors.jsx b/src/files-and-uploads/EditFileErrors.jsx index e8d1d91c38..542a91ce9c 100644 --- a/src/files-and-uploads/EditFileErrors.jsx +++ b/src/files-and-uploads/EditFileErrors.jsx @@ -3,7 +3,7 @@ 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'; +import messages from './messages'; const EditFileErrors = ({ errorMessages, diff --git a/src/files-and-uploads/FileInput.jsx b/src/files-and-uploads/FileInput.jsx index a094cdd15a..2791e1b68b 100644 --- a/src/files-and-uploads/FileInput.jsx +++ b/src/files-and-uploads/FileInput.jsx @@ -1,5 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; +import { getSupportedFormats } from './videos/data/utils'; export const useFileInput = ({ onAddFile, @@ -23,8 +24,9 @@ export const useFileInput = ({ }; }; -const FileInput = ({ fileInput: hook }) => ( +const FileInput = ({ fileInput: hook, supportedFileFormats }) => ( handleAddFile(file), setSelectedRows, @@ -127,6 +129,7 @@ const FileTable = ({ handleSort, handleBulkDownload, handleOpenDeleteConfirmation, + supportedFileFormats, }} /> ); @@ -204,6 +207,7 @@ const FileTable = ({ {isEmpty(files) && loadingStatus !== RequestStatus.IN_PROGRESS ? ( )} - + {!isEmpty(selectedRows) && ( dispatch(addAssetFile(courseId, file, totalCount)); + const supportedFileFormats = { 'video/*': videoSupportedFileFormats }; + + const handleAddFile = (file) => dispatch(addVideoFile(courseId, file)); const handleDeleteFile = (id) => dispatch(deleteVideoFile(courseId, id, totalCount)); // const handleDownloadFile = (selectedRows) => dispatch(fetchAssetDownload({ selectedRows, courseId })); - const handleAddFile = (file) => console.log(file); const handleDownloadFile = (selectedRows) => console.log(selectedRows); // const handleTranscriptCredentials = ({data, global, provider}) => { // dispatch(addTranscriptCredentials({data, global, provider}))} - const videos = useModels('videos', videoIds); const data = { + supportedFileFormats, encodingsDownloadUrl, totalCount, fileIds: videoIds, @@ -81,7 +85,7 @@ const Videos = ({ usagePathStatus, usageErrorMessages: errorMessages.usageMetrics, }; - const maxFileSize = 5 * 1073741824; + const maxFileSize = videoUploadMaxFileSize * 1073741824; const transcriptColumn = { id: 'transcripts', Header: 'Transcript', diff --git a/src/files-and-uploads/videos/data/api.js b/src/files-and-uploads/videos/data/api.js index 7608ea1c23..95087be4b5 100644 --- a/src/files-and-uploads/videos/data/api.js +++ b/src/files-and-uploads/videos/data/api.js @@ -11,7 +11,7 @@ ensureConfig([ export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; export const getVideosUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/videos/${courseId}`; -export const getCoursVideosApiUrl = (courseId) => `${getApiBaseUrl()}/videos/${courseId}/`; +export const getCoursVideosApiUrl = (courseId) => `${getApiBaseUrl()}/videos/${courseId}`; /** * Fetches the course custom pages for provided course @@ -24,6 +24,17 @@ export async function getVideos(courseId) { return camelCaseObject(data); } +/** + * 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); +} + // /** // * Fetch asset file. // * @param {blockId} courseId Course ID for the course to operate on @@ -92,31 +103,54 @@ export async function getVideos(courseId) { */ export async function deleteVideo(courseId, videoId) { await getAuthenticatedHttpClient() - .delete(`${getCoursVideosApiUrl(courseId)}${videoId}`); + .delete(`${getCoursVideosApiUrl(courseId)}/${videoId}`); } -// /** -// * Add asset to course. -// * @param {blockId} courseId Course ID for the course to operate on - -// */ -// export async function addAsset(courseId, file) { -// const formData = new FormData(); -// formData.append('file', file); -// const { data } = await getAuthenticatedHttpClient() -// .post(getAssetsUrl(courseId), formData); -// return camelCaseObject(data); -// } +/** + * Add asset to course. + * @param {blockId} courseId Course ID for the course to operate on -// /** -// * Update locked attribute for provided asset. -// * @param {blockId} courseId Course ID for the course to operate on + */ +export async function addVideo(courseId, file) { + const formData = new FormData(); + formData.append('file', file); + const { data } = await getAuthenticatedHttpClient() + .post(getCoursVideosApiUrl(courseId), formData); + return camelCaseObject(data); +} -// */ -// export async function updateLockStatus({ assetId, courseId, locked }) { -// const { data } = await getAuthenticatedHttpClient() -// .put(`${getAssetsUrl(courseId)}${assetId}`, { -// locked, -// }); -// 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}.`); + await getAuthenticatedHttpClient() + .post(getCoursVideosApiUrl(courseId), [{ + edxVideoId, + message: 'Upload failed', + status: 'upload_failed', + }]); + }); + return uploadErrors; +} diff --git a/src/files-and-uploads/videos/data/slice.js b/src/files-and-uploads/videos/data/slice.js index 0d1409eec2..253d01d583 100644 --- a/src/files-and-uploads/videos/data/slice.js +++ b/src/files-and-uploads/videos/data/slice.js @@ -61,7 +61,7 @@ const slice = createSlice({ state.videoIds = state.videoIds.filter(id => id !== payload.videoId); }, addVideoSuccess: (state, { payload }) => { - state.videoIds = [payload.assetId, ...state.videoIds]; + state.videoIds = [payload.videoId, ...state.videoIds]; }, updateErrors: (state, { payload }) => { const { error, message } = payload; diff --git a/src/files-and-uploads/videos/data/thunks.js b/src/files-and-uploads/videos/data/thunks.js index 7127e1c3c8..9198b8b050 100644 --- a/src/files-and-uploads/videos/data/thunks.js +++ b/src/files-and-uploads/videos/data/thunks.js @@ -1,14 +1,17 @@ -// import { isEmpty } from 'lodash'; +import { isEmpty } from 'lodash'; import { RequestStatus } from '../../../data/constants'; import { - // addModel, addModels, removeModel, + updateModels, // updateModel, } from '../../../generic/model-store'; import { - getVideos, + addVideo, deleteVideo, + fetchVideoList, + getVideos, + uploadVideo, } from './api'; import { setVideoIds, @@ -16,7 +19,7 @@ import { setTotalCount, updateLoadingStatus, deleteVideoSuccess, - // addAssetSuccess, + addVideoSuccess, updateErrors, // clearErrors, updateEditStatus, @@ -74,33 +77,47 @@ export function deleteVideoFile(courseId, id, totalCount) { }; } -// export function addAssetFile(courseId, file, totalCount) { -// return async (dispatch) => { -// dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.IN_PROGRESS })); - -// try { -// const { asset } = await addAsset(courseId, file); -// // const [parsedAssest] = updateFileValues([asset]); -// dispatch(addModel({ -// modelType: 'videos', -// model: { ...parsedAssest }, -// })); -// dispatch(addAssetSuccess({ -// assetId: asset.id, -// })); -// dispatch(setTotalCount({ totalCount: totalCount + 1 })); -// dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.SUCCESSFUL })); -// } 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 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, + ); + if (isEmpty(errors)) { + const { videos } = await fetchVideoList(courseId); + const parsedVideos = updateFileValues(videos); + dispatch(updateModels({ + modelType: 'videos', + models: parsedVideos, + })); + dispatch(addVideoSuccess({ + videoId: '123id', + })); + dispatch(setTotalCount({ totalCount: parsedVideos.length })); + dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.SUCCESSFUL })); + } else { + 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 updateAssetLock({ assetId, courseId, locked }) { // return async (dispatch) => { diff --git a/src/files-and-uploads/videos/data/utils.js b/src/files-and-uploads/videos/data/utils.js index 6e6f5a2af1..ac6724894f 100644 --- a/src/files-and-uploads/videos/data/utils.js +++ b/src/files-and-uploads/videos/data/utils.js @@ -1,5 +1,6 @@ import { InsertDriveFile, Terminal, AudioFile } from '@edx/paragon/icons'; import { ensureConfig, getConfig } from '@edx/frontend-platform'; +import { isArray, isEmpty } from 'lodash'; // import FILES_AND_UPLOAD_TYPE_FILTERS from './constant'; ensureConfig([ @@ -97,3 +98,23 @@ export const sortFiles = (files, sortType) => { } return sortedIds; }; + +export const getSupportedFormats = (supportedFileFormats) => { + if (isEmpty(supportedFileFormats)) { + return null; + } + 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; +}; From 03323b9f3dd39627de705c7abefe2ae55466d2d7 Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Tue, 10 Oct 2023 16:40:36 -0400 Subject: [PATCH 07/46] feat: add video thumbnail functionality --- .stylelintrc.json | 2 +- src/files-and-uploads/EditFileErrors.jsx | 10 +- src/files-and-uploads/FileInput.jsx | 11 +- src/files-and-uploads/FileTable.jsx | 48 +++----- src/files-and-uploads/FileThumbnail.jsx | 75 ++++++------ src/files-and-uploads/FilesAndUploads.jsx | 5 +- .../assets/AssetThumbnail.jsx | 59 +++++++++ .../{files => assets}/messages.js | 0 src/files-and-uploads/data/constant.js | 1 + src/files-and-uploads/messages.js | 4 + .../table-components/GalleryCard.jsx | 42 ++++--- .../table-components/ListCard.jsx | 99 ---------------- .../table-components/index.js | 2 - .../table-custom-columns/ThumbnailColumn.jsx | 35 ++++-- .../videos/VideoThumbnail.jsx | 112 ++++++++++++++++++ .../videos/VideoThumbnail.scss | 61 ++++++++++ src/files-and-uploads/videos/Videos.jsx | 16 ++- src/files-and-uploads/videos/data/api.js | 15 ++- .../videos/data/constants.js | 8 ++ src/files-and-uploads/videos/data/slice.js | 4 +- src/files-and-uploads/videos/data/thunks.js | 52 ++++---- src/files-and-uploads/videos/data/utils.js | 112 +++++++++++++++++- .../info-sidebar/FileInfoVideoSidebar.jsx | 38 ++++++ src/index.scss | 1 + 24 files changed, 577 insertions(+), 235 deletions(-) create mode 100644 src/files-and-uploads/assets/AssetThumbnail.jsx rename src/files-and-uploads/{files => assets}/messages.js (100%) delete mode 100644 src/files-and-uploads/table-components/ListCard.jsx create mode 100644 src/files-and-uploads/videos/VideoThumbnail.jsx create mode 100644 src/files-and-uploads/videos/VideoThumbnail.scss create mode 100644 src/files-and-uploads/videos/data/constants.js create mode 100644 src/files-and-uploads/videos/info-sidebar/FileInfoVideoSidebar.jsx diff --git a/.stylelintrc.json b/.stylelintrc.json index 43148337a9..7c00badf8b 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -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"] diff --git a/src/files-and-uploads/EditFileErrors.jsx b/src/files-and-uploads/EditFileErrors.jsx index 542a91ce9c..3f92205b82 100644 --- a/src/files-and-uploads/EditFileErrors.jsx +++ b/src/files-and-uploads/EditFileErrors.jsx @@ -43,7 +43,7 @@ const EditFileErrors = ({ isError={updateFileStatus === RequestStatus.FAILED} >
    - {errorMessages.lock.map(message => ( + {errorMessages.lock?.map(message => (
  • {intl.formatMessage(messages.errorAlertMessage, { message })}
  • @@ -53,6 +53,11 @@ const EditFileErrors = ({ {intl.formatMessage(messages.errorAlertMessage, { message })} ))} + {errorMessages.thumbnail?.map(message => ( +
  • + {intl.formatMessage(messages.errorAlertMessage, { message })} +
  • + ))}
@@ -62,9 +67,10 @@ EditFileErrors.propTypes = { errorMessages: PropTypes.shape({ add: PropTypes.arrayOf(PropTypes.string).isRequired, delete: PropTypes.arrayOf(PropTypes.string).isRequired, - lock: 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, diff --git a/src/files-and-uploads/FileInput.jsx b/src/files-and-uploads/FileInput.jsx index 2791e1b68b..7ebf110ed4 100644 --- a/src/files-and-uploads/FileInput.jsx +++ b/src/files-and-uploads/FileInput.jsx @@ -24,7 +24,7 @@ export const useFileInput = ({ }; }; -const FileInput = ({ fileInput: hook, supportedFileFormats }) => ( +const FileInput = ({ fileInput: hook, supportedFileFormats, allowMultiple }) => ( ( onChange={hook.addFile} ref={hook.ref} type="file" - multiple + multiple={allowMultiple} /> ); @@ -46,11 +46,16 @@ FileInput.propTypes = { PropTypes.shape({ current: PropTypes.instanceOf(Element) }), ]), }).isRequired, - supportedFileFormats: PropTypes.shape({}), + supportedFileFormats: PropTypes.oneOfType([ + PropTypes.shape({}), + PropTypes.arrayOf(PropTypes.string), + ]), + allowMultiple: PropTypes.bool, }; FileInput.defaultProps = { supportedFileFormats: null, + allowMultiple: true, }; export default FileInput; diff --git a/src/files-and-uploads/FileTable.jsx b/src/files-and-uploads/FileTable.jsx index 5d14ded75f..e0cb362f54 100644 --- a/src/files-and-uploads/FileTable.jsx +++ b/src/files-and-uploads/FileTable.jsx @@ -27,7 +27,6 @@ import FileInfo from './FileInfo'; import FileInput, { useFileInput } from './FileInput'; import { GalleryCard, - ListCard, TableActions, } from './table-components'; import ApiStatusToast from './ApiStatusToast'; @@ -44,6 +43,7 @@ const FileTable = ({ handleDownloadFile, tableColumns, maxFileSize, + thumbnailPreview, // injected intl, }) => { @@ -134,34 +134,20 @@ const FileTable = ({ /> ); - const fileCard = ({ className, original }) => { - if (currentView === 'card') { - return ( - - ); - } - return ( - - ); - }; + const fileCard = ({ className, original }) => ( + + ); + const moreInfoColumn = { id: 'moreInfo', Header: '', @@ -239,13 +225,14 @@ const FileTable = ({ )} - + {!isEmpty(selectedRows) && ( @@ -292,6 +279,7 @@ FileTable.propTypes = { accessor: PropTypes.string, })).isRequired, maxFileSize: PropTypes.number.isRequired, + thumbnailPreview: PropTypes.func.isRequired, // injected intl: intlShape.isRequired, }; diff --git a/src/files-and-uploads/FileThumbnail.jsx b/src/files-and-uploads/FileThumbnail.jsx index d304d0f4e6..fd7e2a6c80 100644 --- a/src/files-and-uploads/FileThumbnail.jsx +++ b/src/files-and-uploads/FileThumbnail.jsx @@ -1,51 +1,48 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { - Icon, - Image, -} from '@edx/paragon'; -import { getSrc } from './data/utils'; -const AssetThumbnail = ({ +const FileThumbnail = ({ thumbnail, wrapperType, externalUrl, displayName, -}) => { - const src = getSrc({ - thumbnail, - externalUrl, - wrapperType, - }); - - return ( -
- {thumbnail ? ( - {`Thumbnail - ) : ( -
- -
- )} -
- ); -}; -AssetThumbnail.defaultProps = { + 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, }; -AssetThumbnail.propTypes = { +FileThumbnail.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, + id: PropTypes.string, + status: PropTypes.string, + thumbnailPreview: PropTypes.func.isRequired, + imageSize: PropTypes.shape({ + height: PropTypes.string.isRequired, + width: PropTypes.string.isRequired, + }).isRequired, }; -export default AssetThumbnail; +export default FileThumbnail; diff --git a/src/files-and-uploads/FilesAndUploads.jsx b/src/files-and-uploads/FilesAndUploads.jsx index 11c9c9e024..a982646e9c 100644 --- a/src/files-and-uploads/FilesAndUploads.jsx +++ b/src/files-and-uploads/FilesAndUploads.jsx @@ -23,6 +23,7 @@ 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 './assets/AssetThumbnail'; const FilesAndUploads = ({ courseId, @@ -52,6 +53,7 @@ const FilesAndUploads = ({ 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 thumbnailPreview = (props) => AssetThumbnail(props); const assets = useModels('assets', assetIds); const data = { @@ -76,7 +78,7 @@ const FilesAndUploads = ({ const thumbnailColumn = { id: 'thumbnail', Header: '', - Cell: ({ row }) => ThumbnailColumn({ row }), + Cell: ({ row }) => ThumbnailColumn({ row, thumbnailPreview }), }; const fileSizeColumn = { id: 'fileSize', @@ -153,6 +155,7 @@ const FilesAndUploads = ({ handleLockFile, tableColumns, maxFileSize, + thumbnailPreview, files: assets, }} /> diff --git a/src/files-and-uploads/assets/AssetThumbnail.jsx b/src/files-and-uploads/assets/AssetThumbnail.jsx new file mode 100644 index 0000000000..9686e21bd9 --- /dev/null +++ b/src/files-and-uploads/assets/AssetThumbnail.jsx @@ -0,0 +1,59 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Icon, + Image, +} from '@edx/paragon'; +import { getSrc } from '../data/utils'; + +const AssetThumbnail = ({ + thumbnail, + wrapperType, + externalUrl, + displayName, + imageSize, +}) => { + const src = getSrc({ + thumbnail, + externalUrl, + wrapperType, + }); + + return ( +
+ {thumbnail ? ( + {`Thumbnail + ) : ( +
+ +
+ )} +
+ ); +}; +AssetThumbnail.defaultProps = { + thumbnail: null, + wrapperType: null, + externalUrl: null, + displayName: null, +}; +AssetThumbnail.propTypes = { + thumbnail: PropTypes.string, + wrapperType: PropTypes.string, + externalUrl: PropTypes.string, + displayName: PropTypes.string, + imageSize: PropTypes.shape({ + width: PropTypes.string, + height: PropTypes.string.isRequired, + }).isRequired, +}; + +export default AssetThumbnail; diff --git a/src/files-and-uploads/files/messages.js b/src/files-and-uploads/assets/messages.js similarity index 100% rename from src/files-and-uploads/files/messages.js rename to src/files-and-uploads/assets/messages.js diff --git a/src/files-and-uploads/data/constant.js b/src/files-and-uploads/data/constant.js index 9fe5121f37..95a95e8f1f 100644 --- a/src/files-and-uploads/data/constant.js +++ b/src/files-and-uploads/data/constant.js @@ -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; diff --git a/src/files-and-uploads/messages.js b/src/files-and-uploads/messages.js index 7eed7d54a8..23871e7e43 100644 --- a/src/files-and-uploads/messages.js +++ b/src/files-and-uploads/messages.js @@ -105,6 +105,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', diff --git a/src/files-and-uploads/table-components/GalleryCard.jsx b/src/files-and-uploads/table-components/GalleryCard.jsx index ec2d19eb4c..dd2109b2ea 100644 --- a/src/files-and-uploads/table-components/GalleryCard.jsx +++ b/src/files-and-uploads/table-components/GalleryCard.jsx @@ -6,10 +6,10 @@ import { Card, Chip, Truncate, - Image, } from '@edx/paragon'; +import { ClosedCaption } from '@edx/paragon/icons'; import FileMenu from '../FileMenu'; -import { getSrc } from '../data/utils'; +import FileThumbnail from '../FileThumbnail'; const GalleryCard = ({ className, @@ -18,15 +18,12 @@ const GalleryCard = ({ handleLockedAsset, handleOpenDeleteConfirmation, handleOpenAssetInfo, + thumbnailPreview, }) => { const lockAsset = () => { - const { locked } = original; - handleLockedAsset(original.id, !locked); + const { locked, id } = original; + handleLockedAsset(id, !locked); }; - const src = getSrc({ - thumbnail: original.thumbnail, - wrapperType: original.wrapperType, - }); return ( @@ -51,13 +48,16 @@ const GalleryCard = ({ />
- {original.thumbnail ? ( - - ) : ( -
- -
- )} +
@@ -65,10 +65,11 @@ const GalleryCard = ({
- + {original.wrapperType} + {original.transcripts?.length > 0 && }
); @@ -82,16 +83,19 @@ GalleryCard.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, + status: PropTypes.string, + transcripts: PropTypes.arrayOf(PropTypes.string), }).isRequired, handleBulkDownload: PropTypes.func.isRequired, handleLockedAsset: PropTypes.func.isRequired, handleOpenDeleteConfirmation: PropTypes.func.isRequired, handleOpenAssetInfo: PropTypes.func.isRequired, + thumbnailPreview: PropTypes.func.isRequired, }; export default GalleryCard; diff --git a/src/files-and-uploads/table-components/ListCard.jsx b/src/files-and-uploads/table-components/ListCard.jsx deleted file mode 100644 index 45285a261e..0000000000 --- a/src/files-and-uploads/table-components/ListCard.jsx +++ /dev/null @@ -1,99 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { - ActionRow, - Icon, - Card, - Chip, - Truncate, - Image, -} from '@edx/paragon'; -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 ( - -
- {original.thumbnail ? ( - - ) : ( -
- -
- )} -
- - -
- - {original.displayName} - -
- - {original.wrapperType} - -
-
- - - handleOpenAssetInfo(original)} - portableUrl={original.portableUrl} - id={original.id} - wrapperType={original.wrapperType} - onDownload={() => handleBulkDownload( - [{ original: { id: original.id, displayName: original.displayName } }], - )} - openDeleteConfirmation={() => handleOpenDeleteConfirmation([{ original }])} - /> - - -
- ); -}; - -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; diff --git a/src/files-and-uploads/table-components/index.js b/src/files-and-uploads/table-components/index.js index 9df0da049c..cafee08288 100644 --- a/src/files-and-uploads/table-components/index.js +++ b/src/files-and-uploads/table-components/index.js @@ -1,9 +1,7 @@ import GalleryCard from './GalleryCard'; -import ListCard from './ListCard'; import TableActions from './TableActions'; export { TableActions, GalleryCard, - ListCard, }; diff --git a/src/files-and-uploads/table-components/table-custom-columns/ThumbnailColumn.jsx b/src/files-and-uploads/table-components/table-custom-columns/ThumbnailColumn.jsx index 0740a5ca18..1df2315b16 100644 --- a/src/files-and-uploads/table-components/table-custom-columns/ThumbnailColumn.jsx +++ b/src/files-and-uploads/table-components/table-custom-columns/ThumbnailColumn.jsx @@ -1,19 +1,29 @@ import React from 'react'; import { PropTypes } from 'prop-types'; -import { Icon, Image } from '@edx/paragon'; -import { getSrc } from '../../data/utils'; +import FileThumbnail from '../../FileThumbnail'; -const ThumbnailColumn = ({ row }) => { - const { thumbnail, wrapperType } = row.original; - const src = getSrc({ thumbnail, wrapperType }); +const ThumbnailColumn = ({ row, thumbnailPreview }) => { + const { + thumbnail, + wrapperType, + externalUrl, + displayName, + id, + status, + } = row.original; return ( - thumbnail ? ( - - ) : ( -
- -
- ) + ); }; @@ -24,6 +34,7 @@ ThumbnailColumn.propTypes = { wrapperType: PropTypes.string.isRequired, }.isRequired, }.isRequired, + thumbnailPreview: PropTypes.func.isRequired, }; export default ThumbnailColumn; diff --git a/src/files-and-uploads/videos/VideoThumbnail.jsx b/src/files-and-uploads/videos/VideoThumbnail.jsx new file mode 100644 index 0000000000..351dc325a1 --- /dev/null +++ b/src/files-and-uploads/videos/VideoThumbnail.jsx @@ -0,0 +1,112 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { VideoFile } from '@edx/paragon/icons'; +import { + Badge, + Button, + Icon, + Image, +} from '@edx/paragon'; +import FileInput, { useFileInput } from '../FileInput'; + +const VideoThumbnail = ({ + thumbnail, + displayName, + id, + imageSize, + handleAddThumbnail, + videoImageSettings, + status, +}) => { + const fileInputControl = useFileInput({ + onAddFile: (file) => handleAddThumbnail(file, id), + setSelectedRows: () => {}, + setAddOpen: () => false, + }); + + let addThumbnailMessage = 'Enable thumbnail upload'; + if (videoImageSettings?.videoImageUploadEnabled) { + if (thumbnail) { + addThumbnailMessage = 'Edit thumbnail'; + } else { + addThumbnailMessage = 'Add thumbnail'; + } + } + const supportedFiles = videoImageSettings?.supportedFileFormats + ? Object.values(videoImageSettings.supportedFileFormats) : null; + let isUploaded = false; + switch (status) { + case 'Uploaded': + isUploaded = true; + break; + case 'Imported': + isUploaded = true; + break; + default: + break; + } + const showThumbnail = videoImageSettings?.videoImageUploadEnabled && thumbnail && isUploaded; + + return ( +
+
+ {showThumbnail ? ( + {`Thumbnail + ) : ( + <> +
+ +
+
+ + {status} + +
+ + )} +
+ +
+ +
+ ); +}; + +VideoThumbnail.propTypes = { + thumbnail: PropTypes.string.isRequired, + 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, +}; + +export default VideoThumbnail; diff --git a/src/files-and-uploads/videos/VideoThumbnail.scss b/src/files-and-uploads/videos/VideoThumbnail.scss new file mode 100644 index 0000000000..e97c75b246 --- /dev/null +++ b/src/files-and-uploads/videos/VideoThumbnail.scss @@ -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; +} diff --git a/src/files-and-uploads/videos/Videos.jsx b/src/files-and-uploads/videos/Videos.jsx index 408bea3a2e..c3b5b4885e 100644 --- a/src/files-and-uploads/videos/Videos.jsx +++ b/src/files-and-uploads/videos/Videos.jsx @@ -18,6 +18,7 @@ import { RequestStatus } from '../../data/constants'; import { useModels, useModel } from '../../generic/model-store'; import { addVideoFile, + addVideoThumbnail, deleteVideoFile, fetchVideos, } from './data/thunks'; @@ -30,6 +31,8 @@ import ThumbnailColumn from '../table-components/table-custom-columns/ThumbnailC 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 { resampleFile } from './data/utils'; const Videos = ({ courseId, @@ -65,6 +68,7 @@ const Videos = ({ encodingsDownloadUrl, videoUploadMaxFileSize, videoSupportedFileFormats, + videoImageSettings, } = pageSettings; const supportedFileFormats = { 'video/*': videoSupportedFileFormats }; @@ -75,6 +79,14 @@ const Videos = ({ const handleDownloadFile = (selectedRows) => console.log(selectedRows); // const handleTranscriptCredentials = ({data, global, provider}) => { // dispatch(addTranscriptCredentials({data, global, provider}))} + const handleAddThumbnail = (file, videoId) => resampleFile({ + file, + dispatch, + courseId, + videoId, + addVideoThumbnail, + }); + const videos = useModels('videos', videoIds); const data = { supportedFileFormats, @@ -85,6 +97,7 @@ const Videos = ({ usagePathStatus, usageErrorMessages: errorMessages.usageMetrics, }; + const thumbnailPreview = (props) => VideoThumbnail({ ...props, handleAddThumbnail, videoImageSettings }); const maxFileSize = videoUploadMaxFileSize * 1073741824; const transcriptColumn = { id: 'transcripts', @@ -116,7 +129,7 @@ const Videos = ({ const videoThumbnailColumn = { id: 'courseVideoImageUrl', Header: '', - Cell: ({ row }) => ThumbnailColumn({ row }), + Cell: ({ row }) => ThumbnailColumn({ row, thumbnailPreview }), }; const tableColumns = [ { ...videoThumbnailColumn }, @@ -179,6 +192,7 @@ const Videos = ({ handleDownloadFile, tableColumns, maxFileSize, + thumbnailPreview, files: videos, }} /> diff --git a/src/files-and-uploads/videos/data/api.js b/src/files-and-uploads/videos/data/api.js index 95087be4b5..f9e3804c42 100644 --- a/src/files-and-uploads/videos/data/api.js +++ b/src/files-and-uploads/videos/data/api.js @@ -107,7 +107,20 @@ export async function deleteVideo(courseId, videoId) { } /** - * Add asset to course. + * 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 */ diff --git a/src/files-and-uploads/videos/data/constants.js b/src/files-and-uploads/videos/data/constants.js new file mode 100644 index 0000000000..b534e7eb73 --- /dev/null +++ b/src/files-and-uploads/videos/data/constants.js @@ -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; diff --git a/src/files-and-uploads/videos/data/slice.js b/src/files-and-uploads/videos/data/slice.js index 253d01d583..bbcf68e1ed 100644 --- a/src/files-and-uploads/videos/data/slice.js +++ b/src/files-and-uploads/videos/data/slice.js @@ -16,7 +16,7 @@ const slice = createSlice({ errors: { add: [], delete: [], - lock: [], + thumbnail: [], download: [], usageMetrics: [], }, @@ -44,7 +44,7 @@ const slice = createSlice({ case 'add': state.addingStatus = status; break; - case 'lock': + case 'thumbnail': state.updatingStatus = status; break; case 'download': diff --git a/src/files-and-uploads/videos/data/thunks.js b/src/files-and-uploads/videos/data/thunks.js index 9198b8b050..a3e3279429 100644 --- a/src/files-and-uploads/videos/data/thunks.js +++ b/src/files-and-uploads/videos/data/thunks.js @@ -1,12 +1,14 @@ import { isEmpty } from 'lodash'; +import { getConfig } from '@edx/frontend-platform'; import { RequestStatus } from '../../../data/constants'; import { addModels, removeModel, + updateModel, updateModels, - // updateModel, } from '../../../generic/model-store'; import { + addThumbnail, addVideo, deleteVideo, fetchVideoList, @@ -119,27 +121,35 @@ export function addVideoFile(courseId, file) { }; } -// export function updateAssetLock({ assetId, courseId, locked }) { -// return async (dispatch) => { -// dispatch(updateEditStatus({ editType: 'lock', status: RequestStatus.IN_PROGRESS })); +export function addVideoThumbnail({ file, videoId, courseId }) { + return async (dispatch) => { + dispatch(updateEditStatus({ editType: 'thumbnail', status: RequestStatus.IN_PROGRESS })); -// try { -// await updateLockStatus({ assetId, courseId, locked }); -// dispatch(updateModel({ -// modelType: 'assets', -// model: { -// id: assetId, -// locked, -// }, -// })); -// dispatch(updateEditStatus({ editType: 'lock', status: RequestStatus.SUCCESSFUL })); -// } catch (error) { -// const lockStatus = locked ? 'lock' : 'unlock'; -// dispatch(updateErrors({ error: 'lock', message: `Failed to ${lockStatus} file id ${assetId}.` })); -// dispatch(updateEditStatus({ editType: 'lock', status: RequestStatus.FAILED })); -// } -// }; -// } + 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 resetErrors({ errorType }) { // return (dispatch) => { dispatch(clearErrors({ error: errorType })); }; diff --git a/src/files-and-uploads/videos/data/utils.js b/src/files-and-uploads/videos/data/utils.js index ac6724894f..e7067f4608 100644 --- a/src/files-and-uploads/videos/data/utils.js +++ b/src/files-and-uploads/videos/data/utils.js @@ -1,7 +1,14 @@ import { InsertDriveFile, Terminal, AudioFile } from '@edx/paragon/icons'; import { ensureConfig, getConfig } from '@edx/frontend-platform'; import { isArray, isEmpty } from 'lodash'; -// import FILES_AND_UPLOAD_TYPE_FILTERS from './constant'; +import { + ASPECT_RATIO, + ASPECT_RATIO_ERROR_MARGIN, + MAX_HEIGHT, + MAX_WIDTH, + MIN_HEIGHT, + MIN_WIDTH, +} from './constants'; ensureConfig([ 'STUDIO_BASE_URL', @@ -18,6 +25,11 @@ export const updateFileValues = (files) => { } = file; const wrapperType = 'video'; + let thumbnail = courseVideoImageUrl; + if (thumbnail.startsWith('/')) { + thumbnail = `${getConfig().STUDIO_BASE_URL}${thumbnail}`; + } + updatedFiles.push({ ...file, displayName: clientVideoId, @@ -26,7 +38,7 @@ export const updateFileValues = (files) => { dateAdded: created.toString(), usageLocations: [], fileSize: null, - thumbnail: courseVideoImageUrl, + thumbnail, }); }); @@ -103,6 +115,9 @@ export const getSupportedFormats = (supportedFileFormats) => { if (isEmpty(supportedFileFormats)) { return null; } + if (isArray(supportedFileFormats)) { + return supportedFileFormats; + } const supportedFormats = []; Object.entries(supportedFileFormats).forEach(([key, value]) => { let format; @@ -118,3 +133,96 @@ export const getSupportedFormats = (supportedFileFormats) => { }); return supportedFormats; }; + +/** resampledFile({ canvasUrl, filename, mimeType }) + * resampledFile 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; +}; + +const hasValidDimensions = (image) => { + const width = image.naturalWidth; + const height = image.naturalHeight; + const imageAspectRatio = Math.abs(width / height) - ASPECT_RATIO; + + if (width < MIN_HEIGHT || 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 = () => { + if (!hasValidDimensions(image)) { + const resampledFile = resampleImage({ image, filename: file.name }); + dispatch(addVideoThumbnail({ courseId, videoId, file: resampledFile })); + } else { + dispatch(addVideoThumbnail({ courseId, videoId, file })); + } + }; + }; + reader.readAsDataURL(file); +}; diff --git a/src/files-and-uploads/videos/info-sidebar/FileInfoVideoSidebar.jsx b/src/files-and-uploads/videos/info-sidebar/FileInfoVideoSidebar.jsx new file mode 100644 index 0000000000..ea82f60a14 --- /dev/null +++ b/src/files-and-uploads/videos/info-sidebar/FileInfoVideoSidebar.jsx @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Tabs, + Tab, +} from '@edx/paragon'; + +import InfoTab from './InfoTab'; +import TranscriptTab from './TranscriptTab'; + +const FileInfoVideoSidebar = ({ + video, +}) => ( + + + + + + + + +); + +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, + }), +}; + +FileInfoVideoSidebar.defaultProps = { + video: null, +}; + +export default FileInfoVideoSidebar; diff --git a/src/index.scss b/src/index.scss index bc0a403f1b..c9dbf0de5b 100755 --- a/src/index.scss +++ b/src/index.scss @@ -19,3 +19,4 @@ @import "course-updates/CourseUpdates"; @import "export-page/CourseExportPage"; @import "import-page/CourseImportPage"; +@import "files-and-uploads/videos/VideoThumbnail.scss" From edded97cb2fae908fe867d9ba9d6765e13d8a96a Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Fri, 13 Oct 2023 15:18:21 -0400 Subject: [PATCH 08/46] feat: add transcript functions --- src/files-and-uploads/FileInfo.jsx | 202 ++++++------------ src/files-and-uploads/FileMenu.jsx | 15 +- src/files-and-uploads/FileTable.jsx | 13 +- .../assets/FileInfoAssetSidebar.jsx | 130 +++++++++++ .../table-components/TableActions.jsx | 7 +- src/files-and-uploads/videos/data/api.js | 146 ++++++++----- src/files-and-uploads/videos/data/slice.js | 5 + src/files-and-uploads/videos/data/thunks.js | 146 +++++++++++-- src/files-and-uploads/videos/data/utils.js | 44 ++-- .../info-sidebar/FileInfoVideoSidebar.jsx | 3 +- .../videos/info-sidebar/InfoTab.jsx | 49 +++++ .../videos/info-sidebar/TranscriptTab.jsx | 129 +++++++++++ .../transcript-item/LanguageSelect.jsx | 60 ++++++ .../transcript-item/Transcript.jsx | 114 ++++++++++ .../transcript-item/TranscriptMenu.jsx | 53 +++++ .../info-sidebar/transcript-item/index.js | 3 + .../info-sidebar/transcript-item/messages.js | 56 +++++ 17 files changed, 927 insertions(+), 248 deletions(-) create mode 100644 src/files-and-uploads/assets/FileInfoAssetSidebar.jsx create mode 100644 src/files-and-uploads/videos/info-sidebar/InfoTab.jsx create mode 100644 src/files-and-uploads/videos/info-sidebar/TranscriptTab.jsx create mode 100644 src/files-and-uploads/videos/info-sidebar/transcript-item/LanguageSelect.jsx create mode 100644 src/files-and-uploads/videos/info-sidebar/transcript-item/Transcript.jsx create mode 100644 src/files-and-uploads/videos/info-sidebar/transcript-item/TranscriptMenu.jsx create mode 100644 src/files-and-uploads/videos/info-sidebar/transcript-item/index.js create mode 100644 src/files-and-uploads/videos/info-sidebar/transcript-item/messages.js diff --git a/src/files-and-uploads/FileInfo.jsx b/src/files-and-uploads/FileInfo.jsx index 30f0afe80b..70114d1c3f 100644 --- a/src/files-and-uploads/FileInfo.jsx +++ b/src/files-and-uploads/FileInfo.jsx @@ -1,176 +1,102 @@ -import React, { useState } from 'react'; +import React 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'; +import FileInfoAssetSidebar from './assets/FileInfoAssetSidebar'; +import FileInfoVideoSidebar from './videos/info-sidebar/FileInfoVideoSidebar'; +import FileThumbnail from './FileThumbnail'; const FileInfo = ({ - asset, + file, isOpen, onClose, handleLockedAsset, + thumbnailPreview, 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 ( - - - -
- - {asset?.displayName} - -
-
-
- -
-
-
- -
- -
- -
- -
- -
- {fileSize} -
- -
- -
- - {asset?.portableUrl} - -
- - navigator.clipboard.writeText(asset?.portableUrl)} - /> -
-
- -
- -
- - {asset?.externalUrl} - -
- - navigator.clipboard.writeText(asset?.externalUrl)} - /> -
- -
- -
- - - -
-
+}) => ( + + + +
+ + {file?.displayName} +
-
- + + + +
+
+
+
- - - - ); -}; +
+ {file?.wrapperType === 'video' ? ( + + ) : ( + + )} +
+
+
+ +
+ +
+ +); + FileInfo.propTypes = { - asset: PropTypes.shape({ + file: 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, dateAdded: PropTypes.string.isRequired, fileSize: PropTypes.number.isRequired, usageLocations: PropTypes.arrayOf(PropTypes.string), - }).isRequired, + status: PropTypes.string, + }), 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, + thumbnailPreview: PropTypes.func.isRequired, +}; + +FileInfo.defaultProps = { + file: null, }; export default injectIntl(FileInfo); diff --git a/src/files-and-uploads/FileMenu.jsx b/src/files-and-uploads/FileMenu.jsx index 6bad4e22fa..b2446855ce 100644 --- a/src/files-and-uploads/FileMenu.jsx +++ b/src/files-and-uploads/FileMenu.jsx @@ -73,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, + portableUrl: PropTypes.string, id: PropTypes.string.isRequired, wrapperType: PropTypes.string.isRequired, // injected intl: intlShape.isRequired, }; +FileMenu.defaultProps = { + externalUrl: null, + handleLock: () => {}, + locked: null, + portableUrl: null, +}; + export default injectIntl(FileMenu); diff --git a/src/files-and-uploads/FileTable.jsx b/src/files-and-uploads/FileTable.jsx index e0cb362f54..6089fe47e4 100644 --- a/src/files-and-uploads/FileTable.jsx +++ b/src/files-and-uploads/FileTable.jsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { useDispatch } from 'react-redux'; import isEmpty from 'lodash/isEmpty'; @@ -70,6 +70,17 @@ const FileTable = ({ encodingsDownloadUrl, supportedFileFormats, } = data; + useEffect(() => { + if (selectedRows) { + const udpatedRows = []; + selectedRows.forEach(row => { + const currentFile = row.original; + const [updatedFile] = files.filter(file => file.id === currentFile.id); + udpatedRows.push({ original: updatedFile }); + }); + setSelectedRows(udpatedRows); + } + }, [files]); const fileInputControl = useFileInput({ onAddFile: (file) => handleAddFile(file), diff --git a/src/files-and-uploads/assets/FileInfoAssetSidebar.jsx b/src/files-and-uploads/assets/FileInfoAssetSidebar.jsx new file mode 100644 index 0000000000..5b88e9a795 --- /dev/null +++ b/src/files-and-uploads/assets/FileInfoAssetSidebar.jsx @@ -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 ( + +
+ +
+ +
+ +
+ {fileSize} +
+ +
+ +
+ + {asset?.portableUrl} + +
+ + navigator.clipboard.writeText(asset?.portableUrl)} + /> +
+
+ +
+ +
+ + {asset?.externalUrl} + +
+ + navigator.clipboard.writeText(asset?.externalUrl)} + /> +
+ +
+ +
+ + + +
+
+ ); +}; +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); diff --git a/src/files-and-uploads/table-components/TableActions.jsx b/src/files-and-uploads/table-components/TableActions.jsx index 1d8e36eca3..af8cb49df5 100644 --- a/src/files-and-uploads/table-components/TableActions.jsx +++ b/src/files-and-uploads/table-components/TableActions.jsx @@ -45,7 +45,6 @@ const TableActions = ({ {encodingsDownloadUrl ? ( @@ -172,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, }), ), diff --git a/src/files-and-uploads/videos/data/api.js b/src/files-and-uploads/videos/data/api.js index f9e3804c42..ca073958bf 100644 --- a/src/files-and-uploads/videos/data/api.js +++ b/src/files-and-uploads/videos/data/api.js @@ -2,8 +2,8 @@ import { camelCaseObject, ensureConfig, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -// import JSZip from 'jszip'; -// import saveAs from 'file-saver'; +import JSZip from 'jszip'; +import saveAs from 'file-saver'; ensureConfig([ 'STUDIO_BASE_URL', @@ -35,64 +35,104 @@ export async function fetchVideoList(courseId) { return camelCaseObject(data); } -// /** -// * Fetch asset file. -// * @param {blockId} courseId Course ID for the course to operate on +export async function deleteTranscript({ videoId, language, apiUrl }) { + await getAuthenticatedHttpClient() + .delete(`${getApiBaseUrl()}${apiUrl}/${videoId}/${language}`); +} -// */ -// export async function getDownload(selectedRows, courseId) { -// const downloadErrors = []; -// if (selectedRows?.length > 1) { -// const zip = new JSZip(); -// const date = new Date().toString(); -// const folder = zip.folder(`${courseId}-assets-${date}`); -// const assetNames = []; -// const assetFetcher = await Promise.allSettled( -// selectedRows.map(async (row) => { -// const asset = row?.original; -// try { -// assetNames.push(asset.displayName); -// const res = await fetch(`${getApiBaseUrl()}/${asset.id}`); -// if (!res.ok) { -// throw new Error(); -// } -// return res.blob(); -// } catch (error) { -// downloadErrors.push(`Failed to download ${asset?.displayName}.`); -// return null; -// } -// }), -// ); -// const definedAssets = assetFetcher.filter(asset => asset.value !== null); -// if (definedAssets.length > 0) { -// definedAssets.forEach((assetBlob, index) => { -// folder.file(assetNames[index], assetBlob.value, { blob: true }); -// }); -// zip.generateAsync({ type: 'blob' }).then(content => { -// saveAs(content, `${courseId}-assets-${date}.zip`); -// }); -// } -// } else if (selectedRows?.length === 1) { -// const asset = selectedRows[0].original; -// try { -// saveAs(`${getApiBaseUrl()}/${asset.id}`, asset.displayName); -// } catch (error) { -// downloadErrors.push(`Failed to download ${asset?.displayName}.`); -// } -// } else { -// downloadErrors.push('No files were selected to download'); -// } -// return downloadErrors; -// } +export async function downloadTranscriipt({ + 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_langage_code', newLanguage); + await getAuthenticatedHttpClient().post(`${getApiBaseUrl()}${apiUrl}`, formData); +} + +export async function getDownloadLink(courseId, edxVideoId) { + const { data } = await getAuthenticatedHttpClient() + .get(`${getVideosUrl(courseId)}/${edxVideoId}`); + return camelCaseObject(data); +} + +/** + * Fetch video file. + * @param {blockId} courseId Course ID for the course to operate on + + */ +export async function getDownload(selectedRows, courseId) { + const downloadErrors = []; + if (selectedRows?.length > 1) { + const zip = new JSZip(); + const date = new Date().toString(); + const folder = zip.folder(`${courseId}-videos-${date}`); + const videoNames = []; + const videoFetcher = await Promise.allSettled( + selectedRows.map(async (row) => { + const video = row?.original; + try { + videoNames.push(video.displayName); + const { downloadLink } = await getDownloadLink(courseId, video.id); + const res = await fetch(downloadLink); + if (!res.ok) { + throw new Error(); + } + return res.blob(); + } catch (error) { + downloadErrors.push(`Failed to download ${video?.displayName}.`); + return null; + } + }), + ); + const definedVideos = videoFetcher.filter(video => video.value !== null); + if (definedVideos.length > 0) { + definedVideos.forEach((videoBlob, index) => { + folder.file(videoNames[index], videoBlob.value, { blob: true }); + }); + zip.generateAsync({ type: 'blob' }).then(content => { + saveAs(content, `${courseId}-videos-${date}.zip`); + }); + } + } else if (selectedRows?.length === 1) { + const video = selectedRows[0].original; + try { + const { downloadLink } = await getDownloadLink(courseId, video.id); + saveAs(downloadLink, video.displayName); + } catch (error) { + downloadErrors.push(`Failed to download ${video?.displayName}.`); + } + } else { + downloadErrors.push('No files were selected to download'); + } + return downloadErrors; +} // /** -// * Fetch where asset is used in a course. +// * Fetch where video is used in a course. // * @param {blockId} courseId Course ID for the course to operate on // */ -// export async function getAssetUsagePaths({ courseId, assetId }) { +// export async function getAssetUsagePaths({ courseId, videoId }) { // const { data } = await getAuthenticatedHttpClient() -// .get(`${getAssetsUrl(courseId)}${assetId}/usage`); +// .get(`${getAssetsUrl(courseId)}${videoId}/usage`); // return camelCaseObject(data); // } diff --git a/src/files-and-uploads/videos/data/slice.js b/src/files-and-uploads/videos/data/slice.js index bbcf68e1ed..250260ce54 100644 --- a/src/files-and-uploads/videos/data/slice.js +++ b/src/files-and-uploads/videos/data/slice.js @@ -13,12 +13,14 @@ const slice = createSlice({ addingStatus: '', deletingStatus: '', usageStatus: '', + transcriptStatus: '', errors: { add: [], delete: [], thumbnail: [], download: [], usageMetrics: [], + transcript: [], }, totalCount: 0, }, @@ -53,6 +55,9 @@ const slice = createSlice({ case 'usageMetrics': state.usageStatus = status; break; + case 'transcript': + state.transcriptStatus = status; + break; default: break; } diff --git a/src/files-and-uploads/videos/data/thunks.js b/src/files-and-uploads/videos/data/thunks.js index a3e3279429..9b77ab7eba 100644 --- a/src/files-and-uploads/videos/data/thunks.js +++ b/src/files-and-uploads/videos/data/thunks.js @@ -14,6 +14,10 @@ import { fetchVideoList, getVideos, uploadVideo, + getDownload, + deleteTranscript, + downloadTranscriipt, + uploadTranscript, } from './api'; import { setVideoIds, @@ -23,7 +27,7 @@ import { deleteVideoSuccess, addVideoSuccess, updateErrors, - // clearErrors, + clearErrors, updateEditStatus, } from './slice'; @@ -151,9 +155,115 @@ export function addVideoThumbnail({ file, videoId, courseId }) { }; } -// export function resetErrors({ errorType }) { -// return (dispatch) => { dispatch(clearErrors({ error: errorType })); }; -// } +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 downloadTranscriipt({ + 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) { + 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 resetErrors({ errorType }) { + return (dispatch) => { dispatch(clearErrors({ error: errorType })); }; +} // export function getUsagePaths({ asset, courseId, setSelectedRows }) { // return async (dispatch) => { @@ -172,17 +282,17 @@ export function addVideoThumbnail({ file, videoId, courseId }) { // }; // } -// export function fetchAssetDownload({ selectedRows, courseId }) { -// return async (dispatch) => { -// dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.IN_PROGRESS })); -// const errors = await getDownload(selectedRows, courseId); -// 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 fetchVideoDownload({ selectedRows, courseId }) { + return async (dispatch) => { + dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.IN_PROGRESS })); + const errors = await getDownload(selectedRows, courseId); + 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 })); + } + }; +} diff --git a/src/files-and-uploads/videos/data/utils.js b/src/files-and-uploads/videos/data/utils.js index e7067f4608..28e2c9be3c 100644 --- a/src/files-and-uploads/videos/data/utils.js +++ b/src/files-and-uploads/videos/data/utils.js @@ -1,4 +1,3 @@ -import { InsertDriveFile, Terminal, AudioFile } from '@edx/paragon/icons'; import { ensureConfig, getConfig } from '@edx/frontend-platform'; import { isArray, isEmpty } from 'lodash'; import { @@ -45,37 +44,24 @@ export const updateFileValues = (files) => { return updatedFiles; }; -export const getSrc = ({ thumbnail, wrapperType, externalUrl }) => { - if (thumbnail) { - return externalUrl || `${getConfig().STUDIO_BASE_URL}${thumbnail}`; - } - switch (wrapperType) { - case 'document': - return InsertDriveFile; - case 'code': - return Terminal; - case 'audio': - return AudioFile; - default: - return InsertDriveFile; +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 getFileSizeToClosestByte = (fileSize, numberOfDivides = 0) => { - if (fileSize > 1000) { - const updatedSize = fileSize / 1000; - const incrementNumberOfDivides = numberOfDivides + 1; - return getFileSizeToClosestByte(updatedSize, incrementNumberOfDivides); - } - const fileSizeFixedDecimal = Number.parseFloat(fileSize).toFixed(2); - switch (numberOfDivides) { - case 1: - return `${fileSizeFixedDecimal} KB`; - case 2: - return `${fileSizeFixedDecimal} MB`; - default: - return `${fileSizeFixedDecimal} B`; - } +export const getLanguages = (availableLanguages) => { + const languages = {}; + availableLanguages?.forEach(language => { + const { languageCode, languageText } = language; + languages[languageCode] = languageText; + }); + return languages; }; export const sortFiles = (files, sortType) => { diff --git a/src/files-and-uploads/videos/info-sidebar/FileInfoVideoSidebar.jsx b/src/files-and-uploads/videos/info-sidebar/FileInfoVideoSidebar.jsx index ea82f60a14..c6c6fa69c0 100644 --- a/src/files-and-uploads/videos/info-sidebar/FileInfoVideoSidebar.jsx +++ b/src/files-and-uploads/videos/info-sidebar/FileInfoVideoSidebar.jsx @@ -15,7 +15,7 @@ const FileInfoVideoSidebar = ({ - + @@ -28,6 +28,7 @@ FileInfoVideoSidebar.propTypes = { id: PropTypes.string.isRequired, dateAdded: PropTypes.string.isRequired, fileSize: PropTypes.number.isRequired, + transcripts: PropTypes.arrayOf(PropTypes.string), }), }; diff --git a/src/files-and-uploads/videos/info-sidebar/InfoTab.jsx b/src/files-and-uploads/videos/info-sidebar/InfoTab.jsx new file mode 100644 index 0000000000..a9e952db91 --- /dev/null +++ b/src/files-and-uploads/videos/info-sidebar/InfoTab.jsx @@ -0,0 +1,49 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Stack } from '@edx/paragon'; +import { injectIntl, FormattedDate } from '@edx/frontend-platform/i18n'; +import { getFileSizeToClosestByte } from '../../data/utils'; +import { getFormattedDuration } from '../data/utils'; + +const InfoTab = ({ video }) => { + const fileSize = getFileSizeToClosestByte(video?.fileSize); + const duration = getFormattedDuration(video?.duration); + + return ( + +
+ Date Added +
+ +
+ File size +
+ {fileSize} +
+ Video length +
+ {duration} +
+ ); +}; + +InfoTab.propTypes = { + video: PropTypes.shape({ + duration: PropTypes.number.isRequired, + dateAdded: PropTypes.string.isRequired, + fileSize: PropTypes.number.isRequired, + }), +}; + +InfoTab.defaultProps = { + video: {}, +}; + +export default injectIntl(InfoTab); diff --git a/src/files-and-uploads/videos/info-sidebar/TranscriptTab.jsx b/src/files-and-uploads/videos/info-sidebar/TranscriptTab.jsx new file mode 100644 index 0000000000..9f1725d03d --- /dev/null +++ b/src/files-and-uploads/videos/info-sidebar/TranscriptTab.jsx @@ -0,0 +1,129 @@ +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 } 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'; + +const TranscriptTab = ({ video }) => { + 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(() => { + setPreviousSelection(transcripts); + }, [transcripts]); + + const handleTranscript = async (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 { + await dispatch(deleteVideoTranscript({ + language, + videoId: id, + apiUrl: transcriptDeleteHandlerUrl, + transcripts, + })); + } + break; + case 'download': + await dispatch(downloadVideoTranscript({ + filename: `${displayName}-${language}.srt`, + language, + videoId: id, + apiUrl: transcriptDownloadHandlerUrl, + })); + break; + case 'upload': + await dispatch(uploadVideoTranscript({ + language, + videoId: id, + apiUrl: transcriptUploadHandlerUrl, + newLanguage, + file, + transcripts, + })); + break; + default: + break; + } + }; + + return ( + + +
    + {errors.transcript.map(message => ( +
  • + {message} + {/* {intl.formatMessage(messages.errorAlertMessage, { message })} */} +
  • + ))} +
+
+ {previousSelection.map(transcript => ( + + ))} + +
+ ); +}; + +TranscriptTab.propTypes = { + video: PropTypes.shape({ + transcripts: PropTypes.arrayOf(PropTypes.string).isRequired, + id: PropTypes.string.isRequired, + displayName: PropTypes.string.isRequired, + }).isRequired, +}; + +export default injectIntl(TranscriptTab); diff --git a/src/files-and-uploads/videos/info-sidebar/transcript-item/LanguageSelect.jsx b/src/files-and-uploads/videos/info-sidebar/transcript-item/LanguageSelect.jsx new file mode 100644 index 0000000000..167115e0e8 --- /dev/null +++ b/src/files-and-uploads/videos/info-sidebar/transcript-item/LanguageSelect.jsx @@ -0,0 +1,60 @@ +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 ( + + + {currentSelection} + + + {Object.entries(options).map(([valueKey, text]) => { + if (valueKey === value) { + return ( + handleSelect(valueKey)} key={`${valueKey}-item`}> + {text} + + ); + } + if (!previousSelection.includes(valueKey)) { + return ( + handleSelect(valueKey)} key={`${valueKey}-item`}> + {text} + + ); + } + return ( + + {text} + + ); + })} + + + ); +}; + +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; diff --git a/src/files-and-uploads/videos/info-sidebar/transcript-item/Transcript.jsx b/src/files-and-uploads/videos/info-sidebar/transcript-item/Transcript.jsx new file mode 100644 index 0000000000..ebb6773852 --- /dev/null +++ b/src/files-and-uploads/videos/info-sidebar/transcript-item/Transcript.jsx @@ -0,0 +1,114 @@ +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 } 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, +}) => { + 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 ? ( + + )} /> + + + + + + + + + + + ) : ( +
+
+ +
+ { transcript === '' ? ( + + ) : ( + + )} +
+ )} + + + ); +}; + +Transcript.propTypes = { + languages: PropTypes.shape({}).isRequired, + transcript: PropTypes.string.isRequired, + previousSelection: PropTypes.arrayOf(PropTypes.string).isRequired, + handleTranscript: PropTypes.func.isRequired, +}; + +export default injectIntl(Transcript); diff --git a/src/files-and-uploads/videos/info-sidebar/transcript-item/TranscriptMenu.jsx b/src/files-and-uploads/videos/info-sidebar/transcript-item/TranscriptMenu.jsx new file mode 100644 index 0000000000..365b85d54c --- /dev/null +++ b/src/files-and-uploads/videos/info-sidebar/transcript-item/TranscriptMenu.jsx @@ -0,0 +1,53 @@ +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, +}) => ( + + + + + + + handleTranscript({ language }, 'download')} + > + + + + + + + +); + +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); diff --git a/src/files-and-uploads/videos/info-sidebar/transcript-item/index.js b/src/files-and-uploads/videos/info-sidebar/transcript-item/index.js new file mode 100644 index 0000000000..9f14ebaa24 --- /dev/null +++ b/src/files-and-uploads/videos/info-sidebar/transcript-item/index.js @@ -0,0 +1,3 @@ +import Transcript from './Transcript'; + +export default Transcript; diff --git a/src/files-and-uploads/videos/info-sidebar/transcript-item/messages.js b/src/files-and-uploads/videos/info-sidebar/transcript-item/messages.js new file mode 100644 index 0000000000..0b59f244ff --- /dev/null +++ b/src/files-and-uploads/videos/info-sidebar/transcript-item/messages.js @@ -0,0 +1,56 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + uploadButtonLabel: { + id: 'authoring.videoeditor.transcripts.upload.label', + defaultMessage: 'Add a transcript', + description: 'Label for upload button', + }, + fileSizeError: { + id: 'authoring.videoeditor.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: 'authoring.videoeditor.transcript.deleteTranscript', + defaultMessage: 'Delete', + description: 'Message Presented To user for action to delete transcript', + }, + replaceTranscript: { + id: 'authoring.videoeditor.transcript.replaceTranscript', + defaultMessage: 'Replace', + description: 'Message Presented To user for action to replace transcript', + }, + downloadTranscript: { + id: 'authoring.videoeditor.transcript.downloadTranscript', + defaultMessage: 'Download', + description: 'Message Presented To user for action to download transcript', + }, + languageSelectPlaceholder: { + id: 'authoring.videoeditor.transcripts.languageSelectPlaceholder', + defaultMessage: 'Select language', + description: 'Placeholder For Dropdown, which allows users to set the language associtated with a transcript', + }, + cancelDeleteLabel: { + id: 'authoring.videoeditor.transcripts.cancelDeleteLabel', + defaultMessage: 'Cancel', + description: 'Label For Button, which allows users to stop the process of deleting a transcript', + }, + confirmDeleteLabel: { + id: 'authoring.videoeditor.transcripts.confirmDeleteLabel', + defaultMessage: 'Delete', + description: 'Label For Button, which allows users to confirm the process of deleting a transcript', + }, + deleteConfirmationMessage: { + id: 'authoring.videoeditor.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: 'authoring.videoeditor.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; From 5239d6ab00faf265683994574c9880709b7edc7e Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Mon, 16 Oct 2023 12:10:21 -0400 Subject: [PATCH 09/46] feat: add video usage functionality --- src/files-and-uploads/FileTable.jsx | 37 ++++++++--------- src/files-and-uploads/FilesAndUploads.jsx | 7 ++++ src/files-and-uploads/data/thunks.js | 10 ++++- src/files-and-uploads/videos/Videos.jsx | 16 ++++++-- src/files-and-uploads/videos/data/api.js | 20 ++++----- src/files-and-uploads/videos/data/thunks.js | 45 ++++++++++++--------- 6 files changed, 81 insertions(+), 54 deletions(-) diff --git a/src/files-and-uploads/FileTable.jsx b/src/files-and-uploads/FileTable.jsx index 6089fe47e4..618a65cad9 100644 --- a/src/files-and-uploads/FileTable.jsx +++ b/src/files-and-uploads/FileTable.jsx @@ -16,8 +16,6 @@ import { import { RequestStatus } from '../data/constants'; import { - resetErrors, - getUsagePaths, updateAssetOrder, } from './data/thunks'; import { sortFiles } from './data/utils'; @@ -30,7 +28,6 @@ import { TableActions, } from './table-components'; import ApiStatusToast from './ApiStatusToast'; -import { clearErrors } from './data/slice'; import MoreInfoColumn from './table-components/table-custom-columns/MoreInfoColumn'; const FileTable = ({ @@ -41,6 +38,8 @@ const FileTable = ({ handleLockFile, handleDeleteFile, handleDownloadFile, + handleUsagePaths, + handleErrorReset, tableColumns, maxFileSize, thumbnailPreview, @@ -48,7 +47,7 @@ const FileTable = ({ intl, }) => { const dispatch = useDispatch(); - const defaultVal = 'card'; + const defaultVal = 'list'; const columnSizes = { xs: 12, sm: 6, @@ -104,18 +103,18 @@ const FileTable = ({ const handleBulkDelete = () => { closeDeleteConfirmation(); setDeleteOpen(); - dispatch(resetErrors({ errorType: 'delete' })); + handleErrorReset({ errorType: 'delete' }); const fileIdsToDelete = selectedRows.map(row => row.original.id); fileIdsToDelete.forEach(id => handleDeleteFile(id)); }; const handleBulkDownload = useCallback(async (selectedFlatRows) => { - dispatch(resetErrors({ errorType: 'download' })); + handleErrorReset({ errorType: 'download' }); handleDownloadFile(selectedFlatRows); }, []); - const handleLockedAsset = (fileId, locked) => { - dispatch(clearErrors({ errorType: 'lock' })); + const handleLockedFile = (fileId, locked) => { + handleErrorReset({ errorType: 'lock' }); handleLockFile({ fileId, locked }); }; @@ -124,10 +123,10 @@ const FileTable = ({ openDeleteConfirmation(); }; - const handleOpenAssetInfo = (original) => { - dispatch(resetErrors({ errorType: 'usageMetrics' })); + const handleOpenFileInfo = (original) => { + handleErrorReset({ errorType: 'usageMetrics' }); setSelectedRows([{ original }]); - dispatch(getUsagePaths({ asset: original, courseId, setSelectedRows })); + handleUsagePaths(original); openAssetInfo(); }; @@ -148,10 +147,10 @@ const FileTable = ({ const fileCard = ({ className, original }) => ( MoreInfoColumn({ row, - handleLock: handleLockedAsset, - onDownload: handleBulkDownload, - openAssetInfo: handleOpenAssetInfo, - openDeleteConfirmation: handleOpenDeleteConfirmation, + handleLock: handleLockedFile, + handleBulkDownload, + handleOpenFileInfo, + handleOpenDeleteConfirmation, }), }; @@ -242,7 +241,7 @@ const FileTable = ({ file={selectedRows[0].original} onClose={closeAssetinfo} isOpen={isAssetInfoOpen} - handleLockedAsset={handleLockedAsset} + handleLockedFile={handleLockedFile} thumbnailPreview={thumbnailPreview} usagePathStatus={usagePathStatus} error={usageErrorMessages} @@ -284,7 +283,9 @@ FileTable.propTypes = { handleAddFile: PropTypes.func.isRequired, handleDeleteFile: PropTypes.func.isRequired, handleDownloadFile: PropTypes.func.isRequired, + handleUsagePaths: PropTypes.func.isRequired, handleLockFile: PropTypes.func, + handleErrorReset: PropTypes.func.isRequired, tableColumns: PropTypes.arrayOf(PropTypes.shape({ Header: PropTypes.string, accessor: PropTypes.string, diff --git a/src/files-and-uploads/FilesAndUploads.jsx b/src/files-and-uploads/FilesAndUploads.jsx index a982646e9c..9738d7cf8d 100644 --- a/src/files-and-uploads/FilesAndUploads.jsx +++ b/src/files-and-uploads/FilesAndUploads.jsx @@ -13,6 +13,8 @@ import { fetchAssets, updateAssetLock, fetchAssetDownload, + getUsagePaths, + resetErrors, } from './data/thunks'; import messages from './messages'; import FilesAndUploadsProvider from './FilesAndUploadsProvider'; @@ -53,6 +55,9 @@ const FilesAndUploads = ({ 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 thumbnailPreview = (props) => AssetThumbnail(props); const assets = useModels('assets', assetIds); @@ -153,6 +158,8 @@ const FilesAndUploads = ({ handleDeleteFile, handleDownloadFile, handleLockFile, + handleUsagePaths, + handleErrorReset, tableColumns, maxFileSize, thumbnailPreview, diff --git a/src/files-and-uploads/data/thunks.js b/src/files-and-uploads/data/thunks.js index 53af42cf05..8669fbfe91 100644 --- a/src/files-and-uploads/data/thunks.js +++ b/src/files-and-uploads/data/thunks.js @@ -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}.` })); diff --git a/src/files-and-uploads/videos/Videos.jsx b/src/files-and-uploads/videos/Videos.jsx index c3b5b4885e..357f592415 100644 --- a/src/files-and-uploads/videos/Videos.jsx +++ b/src/files-and-uploads/videos/Videos.jsx @@ -20,7 +20,10 @@ import { addVideoFile, addVideoThumbnail, deleteVideoFile, + fetchVideoDownload, fetchVideos, + getUsagePaths, + resetErrors, } from './data/thunks'; import messages from './messages'; import VideosProvider from './VideosProvider'; @@ -32,7 +35,7 @@ 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 { resampleFile } from './data/utils'; +import { getFormattedDuration, resampleFile } from './data/utils'; const Videos = ({ courseId, @@ -75,8 +78,10 @@ const Videos = ({ const handleAddFile = (file) => dispatch(addVideoFile(courseId, file)); const handleDeleteFile = (id) => dispatch(deleteVideoFile(courseId, id, totalCount)); - // const handleDownloadFile = (selectedRows) => dispatch(fetchAssetDownload({ selectedRows, courseId })); - const handleDownloadFile = (selectedRows) => console.log(selectedRows); + const handleDownloadFile = (selectedRows) => dispatch(fetchVideoDownload({ selectedRows, courseId })); + const handleUsagePaths = (video) => dispatch(getUsagePaths({ video, courseId })); + const handleErrorReset = (error) => dispatch(resetErrors(error)); + // const handleTranscriptCredentials = ({data, global, provider}) => { // dispatch(addTranscriptCredentials({data, global, provider}))} const handleAddThumbnail = (file, videoId) => resampleFile({ @@ -88,6 +93,7 @@ const Videos = ({ }); const videos = useModels('videos', videoIds); + const data = { supportedFileFormats, encodingsDownloadUrl, @@ -118,7 +124,7 @@ const Videos = ({ Header: 'Video length', Cell: ({ row }) => { const { duration } = row.original; - return duration; + return getFormattedDuration(duration); }, }; const processingStatusColumn = { @@ -190,6 +196,8 @@ const Videos = ({ handleAddFile, handleDeleteFile, handleDownloadFile, + handleUsagePaths, + handleErrorReset, tableColumns, maxFileSize, thumbnailPreview, diff --git a/src/files-and-uploads/videos/data/api.js b/src/files-and-uploads/videos/data/api.js index ca073958bf..d8930c2fcc 100644 --- a/src/files-and-uploads/videos/data/api.js +++ b/src/files-and-uploads/videos/data/api.js @@ -125,16 +125,16 @@ export async function getDownload(selectedRows, courseId) { return downloadErrors; } -// /** -// * Fetch where video is used in a course. -// * @param {blockId} courseId Course ID for the course to operate on - -// */ -// export async function getAssetUsagePaths({ courseId, videoId }) { -// const { data } = await getAuthenticatedHttpClient() -// .get(`${getAssetsUrl(courseId)}${videoId}/usage`); -// return camelCaseObject(data); -// } +/** + * 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. diff --git a/src/files-and-uploads/videos/data/thunks.js b/src/files-and-uploads/videos/data/thunks.js index 9b77ab7eba..413bc39b6d 100644 --- a/src/files-and-uploads/videos/data/thunks.js +++ b/src/files-and-uploads/videos/data/thunks.js @@ -18,6 +18,7 @@ import { deleteTranscript, downloadTranscriipt, uploadTranscript, + getVideoUsagePaths, } from './api'; import { setVideoIds, @@ -57,6 +58,10 @@ export function fetchVideos(courseId) { }; } +export function resetErrors({ errorType }) { + return (dispatch) => { dispatch(clearErrors({ error: errorType })); }; +} + export function updateAssetOrder(courseId, videoIds) { return async (dispatch) => { dispatch(updateLoadingStatus({ courseId, status: RequestStatus.IN_PROGRESS })); @@ -128,7 +133,7 @@ export function addVideoFile(courseId, file) { 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; @@ -261,26 +266,26 @@ export function uploadVideoTranscript({ }; } -export function resetErrors({ errorType }) { - return (dispatch) => { dispatch(clearErrors({ error: errorType })); }; -} - -// export function getUsagePaths({ asset, courseId, setSelectedRows }) { -// return async (dispatch) => { -// dispatch(updateEditStatus({ editType: 'usageMetrics', status: RequestStatus.IN_PROGRESS })); +export function getUsagePaths({ video, 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(updateEditStatus({ editType: 'usageMetrics', status: RequestStatus.SUCCESSFUL })); -// } catch (error) { -// dispatch(updateErrors({ -// error: 'usageMetrics', -// message: `Failed to get usage metrics for ${asset.displayName}.` })); -// dispatch(updateEditStatus({ editType: 'usageMetrics', status: RequestStatus.FAILED })); -// } -// }; -// } + 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, courseId }) { return async (dispatch) => { From 0ba65badc1ed11a05114cecf70f1db74d6fcfb69 Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Mon, 16 Oct 2023 12:11:33 -0400 Subject: [PATCH 10/46] fix: blocked more info menu view --- .../table-components/GalleryCard.jsx | 16 +- .../table-custom-columns/MoreInfoColumn.jsx | 145 +++++++++++++++--- 2 files changed, 130 insertions(+), 31 deletions(-) diff --git a/src/files-and-uploads/table-components/GalleryCard.jsx b/src/files-and-uploads/table-components/GalleryCard.jsx index dd2109b2ea..091d8adc3d 100644 --- a/src/files-and-uploads/table-components/GalleryCard.jsx +++ b/src/files-and-uploads/table-components/GalleryCard.jsx @@ -15,14 +15,14 @@ const GalleryCard = ({ className, original, handleBulkDownload, - handleLockedAsset, + handleLockedFile, handleOpenDeleteConfirmation, - handleOpenAssetInfo, + handleOpenFileInfo, thumbnailPreview, }) => { - const lockAsset = () => { + const lockFile = () => { const { locked, id } = original; - handleLockedAsset(id, !locked); + handleLockedFile(id, !locked); }; return ( @@ -32,9 +32,9 @@ const GalleryCard = ({ handleOpenAssetInfo(original)} + openAssetInfo={() => handleOpenFileInfo(original)} portableUrl={original.portableUrl} id={original.id} wrapperType={original.wrapperType} @@ -92,9 +92,9 @@ GalleryCard.propTypes = { transcripts: PropTypes.arrayOf(PropTypes.string), }).isRequired, handleBulkDownload: PropTypes.func.isRequired, - handleLockedAsset: PropTypes.func.isRequired, + handleLockedFile: PropTypes.func.isRequired, handleOpenDeleteConfirmation: PropTypes.func.isRequired, - handleOpenAssetInfo: PropTypes.func.isRequired, + handleOpenFileInfo: PropTypes.func.isRequired, thumbnailPreview: PropTypes.func.isRequired, }; diff --git a/src/files-and-uploads/table-components/table-custom-columns/MoreInfoColumn.jsx b/src/files-and-uploads/table-components/table-custom-columns/MoreInfoColumn.jsx index b47262c4a3..fbc680bf62 100644 --- a/src/files-and-uploads/table-components/table-custom-columns/MoreInfoColumn.jsx +++ b/src/files-and-uploads/table-components/table-custom-columns/MoreInfoColumn.jsx @@ -1,36 +1,133 @@ -import React from 'react'; +import React, { useState } from 'react'; import { PropTypes } from 'prop-types'; -import FileMenu from '../../FileMenu'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { + Button, + Icon, + IconButton, + ModalPopup, + Menu, + MenuItem, + useToggle, +} from '@edx/paragon'; +import { MoreHoriz } from '@edx/paragon/icons'; + +import messages from '../../messages'; const MoreInfoColumn = ({ row, handleLock, - onDownload, - openAssetInfo, - openDeleteConfirmation, + handleBulkDownload, + handleOpenFileInfo, + handleOpenDeleteConfirmation, + // injected + intl, }) => { + const [isOpen, , close, toggle] = useToggle(); + const [target, setTarget] = useState(null); + const { externalUrl, locked, portableUrl, id, wrapperType, + displayName, } = row.original; - return ( - + <> + + + + {wrapperType === 'video' ? ( + { + navigator.clipboard.writeText(id); + close(); + }} + > + Copy video ID + + ) : ( + <> + { + navigator.clipboard.writeText(portableUrl); + close(); + }} + > + {intl.formatMessage(messages.copyStudioUrlTitle)} + + { + navigator.clipboard.writeText(externalUrl); + close(); + }} + > + {intl.formatMessage(messages.copyWebUrlTitle)} + + handleLock(id, !locked)} + > + {locked ? intl.formatMessage(messages.unlockMenuTitle) : intl.formatMessage(messages.lockMenuTitle)} + + + )} + handleBulkDownload( + [{ original: { id, displayName } }], + )} + > + {intl.formatMessage(messages.downloadTitle)} + + handleOpenFileInfo(row.original)} + > + {intl.formatMessage(messages.infoTitle)} + +
+ { + handleOpenDeleteConfirmation([{ original: row.original }]); + close(); + }} + > + {intl.formatMessage(messages.deleteTitle)} + +
+
+ ); }; @@ -45,9 +142,11 @@ MoreInfoColumn.propTypes = { }.isRequired, }.isRequired, handleLock: PropTypes.func.isRequired, - onDownload: PropTypes.func.isRequired, - openAssetInfo: PropTypes.func.isRequired, - openDeleteConfirmation: PropTypes.func.isRequired, + handleBulkDownload: PropTypes.func.isRequired, + handleOpenFileInfo: PropTypes.func.isRequired, + handleOpenDeleteConfirmation: PropTypes.func.isRequired, + // injected + intl: intlShape.isRequired, }; -export default MoreInfoColumn; +export default injectIntl(MoreInfoColumn); From 19c065e2f6334355e1c3cff11f9f9f0703d42000 Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Tue, 17 Oct 2023 15:26:10 -0400 Subject: [PATCH 11/46] feat: add transcript setting sheet --- src/files-and-uploads/videos/Videos.jsx | 38 +++-- src/files-and-uploads/videos/data/api.js | 64 +++++++- src/files-and-uploads/videos/data/slice.js | 8 + src/files-and-uploads/videos/data/thunks.js | 49 ++++++ src/files-and-uploads/videos/data/utils.js | 19 +++ .../transcript-settings/Cielo24Form.jsx | 120 +++++++++++++++ .../transcript-settings/FormDropdown.jsx | 73 +++++++++ .../OrderTranscriptForm.jsx | 143 ++++++++++++++++++ .../ThreePlayMediaForm.jsx | 135 +++++++++++++++++ .../TranscriptSettings.jsx | 118 +++++++++++++++ .../videos/transcript-settings/index.js | 3 + 11 files changed, 759 insertions(+), 11 deletions(-) create mode 100644 src/files-and-uploads/videos/transcript-settings/Cielo24Form.jsx create mode 100644 src/files-and-uploads/videos/transcript-settings/FormDropdown.jsx create mode 100644 src/files-and-uploads/videos/transcript-settings/OrderTranscriptForm.jsx create mode 100644 src/files-and-uploads/videos/transcript-settings/ThreePlayMediaForm.jsx create mode 100644 src/files-and-uploads/videos/transcript-settings/TranscriptSettings.jsx create mode 100644 src/files-and-uploads/videos/transcript-settings/index.js diff --git a/src/files-and-uploads/videos/Videos.jsx b/src/files-and-uploads/videos/Videos.jsx index 357f592415..140eeed5a1 100644 --- a/src/files-and-uploads/videos/Videos.jsx +++ b/src/files-and-uploads/videos/Videos.jsx @@ -1,6 +1,7 @@ /* eslint-disable no-console */ import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; +import { isEmpty } from 'lodash'; import { useDispatch, useSelector } from 'react-redux'; import { injectIntl, @@ -19,11 +20,14 @@ import { useModels, useModel } from '../../generic/model-store'; import { addVideoFile, addVideoThumbnail, + clearAutomatedTranscript, deleteVideoFile, fetchVideoDownload, fetchVideos, getUsagePaths, resetErrors, + updateTranscriptCredentials, + updateTranscriptPreference, } from './data/thunks'; import messages from './messages'; import VideosProvider from './VideosProvider'; @@ -43,7 +47,7 @@ const Videos = ({ intl, }) => { const dispatch = useDispatch(); - const [isTranscriptSettngsOpen, openTranscriptSettngs, closeTranscriptSettngs] = useToggle(false); + const [isTranscriptSettngsOpen, openTranscriptSettings, closeTranscriptSettings] = useToggle(false); const courseDetails = useModel('courseDetails', courseId); document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.heading)); @@ -55,6 +59,7 @@ const Videos = ({ totalCount, videoIds, loadingStatus, + transcriptStatus, addingStatus: addVideoStatus, deletingStatus: deleteVideoStatus, updatingStatus: updateVideoStatus, @@ -65,8 +70,6 @@ const Videos = ({ const { isVideoTranscriptEnabled, - activeTranscriptPreferences, - transcriptAvailableLanguages, transcriptCredentials, encodingsDownloadUrl, videoUploadMaxFileSize, @@ -81,9 +84,17 @@ const Videos = ({ const handleDownloadFile = (selectedRows) => dispatch(fetchVideoDownload({ selectedRows, courseId })); const handleUsagePaths = (video) => dispatch(getUsagePaths({ video, courseId })); const handleErrorReset = (error) => dispatch(resetErrors(error)); + const handleOrderTranscripts = (data, provider) => { + handleErrorReset({ errorType: 'transcript' }); + if (provider === 'order') { + dispatch(clearAutomatedTranscript({ courseId })); + } else if (isEmpty(transcriptCredentials)) { + dispatch(updateTranscriptCredentials({ courseId, data: { ...data, provider, global: false } })); + } else { + dispatch(updateTranscriptPreference({ courseId, data: { ...data, provider, global: false } })); + } + }; - // const handleTranscriptCredentials = ({data, global, provider}) => { - // dispatch(addTranscriptCredentials({data, global, provider}))} const handleAddThumbnail = (file, videoId) => resampleFile({ file, dispatch, @@ -172,7 +183,14 @@ const Videos = ({
{isVideoTranscriptEnabled ? ( - ) : null} @@ -182,10 +200,10 @@ const Videos = ({ ) : null} diff --git a/src/files-and-uploads/videos/data/api.js b/src/files-and-uploads/videos/data/api.js index d8930c2fcc..1219001cc8 100644 --- a/src/files-and-uploads/videos/data/api.js +++ b/src/files-and-uploads/videos/data/api.js @@ -21,7 +21,15 @@ export const getCoursVideosApiUrl = (courseId) => `${getApiBaseUrl()}/videos/${c export async function getVideos(courseId) { const { data } = await getAuthenticatedHttpClient() .get(getVideosUrl(courseId)); - return camelCaseObject(data); + const { video_transcript_settings: videoTranscriptSettings } = data; + const { transcription_plans: transcriptionPlans } = videoTranscriptSettings; + return { + ...camelCaseObject(data), + videoTranscriptSettings: { + ...camelCaseObject(videoTranscriptSettings), + transcriptionPlans, + }, + }; } /** @@ -207,3 +215,57 @@ export async function uploadVideo( }); return uploadErrors; } + +export async function deleteTranscriptPreferences(courseId) { + await getAuthenticatedHttpClient().delete(`${getApiBaseUrl()}/transcript_preferences/${courseId}`); +} + +export async function setTranscriptPreferences(courseId, preferences) { + const { + cieloFidelity, + cieloTurnaround, + global, + preferredLanguages, + provider, + threePlayTurnaround, + videoSourceLanguage, + } = preferences; + const postJson = { + cielo24_fideltiy: cieloFidelity.toUpperCase(), + cielo24_turnaround: cieloTurnaround, + 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; + } + const { data } = await getAuthenticatedHttpClient() + .post(`${getApiBaseUrl()}/transcript_credentials/${courseId}`, postJson); + return camelCaseObject(data); +} diff --git a/src/files-and-uploads/videos/data/slice.js b/src/files-and-uploads/videos/data/slice.js index 250260ce54..7e899ff249 100644 --- a/src/files-and-uploads/videos/data/slice.js +++ b/src/files-and-uploads/videos/data/slice.js @@ -68,6 +68,12 @@ const slice = createSlice({ addVideoSuccess: (state, { payload }) => { state.videoIds = [payload.videoId, ...state.videoIds]; }, + updateTranscriptCredentialsSuccess: (state, { payload }) => { + state.pageSettings.transcriptCredentials = payload; + }, + updateTranscriptPreferenceSuccess: (state, { payload }) => { + state.pageSettings.activeTranscriptPreferences = payload; + }, updateErrors: (state, { payload }) => { const { error, message } = payload; const currentErrorState = state.errors[error]; @@ -90,6 +96,8 @@ export const { updateErrors, clearErrors, updateEditStatus, + updateTranscriptCredentialsSuccess, + updateTranscriptPreferenceSuccess, } = slice.actions; export const { diff --git a/src/files-and-uploads/videos/data/thunks.js b/src/files-and-uploads/videos/data/thunks.js index 413bc39b6d..dac2388eeb 100644 --- a/src/files-and-uploads/videos/data/thunks.js +++ b/src/files-and-uploads/videos/data/thunks.js @@ -19,6 +19,9 @@ import { downloadTranscriipt, uploadTranscript, getVideoUsagePaths, + deleteTranscriptPreferences, + setTranscriptCredentials, + setTranscriptPreferences, } from './api'; import { setVideoIds, @@ -30,6 +33,8 @@ import { updateErrors, clearErrors, updateEditStatus, + updateTranscriptCredentialsSuccess, + updateTranscriptPreferenceSuccess, } from './slice'; import { updateFileValues } from './utils'; @@ -301,3 +306,47 @@ export function fetchVideoDownload({ selectedRows, courseId }) { } }; } + +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 { + const credentials = await setTranscriptCredentials(courseId, data); + dispatch(updateTranscriptCredentialsSuccess(credentials)); + 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 })); + } + }; +} diff --git a/src/files-and-uploads/videos/data/utils.js b/src/files-and-uploads/videos/data/utils.js index 28e2c9be3c..84abab4bed 100644 --- a/src/files-and-uploads/videos/data/utils.js +++ b/src/files-and-uploads/videos/data/utils.js @@ -212,3 +212,22 @@ export const resampleFile = ({ }; 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; +}; diff --git a/src/files-and-uploads/videos/transcript-settings/Cielo24Form.jsx b/src/files-and-uploads/videos/transcript-settings/Cielo24Form.jsx new file mode 100644 index 0000000000..9fe7b27324 --- /dev/null +++ b/src/files-and-uploads/videos/transcript-settings/Cielo24Form.jsx @@ -0,0 +1,120 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { isEmpty } from 'lodash'; +import { Form, Stack, TransitionReplace } from '@edx/paragon'; +import FormDropdown from './FormDropdown'; +import { getFidelityOptions } from '../data/utils'; + +const Cielo24Form = ({ + hasTranscriptCredentials, + data, + setData, + transcriptionPlan, +}) => { + const { fidelity } = transcriptionPlan; + const selectedLanguage = data.preferredLanguages ? data.preferredLanguages : ''; + const turnaroundOptions = transcriptionPlan.turnaround; + const fidelityOptions = getFidelityOptions(fidelity); + const sourceLanguageOptions = data.cieloFidelity ? fidelity[data.cieloFidelity]?.languages : {}; + const languages = data.cieloFidelity === 'PROFESSIONAL' ? sourceLanguageOptions : { + [data.videoSourceLanguage]: sourceLanguageOptions[data.videoSourceLanguage], + }; + + if (hasTranscriptCredentials) { + return ( + + + + Transcript turnaround + + setData({ ...data, cieloTurnaround: value })} + placeholderText="Select turnaround" + /> + + + + Transcript fidelity + + setData({ ...data, cieloFidelity: value, videoSourceLanguage: '' })} + placeholderText="Select fidelity" + /> + + + {isEmpty(data.cieloFidelity) ? null : ( + + + Video Source Language + + setData({ ...data, videoSourceLanguage: value, preferredLanguages: '' })} + placeholderText="Select language" + /> + + )} + + + {isEmpty(data.videoSourceLanguage) ? null : ( + + + Transcript language + + setData({ ...data, preferredLanguages: [value] })} + placeholderText="Select language" + /> + + )} + + + ); + } + + return ( + +
+ Enter the account information for your organization. +
+ + + API Key + + setData({ ...data, apiKey: e.target.value })} /> + + + + Username + + setData({ ...data, username: e.target.value })} /> + +
+ ); +}; + +Cielo24Form.propTypes = { + hasTranscriptCredentials: PropTypes.bool.isRequired, + data: PropTypes.shape({ + apiKey: PropTypes.string, + apiSecretKey: PropTypes.string, + cieloTurnaround: PropTypes.string, + cieloFidelity: 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, +}; + +export default Cielo24Form; diff --git a/src/files-and-uploads/videos/transcript-settings/FormDropdown.jsx b/src/files-and-uploads/videos/transcript-settings/FormDropdown.jsx new file mode 100644 index 0000000000..b537a3596b --- /dev/null +++ b/src/files-and-uploads/videos/transcript-settings/FormDropdown.jsx @@ -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 ( + + + + {currentSelection} + + + + {Object.entries(options).map(([valueKey, text]) => { + if (allowMultiple) { + return ( + handleSelect([valueKey, e.target.checked])} key={`${valueKey}-item`}> + {text} + + ); + } + if (valueKey === value) { + return ( + handleSelect(valueKey)} key={`${valueKey}-item`}> + {text} + + ); + } + return ( + handleSelect(valueKey)} key={`${valueKey}-item`}> + {text} + + ); + })} + + + ); +}; + +FormDropdown.propTypes = { + value: PropTypes.oneOf([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; diff --git a/src/files-and-uploads/videos/transcript-settings/OrderTranscriptForm.jsx b/src/files-and-uploads/videos/transcript-settings/OrderTranscriptForm.jsx new file mode 100644 index 0000000000..cad77d4c68 --- /dev/null +++ b/src/files-and-uploads/videos/transcript-settings/OrderTranscriptForm.jsx @@ -0,0 +1,143 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { isEmpty } from 'lodash'; +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'; + +const OrderTranscriptForm = ({ + setTranscriptType, + activeTranscriptPreferences, + transcriptType, + transcriptCredentials, + closeTranscriptSettings, + handleOrderTranscripts, + transcriptionPlans, + errorMessages, + transcriptStatus, +}) => { + const [data, setData] = useState({}); + const hasTranscriptCredentials = !isEmpty(transcriptCredentials); + const handleDiscard = () => { + setTranscriptType(activeTranscriptPreferences); + closeTranscriptSettings(); + }; + + let form; + switch (transcriptType) { + case 'Cielo24': + form = ( + + ); + break; + case '3PlayMedia': + form = ( + + ); + break; + default: + break; + } + return ( + <> + +
    + {errorMessages.transcript.map(message => ( +
  • + {message} +
  • + ))} +
+
+ { + setTranscriptType(e.target.value); + setData({ + videoSourceLanguage: '', + }); + }} + > + + None + + + Cielo24 + + + 3Play Media + + + {form} + + + + + + ); +}; + +OrderTranscriptForm.propTypes = { + setTranscriptType: PropTypes.func.isRequired, + activeTranscriptPreferences: PropTypes.shape({}), + transcriptType: PropTypes.string.isRequired, + transcriptCredentials: PropTypes.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, +}; + +OrderTranscriptForm.defaultProps = { + activeTranscriptPreferences: null, +}; + +export default OrderTranscriptForm; diff --git a/src/files-and-uploads/videos/transcript-settings/ThreePlayMediaForm.jsx b/src/files-and-uploads/videos/transcript-settings/ThreePlayMediaForm.jsx new file mode 100644 index 0000000000..29d065dddf --- /dev/null +++ b/src/files-and-uploads/videos/transcript-settings/ThreePlayMediaForm.jsx @@ -0,0 +1,135 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { isEmpty } from 'lodash'; +import { + Form, + Icon, + Stack, + TransitionReplace, +} from '@edx/paragon'; +import { Check } from '@edx/paragon/icons'; +import FormDropdown from './FormDropdown'; +import { getLanguageOptions } from '../data/utils'; + +const ThreePlayMediaForm = ({ + hasTranscriptCredentials, + data, + setData, + transcriptionPlan, +}) => { + 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; + + if (hasTranscriptCredentials) { + return ( + + + + Transcript turnaround + + setData({ ...data, threePlayTurnaround: value })} + placeholderText="Select turnaround" + /> + + + + Video Source Language + + setData({ ...data, videoSourceLanguage: value, preferredLanguages: [] })} + placeholderText="Select language" + /> + + + {!isEmpty(data.videoSourceLanguage) ? ( + + + Transcript language + + { + 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="Select language(s)" + /> + +
    + {selectedLanguages.map(language => ( +
  • + {languages[language]} +
  • + ))} +
+
+
+ ) : null } +
+
+ ); + } + return ( + +
+ Enter the account information for your organization. +
+ + + API Key + + setData({ ...data, apiKey: e.target.value })} /> + + + + API Secret + + setData({ ...data, apiSecretKey: e.target.value })} /> + +
+ ); +}; + +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, +}; + +export default ThreePlayMediaForm; diff --git a/src/files-and-uploads/videos/transcript-settings/TranscriptSettings.jsx b/src/files-and-uploads/videos/transcript-settings/TranscriptSettings.jsx new file mode 100644 index 0000000000..b18de188c6 --- /dev/null +++ b/src/files-and-uploads/videos/transcript-settings/TranscriptSettings.jsx @@ -0,0 +1,118 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; +import { + ActionRow, + Collapsible, + Icon, IconButton, + Sheet, + TransitionReplace, +} from '@edx/paragon'; +import { ChevronLeft, ChevronRight, Close } from '@edx/paragon/icons'; +import OrderTranscriptForm from './OrderTranscriptForm'; + +const TranscriptSettings = ({ + isTranscriptSettngsOpen, + closeTranscriptSettings, + handleOrderTranscripts, + errorMessages, + transcriptStatus, +}) => { + const { + activeTranscriptPreferences, + transcriptCredentials, + videoTranscriptSettings, + } = useSelector(state => state.videos.pageSettings); + const { transcriptionPlans } = videoTranscriptSettings; + const [transcriptType, setTranscriptType] = useState(activeTranscriptPreferences); + + return ( + +
+ + + {transcriptType ? ( + setTranscriptType(null)} + /> + ) : ( +
+ Transcript settings +
+ )} +
+ + +
+ + {transcriptType ? ( +
+ { + transcriptType === 'expert' ? ( + 'Selected transcript type!' + ) : ( + + ) + } +
+ ) : ( +
+ + setTranscriptType('order')} + > + Order Transcripts + + + +
+ {/* + setTranscriptType('expert')} + > + Get free translations + + + */} +
+ )} +
+
+
+ ); +}; + +TranscriptSettings.propTypes = { + closeTranscriptSettings: PropTypes.func.isRequired, + isTranscriptSettngsOpen: PropTypes.bool.isRequired, + transcriptStatus: PropTypes.string.isRequired, + errorMessages: PropTypes.shape({ + transcript: PropTypes.arrayOf(PropTypes.string).isRequired, + }).isRequired, + handleOrderTranscripts: PropTypes.func.isRequired, +}; + +export default TranscriptSettings; diff --git a/src/files-and-uploads/videos/transcript-settings/index.js b/src/files-and-uploads/videos/transcript-settings/index.js new file mode 100644 index 0000000000..00661e5b6d --- /dev/null +++ b/src/files-and-uploads/videos/transcript-settings/index.js @@ -0,0 +1,3 @@ +import TranscriptSettings from './TranscriptSettings'; + +export default TranscriptSettings; From 926740f5bfc598d223d548a7407a71722702fe61 Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Tue, 17 Oct 2023 17:22:21 -0400 Subject: [PATCH 12/46] fix: thumbnail rendering --- src/files-and-uploads/assets/AssetThumbnail.jsx | 14 ++++++++++---- .../table-components/GalleryCard.jsx | 2 +- src/files-and-uploads/videos/VideoThumbnail.jsx | 15 +++++++++++---- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/files-and-uploads/assets/AssetThumbnail.jsx b/src/files-and-uploads/assets/AssetThumbnail.jsx index 9686e21bd9..22c5db231b 100644 --- a/src/files-and-uploads/assets/AssetThumbnail.jsx +++ b/src/files-and-uploads/assets/AssetThumbnail.jsx @@ -18,19 +18,25 @@ const AssetThumbnail = ({ externalUrl, wrapperType, }); + const { width, height } = imageSize; return ( -
+
{thumbnail ? ( {`Thumbnail ) : (
diff --git a/src/files-and-uploads/table-components/GalleryCard.jsx b/src/files-and-uploads/table-components/GalleryCard.jsx index b567587c4d..d7824ddc94 100644 --- a/src/files-and-uploads/table-components/GalleryCard.jsx +++ b/src/files-and-uploads/table-components/GalleryCard.jsx @@ -48,7 +48,7 @@ const GalleryCard = ({ )} /> -
+
+
{showThumbnail ? ( {`Thumbnail ) : ( <>
From 9643860e3384cc2d515823856b0e09df4041d04f Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Tue, 17 Oct 2023 17:23:47 -0400 Subject: [PATCH 13/46] chore: update folder names and structure --- src/CourseAuthoringRoutes.jsx | 4 +- src/files-and-uploads/assets/messages.js | 14 -- src/files-and-uploads/index.js | 1 - .../ApiStatusToast.jsx | 0 .../EditFileErrors.jsx | 0 .../FileInfo.jsx | 0 .../FileInput.jsx | 0 .../FileMenu.jsx | 0 .../FileTable.jsx | 0 .../FileThumbnail.jsx | 0 .../UsageMetricsMessage.jsx | 0 .../assets/AssetThumbnail.jsx | 0 .../assets/FileInfoAssetSidebar.jsx | 2 +- .../assets}/FilesAndUploads.jsx | 22 +-- .../assets}/FilesAndUploads.test.jsx | 14 +- .../assets}/FilesAndUploadsProvider.jsx | 0 src/files-and-videos/assets/index.js | 3 + src/files-and-videos/assets/messages.js | 46 ++++++ .../data/api.js | 0 .../data/api.test.js | 0 .../data/constant.js | 0 .../data/slice.js | 0 .../data/thunks.js | 0 .../data/utils.js | 0 .../data/utils.test.js | 0 .../factories/mockApiResponses.jsx | 0 .../messages.js | 36 ----- .../table-components/GalleryCard.jsx | 0 .../table-components/GalleryCard.scss | 0 .../table-components/TableActions.jsx | 0 .../table-components/index.js | 0 .../table-custom-columns/AccessColumn.jsx | 0 .../table-custom-columns/ActiveColumn.jsx | 0 .../table-custom-columns/MoreInfoColumn.jsx | 0 .../table-custom-columns/StatusColumn.jsx | 0 .../table-custom-columns/ThumbnailColumn.jsx | 0 .../table-custom-columns/index.js | 0 .../videos/VideoThumbnail.jsx | 0 .../videos/VideoThumbnail.scss | 0 .../videos/Videos.jsx | 0 .../videos/VideosProvider.jsx | 0 .../videos/data/api.js | 0 .../videos/data/constants.js | 0 .../videos/data/slice.js | 0 .../videos/data/thunks.js | 0 .../videos/data/utils.js | 0 src/files-and-videos/videos/index.js | 3 + .../info-sidebar/FileInfoVideoSidebar.jsx | 19 ++- .../videos/info-sidebar/InfoTab.jsx | 9 +- .../videos/info-sidebar/TranscriptTab.jsx | 16 +- .../videos/info-sidebar/messages.js | 40 +++++ .../transcript-item/LanguageSelect.jsx | 0 .../transcript-item/Transcript.jsx | 8 +- .../transcript-item/TranscriptMenu.jsx | 0 .../info-sidebar/transcript-item/index.js | 0 .../info-sidebar/transcript-item/messages.js | 23 ++- .../videos/messages.js | 4 - .../transcript-settings/Cielo24Form.jsx | 30 ++-- .../transcript-settings/FormDropdown.jsx | 0 .../OrderTranscriptForm.jsx | 24 ++- .../ThreePlayMediaForm.jsx | 26 ++-- .../TranscriptSettings.jsx | 18 +-- .../videos/transcript-settings/index.js | 0 .../videos/transcript-settings/messages.js | 145 ++++++++++++++++++ src/index.scss | 4 +- src/store.js | 4 +- 66 files changed, 364 insertions(+), 151 deletions(-) delete mode 100644 src/files-and-uploads/assets/messages.js delete mode 100644 src/files-and-uploads/index.js rename src/{files-and-uploads => files-and-videos}/ApiStatusToast.jsx (100%) rename src/{files-and-uploads => files-and-videos}/EditFileErrors.jsx (100%) rename src/{files-and-uploads => files-and-videos}/FileInfo.jsx (100%) rename src/{files-and-uploads => files-and-videos}/FileInput.jsx (100%) rename src/{files-and-uploads => files-and-videos}/FileMenu.jsx (100%) rename src/{files-and-uploads => files-and-videos}/FileTable.jsx (100%) rename src/{files-and-uploads => files-and-videos}/FileThumbnail.jsx (100%) rename src/{files-and-uploads => files-and-videos}/UsageMetricsMessage.jsx (100%) rename src/{files-and-uploads => files-and-videos}/assets/AssetThumbnail.jsx (100%) rename src/{files-and-uploads => files-and-videos}/assets/FileInfoAssetSidebar.jsx (99%) rename src/{files-and-uploads => files-and-videos/assets}/FilesAndUploads.jsx (87%) rename src/{files-and-uploads => files-and-videos/assets}/FilesAndUploads.test.jsx (98%) rename src/{files-and-uploads => files-and-videos/assets}/FilesAndUploadsProvider.jsx (100%) create mode 100644 src/files-and-videos/assets/index.js create mode 100644 src/files-and-videos/assets/messages.js rename src/{files-and-uploads => files-and-videos}/data/api.js (100%) rename src/{files-and-uploads => files-and-videos}/data/api.test.js (100%) rename src/{files-and-uploads => files-and-videos}/data/constant.js (100%) rename src/{files-and-uploads => files-and-videos}/data/slice.js (100%) rename src/{files-and-uploads => files-and-videos}/data/thunks.js (100%) rename src/{files-and-uploads => files-and-videos}/data/utils.js (100%) rename src/{files-and-uploads => files-and-videos}/data/utils.test.js (100%) rename src/{files-and-uploads => files-and-videos}/factories/mockApiResponses.jsx (100%) rename src/{files-and-uploads => files-and-videos}/messages.js (77%) rename src/{files-and-uploads => files-and-videos}/table-components/GalleryCard.jsx (100%) rename src/{files-and-uploads => files-and-videos}/table-components/GalleryCard.scss (100%) rename src/{files-and-uploads => files-and-videos}/table-components/TableActions.jsx (100%) rename src/{files-and-uploads => files-and-videos}/table-components/index.js (100%) rename src/{files-and-uploads => files-and-videos}/table-components/table-custom-columns/AccessColumn.jsx (100%) rename src/{files-and-uploads => files-and-videos}/table-components/table-custom-columns/ActiveColumn.jsx (100%) rename src/{files-and-uploads => files-and-videos}/table-components/table-custom-columns/MoreInfoColumn.jsx (100%) rename src/{files-and-uploads => files-and-videos}/table-components/table-custom-columns/StatusColumn.jsx (100%) rename src/{files-and-uploads => files-and-videos}/table-components/table-custom-columns/ThumbnailColumn.jsx (100%) rename src/{files-and-uploads => files-and-videos}/table-components/table-custom-columns/index.js (100%) rename src/{files-and-uploads => files-and-videos}/videos/VideoThumbnail.jsx (100%) rename src/{files-and-uploads => files-and-videos}/videos/VideoThumbnail.scss (100%) rename src/{files-and-uploads => files-and-videos}/videos/Videos.jsx (100%) rename src/{files-and-uploads => files-and-videos}/videos/VideosProvider.jsx (100%) rename src/{files-and-uploads => files-and-videos}/videos/data/api.js (100%) rename src/{files-and-uploads => files-and-videos}/videos/data/constants.js (100%) rename src/{files-and-uploads => files-and-videos}/videos/data/slice.js (100%) rename src/{files-and-uploads => files-and-videos}/videos/data/thunks.js (100%) rename src/{files-and-uploads => files-and-videos}/videos/data/utils.js (100%) create mode 100644 src/files-and-videos/videos/index.js rename src/{files-and-uploads => files-and-videos}/videos/info-sidebar/FileInfoVideoSidebar.jsx (61%) rename src/{files-and-uploads => files-and-videos}/videos/info-sidebar/InfoTab.jsx (78%) rename src/{files-and-uploads => files-and-videos}/videos/info-sidebar/TranscriptTab.jsx (91%) create mode 100644 src/files-and-videos/videos/info-sidebar/messages.js rename src/{files-and-uploads => files-and-videos}/videos/info-sidebar/transcript-item/LanguageSelect.jsx (100%) rename src/{files-and-uploads => files-and-videos}/videos/info-sidebar/transcript-item/Transcript.jsx (93%) rename src/{files-and-uploads => files-and-videos}/videos/info-sidebar/transcript-item/TranscriptMenu.jsx (100%) rename src/{files-and-uploads => files-and-videos}/videos/info-sidebar/transcript-item/index.js (100%) rename src/{files-and-uploads => files-and-videos}/videos/info-sidebar/transcript-item/messages.js (68%) rename src/{files-and-uploads => files-and-videos}/videos/messages.js (78%) rename src/{files-and-uploads => files-and-videos}/videos/transcript-settings/Cielo24Form.jsx (76%) rename src/{files-and-uploads => files-and-videos}/videos/transcript-settings/FormDropdown.jsx (100%) rename src/{files-and-uploads => files-and-videos}/videos/transcript-settings/OrderTranscriptForm.jsx (83%) rename src/{files-and-uploads => files-and-videos}/videos/transcript-settings/ThreePlayMediaForm.jsx (80%) rename src/{files-and-uploads => files-and-videos}/videos/transcript-settings/TranscriptSettings.jsx (85%) rename src/{files-and-uploads => files-and-videos}/videos/transcript-settings/index.js (100%) create mode 100644 src/files-and-videos/videos/transcript-settings/messages.js diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index 37b3f98626..a478a4d27a 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -9,7 +9,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 FilesAndUploads from './files-and-videos/assets'; import { AdvancedSettings } from './advanced-settings'; import ScheduleAndDetails from './schedule-and-details'; import { GradingSettings } from './grading-settings'; @@ -17,7 +17,7 @@ import CourseTeam from './course-team/CourseTeam'; import { CourseUpdates } from './course-updates'; import CourseExportPage from './export-page/CourseExportPage'; import CourseImportPage from './import-page/CourseImportPage'; -import Videos from './files-and-uploads/videos/Videos'; +import Videos from './files-and-videos/videos'; /** * As of this writing, these routes are mounted at a path prefixed with the following: diff --git a/src/files-and-uploads/assets/messages.js b/src/files-and-uploads/assets/messages.js deleted file mode 100644 index d5c7bcf249..0000000000 --- a/src/files-and-uploads/assets/messages.js +++ /dev/null @@ -1,14 +0,0 @@ -import { defineMessages } from '@edx/frontend-platform/i18n'; - -const messages = defineMessages({ - heading: { - id: 'course-authoring.files-and-uploads.heading', - defaultMessage: 'Files and uploads', - }, - subheading: { - id: 'course-authoring.files-and-uploads.subheading', - defaultMessage: 'Content', - }, -}); - -export default messages; diff --git a/src/files-and-uploads/index.js b/src/files-and-uploads/index.js deleted file mode 100644 index c0b84a0096..0000000000 --- a/src/files-and-uploads/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './FilesAndUploads'; diff --git a/src/files-and-uploads/ApiStatusToast.jsx b/src/files-and-videos/ApiStatusToast.jsx similarity index 100% rename from src/files-and-uploads/ApiStatusToast.jsx rename to src/files-and-videos/ApiStatusToast.jsx diff --git a/src/files-and-uploads/EditFileErrors.jsx b/src/files-and-videos/EditFileErrors.jsx similarity index 100% rename from src/files-and-uploads/EditFileErrors.jsx rename to src/files-and-videos/EditFileErrors.jsx diff --git a/src/files-and-uploads/FileInfo.jsx b/src/files-and-videos/FileInfo.jsx similarity index 100% rename from src/files-and-uploads/FileInfo.jsx rename to src/files-and-videos/FileInfo.jsx diff --git a/src/files-and-uploads/FileInput.jsx b/src/files-and-videos/FileInput.jsx similarity index 100% rename from src/files-and-uploads/FileInput.jsx rename to src/files-and-videos/FileInput.jsx diff --git a/src/files-and-uploads/FileMenu.jsx b/src/files-and-videos/FileMenu.jsx similarity index 100% rename from src/files-and-uploads/FileMenu.jsx rename to src/files-and-videos/FileMenu.jsx diff --git a/src/files-and-uploads/FileTable.jsx b/src/files-and-videos/FileTable.jsx similarity index 100% rename from src/files-and-uploads/FileTable.jsx rename to src/files-and-videos/FileTable.jsx diff --git a/src/files-and-uploads/FileThumbnail.jsx b/src/files-and-videos/FileThumbnail.jsx similarity index 100% rename from src/files-and-uploads/FileThumbnail.jsx rename to src/files-and-videos/FileThumbnail.jsx diff --git a/src/files-and-uploads/UsageMetricsMessage.jsx b/src/files-and-videos/UsageMetricsMessage.jsx similarity index 100% rename from src/files-and-uploads/UsageMetricsMessage.jsx rename to src/files-and-videos/UsageMetricsMessage.jsx diff --git a/src/files-and-uploads/assets/AssetThumbnail.jsx b/src/files-and-videos/assets/AssetThumbnail.jsx similarity index 100% rename from src/files-and-uploads/assets/AssetThumbnail.jsx rename to src/files-and-videos/assets/AssetThumbnail.jsx diff --git a/src/files-and-uploads/assets/FileInfoAssetSidebar.jsx b/src/files-and-videos/assets/FileInfoAssetSidebar.jsx similarity index 99% rename from src/files-and-uploads/assets/FileInfoAssetSidebar.jsx rename to src/files-and-videos/assets/FileInfoAssetSidebar.jsx index 5b88e9a795..5bc69b4f2e 100644 --- a/src/files-and-uploads/assets/FileInfoAssetSidebar.jsx +++ b/src/files-and-videos/assets/FileInfoAssetSidebar.jsx @@ -19,7 +19,7 @@ import { import { ContentCopy, InfoOutline } from '@edx/paragon/icons'; import { getFileSizeToClosestByte } from '../data/utils'; -import messages from '../messages'; +import messages from './messages'; const FileInfoAssetSidebar = ({ asset, diff --git a/src/files-and-uploads/FilesAndUploads.jsx b/src/files-and-videos/assets/FilesAndUploads.jsx similarity index 87% rename from src/files-and-uploads/FilesAndUploads.jsx rename to src/files-and-videos/assets/FilesAndUploads.jsx index 9738d7cf8d..c9bd10cb0e 100644 --- a/src/files-and-uploads/FilesAndUploads.jsx +++ b/src/files-and-videos/assets/FilesAndUploads.jsx @@ -5,8 +5,8 @@ import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/ 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 { RequestStatus } from '../../data/constants'; +import { useModels, useModel } from '../../generic/model-store'; import { addAssetFile, deleteAssetFile, @@ -15,17 +15,17 @@ import { fetchAssetDownload, getUsagePaths, resetErrors, -} from './data/thunks'; +} 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 './assets/AssetThumbnail'; +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, diff --git a/src/files-and-uploads/FilesAndUploads.test.jsx b/src/files-and-videos/assets/FilesAndUploads.test.jsx similarity index 98% rename from src/files-and-uploads/FilesAndUploads.test.jsx rename to src/files-and-videos/assets/FilesAndUploads.test.jsx index 4d4d4299dd..72a38ab408 100644 --- a/src/files-and-uploads/FilesAndUploads.test.jsx +++ b/src/files-and-videos/assets/FilesAndUploads.test.jsx @@ -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, @@ -27,7 +27,7 @@ import { getStatusValue, courseId, initialState, -} from './factories/mockApiResponses'; +} from '../factories/mockApiResponses'; import { fetchAssets, @@ -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; diff --git a/src/files-and-uploads/FilesAndUploadsProvider.jsx b/src/files-and-videos/assets/FilesAndUploadsProvider.jsx similarity index 100% rename from src/files-and-uploads/FilesAndUploadsProvider.jsx rename to src/files-and-videos/assets/FilesAndUploadsProvider.jsx diff --git a/src/files-and-videos/assets/index.js b/src/files-and-videos/assets/index.js new file mode 100644 index 0000000000..7bfa2519d8 --- /dev/null +++ b/src/files-and-videos/assets/index.js @@ -0,0 +1,3 @@ +import FilesAndUploads from './FilesAndUploads'; + +export default FilesAndUploads; diff --git a/src/files-and-videos/assets/messages.js b/src/files-and-videos/assets/messages.js new file mode 100644 index 0000000000..328bbb13c3 --- /dev/null +++ b/src/files-and-videos/assets/messages.js @@ -0,0 +1,46 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + heading: { + id: 'course-authoring.files-and-uploads.heading', + defaultMessage: 'Files and uploads', + }, + 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; diff --git a/src/files-and-uploads/data/api.js b/src/files-and-videos/data/api.js similarity index 100% rename from src/files-and-uploads/data/api.js rename to src/files-and-videos/data/api.js diff --git a/src/files-and-uploads/data/api.test.js b/src/files-and-videos/data/api.test.js similarity index 100% rename from src/files-and-uploads/data/api.test.js rename to src/files-and-videos/data/api.test.js diff --git a/src/files-and-uploads/data/constant.js b/src/files-and-videos/data/constant.js similarity index 100% rename from src/files-and-uploads/data/constant.js rename to src/files-and-videos/data/constant.js diff --git a/src/files-and-uploads/data/slice.js b/src/files-and-videos/data/slice.js similarity index 100% rename from src/files-and-uploads/data/slice.js rename to src/files-and-videos/data/slice.js diff --git a/src/files-and-uploads/data/thunks.js b/src/files-and-videos/data/thunks.js similarity index 100% rename from src/files-and-uploads/data/thunks.js rename to src/files-and-videos/data/thunks.js diff --git a/src/files-and-uploads/data/utils.js b/src/files-and-videos/data/utils.js similarity index 100% rename from src/files-and-uploads/data/utils.js rename to src/files-and-videos/data/utils.js diff --git a/src/files-and-uploads/data/utils.test.js b/src/files-and-videos/data/utils.test.js similarity index 100% rename from src/files-and-uploads/data/utils.test.js rename to src/files-and-videos/data/utils.test.js diff --git a/src/files-and-uploads/factories/mockApiResponses.jsx b/src/files-and-videos/factories/mockApiResponses.jsx similarity index 100% rename from src/files-and-uploads/factories/mockApiResponses.jsx rename to src/files-and-videos/factories/mockApiResponses.jsx diff --git a/src/files-and-uploads/messages.js b/src/files-and-videos/messages.js similarity index 77% rename from src/files-and-uploads/messages.js rename to src/files-and-videos/messages.js index 23871e7e43..883bcaa7b8 100644 --- a/src/files-and-uploads/messages.js +++ b/src/files-and-videos/messages.js @@ -1,14 +1,6 @@ import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages({ - heading: { - id: 'course-authoring.files-and-uploads.heading', - defaultMessage: 'Files and uploads', - }, - 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', diff --git a/src/files-and-uploads/table-components/GalleryCard.jsx b/src/files-and-videos/table-components/GalleryCard.jsx similarity index 100% rename from src/files-and-uploads/table-components/GalleryCard.jsx rename to src/files-and-videos/table-components/GalleryCard.jsx diff --git a/src/files-and-uploads/table-components/GalleryCard.scss b/src/files-and-videos/table-components/GalleryCard.scss similarity index 100% rename from src/files-and-uploads/table-components/GalleryCard.scss rename to src/files-and-videos/table-components/GalleryCard.scss diff --git a/src/files-and-uploads/table-components/TableActions.jsx b/src/files-and-videos/table-components/TableActions.jsx similarity index 100% rename from src/files-and-uploads/table-components/TableActions.jsx rename to src/files-and-videos/table-components/TableActions.jsx diff --git a/src/files-and-uploads/table-components/index.js b/src/files-and-videos/table-components/index.js similarity index 100% rename from src/files-and-uploads/table-components/index.js rename to src/files-and-videos/table-components/index.js diff --git a/src/files-and-uploads/table-components/table-custom-columns/AccessColumn.jsx b/src/files-and-videos/table-components/table-custom-columns/AccessColumn.jsx similarity index 100% rename from src/files-and-uploads/table-components/table-custom-columns/AccessColumn.jsx rename to src/files-and-videos/table-components/table-custom-columns/AccessColumn.jsx diff --git a/src/files-and-uploads/table-components/table-custom-columns/ActiveColumn.jsx b/src/files-and-videos/table-components/table-custom-columns/ActiveColumn.jsx similarity index 100% rename from src/files-and-uploads/table-components/table-custom-columns/ActiveColumn.jsx rename to src/files-and-videos/table-components/table-custom-columns/ActiveColumn.jsx diff --git a/src/files-and-uploads/table-components/table-custom-columns/MoreInfoColumn.jsx b/src/files-and-videos/table-components/table-custom-columns/MoreInfoColumn.jsx similarity index 100% rename from src/files-and-uploads/table-components/table-custom-columns/MoreInfoColumn.jsx rename to src/files-and-videos/table-components/table-custom-columns/MoreInfoColumn.jsx diff --git a/src/files-and-uploads/table-components/table-custom-columns/StatusColumn.jsx b/src/files-and-videos/table-components/table-custom-columns/StatusColumn.jsx similarity index 100% rename from src/files-and-uploads/table-components/table-custom-columns/StatusColumn.jsx rename to src/files-and-videos/table-components/table-custom-columns/StatusColumn.jsx diff --git a/src/files-and-uploads/table-components/table-custom-columns/ThumbnailColumn.jsx b/src/files-and-videos/table-components/table-custom-columns/ThumbnailColumn.jsx similarity index 100% rename from src/files-and-uploads/table-components/table-custom-columns/ThumbnailColumn.jsx rename to src/files-and-videos/table-components/table-custom-columns/ThumbnailColumn.jsx diff --git a/src/files-and-uploads/table-components/table-custom-columns/index.js b/src/files-and-videos/table-components/table-custom-columns/index.js similarity index 100% rename from src/files-and-uploads/table-components/table-custom-columns/index.js rename to src/files-and-videos/table-components/table-custom-columns/index.js diff --git a/src/files-and-uploads/videos/VideoThumbnail.jsx b/src/files-and-videos/videos/VideoThumbnail.jsx similarity index 100% rename from src/files-and-uploads/videos/VideoThumbnail.jsx rename to src/files-and-videos/videos/VideoThumbnail.jsx diff --git a/src/files-and-uploads/videos/VideoThumbnail.scss b/src/files-and-videos/videos/VideoThumbnail.scss similarity index 100% rename from src/files-and-uploads/videos/VideoThumbnail.scss rename to src/files-and-videos/videos/VideoThumbnail.scss diff --git a/src/files-and-uploads/videos/Videos.jsx b/src/files-and-videos/videos/Videos.jsx similarity index 100% rename from src/files-and-uploads/videos/Videos.jsx rename to src/files-and-videos/videos/Videos.jsx diff --git a/src/files-and-uploads/videos/VideosProvider.jsx b/src/files-and-videos/videos/VideosProvider.jsx similarity index 100% rename from src/files-and-uploads/videos/VideosProvider.jsx rename to src/files-and-videos/videos/VideosProvider.jsx diff --git a/src/files-and-uploads/videos/data/api.js b/src/files-and-videos/videos/data/api.js similarity index 100% rename from src/files-and-uploads/videos/data/api.js rename to src/files-and-videos/videos/data/api.js diff --git a/src/files-and-uploads/videos/data/constants.js b/src/files-and-videos/videos/data/constants.js similarity index 100% rename from src/files-and-uploads/videos/data/constants.js rename to src/files-and-videos/videos/data/constants.js diff --git a/src/files-and-uploads/videos/data/slice.js b/src/files-and-videos/videos/data/slice.js similarity index 100% rename from src/files-and-uploads/videos/data/slice.js rename to src/files-and-videos/videos/data/slice.js diff --git a/src/files-and-uploads/videos/data/thunks.js b/src/files-and-videos/videos/data/thunks.js similarity index 100% rename from src/files-and-uploads/videos/data/thunks.js rename to src/files-and-videos/videos/data/thunks.js diff --git a/src/files-and-uploads/videos/data/utils.js b/src/files-and-videos/videos/data/utils.js similarity index 100% rename from src/files-and-uploads/videos/data/utils.js rename to src/files-and-videos/videos/data/utils.js diff --git a/src/files-and-videos/videos/index.js b/src/files-and-videos/videos/index.js new file mode 100644 index 0000000000..b2eadf9d1e --- /dev/null +++ b/src/files-and-videos/videos/index.js @@ -0,0 +1,3 @@ +import Videos from './Videos'; + +export default Videos; diff --git a/src/files-and-uploads/videos/info-sidebar/FileInfoVideoSidebar.jsx b/src/files-and-videos/videos/info-sidebar/FileInfoVideoSidebar.jsx similarity index 61% rename from src/files-and-uploads/videos/info-sidebar/FileInfoVideoSidebar.jsx rename to src/files-and-videos/videos/info-sidebar/FileInfoVideoSidebar.jsx index c6c6fa69c0..09ae0b66fb 100644 --- a/src/files-and-uploads/videos/info-sidebar/FileInfoVideoSidebar.jsx +++ b/src/files-and-videos/videos/info-sidebar/FileInfoVideoSidebar.jsx @@ -1,21 +1,30 @@ 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, }) => ( - + - + @@ -30,10 +39,12 @@ FileInfoVideoSidebar.propTypes = { fileSize: PropTypes.number.isRequired, transcripts: PropTypes.arrayOf(PropTypes.string), }), + // injected + intl: intlShape.isRequired, }; FileInfoVideoSidebar.defaultProps = { video: null, }; -export default FileInfoVideoSidebar; +export default injectIntl(FileInfoVideoSidebar); diff --git a/src/files-and-uploads/videos/info-sidebar/InfoTab.jsx b/src/files-and-videos/videos/info-sidebar/InfoTab.jsx similarity index 78% rename from src/files-and-uploads/videos/info-sidebar/InfoTab.jsx rename to src/files-and-videos/videos/info-sidebar/InfoTab.jsx index a9e952db91..1662dab266 100644 --- a/src/files-and-uploads/videos/info-sidebar/InfoTab.jsx +++ b/src/files-and-videos/videos/info-sidebar/InfoTab.jsx @@ -1,9 +1,10 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Stack } from '@edx/paragon'; -import { injectIntl, FormattedDate } from '@edx/frontend-platform/i18n'; +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); @@ -12,7 +13,7 @@ const InfoTab = ({ video }) => { return (
- Date Added +
{ minute="numeric" />
- File size +
{fileSize}
- Video length +
{duration}
diff --git a/src/files-and-uploads/videos/info-sidebar/TranscriptTab.jsx b/src/files-and-videos/videos/info-sidebar/TranscriptTab.jsx similarity index 91% rename from src/files-and-uploads/videos/info-sidebar/TranscriptTab.jsx rename to src/files-and-videos/videos/info-sidebar/TranscriptTab.jsx index 9f1725d03d..860d2c665d 100644 --- a/src/files-and-uploads/videos/info-sidebar/TranscriptTab.jsx +++ b/src/files-and-videos/videos/info-sidebar/TranscriptTab.jsx @@ -5,7 +5,7 @@ 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 } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { getLanguages } from '../data/utils'; import Transcript from './transcript-item'; import { @@ -15,8 +15,13 @@ import { uploadVideoTranscript, } from '../data/thunks'; import { RequestStatus } from '../../../data/constants'; +import messages from './messages'; -const TranscriptTab = ({ video }) => { +const TranscriptTab = ({ + video, + // injected + intl, +}) => { const dispatch = useDispatch(); const { transcriptStatus, errors } = useSelector(state => state.videos); const { @@ -89,8 +94,7 @@ const TranscriptTab = ({ video }) => {
    {errors.transcript.map(message => (
  • - {message} - {/* {intl.formatMessage(messages.errorAlertMessage, { message })} */} + {intl.formatMessage(messages.errorAlertMessage, { message })}
  • ))}
@@ -112,7 +116,7 @@ const TranscriptTab = ({ video }) => { className="text-primary-500 justify-content-start pl-0" onClick={() => setPreviousSelection([...previousSelection, ''])} > - Add a transcript + {intl.formatMessage(messages.uploadButtonLabel)} ); @@ -124,6 +128,8 @@ TranscriptTab.propTypes = { id: PropTypes.string.isRequired, displayName: PropTypes.string.isRequired, }).isRequired, + // injected + intl: intlShape.isRequired, }; export default injectIntl(TranscriptTab); diff --git a/src/files-and-videos/videos/info-sidebar/messages.js b/src/files-and-videos/videos/info-sidebar/messages.js new file mode 100644 index 0000000000..3beb245c61 --- /dev/null +++ b/src/files-and-videos/videos/info-sidebar/messages.js @@ -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; diff --git a/src/files-and-uploads/videos/info-sidebar/transcript-item/LanguageSelect.jsx b/src/files-and-videos/videos/info-sidebar/transcript-item/LanguageSelect.jsx similarity index 100% rename from src/files-and-uploads/videos/info-sidebar/transcript-item/LanguageSelect.jsx rename to src/files-and-videos/videos/info-sidebar/transcript-item/LanguageSelect.jsx diff --git a/src/files-and-uploads/videos/info-sidebar/transcript-item/Transcript.jsx b/src/files-and-videos/videos/info-sidebar/transcript-item/Transcript.jsx similarity index 93% rename from src/files-and-uploads/videos/info-sidebar/transcript-item/Transcript.jsx rename to src/files-and-videos/videos/info-sidebar/transcript-item/Transcript.jsx index ebb6773852..b82c54e43c 100644 --- a/src/files-and-uploads/videos/info-sidebar/transcript-item/Transcript.jsx +++ b/src/files-and-videos/videos/info-sidebar/transcript-item/Transcript.jsx @@ -8,7 +8,7 @@ import { useToggle, } from '@edx/paragon'; import { DeleteOutline } from '@edx/paragon/icons'; -import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; +import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n'; import { isEmpty } from 'lodash'; import LanguageSelect from './LanguageSelect'; import TranscriptMenu from './TranscriptMenu'; @@ -20,6 +20,8 @@ const Transcript = ({ transcript, previousSelection, handleTranscript, + // injected + intl, }) => { const [isConfirmationOpen, openConfirmation, closeConfirmation] = useToggle(); const [newLanguage, setNewLanguage] = useState(transcript); @@ -74,7 +76,7 @@ const Transcript = ({ @@ -109,6 +111,8 @@ Transcript.propTypes = { transcript: PropTypes.string.isRequired, previousSelection: PropTypes.arrayOf(PropTypes.string).isRequired, handleTranscript: PropTypes.func.isRequired, + // injected + intl: intlShape.isRequired, }; export default injectIntl(Transcript); diff --git a/src/files-and-uploads/videos/info-sidebar/transcript-item/TranscriptMenu.jsx b/src/files-and-videos/videos/info-sidebar/transcript-item/TranscriptMenu.jsx similarity index 100% rename from src/files-and-uploads/videos/info-sidebar/transcript-item/TranscriptMenu.jsx rename to src/files-and-videos/videos/info-sidebar/transcript-item/TranscriptMenu.jsx diff --git a/src/files-and-uploads/videos/info-sidebar/transcript-item/index.js b/src/files-and-videos/videos/info-sidebar/transcript-item/index.js similarity index 100% rename from src/files-and-uploads/videos/info-sidebar/transcript-item/index.js rename to src/files-and-videos/videos/info-sidebar/transcript-item/index.js diff --git a/src/files-and-uploads/videos/info-sidebar/transcript-item/messages.js b/src/files-and-videos/videos/info-sidebar/transcript-item/messages.js similarity index 68% rename from src/files-and-uploads/videos/info-sidebar/transcript-item/messages.js rename to src/files-and-videos/videos/info-sidebar/transcript-item/messages.js index 0b59f244ff..b8b53f11ee 100644 --- a/src/files-and-uploads/videos/info-sidebar/transcript-item/messages.js +++ b/src/files-and-videos/videos/info-sidebar/transcript-item/messages.js @@ -1,53 +1,48 @@ import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages({ - uploadButtonLabel: { - id: 'authoring.videoeditor.transcripts.upload.label', - defaultMessage: 'Add a transcript', - description: 'Label for upload button', - }, fileSizeError: { - id: 'authoring.videoeditor.transcript.error.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: 'authoring.videoeditor.transcript.deleteTranscript', + id: 'course-authoriong.video-uploads.file-info.transcript.deleteTranscript', defaultMessage: 'Delete', description: 'Message Presented To user for action to delete transcript', }, replaceTranscript: { - id: 'authoring.videoeditor.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: 'authoring.videoeditor.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: 'authoring.videoeditor.transcripts.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: 'authoring.videoeditor.transcripts.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: 'authoring.videoeditor.transcripts.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: 'authoring.videoeditor.transcripts.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: 'authoring.videoeditor.transcripts.deleteConfirmationTitle', + 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', }, diff --git a/src/files-and-uploads/videos/messages.js b/src/files-and-videos/videos/messages.js similarity index 78% rename from src/files-and-uploads/videos/messages.js rename to src/files-and-videos/videos/messages.js index 39e731597e..9318c45af7 100644 --- a/src/files-and-uploads/videos/messages.js +++ b/src/files-and-videos/videos/messages.js @@ -5,10 +5,6 @@ const messages = defineMessages({ id: 'course-authoring.video-uploads.heading', defaultMessage: 'Video uploads', }, - subheading: { - id: 'course-authoring.video-uploads.subheading', - defaultMessage: 'Content', - }, transcriptSettingsButtonLabel: { id: 'course-authoring.video-uploads.transcript-settings.button.toggle', defaultMessage: 'Transcript settings', diff --git a/src/files-and-uploads/videos/transcript-settings/Cielo24Form.jsx b/src/files-and-videos/videos/transcript-settings/Cielo24Form.jsx similarity index 76% rename from src/files-and-uploads/videos/transcript-settings/Cielo24Form.jsx rename to src/files-and-videos/videos/transcript-settings/Cielo24Form.jsx index 9fe7b27324..cf96a68454 100644 --- a/src/files-and-uploads/videos/transcript-settings/Cielo24Form.jsx +++ b/src/files-and-videos/videos/transcript-settings/Cielo24Form.jsx @@ -1,15 +1,19 @@ 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, }) => { const { fidelity } = transcriptionPlan; const selectedLanguage = data.preferredLanguages ? data.preferredLanguages : ''; @@ -25,37 +29,37 @@ const Cielo24Form = ({ - Transcript turnaround + setData({ ...data, cieloTurnaround: value })} - placeholderText="Select turnaround" + placeholderText={intl.formatMessage(messages.cieloTranscriptLanguagePlaceholder)} /> - Transcript fidelity + setData({ ...data, cieloFidelity: value, videoSourceLanguage: '' })} - placeholderText="Select fidelity" + placeholderText={intl.formatMessage(messages.cieloFidelityPlaceholder)} /> {isEmpty(data.cieloFidelity) ? null : ( - Video Source Language + setData({ ...data, videoSourceLanguage: value, preferredLanguages: '' })} - placeholderText="Select language" + placeholderText={intl.formatMessage(messages.cieloSourceLanguagePlaceholder)} /> )} @@ -64,13 +68,13 @@ const Cielo24Form = ({ {isEmpty(data.videoSourceLanguage) ? null : ( - Transcript language + setData({ ...data, preferredLanguages: [value] })} - placeholderText="Select language" + placeholderText={intl.formatMessage(messages.cieloTranscriptLanguagePlaceholder)} /> )} @@ -82,17 +86,17 @@ const Cielo24Form = ({ return (
- Enter the account information for your organization. +
- API Key + setData({ ...data, apiKey: e.target.value })} /> - Username + setData({ ...data, username: e.target.value })} /> @@ -115,6 +119,8 @@ Cielo24Form.propTypes = { turnaround: PropTypes.shape({}), fidelity: PropTypes.shape({}), }).isRequired, + // injected + intl: intlShape.isRequired, }; -export default Cielo24Form; +export default injectIntl(Cielo24Form); diff --git a/src/files-and-uploads/videos/transcript-settings/FormDropdown.jsx b/src/files-and-videos/videos/transcript-settings/FormDropdown.jsx similarity index 100% rename from src/files-and-uploads/videos/transcript-settings/FormDropdown.jsx rename to src/files-and-videos/videos/transcript-settings/FormDropdown.jsx diff --git a/src/files-and-uploads/videos/transcript-settings/OrderTranscriptForm.jsx b/src/files-and-videos/videos/transcript-settings/OrderTranscriptForm.jsx similarity index 83% rename from src/files-and-uploads/videos/transcript-settings/OrderTranscriptForm.jsx rename to src/files-and-videos/videos/transcript-settings/OrderTranscriptForm.jsx index cad77d4c68..1136a65aaf 100644 --- a/src/files-and-uploads/videos/transcript-settings/OrderTranscriptForm.jsx +++ b/src/files-and-videos/videos/transcript-settings/OrderTranscriptForm.jsx @@ -1,11 +1,13 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; import { isEmpty } from 'lodash'; +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'; const OrderTranscriptForm = ({ setTranscriptType, @@ -17,6 +19,8 @@ const OrderTranscriptForm = ({ transcriptionPlans, errorMessages, transcriptStatus, + // injected + intl, }) => { const [data, setData] = useState({}); const hasTranscriptCredentials = !isEmpty(transcriptCredentials); @@ -63,7 +67,7 @@ const OrderTranscriptForm = ({
    {errorMessages.transcript.map(message => (
  • - {message} + {intl.formatMessage(messages.errorAlertMessage, { message })}
  • ))}
@@ -86,27 +90,31 @@ const OrderTranscriptForm = ({ aria-label="none radio" className="text-center" > - None + - Cielo24 + - 3Play Media + {form} - - + + ); @@ -134,10 +142,12 @@ OrderTranscriptForm.propTypes = { languages: PropTypes.shape({}), }).isRequired, }).isRequired, + // injected + intl: intlShape.isRequired, }; OrderTranscriptForm.defaultProps = { activeTranscriptPreferences: null, }; -export default OrderTranscriptForm; +export default injectIntl(OrderTranscriptForm); diff --git a/src/files-and-uploads/videos/transcript-settings/ThreePlayMediaForm.jsx b/src/files-and-videos/videos/transcript-settings/ThreePlayMediaForm.jsx similarity index 80% rename from src/files-and-uploads/videos/transcript-settings/ThreePlayMediaForm.jsx rename to src/files-and-videos/videos/transcript-settings/ThreePlayMediaForm.jsx index 29d065dddf..004d71576b 100644 --- a/src/files-and-uploads/videos/transcript-settings/ThreePlayMediaForm.jsx +++ b/src/files-and-videos/videos/transcript-settings/ThreePlayMediaForm.jsx @@ -1,6 +1,7 @@ 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, @@ -10,12 +11,15 @@ import { 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, }) => { const selectedLanguages = data.preferredLanguages ? data.preferredLanguages : []; const turnaroundOptions = transcriptionPlan.turnaround; @@ -34,31 +38,31 @@ const ThreePlayMediaForm = ({ - Transcript turnaround + setData({ ...data, threePlayTurnaround: value })} - placeholderText="Select turnaround" + placeholderText={intl.formatMessage(messages.threePlayMediaTurnaroundPlaceholder)} /> - Video Source Language + setData({ ...data, videoSourceLanguage: value, preferredLanguages: [] })} - placeholderText="Select language" + placeholderText={intl.formatMessage(messages.threePlayMediaSourceLanguagePlaceholder)} /> {!isEmpty(data.videoSourceLanguage) ? ( - Transcript language +
    @@ -97,17 +101,17 @@ const ThreePlayMediaForm = ({ return (
    - Enter the account information for your organization. +
    - API Key + setData({ ...data, apiKey: e.target.value })} /> - API Secret + setData({ ...data, apiSecretKey: e.target.value })} /> @@ -130,6 +134,8 @@ ThreePlayMediaForm.propTypes = { translations: PropTypes.shape({}), languages: PropTypes.shape({}), }).isRequired, + // injected + intl: intlShape.isRequired, }; -export default ThreePlayMediaForm; +export default injectIntl(ThreePlayMediaForm); diff --git a/src/files-and-uploads/videos/transcript-settings/TranscriptSettings.jsx b/src/files-and-videos/videos/transcript-settings/TranscriptSettings.jsx similarity index 85% rename from src/files-and-uploads/videos/transcript-settings/TranscriptSettings.jsx rename to src/files-and-videos/videos/transcript-settings/TranscriptSettings.jsx index b18de188c6..ebf40945c6 100644 --- a/src/files-and-uploads/videos/transcript-settings/TranscriptSettings.jsx +++ b/src/files-and-videos/videos/transcript-settings/TranscriptSettings.jsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; +import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; import { ActionRow, Collapsible, @@ -10,6 +11,7 @@ import { } from '@edx/paragon'; import { ChevronLeft, ChevronRight, Close } from '@edx/paragon/icons'; import OrderTranscriptForm from './OrderTranscriptForm'; +import messages from './messages'; const TranscriptSettings = ({ isTranscriptSettngsOpen, @@ -46,7 +48,7 @@ const TranscriptSettings = ({ /> ) : (
    - Transcript settings +
    )} @@ -83,20 +85,10 @@ const TranscriptSettings = ({ className="row m-0 justify-content-between align-items-center" onClick={() => setTranscriptType('order')} > - Order Transcripts + -
    - {/* - setTranscriptType('expert')} - > - Get free translations - - - */}
)} @@ -115,4 +107,4 @@ TranscriptSettings.propTypes = { handleOrderTranscripts: PropTypes.func.isRequired, }; -export default TranscriptSettings; +export default injectIntl(TranscriptSettings); diff --git a/src/files-and-uploads/videos/transcript-settings/index.js b/src/files-and-videos/videos/transcript-settings/index.js similarity index 100% rename from src/files-and-uploads/videos/transcript-settings/index.js rename to src/files-and-videos/videos/transcript-settings/index.js diff --git a/src/files-and-videos/videos/transcript-settings/messages.js b/src/files-and-videos/videos/transcript-settings/messages.js new file mode 100644 index 0000000000..880f7dd3fa --- /dev/null +++ b/src/files-and-videos/videos/transcript-settings/messages.js @@ -0,0 +1,145 @@ +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', + }, + 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; diff --git a/src/index.scss b/src/index.scss index c71aa6a60b..5d1f7bd4a0 100755 --- a/src/index.scss +++ b/src/index.scss @@ -19,5 +19,5 @@ @import "course-updates/CourseUpdates"; @import "export-page/CourseExportPage"; @import "import-page/CourseImportPage"; -@import "files-and-uploads/videos/VideoThumbnail.scss"; -@import "files-and-uploads/table-components/GalleryCard" +@import "files-and-videos/videos/VideoThumbnail.scss"; +@import "files-and-videos/table-components/GalleryCard" diff --git a/src/store.js b/src/store.js index c21cad7f5f..99a7265219 100644 --- a/src/store.js +++ b/src/store.js @@ -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,7 +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-uploads/videos/data/slice'; +import { reducer as videosReducer } from './files-and-videos/videos/data/slice'; export default function initializeStore(preloadedState = undefined) { return configureStore({ From 1fb9f3bfc9813a6b6b558ac64a0aa32e2f21447f Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Wed, 18 Oct 2023 09:35:30 -0400 Subject: [PATCH 14/46] fix: table thumbnail style --- src/files-and-videos/assets/AssetThumbnail.jsx | 5 +++-- src/files-and-videos/videos/VideoThumbnail.jsx | 18 ++++++------------ 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/src/files-and-videos/assets/AssetThumbnail.jsx b/src/files-and-videos/assets/AssetThumbnail.jsx index 22c5db231b..cff4423a43 100644 --- a/src/files-and-videos/assets/AssetThumbnail.jsx +++ b/src/files-and-videos/assets/AssetThumbnail.jsx @@ -21,7 +21,7 @@ const AssetThumbnail = ({ const { width, height } = imageSize; return ( -
+
{thumbnail ? ( {`Thumbnail ) : (
diff --git a/src/files-and-videos/videos/VideoThumbnail.jsx b/src/files-and-videos/videos/VideoThumbnail.jsx index fe2fde25ee..01378e2977 100644 --- a/src/files-and-videos/videos/VideoThumbnail.jsx +++ b/src/files-and-videos/videos/VideoThumbnail.jsx @@ -46,28 +46,22 @@ const VideoThumbnail = ({ break; } const showThumbnail = videoImageSettings?.videoImageUploadEnabled && thumbnail && isUploaded; - const { width, height } = imageSize; return ( -
+
{showThumbnail ? ( {`Thumbnail this.src=VideoFile} /> ) : ( <>
From 10903f0e5b2bbd95f7a8bc13c61dca4807b0770a Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Wed, 18 Oct 2023 10:05:56 -0400 Subject: [PATCH 15/46] fix: thumbnail alt message --- src/files-and-videos/FileTable.jsx | 2 +- src/files-and-videos/assets/AssetThumbnail.jsx | 10 ++++++++-- src/files-and-videos/assets/messages.js | 4 ++++ src/files-and-videos/videos/VideoThumbnail.jsx | 11 ++++++++--- src/files-and-videos/videos/messages.js | 4 ++++ 5 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/files-and-videos/FileTable.jsx b/src/files-and-videos/FileTable.jsx index 618a65cad9..0c6a274391 100644 --- a/src/files-and-videos/FileTable.jsx +++ b/src/files-and-videos/FileTable.jsx @@ -47,7 +47,7 @@ const FileTable = ({ intl, }) => { const dispatch = useDispatch(); - const defaultVal = 'list'; + const defaultVal = 'card'; const columnSizes = { xs: 12, sm: 6, diff --git a/src/files-and-videos/assets/AssetThumbnail.jsx b/src/files-and-videos/assets/AssetThumbnail.jsx index cff4423a43..c47efacd71 100644 --- a/src/files-and-videos/assets/AssetThumbnail.jsx +++ b/src/files-and-videos/assets/AssetThumbnail.jsx @@ -1,10 +1,12 @@ 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 messages from './messages'; const AssetThumbnail = ({ thumbnail, @@ -12,6 +14,8 @@ const AssetThumbnail = ({ externalUrl, displayName, imageSize, + // injected + intl, }) => { const src = getSrc({ thumbnail, @@ -33,7 +37,7 @@ const AssetThumbnail = ({ }} className="border rounded p-1" src={src} - alt={`Thumbnail of ${displayName}`} + alt={intl.formatMessage(messages.thumbnailAltMessage, { displayName })} /> ) : (
{ const fileInputControl = useFileInput({ onAddFile: (file) => handleAddThumbnail(file, id), @@ -55,8 +59,7 @@ const VideoThumbnail = ({ style={imageSize} className="border rounded p-1" src={thumbnail} - alt={`${displayName} thumbnail`} - onrror={() => this.src=VideoFile} + alt={intl.formatMessage(messages.thumbnailAltMessage, { displayName })} /> ) : ( <> @@ -108,6 +111,8 @@ VideoThumbnail.propTypes = { supportedFileFormats: PropTypes.shape({}), }).isRequired, status: PropTypes.string.isRequired, + // injected + intl: intlShape.isRequired, }; -export default VideoThumbnail; +export default injectIntl(VideoThumbnail); diff --git a/src/files-and-videos/videos/messages.js b/src/files-and-videos/videos/messages.js index 9318c45af7..85a212954d 100644 --- a/src/files-and-videos/videos/messages.js +++ b/src/files-and-videos/videos/messages.js @@ -9,6 +9,10 @@ const messages = defineMessages({ 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; From 09c1b00726c31ed40b06fff81456685108800315 Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Wed, 18 Oct 2023 11:45:44 -0400 Subject: [PATCH 16/46] fix: failing test --- src/files-and-videos/FileInfo.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/files-and-videos/FileInfo.jsx b/src/files-and-videos/FileInfo.jsx index 70114d1c3f..a097aa73cf 100644 --- a/src/files-and-videos/FileInfo.jsx +++ b/src/files-and-videos/FileInfo.jsx @@ -20,7 +20,7 @@ const FileInfo = ({ file, isOpen, onClose, - handleLockedAsset, + handleLockedFile, thumbnailPreview, usagePathStatus, error, @@ -61,7 +61,7 @@ const FileInfo = ({ {file?.wrapperType === 'video' ? ( ) : ( - + )}
From 4442bc97a86007f7c353eee5160ca2029487d635 Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Wed, 18 Oct 2023 12:11:43 -0400 Subject: [PATCH 17/46] fix: lint error --- src/files-and-videos/FileInfo.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/files-and-videos/FileInfo.jsx b/src/files-and-videos/FileInfo.jsx index a097aa73cf..3bbc09a1e3 100644 --- a/src/files-and-videos/FileInfo.jsx +++ b/src/files-and-videos/FileInfo.jsx @@ -89,7 +89,7 @@ FileInfo.propTypes = { }), onClose: PropTypes.func.isRequired, isOpen: PropTypes.bool.isRequired, - handleLockedAsset: PropTypes.func.isRequired, + handleLockedFile: PropTypes.func.isRequired, usagePathStatus: PropTypes.string.isRequired, error: PropTypes.arrayOf(PropTypes.string).isRequired, thumbnailPreview: PropTypes.func.isRequired, From 7da3fd42b8607f356e35f5dbbed3d958155f57b5 Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Wed, 18 Oct 2023 12:12:13 -0400 Subject: [PATCH 18/46] fix: onclick not working with keyboard --- .../videos/transcript-settings/TranscriptSettings.jsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/files-and-videos/videos/transcript-settings/TranscriptSettings.jsx b/src/files-and-videos/videos/transcript-settings/TranscriptSettings.jsx index ebf40945c6..c860c26d9c 100644 --- a/src/files-and-videos/videos/transcript-settings/TranscriptSettings.jsx +++ b/src/files-and-videos/videos/transcript-settings/TranscriptSettings.jsx @@ -80,10 +80,11 @@ const TranscriptSettings = ({
) : (
- + setTranscriptType('order')} + > setTranscriptType('order')} > From 77fa51b75092e88b5b262c10177e2b81f703fa76 Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Wed, 18 Oct 2023 16:06:39 -0400 Subject: [PATCH 19/46] fix: undefined not iterable error --- src/files-and-videos/videos/Videos.jsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/files-and-videos/videos/Videos.jsx b/src/files-and-videos/videos/Videos.jsx index 140eeed5a1..9f6d2a31ac 100644 --- a/src/files-and-videos/videos/Videos.jsx +++ b/src/files-and-videos/videos/Videos.jsx @@ -40,6 +40,7 @@ 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, @@ -77,8 +78,8 @@ const Videos = ({ videoImageSettings, } = pageSettings; - const supportedFileFormats = { 'video/*': videoSupportedFileFormats }; - + 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, courseId })); From e6cd5ba83a6a1fb9a730fc615ac34e5ddf5fabab Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Fri, 20 Oct 2023 09:52:21 -0400 Subject: [PATCH 20/46] chore: add TranscriptTab tests --- .../videos/factories/mockApiResponses.jsx | 223 +++++++++++++++++ .../videos/info-sidebar/TranscriptTab.jsx | 8 +- .../info-sidebar/TranscriptTab.test.jsx | 225 ++++++++++++++++++ .../transcript-item/LanguageSelect.jsx | 3 +- .../transcript-item/Transcript.jsx | 11 +- .../transcript-item/TranscriptMenu.jsx | 1 + 6 files changed, 463 insertions(+), 8 deletions(-) create mode 100644 src/files-and-videos/videos/factories/mockApiResponses.jsx create mode 100644 src/files-and-videos/videos/info-sidebar/TranscriptTab.test.jsx diff --git a/src/files-and-videos/videos/factories/mockApiResponses.jsx b/src/files-and-videos/videos/factories/mockApiResponses.jsx new file mode 100644 index 0000000000..da9a31125e --- /dev/null +++ b/src/files-and-videos/videos/factories/mockApiResponses.jsx @@ -0,0 +1,223 @@ +import { RequestStatus } from '../../../data/constants'; + +export const courseId = 'course-v1:edX+DemoX+Demo_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" }, + ], + videoTranscriptSettings: { + transcriptDownloadHandlerUrl: '/transcript_download/', + transcriptUploadHandlerUrl: "/transcript_upload/", + transcriptDeleteHandlerUrl: `/transcript_delete/${courseId}`, + }, + }, + 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', + }, + }, + }, +}; + +export const generateFetchVideosApiResponse = () => ({ + "image_upload_url": "/video_images/course-v1:krisEdx+ka101+2023-01", + "video_handler_url": "/videos/course-v1:krisEdx+ka101+2023-01", + "encodings_download_url": "/video_encodings_download/course-v1:krisEdx+ka101+2023-01", + "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', + }, + { + edx_video_id: 'mOckID5', + clientVideoId: 'mOckID5.mp4', + created: '', + courseVideoImageUrl: 'http:/video', + transcripts: ['en'], + status: 'Failed', + }, + { + edx_video_id: 'mOckID3', + clientVideoId: 'mOckID3.mp4', + created: '', + courseVideoImageUrl: null, + transcripts: ['en'], + status: 'Ready', + }, + ], + "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-v1:krisEdx+ka101+2023-01", + "trancript_download_file_format": "srt", + "transcript_preferences_handler_url": "/transcript_preferences/course-v1:krisEdx+ka101+2023-01", + "transcript_credentials_handler_url": "/transcript_credentials/course-v1:krisEdx+ka101+2023-01", + "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", + "vi": "Vietnamese", + }, + "translations": { + "es": [ + "en" + ], + "en": [ + "el", + "en", + "zh", + "vi", + ] + } + } + } + }, + "pagination_context": {} + } +); +export const generateAddVideoApiResponse = () => ({ + videos: [ + { + edx_video_id: 'mOckID4', + clientVideoId: 'mOckID4.mov', + created: '', + courseVideoImageUrl: null, + transcripts: ['en'], + status: 'Uploaded', + }, + ], +}); + +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; + } +}; diff --git a/src/files-and-videos/videos/info-sidebar/TranscriptTab.jsx b/src/files-and-videos/videos/info-sidebar/TranscriptTab.jsx index 860d2c665d..560257db40 100644 --- a/src/files-and-videos/videos/info-sidebar/TranscriptTab.jsx +++ b/src/files-and-videos/videos/info-sidebar/TranscriptTab.jsx @@ -41,7 +41,7 @@ const TranscriptTab = ({ setPreviousSelection(transcripts); }, [transcripts]); - const handleTranscript = async (data, actionType) => { + const handleTranscript = (data, actionType) => { const { language, newLanguage, @@ -54,7 +54,7 @@ const TranscriptTab = ({ const updatedSelection = previousSelection.filter(selection => selection !== ''); setPreviousSelection(updatedSelection); } else { - await dispatch(deleteVideoTranscript({ + dispatch(deleteVideoTranscript({ language, videoId: id, apiUrl: transcriptDeleteHandlerUrl, @@ -63,7 +63,7 @@ const TranscriptTab = ({ } break; case 'download': - await dispatch(downloadVideoTranscript({ + dispatch(downloadVideoTranscript({ filename: `${displayName}-${language}.srt`, language, videoId: id, @@ -71,7 +71,7 @@ const TranscriptTab = ({ })); break; case 'upload': - await dispatch(uploadVideoTranscript({ + dispatch(uploadVideoTranscript({ language, videoId: id, apiUrl: transcriptUploadHandlerUrl, diff --git a/src/files-and-videos/videos/info-sidebar/TranscriptTab.test.jsx b/src/files-and-videos/videos/info-sidebar/TranscriptTab.test.jsx new file mode 100644 index 0000000000..9faa55cf76 --- /dev/null +++ b/src/files-and-videos/videos/info-sidebar/TranscriptTab.test.jsx @@ -0,0 +1,225 @@ +import { + render, + act, + fireEvent, + screen, + waitFor, + within, +} 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 { + // generateUpdateVisiblityApiResponse, + courseId, + initialState, + // generateXblockData, +} from '../factories/mockApiResponses'; + +import { getApiBaseUrl } from '../data/api'; +import messages from './messages'; +import { default as transcriptRowMessages } from './transcript-item/messages'; +import VideosProvider from '../VideosProvider'; +import { deleteVideoTranscript, downloadVideoTranscript } 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( + + + + + + + , + ); +}; + +describe('TranscriptTab', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: false, + roles: [], + }, + }); + store = initializeStore(initialState); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + 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 upload new transcript', async () => { + renderComponent(defaultProps); + const addButton = screen.getByText(messages.uploadButtonLabel.defaultMessage); + axiosMock.onPost(`${getApiBaseUrl()}/transcript_upload/`).reply(204); + const file = new File(['(⌐□_□)'], 'download.srt', { type: 'text/srt' }); + fireEvent.click(addButton); + + 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 contain transcript row', () => { + const updatedProps = { ...defaultProps, transcripts: ['ar']} + renderComponent(updatedProps); + const addButton = screen.getByText(messages.uploadButtonLabel.defaultMessage); + const transcriptRow = screen.getByTestId('transcript-ar'); + expect(addButton).toBeInTheDocument(); + expect(transcriptRow).toBeInTheDocument(); + }); + + it('should open delete confirmation modal and handle cancel', async () => { + const updatedProps = { ...defaultProps, transcripts: ['ar']} + renderComponent(updatedProps); + const menuButton = screen.getByTestId('ar-transcript-menu'); + await waitFor(() => { + fireEvent.click(menuButton); + }); + const deleteButton = screen.getByText(transcriptRowMessages.deleteTranscript.defaultMessage).closest('a'); + fireEvent.click(deleteButton); + + expect(screen.getByText(transcriptRowMessages.deleteConfirmationHeader.defaultMessage)).toBeVisible(); + + const cancelButton = screen.getByText(transcriptRowMessages.cancelDeleteLabel.defaultMessage); + fireEvent.click(cancelButton); + + expect(screen.queryByText(transcriptRowMessages.deleteConfirmationHeader.defaultMessage)).toBeNull(); + }); + + it('should open delete confirmation modal and handle delete', async () => { + const updatedProps = { ...defaultProps, transcripts: ['ar']} + renderComponent(updatedProps); + const menuButton = screen.getByTestId('ar-transcript-menu'); + await waitFor(() => { + fireEvent.click(menuButton); + }); + const deleteButton = screen.getByText(transcriptRowMessages.deleteTranscript.defaultMessage).closest('a'); + fireEvent.click(deleteButton); + + expect(screen.getByText(transcriptRowMessages.deleteConfirmationHeader.defaultMessage)).toBeVisible(); + + 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 download transcript', async () => { + const updatedProps = { ...defaultProps, transcripts: ['ar']} + renderComponent(updatedProps); + const menuButton = screen.getByTestId('ar-transcript-menu'); + await waitFor(() => { + fireEvent.click(menuButton); + }); + const downloadButton = screen.getByText( + transcriptRowMessages.downloadTranscript.defaultMessage, + ).closest('a'); + 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 replace transcript', 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('ar-transcript-menu'); + await waitFor(() => { + fireEvent.click(menuButton); + }); + const replaceButton = screen.getByText( + transcriptRowMessages.replaceTranscript.defaultMessage, + ).closest('a'); + axiosMock.onPost(`${getApiBaseUrl()}/transcript_upload/`).reply(204); + const file = new File(['(⌐□_□)'], 'download.srt', { type: 'text/srt' }); + + await act(async () => { + fireEvent.click(replaceButton); + 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']); + }); +}); diff --git a/src/files-and-videos/videos/info-sidebar/transcript-item/LanguageSelect.jsx b/src/files-and-videos/videos/info-sidebar/transcript-item/LanguageSelect.jsx index 167115e0e8..5c096bb6a4 100644 --- a/src/files-and-videos/videos/info-sidebar/transcript-item/LanguageSelect.jsx +++ b/src/files-and-videos/videos/info-sidebar/transcript-item/LanguageSelect.jsx @@ -18,7 +18,8 @@ const LanguageSelect = ({ variant="teritary" className="border border-gray-700 justify-content-between" style={{ minWidth: '100%' }} - id="language-select-dropdown" + id={`language-select-dropdown-${currentSelection}`} + data-testid='language-select-dropdown' > {currentSelection} diff --git a/src/files-and-videos/videos/info-sidebar/transcript-item/Transcript.jsx b/src/files-and-videos/videos/info-sidebar/transcript-item/Transcript.jsx index b82c54e43c..35200f262d 100644 --- a/src/files-and-videos/videos/info-sidebar/transcript-item/Transcript.jsx +++ b/src/files-and-videos/videos/info-sidebar/transcript-item/Transcript.jsx @@ -28,11 +28,12 @@ const Transcript = ({ const language = transcript; const input = useFileInput({ - onAddFile: (file) => handleTranscript({ + onAddFile: (file) => { + handleTranscript({ file, language, newLanguage, - }, 'upload'), + }, 'upload')}, setSelectedRows: () => {}, setAddOpen: () => {}, }); @@ -71,7 +72,11 @@ const Transcript = ({ ) : ( -
+
Date: Fri, 20 Oct 2023 09:53:19 -0400 Subject: [PATCH 21/46] chore: update FilesAndUploads tests --- src/files-and-videos/FileMenu.jsx | 2 +- .../assets/FilesAndUploads.test.jsx | 20 +++++++++---------- .../factories/mockApiResponses.jsx | 2 +- .../table-custom-columns/ActiveColumn.jsx | 2 +- .../table-custom-columns/MoreInfoColumn.jsx | 12 ++++++++--- 5 files changed, 22 insertions(+), 16 deletions(-) rename src/files-and-videos/{ => assets}/factories/mockApiResponses.jsx (98%) diff --git a/src/files-and-videos/FileMenu.jsx b/src/files-and-videos/FileMenu.jsx index b2446855ce..cf1d61d172 100644 --- a/src/files-and-videos/FileMenu.jsx +++ b/src/files-and-videos/FileMenu.jsx @@ -29,7 +29,7 @@ const FileMenu = ({ src={MoreHoriz} iconAs={Icon} variant="primary" - alt="asset-menu-toggle" + alt="file-menu-toggle" /> {wrapperType === 'video' ? ( diff --git a/src/files-and-videos/assets/FilesAndUploads.test.jsx b/src/files-and-videos/assets/FilesAndUploads.test.jsx index 72a38ab408..b6ba5d7367 100644 --- a/src/files-and-videos/assets/FilesAndUploads.test.jsx +++ b/src/files-and-videos/assets/FilesAndUploads.test.jsx @@ -27,7 +27,7 @@ import { getStatusValue, courseId, initialState, -} from '../factories/mockApiResponses'; +} from './factories/mockApiResponses'; import { fetchAssets, @@ -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, diff --git a/src/files-and-videos/factories/mockApiResponses.jsx b/src/files-and-videos/assets/factories/mockApiResponses.jsx similarity index 98% rename from src/files-and-videos/factories/mockApiResponses.jsx rename to src/files-and-videos/assets/factories/mockApiResponses.jsx index 6b99e18d62..e7df3d0e70 100644 --- a/src/files-and-videos/factories/mockApiResponses.jsx +++ b/src/files-and-videos/assets/factories/mockApiResponses.jsx @@ -1,4 +1,4 @@ -import { RequestStatus } from '../../data/constants'; +import { RequestStatus } from '../../../data/constants'; export const courseId = 'course-v1:edX+DemoX+Demo_Course'; diff --git a/src/files-and-videos/table-components/table-custom-columns/ActiveColumn.jsx b/src/files-and-videos/table-components/table-custom-columns/ActiveColumn.jsx index a604dfe348..149c0736ef 100644 --- a/src/files-and-videos/table-components/table-custom-columns/ActiveColumn.jsx +++ b/src/files-and-videos/table-components/table-custom-columns/ActiveColumn.jsx @@ -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 ? : null; }; diff --git a/src/files-and-videos/table-components/table-custom-columns/MoreInfoColumn.jsx b/src/files-and-videos/table-components/table-custom-columns/MoreInfoColumn.jsx index d560ca6be1..e125bd8410 100644 --- a/src/files-and-videos/table-components/table-custom-columns/MoreInfoColumn.jsx +++ b/src/files-and-videos/table-components/table-custom-columns/MoreInfoColumn.jsx @@ -36,7 +36,13 @@ const MoreInfoColumn = ({ } = row.original; return ( <> - + Date: Mon, 23 Oct 2023 16:17:26 -0400 Subject: [PATCH 22/46] chore: add test for transriopt settings --- src/files-and-videos/videos/data/api.js | 61 +-- src/files-and-videos/videos/data/slice.js | 6 +- src/files-and-videos/videos/data/thunks.js | 40 +- src/files-and-videos/videos/data/utils.js | 39 +- .../videos/factories/mockApiResponses.jsx | 265 +++++++------ .../transcript-settings/Cielo24Form.jsx | 26 +- .../transcript-settings/FormDropdown.jsx | 4 +- .../OrderTranscriptForm.jsx | 34 +- .../ThreePlayMediaForm.jsx | 4 +- .../TranscriptSettings.jsx | 69 ++-- .../TranscriptSettings.test.jsx | 359 ++++++++++++++++++ 11 files changed, 649 insertions(+), 258 deletions(-) create mode 100644 src/files-and-videos/videos/transcript-settings/TranscriptSettings.test.jsx diff --git a/src/files-and-videos/videos/data/api.js b/src/files-and-videos/videos/data/api.js index 1219001cc8..057480940c 100644 --- a/src/files-and-videos/videos/data/api.js +++ b/src/files-and-videos/videos/data/api.js @@ -2,7 +2,6 @@ import { camelCaseObject, ensureConfig, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import JSZip from 'jszip'; import saveAs from 'file-saver'; ensureConfig([ @@ -48,7 +47,7 @@ export async function deleteTranscript({ videoId, language, apiUrl }) { .delete(`${getApiBaseUrl()}${apiUrl}/${videoId}/${language}`); } -export async function downloadTranscriipt({ +export async function downloadTranscript({ videoId, language, apiUrl, @@ -71,7 +70,7 @@ export async function uploadTranscript({ formData.append('file', file); formData.append('edx_video_id', videoId); formData.append('language_code', language); - formData.append('new_langage_code', newLanguage); + formData.append('new_language_code', newLanguage); await getAuthenticatedHttpClient().post(`${getApiBaseUrl()}${apiUrl}`, formData); } @@ -88,45 +87,18 @@ export async function getDownloadLink(courseId, edxVideoId) { */ export async function getDownload(selectedRows, courseId) { const downloadErrors = []; - if (selectedRows?.length > 1) { - const zip = new JSZip(); - const date = new Date().toString(); - const folder = zip.folder(`${courseId}-videos-${date}`); - const videoNames = []; - const videoFetcher = await Promise.allSettled( - selectedRows.map(async (row) => { + if (selectedRows?.length > 0) { + await Promise.allSettled( + selectedRows.map(async row => { const video = row?.original; try { - videoNames.push(video.displayName); const { downloadLink } = await getDownloadLink(courseId, video.id); - const res = await fetch(downloadLink); - if (!res.ok) { - throw new Error(); - } - return res.blob(); + saveAs(downloadLink, video.displayName); } catch (error) { downloadErrors.push(`Failed to download ${video?.displayName}.`); - return null; } }), ); - const definedVideos = videoFetcher.filter(video => video.value !== null); - if (definedVideos.length > 0) { - definedVideos.forEach((videoBlob, index) => { - folder.file(videoNames[index], videoBlob.value, { blob: true }); - }); - zip.generateAsync({ type: 'blob' }).then(content => { - saveAs(content, `${courseId}-videos-${date}.zip`); - }); - } - } else if (selectedRows?.length === 1) { - const video = selectedRows[0].original; - try { - const { downloadLink } = await getDownloadLink(courseId, video.id); - saveAs(downloadLink, video.displayName); - } catch (error) { - downloadErrors.push(`Failed to download ${video?.displayName}.`); - } } else { downloadErrors.push('No files were selected to download'); } @@ -173,10 +145,12 @@ export async function addThumbnail({ courseId, videoId, file }) { */ export async function addVideo(courseId, file) { - const formData = new FormData(); - formData.append('file', file); + const postJson = { + files: [{ file_name: file.name, content_type: file.type }], + }; + const { data } = await getAuthenticatedHttpClient() - .post(getCoursVideosApiUrl(courseId), formData); + .post(getCoursVideosApiUrl(courseId), postJson); return camelCaseObject(data); } @@ -205,7 +179,7 @@ export async function uploadVideo( }]); }) .catch(async () => { - uploadErrors.push(`Failed to upload ${uploadFile.name}.`); + uploadErrors.push(`Failed to upload ${uploadFile.name} to server.`); await getAuthenticatedHttpClient() .post(getCoursVideosApiUrl(courseId), [{ edxVideoId, @@ -222,8 +196,8 @@ export async function deleteTranscriptPreferences(courseId) { export async function setTranscriptPreferences(courseId, preferences) { const { - cieloFidelity, - cieloTurnaround, + cielo24Fidelity, + cielo24Turnaround, global, preferredLanguages, provider, @@ -231,8 +205,8 @@ export async function setTranscriptPreferences(courseId, preferences) { videoSourceLanguage, } = preferences; const postJson = { - cielo24_fideltiy: cieloFidelity.toUpperCase(), - cielo24_turnaround: cieloTurnaround, + cielo24_fideltiy: cielo24Fidelity?.toUpperCase(), + cielo24_turnaround: cielo24Turnaround, global, preferred_languages: preferredLanguages, provider, @@ -265,7 +239,6 @@ export async function setTranscriptCredentials(courseId, formFields) { const { username } = otherFields; postJson.username = username; } - const { data } = await getAuthenticatedHttpClient() + await getAuthenticatedHttpClient() .post(`${getApiBaseUrl()}/transcript_credentials/${courseId}`, postJson); - return camelCaseObject(data); } diff --git a/src/files-and-videos/videos/data/slice.js b/src/files-and-videos/videos/data/slice.js index 7e899ff249..3e766292ff 100644 --- a/src/files-and-videos/videos/data/slice.js +++ b/src/files-and-videos/videos/data/slice.js @@ -69,7 +69,11 @@ const slice = createSlice({ state.videoIds = [payload.videoId, ...state.videoIds]; }, updateTranscriptCredentialsSuccess: (state, { payload }) => { - state.pageSettings.transcriptCredentials = payload; + const { provider } = payload; + state.pageSettings.transcriptCredentials = { + ...state.pageSettings.transcriptCredentials, + [provider]: true, + }; }, updateTranscriptPreferenceSuccess: (state, { payload }) => { state.pageSettings.activeTranscriptPreferences = payload; diff --git a/src/files-and-videos/videos/data/thunks.js b/src/files-and-videos/videos/data/thunks.js index dac2388eeb..072c929cc1 100644 --- a/src/files-and-videos/videos/data/thunks.js +++ b/src/files-and-videos/videos/data/thunks.js @@ -1,4 +1,4 @@ -import { isEmpty } from 'lodash'; +import { camelCase, isEmpty } from 'lodash'; import { getConfig } from '@edx/frontend-platform'; import { RequestStatus } from '../../../data/constants'; import { @@ -16,7 +16,7 @@ import { uploadVideo, getDownload, deleteTranscript, - downloadTranscriipt, + downloadTranscript, uploadTranscript, getVideoUsagePaths, deleteTranscriptPreferences, @@ -96,6 +96,7 @@ export function deleteVideoFile(courseId, id, totalCount) { 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]; @@ -105,19 +106,18 @@ export function addVideoFile(courseId, file) { file, edxVideoId, ); - if (isEmpty(errors)) { - const { videos } = await fetchVideoList(courseId); - const parsedVideos = updateFileValues(videos); - dispatch(updateModels({ - modelType: 'videos', - models: parsedVideos, - })); - dispatch(addVideoSuccess({ - videoId: '123id', - })); - dispatch(setTotalCount({ totalCount: parsedVideos.length })); - dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.SUCCESSFUL })); - } else { + 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 })); }); @@ -154,7 +154,7 @@ export function addVideoThumbnail({ file, videoId, courseId }) { })); dispatch(updateEditStatus({ editType: 'thumbnail', status: RequestStatus.SUCCESSFUL })); } catch (error) { - if (error.response.data.error) { + if (error.response?.data?.error) { const message = error.response.data.error; dispatch(updateErrors({ error: 'thumbnail', message })); } else { @@ -207,7 +207,7 @@ export function downloadVideoTranscript({ dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.IN_PROGRESS })); try { - await downloadTranscriipt({ + await downloadTranscript({ videoId, language, apiUrl, @@ -260,7 +260,7 @@ export function uploadVideoTranscript({ dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.SUCCESSFUL })); } catch (error) { if (error.response) { - const message = 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}.`; @@ -326,8 +326,8 @@ export function updateTranscriptCredentials({ courseId, data }) { dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.IN_PROGRESS })); try { - const credentials = await setTranscriptCredentials(courseId, data); - dispatch(updateTranscriptCredentialsSuccess(credentials)); + 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.` })); diff --git a/src/files-and-videos/videos/data/utils.js b/src/files-and-videos/videos/data/utils.js index 84abab4bed..3f6886e6c9 100644 --- a/src/files-and-videos/videos/data/utils.js +++ b/src/files-and-videos/videos/data/utils.js @@ -25,7 +25,7 @@ export const updateFileValues = (files) => { const wrapperType = 'video'; let thumbnail = courseVideoImageUrl; - if (thumbnail.startsWith('/')) { + if (thumbnail && thumbnail.startsWith('/')) { thumbnail = `${getConfig().STUDIO_BASE_URL}${thumbnail}`; } @@ -231,3 +231,40 @@ export const getFidelityOptions = (fidelities) => { }); return options; }; + +export const checkCredentials = (transcriptCredentials) => { + const cieloHasCredentials = transcriptCredentials?.cielo24; + const threePlayHasCredentials = transcriptCredentials?.['3PlayMedia']; + return [cieloHasCredentials, threePlayHasCredentials]; +}; + +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; +}; diff --git a/src/files-and-videos/videos/factories/mockApiResponses.jsx b/src/files-and-videos/videos/factories/mockApiResponses.jsx index da9a31125e..535b8228b5 100644 --- a/src/files-and-videos/videos/factories/mockApiResponses.jsx +++ b/src/files-and-videos/videos/factories/mockApiResponses.jsx @@ -1,6 +1,6 @@ import { RequestStatus } from '../../../data/constants'; -export const courseId = 'course-v1:edX+DemoX+Demo_Course'; +export const courseId = 'course'; export const initialState = { courseDetail: { @@ -11,15 +11,47 @@ export const initialState = { videoIds: ['mOckID0'], pageSettings: { transcriptAvailableLanguages: [ - { languageCode: "ar", languageText: "Arabic" }, - { languageCode: "en", languageText: "English" }, - { languageCode: "fr", languageText: "French" }, + { languageCode: 'ar', languageText: 'Arabic' }, + { languageCode: 'en', languageText: 'English' }, + { languageCode: 'fr', languageText: 'French' }, ], + activeTranscriptPreferences: null, videoTranscriptSettings: { - transcriptDownloadHandlerUrl: '/transcript_download/', - transcriptUploadHandlerUrl: "/transcript_upload/", - transcriptDeleteHandlerUrl: `/transcript_delete/${courseId}`, + 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: '', @@ -58,137 +90,102 @@ export const initialState = { }; export const generateFetchVideosApiResponse = () => ({ - "image_upload_url": "/video_images/course-v1:krisEdx+ka101+2023-01", - "video_handler_url": "/videos/course-v1:krisEdx+ka101+2023-01", - "encodings_download_url": "/video_encodings_download/course-v1:krisEdx+ka101+2023-01", - "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', + 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', + }, + { + edx_video_id: 'mOckID5', + clientVideoId: 'mOckID5.mp4', + created: '', + courseVideoImageUrl: 'http:/video', + transcripts: ['en'], + status: 'Failed', + }, + { + edx_video_id: 'mOckID3', + clientVideoId: 'mOckID3.mp4', + created: '', + courseVideoImageUrl: null, + transcripts: ['en'], + status: 'Ready', + }, + ], + 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)' }, + }, }, - { - edx_video_id: 'mOckID5', - clientVideoId: 'mOckID5.mp4', - created: '', - courseVideoImageUrl: 'http:/video', - transcripts: ['en'], - status: 'Failed', + }, + '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)', }, - { - edx_video_id: 'mOckID3', - clientVideoId: 'mOckID3.mp4', - created: '', - courseVideoImageUrl: null, - transcripts: ['en'], - status: 'Ready', + languages: { en: 'English', el: 'Greek', zh: 'Chinese' }, + translations: { + es: ['en'], + en: ['el', 'en', 'zh'], }, - ], - "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-v1:krisEdx+ka101+2023-01", - "trancript_download_file_format": "srt", - "transcript_preferences_handler_url": "/transcript_preferences/course-v1:krisEdx+ka101+2023-01", - "transcript_credentials_handler_url": "/transcript_credentials/course-v1:krisEdx+ka101+2023-01", - "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", - "vi": "Vietnamese", - }, - "translations": { - "es": [ - "en" - ], - "en": [ - "el", - "en", - "zh", - "vi", - ] - } - } - } }, - "pagination_context": {} - } -); + }, + }, + pagination_context: {}, +}); + export const generateAddVideoApiResponse = () => ({ videos: [ { diff --git a/src/files-and-videos/videos/transcript-settings/Cielo24Form.jsx b/src/files-and-videos/videos/transcript-settings/Cielo24Form.jsx index cf96a68454..99fb373a38 100644 --- a/src/files-and-videos/videos/transcript-settings/Cielo24Form.jsx +++ b/src/files-and-videos/videos/transcript-settings/Cielo24Form.jsx @@ -19,8 +19,8 @@ const Cielo24Form = ({ const selectedLanguage = data.preferredLanguages ? data.preferredLanguages : ''; const turnaroundOptions = transcriptionPlan.turnaround; const fidelityOptions = getFidelityOptions(fidelity); - const sourceLanguageOptions = data.cieloFidelity ? fidelity[data.cieloFidelity]?.languages : {}; - const languages = data.cieloFidelity === 'PROFESSIONAL' ? sourceLanguageOptions : { + const sourceLanguageOptions = data.cielo24Fidelity ? fidelity[data.cielo24Fidelity]?.languages : {}; + const languages = data.cielo24Fidelity === 'PROFESSIONAL' ? sourceLanguageOptions : { [data.videoSourceLanguage]: sourceLanguageOptions[data.videoSourceLanguage], }; @@ -29,13 +29,13 @@ const Cielo24Form = ({ - + setData({ ...data, cieloTurnaround: value })} - placeholderText={intl.formatMessage(messages.cieloTranscriptLanguagePlaceholder)} + handleSelect={(value) => setData({ ...data, cielo24Turnaround: value })} + placeholderText={intl.formatMessage(messages.cieloTurnaroundPlaceholder)} /> @@ -43,14 +43,14 @@ const Cielo24Form = ({ setData({ ...data, cieloFidelity: value, videoSourceLanguage: '' })} + handleSelect={(value) => setData({ ...data, cielo24Fidelity: value, videoSourceLanguage: '' })} placeholderText={intl.formatMessage(messages.cieloFidelityPlaceholder)} /> - {isEmpty(data.cieloFidelity) ? null : ( + {isEmpty(data.cielo24Fidelity) ? null : ( @@ -58,7 +58,7 @@ const Cielo24Form = ({ setData({ ...data, videoSourceLanguage: value, preferredLanguages: '' })} + handleSelect={(value) => setData({ ...data, videoSourceLanguage: value, preferredLanguages: [] })} placeholderText={intl.formatMessage(messages.cieloSourceLanguagePlaceholder)} /> @@ -85,7 +85,7 @@ const Cielo24Form = ({ return ( -
+
@@ -109,8 +109,8 @@ Cielo24Form.propTypes = { data: PropTypes.shape({ apiKey: PropTypes.string, apiSecretKey: PropTypes.string, - cieloTurnaround: PropTypes.string, - cieloFidelity: PropTypes.string, + cielo24Turnaround: PropTypes.string, + cielo24Fidelity: PropTypes.string, preferredLanguages: PropTypes.arrayOf(PropTypes.string), videoSourceLanguage: PropTypes.string, }).isRequired, diff --git a/src/files-and-videos/videos/transcript-settings/FormDropdown.jsx b/src/files-and-videos/videos/transcript-settings/FormDropdown.jsx index b537a3596b..1d92d57448 100644 --- a/src/files-and-videos/videos/transcript-settings/FormDropdown.jsx +++ b/src/files-and-videos/videos/transcript-settings/FormDropdown.jsx @@ -35,7 +35,7 @@ const FormDropdown = ({ {Object.entries(options).map(([valueKey, text]) => { if (allowMultiple) { return ( - handleSelect([valueKey, e.target.checked])} key={`${valueKey}-item`}> + handleSelect([valueKey, e.target.checked])} key={`${valueKey}-item`}> {text} ); @@ -59,7 +59,7 @@ const FormDropdown = ({ }; FormDropdown.propTypes = { - value: PropTypes.oneOf([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]).isRequired, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]).isRequired, allowMultiple: PropTypes.bool, options: PropTypes.shape({}).isRequired, handleSelect: PropTypes.func.isRequired, diff --git a/src/files-and-videos/videos/transcript-settings/OrderTranscriptForm.jsx b/src/files-and-videos/videos/transcript-settings/OrderTranscriptForm.jsx index 1136a65aaf..cf54403bf2 100644 --- a/src/files-and-videos/videos/transcript-settings/OrderTranscriptForm.jsx +++ b/src/files-and-videos/videos/transcript-settings/OrderTranscriptForm.jsx @@ -1,6 +1,5 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; -import { isEmpty } from 'lodash'; import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Button, SelectableBox, Stack } from '@edx/paragon'; import { ErrorAlert } from '@edx/frontend-lib-content-components'; @@ -8,6 +7,7 @@ import Cielo24Form from './Cielo24Form'; import ThreePlayMediaForm from './ThreePlayMediaForm'; import { RequestStatus } from '../../../data/constants'; import messages from './messages'; +import { checkCredentials, validateForm } from '../data/utils'; const OrderTranscriptForm = ({ setTranscriptType, @@ -22,20 +22,32 @@ const OrderTranscriptForm = ({ // injected intl, }) => { - const [data, setData] = useState({}); - const hasTranscriptCredentials = !isEmpty(transcriptCredentials); + const [data, setData] = useState(activeTranscriptPreferences || { videoSourceLanguage: '' }); + + 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 = ( { setTranscriptType(e.target.value); - setData({ - videoSourceLanguage: '', - }); }} > {form} - @@ -98,7 +99,7 @@ const VideoThumbnail = ({ }; VideoThumbnail.propTypes = { - thumbnail: PropTypes.string.isRequired, + thumbnail: PropTypes.string, displayName: PropTypes.string.isRequired, id: PropTypes.string.isRequired, imageSize: PropTypes.shape({ @@ -115,4 +116,8 @@ VideoThumbnail.propTypes = { intl: intlShape.isRequired, }; +VideoThumbnail.defaultProps = { + thumbnail: null, +}; + export default injectIntl(VideoThumbnail); diff --git a/src/files-and-videos/videos/Videos.jsx b/src/files-and-videos/videos/Videos.jsx index 9f6d2a31ac..315afbe255 100644 --- a/src/files-and-videos/videos/Videos.jsx +++ b/src/files-and-videos/videos/Videos.jsx @@ -1,7 +1,5 @@ -/* eslint-disable no-console */ import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; -import { isEmpty } from 'lodash'; import { useDispatch, useSelector } from 'react-redux'; import { injectIntl, @@ -20,14 +18,11 @@ import { useModels, useModel } from '../../generic/model-store'; import { addVideoFile, addVideoThumbnail, - clearAutomatedTranscript, deleteVideoFile, fetchVideoDownload, fetchVideos, getUsagePaths, resetErrors, - updateTranscriptCredentials, - updateTranscriptPreference, } from './data/thunks'; import messages from './messages'; import VideosProvider from './VideosProvider'; @@ -71,7 +66,6 @@ const Videos = ({ const { isVideoTranscriptEnabled, - transcriptCredentials, encodingsDownloadUrl, videoUploadMaxFileSize, videoSupportedFileFormats, @@ -79,22 +73,12 @@ const Videos = ({ } = 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, courseId })); const handleUsagePaths = (video) => dispatch(getUsagePaths({ video, courseId })); const handleErrorReset = (error) => dispatch(resetErrors(error)); - const handleOrderTranscripts = (data, provider) => { - handleErrorReset({ errorType: 'transcript' }); - if (provider === 'order') { - dispatch(clearAutomatedTranscript({ courseId })); - } else if (isEmpty(transcriptCredentials)) { - dispatch(updateTranscriptCredentials({ courseId, data: { ...data, provider, global: false } })); - } else { - dispatch(updateTranscriptPreference({ courseId, data: { ...data, provider, global: false } })); - } - }; const handleAddThumbnail = (file, videoId) => resampleFile({ file, @@ -122,7 +106,7 @@ const Videos = ({ Header: 'Transcript', Cell: ({ row }) => { const { transcripts } = row.original; - const numOfTranscripts = transcripts.length; + const numOfTranscripts = transcripts?.length; return numOfTranscripts > 0 ? `(${numOfTranscripts}) available` : null; }, }; @@ -202,9 +186,10 @@ const Videos = ({ {...{ isTranscriptSettngsOpen, closeTranscriptSettings, - handleOrderTranscripts, + handleErrorReset, errorMessages, transcriptStatus, + courseId, }} /> ) : null} diff --git a/src/files-and-videos/videos/Videos.test.jsx b/src/files-and-videos/videos/Videos.test.jsx new file mode 100644 index 0000000000..880f1af8f2 --- /dev/null +++ b/src/files-and-videos/videos/Videos.test.jsx @@ -0,0 +1,615 @@ +import { + render, + act, + fireEvent, + screen, + waitFor, + within, +} 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 Videos from './Videos'; +import { + generateFetchVideosApiResponse, + generateEmptyApiResponse, + generateNewVideoApiResponse, + generateAddVideoApiResponse, + getStatusValue, + courseId, + initialState, +} from './factories/mockApiResponses'; + +import { + fetchVideos, + addVideoFile, + deleteVideoFile, + getUsagePaths, + addVideoThumbnail, +} from './data/thunks'; +import { getVideosUrl, getCoursVideosApiUrl, getApiBaseUrl } from './data/api'; +import messages from '../messages'; + +let axiosMock; +let store; +let file; +ReactDOM.createPortal = jest.fn(node => node); +jest.mock('file-saver'); + +const renderComponent = () => { + render( + + + + + , + ); +}; + +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 have Video uploads title', async () => { + renderComponent(); + await emptyMockStore(RequestStatus.SUCCESSFUL); + expect(screen.getByText('Video uploads')).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 assets', () => { + 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 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'); + + axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1`).reply(200, { download_link: 'http://download.org' }); + + 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 asset file 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[1]); + 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'); + + axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1`).reply(404); + await waitFor(() => { + fireEvent.click(downloadButton); + }); + + const updateStatus = store.getState().videos.updatingStatus; + expect(updateStatus).toEqual(RequestStatus.FAILED); + + expect(screen.getByText('Error')).toBeVisible(); + }); + }); + }); +}); From 382a811cc1c3e901843d2f6fd75fb4ab058e59b7 Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Tue, 24 Oct 2023 10:40:38 -0400 Subject: [PATCH 25/46] fix: broken files-and-uploads test --- src/files-and-videos/assets/messages.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/files-and-videos/assets/messages.js b/src/files-and-videos/assets/messages.js index 4696d68be7..ccc1273dc9 100644 --- a/src/files-and-videos/assets/messages.js +++ b/src/files-and-videos/assets/messages.js @@ -3,7 +3,7 @@ import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages({ heading: { id: 'course-authoring.files-and-uploads.heading', - defaultMessage: 'Files and uploads', + defaultMessage: 'Files', }, thumbnailAltMessage: { id: 'course-authoring.files-and-uploads.thumbnail.alt', From 45e4a5493dc9b91d29269157c0f9d3789b1fd69c Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Tue, 24 Oct 2023 16:44:03 -0400 Subject: [PATCH 26/46] chore: increase code coverage --- src/files-and-videos/data/utils.js | 3 -- src/files-and-videos/videos/Videos.jsx | 2 +- src/files-and-videos/videos/Videos.test.jsx | 22 ++++++++- .../videos/factories/mockApiResponses.jsx | 1 + .../info-sidebar/TranscriptTab.test.jsx | 16 ++++++ .../transcript-item/LanguageSelect.jsx | 2 +- .../transcript-item/Transcript.jsx | 1 + .../transcript-settings/FormDropdown.jsx | 2 +- .../TranscriptSettings.test.jsx | 49 ++++++++++++++++++- 9 files changed, 90 insertions(+), 8 deletions(-) diff --git a/src/files-and-videos/data/utils.js b/src/files-and-videos/data/utils.js index 67ff620a51..9729065df3 100644 --- a/src/files-and-videos/data/utils.js +++ b/src/files-and-videos/data/utils.js @@ -2,7 +2,6 @@ import { InsertDriveFile, Terminal, AudioFile, - VideoFile, } from '@edx/paragon/icons'; import { ensureConfig, getConfig } from '@edx/frontend-platform'; import FILES_AND_UPLOAD_TYPE_FILTERS from './constant'; @@ -51,8 +50,6 @@ export const getSrc = ({ thumbnail, wrapperType, externalUrl }) => { return Terminal; case 'audio': return AudioFile; - case 'video': - return VideoFile; default: return InsertDriveFile; } diff --git a/src/files-and-videos/videos/Videos.jsx b/src/files-and-videos/videos/Videos.jsx index 315afbe255..3ac45dde2c 100644 --- a/src/files-and-videos/videos/Videos.jsx +++ b/src/files-and-videos/videos/Videos.jsx @@ -173,7 +173,7 @@ const Videos = ({ size="sm" onClick={() => { openTranscriptSettings(); - handleErrorReset({ errorType: 'transcripts' }); + handleErrorReset({ errorType: 'transcript' }); }} > diff --git a/src/files-and-videos/videos/Videos.test.jsx b/src/files-and-videos/videos/Videos.test.jsx index 880f1af8f2..d2fb866d61 100644 --- a/src/files-and-videos/videos/Videos.test.jsx +++ b/src/files-and-videos/videos/Videos.test.jsx @@ -37,6 +37,7 @@ import { addVideoThumbnail, } from './data/thunks'; import { getVideosUrl, getCoursVideosApiUrl, getApiBaseUrl } from './data/api'; +import videoMessages from './messages'; import messages from '../messages'; let axiosMock; @@ -98,10 +99,16 @@ describe('FilesAndUploads', () => { 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('Video uploads')).toBeVisible(); + expect(screen.getByText(videoMessages.heading.defaultMessage)).toBeVisible(); }); it('should render dropzone', async () => { @@ -155,6 +162,19 @@ describe('FilesAndUploads', () => { }); 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); diff --git a/src/files-and-videos/videos/factories/mockApiResponses.jsx b/src/files-and-videos/videos/factories/mockApiResponses.jsx index 535b8228b5..f46d11c661 100644 --- a/src/files-and-videos/videos/factories/mockApiResponses.jsx +++ b/src/files-and-videos/videos/factories/mockApiResponses.jsx @@ -15,6 +15,7 @@ export const initialState = { { languageCode: 'en', languageText: 'English' }, { languageCode: 'fr', languageText: 'French' }, ], + isVideoTranscriptEnabled: false, activeTranscriptPreferences: null, videoTranscriptSettings: { transcriptDownloadHandlerUrl: '/transcript_download/', diff --git a/src/files-and-videos/videos/info-sidebar/TranscriptTab.test.jsx b/src/files-and-videos/videos/info-sidebar/TranscriptTab.test.jsx index 5a25bd166b..03649f7398 100644 --- a/src/files-and-videos/videos/info-sidebar/TranscriptTab.test.jsx +++ b/src/files-and-videos/videos/info-sidebar/TranscriptTab.test.jsx @@ -86,6 +86,22 @@ describe('TranscriptTab', () => { expect(transcriptRow).toBeNull(); }); + it('should delete empty transcrip 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(); + }); + it('should upload new transcript', async () => { renderComponent(defaultProps); const addButton = screen.getByText(messages.uploadButtonLabel.defaultMessage); diff --git a/src/files-and-videos/videos/info-sidebar/transcript-item/LanguageSelect.jsx b/src/files-and-videos/videos/info-sidebar/transcript-item/LanguageSelect.jsx index ff6b2848ab..12ed61c6b7 100644 --- a/src/files-and-videos/videos/info-sidebar/transcript-item/LanguageSelect.jsx +++ b/src/files-and-videos/videos/info-sidebar/transcript-item/LanguageSelect.jsx @@ -27,7 +27,7 @@ const LanguageSelect = ({ {Object.entries(options).map(([valueKey, text]) => { if (valueKey === value) { return ( - handleSelect(valueKey)} key={`${valueKey}-item`}> + {text} ); diff --git a/src/files-and-videos/videos/info-sidebar/transcript-item/Transcript.jsx b/src/files-and-videos/videos/info-sidebar/transcript-item/Transcript.jsx index b1640a5379..c0e194bc1e 100644 --- a/src/files-and-videos/videos/info-sidebar/transcript-item/Transcript.jsx +++ b/src/files-and-videos/videos/info-sidebar/transcript-item/Transcript.jsx @@ -92,6 +92,7 @@ const Transcript = ({ iconAs={Icon} src={DeleteOutline} onClick={openConfirmation} + alt="delete empty transcript" /> ) : ( handleSelect(valueKey)} key={`${valueKey}-item`}> + {text} ); diff --git a/src/files-and-videos/videos/transcript-settings/TranscriptSettings.test.jsx b/src/files-and-videos/videos/transcript-settings/TranscriptSettings.test.jsx index 7d73d43a73..f7b95c0cc3 100644 --- a/src/files-and-videos/videos/transcript-settings/TranscriptSettings.test.jsx +++ b/src/files-and-videos/videos/transcript-settings/TranscriptSettings.test.jsx @@ -308,7 +308,7 @@ describe('TranscriptSettings', () => { expect(screen.getByText(messages.cieloFidelityLabel.defaultMessage)).toBeVisible(); }); - it('should handle 3Play Media credential update', async () => { + it('should handle 3Play Media credential update with english as source language', async () => { const apiResponse = { videoSourceLanguage: 'en', threePlayTurnaround: 'two_hour', @@ -343,6 +343,7 @@ describe('TranscriptSettings', () => { userEvent.click(language); userEvent.click(screen.getByText('Arabic')); userEvent.click(screen.getByText('French')); + userEvent.click(screen.getAllByText('Arabic')[0]); expect(updateButton).not.toHaveAttribute('disabled'); }); @@ -355,5 +356,51 @@ describe('TranscriptSettings', () => { expect(transcriptStatus).toEqual(RequestStatus.SUCCESSFUL); }); + + it('should handle 3Play Media credential update with english as source language', async () => { + const apiResponse = { + videoSourceLanguage: 'en', + threePlayTurnaround: 'two_hour', + preferredLanguages: ['ar', 'fr'], + provider: '3PlayMedia', + global: false, + }; + + renderComponent(defaultProps); + const orderButton = screen.getByText(messages.orderTranscriptsTitle.defaultMessage); + await act(async () => { + userEvent.click(orderButton); + }); + const threePlayButton = screen.getAllByLabelText('3PlayMedia radio')[0]; + await act(async () => { + userEvent.click(threePlayButton); + }); + const updateButton = screen.getByText(messages.updateSettingsLabel.defaultMessage); + + expect(updateButton).toHaveAttribute('disabled'); + + 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); + }); }); }); From 762b20073f951aba92995638ca6ec0a1e727f729 Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Wed, 25 Oct 2023 11:56:30 -0400 Subject: [PATCH 27/46] fix: videos not sorting --- src/files-and-videos/FileTable.jsx | 12 +++---- .../assets/FilesAndUploads.jsx | 5 +++ src/files-and-videos/videos/Videos.jsx | 6 +++- src/files-and-videos/videos/data/thunks.js | 2 +- src/files-and-videos/videos/data/utils.js | 33 ------------------- 5 files changed, 15 insertions(+), 43 deletions(-) diff --git a/src/files-and-videos/FileTable.jsx b/src/files-and-videos/FileTable.jsx index d05854edca..0312c41245 100644 --- a/src/files-and-videos/FileTable.jsx +++ b/src/files-and-videos/FileTable.jsx @@ -1,6 +1,5 @@ import React, { useCallback, useEffect, useState } from 'react'; import PropTypes from 'prop-types'; -import { useDispatch } from 'react-redux'; import isEmpty from 'lodash/isEmpty'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { @@ -15,9 +14,6 @@ import { } from '@edx/paragon'; import { RequestStatus } from '../data/constants'; -import { - updateAssetOrder, -} from './data/thunks'; import { sortFiles } from './data/utils'; import messages from './messages'; @@ -31,7 +27,6 @@ import ApiStatusToast from './ApiStatusToast'; import MoreInfoColumn from './table-components/table-custom-columns/MoreInfoColumn'; const FileTable = ({ - courseId, files, data, handleAddFile, @@ -40,13 +35,13 @@ const FileTable = ({ handleDownloadFile, handleUsagePaths, handleErrorReset, + handleFileOrder, tableColumns, maxFileSize, thumbnailPreview, // injected intl, }) => { - const dispatch = useDispatch(); const defaultVal = 'card'; const columnSizes = { xs: 12, @@ -96,8 +91,8 @@ const FileTable = ({ }; const handleSort = (sortType) => { - const newAssetIdOrder = sortFiles(files, sortType); - dispatch(updateAssetOrder(courseId, newAssetIdOrder, sortType)); + const newFileIdOrder = sortFiles(files, sortType); + handleFileOrder({ newFileIdOrder, sortType }); }; const handleBulkDelete = () => { @@ -288,6 +283,7 @@ FileTable.propTypes = { 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, diff --git a/src/files-and-videos/assets/FilesAndUploads.jsx b/src/files-and-videos/assets/FilesAndUploads.jsx index c9bd10cb0e..c901b409e3 100644 --- a/src/files-and-videos/assets/FilesAndUploads.jsx +++ b/src/files-and-videos/assets/FilesAndUploads.jsx @@ -15,6 +15,7 @@ import { fetchAssetDownload, getUsagePaths, resetErrors, + updateAssetOrder, } from '../data/thunks'; import messages from './messages'; import FilesAndUploadsProvider from './FilesAndUploadsProvider'; @@ -57,6 +58,9 @@ const FilesAndUploads = ({ 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); @@ -160,6 +164,7 @@ const FilesAndUploads = ({ handleLockFile, handleUsagePaths, handleErrorReset, + handleFileOrder, tableColumns, maxFileSize, thumbnailPreview, diff --git a/src/files-and-videos/videos/Videos.jsx b/src/files-and-videos/videos/Videos.jsx index 3ac45dde2c..029455ee29 100644 --- a/src/files-and-videos/videos/Videos.jsx +++ b/src/files-and-videos/videos/Videos.jsx @@ -23,6 +23,7 @@ import { fetchVideos, getUsagePaths, resetErrors, + updateVideoOrder, } from './data/thunks'; import messages from './messages'; import VideosProvider from './VideosProvider'; @@ -79,7 +80,9 @@ const Videos = ({ const handleDownloadFile = (selectedRows) => dispatch(fetchVideoDownload({ selectedRows, courseId })); 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, @@ -202,6 +205,7 @@ const Videos = ({ handleDownloadFile, handleUsagePaths, handleErrorReset, + handleFileOrder, tableColumns, maxFileSize, thumbnailPreview, diff --git a/src/files-and-videos/videos/data/thunks.js b/src/files-and-videos/videos/data/thunks.js index 072c929cc1..73e94d0d32 100644 --- a/src/files-and-videos/videos/data/thunks.js +++ b/src/files-and-videos/videos/data/thunks.js @@ -67,7 +67,7 @@ export function resetErrors({ errorType }) { return (dispatch) => { dispatch(clearErrors({ error: errorType })); }; } -export function updateAssetOrder(courseId, videoIds) { +export function updateVideoOrder(courseId, videoIds) { return async (dispatch) => { dispatch(updateLoadingStatus({ courseId, status: RequestStatus.IN_PROGRESS })); dispatch(setVideoIds({ videoIds })); diff --git a/src/files-and-videos/videos/data/utils.js b/src/files-and-videos/videos/data/utils.js index 3f6886e6c9..3a8a8a01ca 100644 --- a/src/files-and-videos/videos/data/utils.js +++ b/src/files-and-videos/videos/data/utils.js @@ -64,39 +64,6 @@ export const getLanguages = (availableLanguages) => { return languages; }; -export const sortFiles = (files, sortType) => { - const [sort, direction] = sortType.split(','); - let sortedFiles; - if (sort === 'displayName') { - sortedFiles = files.sort((f1, f2) => { - const lowerCaseF1 = f1[sort].toLowerCase(); - const lowerCaseF2 = f2[sort].toLowerCase(); - if (lowerCaseF1 < lowerCaseF2) { - return 1; - } - if (lowerCaseF1 > lowerCaseF2) { - return -1; - } - return 0; - }); - } else { - sortedFiles = files.sort((f1, f2) => { - if (f1[sort] < f2[sort]) { - return 1; - } - if (f1[sort] > f2[sort]) { - return -1; - } - return 0; - }); - } - const sortedIds = sortedFiles.map(file => file.id); - if (direction === 'asc') { - return sortedIds.reverse(); - } - return sortedIds; -}; - export const getSupportedFormats = (supportedFileFormats) => { if (isEmpty(supportedFileFormats)) { return null; From f6b7ce4658d18766565c156fb0da055d4cf9138a Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Wed, 25 Oct 2023 12:35:32 -0400 Subject: [PATCH 28/46] chore: increase code coverage --- src/files-and-videos/FileMenu.jsx | 2 +- .../table-custom-columns/MoreInfoColumn.jsx | 6 ++- .../videos/factories/mockApiResponses.jsx | 4 ++ .../TranscriptSettings.test.jsx | 46 +++++++++++++++++++ 4 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/files-and-videos/FileMenu.jsx b/src/files-and-videos/FileMenu.jsx index cf1d61d172..8a84511cdd 100644 --- a/src/files-and-videos/FileMenu.jsx +++ b/src/files-and-videos/FileMenu.jsx @@ -88,7 +88,7 @@ FileMenu.propTypes = { FileMenu.defaultProps = { externalUrl: null, - handleLock: () => {}, + handleLock: null, locked: null, portableUrl: null, }; diff --git a/src/files-and-videos/table-components/table-custom-columns/MoreInfoColumn.jsx b/src/files-and-videos/table-components/table-custom-columns/MoreInfoColumn.jsx index e125bd8410..0dfd12880e 100644 --- a/src/files-and-videos/table-components/table-custom-columns/MoreInfoColumn.jsx +++ b/src/files-and-videos/table-components/table-custom-columns/MoreInfoColumn.jsx @@ -150,7 +150,7 @@ MoreInfoColumn.propTypes = { wrapperType: PropTypes.string, }.isRequired, }).isRequired, - handleLock: PropTypes.func.isRequired, + handleLock: PropTypes.func, handleBulkDownload: PropTypes.func.isRequired, handleOpenFileInfo: PropTypes.func.isRequired, handleOpenDeleteConfirmation: PropTypes.func.isRequired, @@ -158,4 +158,8 @@ MoreInfoColumn.propTypes = { intl: intlShape.isRequired, }; +MoreInfoColumn.defaultProps = { + handleLock: null, +}; + export default injectIntl(MoreInfoColumn); diff --git a/src/files-and-videos/videos/factories/mockApiResponses.jsx b/src/files-and-videos/videos/factories/mockApiResponses.jsx index f46d11c661..bfb595f408 100644 --- a/src/files-and-videos/videos/factories/mockApiResponses.jsx +++ b/src/files-and-videos/videos/factories/mockApiResponses.jsx @@ -103,6 +103,7 @@ export const generateFetchVideosApiResponse = () => ({ courseVideoImageUrl: '/video', transcripts: [], status: 'Imported', + duration: 12333, }, { edx_video_id: 'mOckID5', @@ -111,6 +112,7 @@ export const generateFetchVideosApiResponse = () => ({ courseVideoImageUrl: 'http:/video', transcripts: ['en'], status: 'Failed', + duration: 12, }, { edx_video_id: 'mOckID3', @@ -119,6 +121,7 @@ export const generateFetchVideosApiResponse = () => ({ courseVideoImageUrl: null, transcripts: ['en'], status: 'Ready', + duration: null, }, ], concurrent_upload_limit: 4, @@ -196,6 +199,7 @@ export const generateAddVideoApiResponse = () => ({ courseVideoImageUrl: null, transcripts: ['en'], status: 'Uploaded', + duration: 168.001, }, ], }); diff --git a/src/files-and-videos/videos/transcript-settings/TranscriptSettings.test.jsx b/src/files-and-videos/videos/transcript-settings/TranscriptSettings.test.jsx index f7b95c0cc3..750ec0c904 100644 --- a/src/files-and-videos/videos/transcript-settings/TranscriptSettings.test.jsx +++ b/src/files-and-videos/videos/transcript-settings/TranscriptSettings.test.jsx @@ -80,6 +80,52 @@ describe('TranscriptSettings', () => { expect(selectableButtons).toBeVisible(); }); + it('should delete transcript preferences', async () => { + 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 updateButton = screen.getByText(messages.updateSettingsLabel.defaultMessage); + + expect(updateButton).toHaveAttribute('disabled'); + + const noneButton = screen.getAllByLabelText('none radio')[0]; + await act(async () => { + userEvent.click(noneButton); + }); + + 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 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); From 97f1c7edcaa0d5c72d0f21f7c81cb603315e404a Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Wed, 25 Oct 2023 13:52:40 -0400 Subject: [PATCH 29/46] fix: broken disabled thumbnail upload --- .../videos/VideoThumbnail.jsx | 52 +++++++++++-------- .../videos/factories/mockApiResponses.jsx | 15 ++++++ 2 files changed, 44 insertions(+), 23 deletions(-) diff --git a/src/files-and-videos/videos/VideoThumbnail.jsx b/src/files-and-videos/videos/VideoThumbnail.jsx index b6ad3bb37e..33f3e9ea88 100644 --- a/src/files-and-videos/videos/VideoThumbnail.jsx +++ b/src/files-and-videos/videos/VideoThumbnail.jsx @@ -27,13 +27,12 @@ const VideoThumbnail = ({ setSelectedRows: () => {}, setAddOpen: () => false, }); + const allowThumbnailUpload = videoImageSettings?.videoImageUploadEnabled - let addThumbnailMessage = 'Enable thumbnail upload'; - if (videoImageSettings?.videoImageUploadEnabled) { + let addThumbnailMessage = 'Add thumbnail'; + if (allowThumbnailUpload) { if (thumbnail) { addThumbnailMessage = 'Edit thumbnail'; - } else { - addThumbnailMessage = 'Add thumbnail'; } } const supportedFiles = videoImageSettings?.supportedFileFormats @@ -49,11 +48,11 @@ const VideoThumbnail = ({ default: break; } - const showThumbnail = videoImageSettings?.videoImageUploadEnabled && thumbnail && isUploaded; + const showThumbnail = allowThumbnailUpload && thumbnail && isUploaded; return (
-
+ {allowThumbnailUpload &&
} {showThumbnail ? (
- + {!isUploaded && ( + {status} + )}
)} -
- -
- + {allowThumbnailUpload && ( + <> +
+ +
+ + + )} +
); }; diff --git a/src/files-and-videos/videos/factories/mockApiResponses.jsx b/src/files-and-videos/videos/factories/mockApiResponses.jsx index bfb595f408..282df94814 100644 --- a/src/files-and-videos/videos/factories/mockApiResponses.jsx +++ b/src/files-and-videos/videos/factories/mockApiResponses.jsx @@ -15,6 +15,21 @@ export const initialState = { { 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: { From be1a5b83b3b867e7767578d0c2bece82c12c5c11 Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Wed, 25 Oct 2023 15:32:00 -0400 Subject: [PATCH 30/46] fix: lint errors --- src/files-and-videos/videos/VideoThumbnail.jsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/files-and-videos/videos/VideoThumbnail.jsx b/src/files-and-videos/videos/VideoThumbnail.jsx index 33f3e9ea88..f06d850a01 100644 --- a/src/files-and-videos/videos/VideoThumbnail.jsx +++ b/src/files-and-videos/videos/VideoThumbnail.jsx @@ -27,7 +27,7 @@ const VideoThumbnail = ({ setSelectedRows: () => {}, setAddOpen: () => false, }); - const allowThumbnailUpload = videoImageSettings?.videoImageUploadEnabled + const allowThumbnailUpload = videoImageSettings?.videoImageUploadEnabled; let addThumbnailMessage = 'Add thumbnail'; if (allowThumbnailUpload) { @@ -73,8 +73,8 @@ const VideoThumbnail = ({
{!isUploaded && ( - {status} - + {status} + )}
@@ -99,7 +99,6 @@ const VideoThumbnail = ({ /> )} -
); }; From fa3f571f8a5aac8ae2737aa0f4ada66e68084ba5 Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Wed, 25 Oct 2023 19:19:20 -0400 Subject: [PATCH 31/46] feat: add missing video attributes --- .../table-components/GalleryCard.jsx | 12 +++++++++--- src/files-and-videos/videos/Videos.jsx | 2 +- src/files-and-videos/videos/data/api.js | 16 ++-------------- src/files-and-videos/videos/data/thunks.js | 4 ++-- src/files-and-videos/videos/data/utils.js | 1 - 5 files changed, 14 insertions(+), 21 deletions(-) diff --git a/src/files-and-videos/table-components/GalleryCard.jsx b/src/files-and-videos/table-components/GalleryCard.jsx index d7824ddc94..a5347ee07f 100644 --- a/src/files-and-videos/table-components/GalleryCard.jsx +++ b/src/files-and-videos/table-components/GalleryCard.jsx @@ -39,9 +39,14 @@ const GalleryCard = ({ portableUrl={original.portableUrl} id={original.id} wrapperType={original.wrapperType} - onDownload={() => handleBulkDownload( - [{ original: { id: original.id, displayName: original.displayName } }], - )} + onDownload={() => handleBulkDownload([{ + original: { + id: original.id, + displayName: + original.displayName, + downloadLink: original?.downloadLink, + }, + }])} openDeleteConfirmation={() => handleOpenDeleteConfirmation([{ original }])} /> @@ -91,6 +96,7 @@ GalleryCard.propTypes = { portableUrl: PropTypes.string, status: PropTypes.string, transcripts: PropTypes.arrayOf(PropTypes.string), + downloadLink: PropTypes.string, }).isRequired, handleBulkDownload: PropTypes.func.isRequired, handleLockedFile: PropTypes.func.isRequired, diff --git a/src/files-and-videos/videos/Videos.jsx b/src/files-and-videos/videos/Videos.jsx index 029455ee29..b8fbf0065b 100644 --- a/src/files-and-videos/videos/Videos.jsx +++ b/src/files-and-videos/videos/Videos.jsx @@ -77,7 +77,7 @@ const Videos = ({ const handleAddFile = (file) => dispatch(addVideoFile(courseId, file)); const handleDeleteFile = (id) => dispatch(deleteVideoFile(courseId, id, totalCount)); - const handleDownloadFile = (selectedRows) => dispatch(fetchVideoDownload({ selectedRows, courseId })); + const handleDownloadFile = (selectedRows) => dispatch(fetchVideoDownload({ selectedRows })); const handleUsagePaths = (video) => dispatch(getUsagePaths({ video, courseId })); const handleErrorReset = (error) => dispatch(resetErrors(error)); const handleFileOrder = ({ newFileIdOrder, sortType }) => { diff --git a/src/files-and-videos/videos/data/api.js b/src/files-and-videos/videos/data/api.js index 057480940c..ad64367871 100644 --- a/src/files-and-videos/videos/data/api.js +++ b/src/files-and-videos/videos/data/api.js @@ -74,26 +74,14 @@ export async function uploadTranscript({ await getAuthenticatedHttpClient().post(`${getApiBaseUrl()}${apiUrl}`, formData); } -export async function getDownloadLink(courseId, edxVideoId) { - const { data } = await getAuthenticatedHttpClient() - .get(`${getVideosUrl(courseId)}/${edxVideoId}`); - return camelCaseObject(data); -} - -/** - * Fetch video file. - * @param {blockId} courseId Course ID for the course to operate on - - */ -export async function getDownload(selectedRows, courseId) { +export async function getDownload(selectedRows) { const downloadErrors = []; if (selectedRows?.length > 0) { await Promise.allSettled( selectedRows.map(async row => { const video = row?.original; try { - const { downloadLink } = await getDownloadLink(courseId, video.id); - saveAs(downloadLink, video.displayName); + saveAs(video.downloadLink, video.displayName); } catch (error) { downloadErrors.push(`Failed to download ${video?.displayName}.`); } diff --git a/src/files-and-videos/videos/data/thunks.js b/src/files-and-videos/videos/data/thunks.js index 73e94d0d32..f25e701d1a 100644 --- a/src/files-and-videos/videos/data/thunks.js +++ b/src/files-and-videos/videos/data/thunks.js @@ -292,10 +292,10 @@ export function getUsagePaths({ video, courseId }) { }; } -export function fetchVideoDownload({ selectedRows, courseId }) { +export function fetchVideoDownload({ selectedRows }) { return async (dispatch) => { dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.IN_PROGRESS })); - const errors = await getDownload(selectedRows, courseId); + const errors = await getDownload(selectedRows); if (isEmpty(errors)) { dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.SUCCESSFUL })); } else { diff --git a/src/files-and-videos/videos/data/utils.js b/src/files-and-videos/videos/data/utils.js index 3a8a8a01ca..a1d63a1f8e 100644 --- a/src/files-and-videos/videos/data/utils.js +++ b/src/files-and-videos/videos/data/utils.js @@ -36,7 +36,6 @@ export const updateFileValues = (files) => { wrapperType, dateAdded: created.toString(), usageLocations: [], - fileSize: null, thumbnail, }); }); From 900288d6eb0255a4e61d9c3b3d2f9984b547a640 Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Thu, 26 Oct 2023 12:25:48 -0400 Subject: [PATCH 32/46] fix: failing tests --- src/files-and-videos/videos/Videos.test.jsx | 7 +-- src/files-and-videos/videos/data/api.js | 17 ++++-- src/files-and-videos/videos/data/api.test.js | 55 +++++++++++++++++++ .../videos/factories/mockApiResponses.jsx | 4 ++ 4 files changed, 75 insertions(+), 8 deletions(-) create mode 100644 src/files-and-videos/videos/data/api.test.js diff --git a/src/files-and-videos/videos/Videos.test.jsx b/src/files-and-videos/videos/Videos.test.jsx index d2fb866d61..69d66780e1 100644 --- a/src/files-and-videos/videos/Videos.test.jsx +++ b/src/files-and-videos/videos/Videos.test.jsx @@ -35,6 +35,7 @@ import { deleteVideoFile, getUsagePaths, addVideoThumbnail, + fetchVideoDownload, } from './data/thunks'; import { getVideosUrl, getCoursVideosApiUrl, getApiBaseUrl } from './data/api'; import videoMessages from './messages'; @@ -303,8 +304,6 @@ describe('FilesAndUploads', () => { const downloadButton = screen.getByText(messages.downloadTitle.defaultMessage).closest('a'); expect(downloadButton).not.toHaveClass('disabled'); - axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1`).reply(200, { download_link: 'http://download.org' }); - await act(async () => { fireEvent.click(downloadButton); }); @@ -610,7 +609,7 @@ describe('FilesAndUploads', () => { await mockStore(RequestStatus.SUCCESSFUL); const selectCardButtons = screen.getAllByTestId('datatable-select-column-checkbox-cell'); fireEvent.click(selectCardButtons[0]); - fireEvent.click(selectCardButtons[1]); + fireEvent.click(selectCardButtons[2]); const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage); expect(actionsButton).toBeVisible(); @@ -620,9 +619,9 @@ describe('FilesAndUploads', () => { const downloadButton = screen.getByText(messages.downloadTitle.defaultMessage).closest('a'); expect(downloadButton).not.toHaveClass('disabled'); - axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1`).reply(404); await waitFor(() => { fireEvent.click(downloadButton); + executeThunk(fetchVideoDownload([{ original: { displayName: 'mOckID1', id: '2' } }]), store.dispatch); }); const updateStatus = store.getState().videos.updatingStatus; diff --git a/src/files-and-videos/videos/data/api.js b/src/files-and-videos/videos/data/api.js index ad64367871..5a812d044a 100644 --- a/src/files-and-videos/videos/data/api.js +++ b/src/files-and-videos/videos/data/api.js @@ -3,6 +3,7 @@ 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', @@ -79,17 +80,25 @@ export async function getDownload(selectedRows) { if (selectedRows?.length > 0) { await Promise.allSettled( selectedRows.map(async row => { - const video = row?.original; try { - saveAs(video.downloadLink, video.displayName); + const video = row.original; + const { downloadLink } = video; + console.log(downloadLink); + 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?.displayName}.`); + downloadErrors.push(`Failed to download video.`); } }), ); } else { - downloadErrors.push('No files were selected to download'); + downloadErrors.push('No files were selected to download.'); } + console.log(downloadErrors); return downloadErrors; } diff --git a/src/files-and-videos/videos/data/api.test.js b/src/files-and-videos/videos/data/api.test.js new file mode 100644 index 0000000000..9f282d9116 --- /dev/null +++ b/src/files-and-videos/videos/data/api.test.js @@ -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); + }); + }); + }); +}); diff --git a/src/files-and-videos/videos/factories/mockApiResponses.jsx b/src/files-and-videos/videos/factories/mockApiResponses.jsx index 282df94814..8a5085b704 100644 --- a/src/files-and-videos/videos/factories/mockApiResponses.jsx +++ b/src/files-and-videos/videos/factories/mockApiResponses.jsx @@ -100,6 +100,7 @@ export const initialState = { courseVideoImageUrl: '/video', transcripts: [], status: 'Imported', + downloadLink: 'http://mOckID0.mp4', }, }, }, @@ -119,6 +120,7 @@ export const generateFetchVideosApiResponse = () => ({ transcripts: [], status: 'Imported', duration: 12333, + downloadLink: 'http://mOckID1.mp4', }, { edx_video_id: 'mOckID5', @@ -128,6 +130,7 @@ export const generateFetchVideosApiResponse = () => ({ transcripts: ['en'], status: 'Failed', duration: 12, + downloadLink: 'http://mOckID5.mp4', }, { edx_video_id: 'mOckID3', @@ -137,6 +140,7 @@ export const generateFetchVideosApiResponse = () => ({ transcripts: ['en'], status: 'Ready', duration: null, + downloadLink: '', }, ], concurrent_upload_limit: 4, From 30368cae6bf00d44bf405884a1a63b0ce442f09e Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Thu, 26 Oct 2023 12:31:17 -0400 Subject: [PATCH 33/46] fiux: lint errors --- src/files-and-videos/videos/data/api.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/files-and-videos/videos/data/api.js b/src/files-and-videos/videos/data/api.js index 5a812d044a..16fdc73948 100644 --- a/src/files-and-videos/videos/data/api.js +++ b/src/files-and-videos/videos/data/api.js @@ -83,22 +83,19 @@ export async function getDownload(selectedRows) { try { const video = row.original; const { downloadLink } = video; - console.log(downloadLink); if (!isEmpty(downloadLink)) { saveAs(downloadLink, video.displayName); - } - else { + } else { downloadErrors.push(`Cannot find download file for ${video?.displayName}.`); } } catch (error) { - downloadErrors.push(`Failed to download video.`); + downloadErrors.push('Failed to download video.'); } }), ); } else { downloadErrors.push('No files were selected to download.'); } - console.log(downloadErrors); return downloadErrors; } From 12aff008b7933308aad6272da319559bcbee49e0 Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Thu, 26 Oct 2023 12:32:56 -0400 Subject: [PATCH 34/46] fix: lint error --- src/files-and-videos/videos/data/api.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/files-and-videos/videos/data/api.test.js b/src/files-and-videos/videos/data/api.test.js index 9f282d9116..7604a7a08a 100644 --- a/src/files-and-videos/videos/data/api.test.js +++ b/src/files-and-videos/videos/data/api.test.js @@ -46,7 +46,7 @@ describe('api.js', () => { 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'} }, + { asset: { displayName: 'test1', id: '1', download_link: 'test1.com' } }, ]); expect(actual).toEqual(expected); }); From 68c2acb324a171f25f09988193a92e1d8734f80f Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Tue, 31 Oct 2023 16:50:52 -0400 Subject: [PATCH 35/46] chore: update files-and-videos subfolder names --- src/CourseAuthoringRoutes.jsx | 8 ++++---- src/files-and-videos/FileInfo.jsx | 4 ++-- src/files-and-videos/FileInput.jsx | 2 +- src/files-and-videos/data/utils.test.js | 5 +++++ .../{assets => files-page}/AssetThumbnail.jsx | 0 .../{assets => files-page}/FileInfoAssetSidebar.jsx | 0 .../{assets => files-page}/FilesAndUploads.jsx | 0 .../{assets => files-page}/FilesAndUploads.test.jsx | 0 .../{assets => files-page}/FilesAndUploadsProvider.jsx | 0 .../{assets => files-page}/factories/mockApiResponses.jsx | 0 src/files-and-videos/{assets => files-page}/index.js | 0 src/files-and-videos/{assets => files-page}/messages.js | 0 src/files-and-videos/index.js | 3 +++ .../{videos => videos-page}/VideoThumbnail.jsx | 8 ++++++-- .../{videos => videos-page}/VideoThumbnail.scss | 0 src/files-and-videos/{videos => videos-page}/Videos.jsx | 4 ++-- .../{videos => videos-page}/Videos.test.jsx | 1 - .../{videos => videos-page}/VideosProvider.jsx | 0 src/files-and-videos/{videos => videos-page}/data/api.js | 0 .../{videos => videos-page}/data/api.test.js | 0 .../{videos => videos-page}/data/constants.js | 0 .../{videos => videos-page}/data/slice.js | 0 .../{videos => videos-page}/data/thunks.js | 0 .../{videos => videos-page}/data/utils.js | 0 .../factories/mockApiResponses.jsx | 0 src/files-and-videos/{videos => videos-page}/index.js | 0 .../info-sidebar/FileInfoVideoSidebar.jsx | 0 .../{videos => videos-page}/info-sidebar/InfoTab.jsx | 0 .../info-sidebar/TranscriptTab.jsx | 0 .../info-sidebar/TranscriptTab.test.jsx | 0 .../{videos => videos-page}/info-sidebar/messages.js | 0 .../info-sidebar/transcript-item/LanguageSelect.jsx | 0 .../info-sidebar/transcript-item/Transcript.jsx | 0 .../info-sidebar/transcript-item/TranscriptMenu.jsx | 0 .../info-sidebar/transcript-item/index.js | 0 .../info-sidebar/transcript-item/messages.js | 0 src/files-and-videos/{videos => videos-page}/messages.js | 0 .../transcript-settings/Cielo24Form.jsx | 0 .../transcript-settings/FormDropdown.jsx | 0 .../transcript-settings/OrderTranscriptForm.jsx | 0 .../transcript-settings/ThreePlayMediaForm.jsx | 0 .../transcript-settings/TranscriptSettings.jsx | 6 +++--- .../transcript-settings/TranscriptSettings.test.jsx | 0 .../{videos => videos-page}/transcript-settings/index.js | 0 .../transcript-settings/messages.js | 0 src/index.scss | 2 +- src/store.js | 2 +- 47 files changed, 28 insertions(+), 17 deletions(-) rename src/files-and-videos/{assets => files-page}/AssetThumbnail.jsx (100%) rename src/files-and-videos/{assets => files-page}/FileInfoAssetSidebar.jsx (100%) rename src/files-and-videos/{assets => files-page}/FilesAndUploads.jsx (100%) rename src/files-and-videos/{assets => files-page}/FilesAndUploads.test.jsx (100%) rename src/files-and-videos/{assets => files-page}/FilesAndUploadsProvider.jsx (100%) rename src/files-and-videos/{assets => files-page}/factories/mockApiResponses.jsx (100%) rename src/files-and-videos/{assets => files-page}/index.js (100%) rename src/files-and-videos/{assets => files-page}/messages.js (100%) create mode 100644 src/files-and-videos/index.js rename src/files-and-videos/{videos => videos-page}/VideoThumbnail.jsx (94%) rename src/files-and-videos/{videos => videos-page}/VideoThumbnail.scss (100%) rename src/files-and-videos/{videos => videos-page}/Videos.jsx (97%) rename src/files-and-videos/{videos => videos-page}/Videos.test.jsx (99%) rename src/files-and-videos/{videos => videos-page}/VideosProvider.jsx (100%) rename src/files-and-videos/{videos => videos-page}/data/api.js (100%) rename src/files-and-videos/{videos => videos-page}/data/api.test.js (100%) rename src/files-and-videos/{videos => videos-page}/data/constants.js (100%) rename src/files-and-videos/{videos => videos-page}/data/slice.js (100%) rename src/files-and-videos/{videos => videos-page}/data/thunks.js (100%) rename src/files-and-videos/{videos => videos-page}/data/utils.js (100%) rename src/files-and-videos/{videos => videos-page}/factories/mockApiResponses.jsx (100%) rename src/files-and-videos/{videos => videos-page}/index.js (100%) rename src/files-and-videos/{videos => videos-page}/info-sidebar/FileInfoVideoSidebar.jsx (100%) rename src/files-and-videos/{videos => videos-page}/info-sidebar/InfoTab.jsx (100%) rename src/files-and-videos/{videos => videos-page}/info-sidebar/TranscriptTab.jsx (100%) rename src/files-and-videos/{videos => videos-page}/info-sidebar/TranscriptTab.test.jsx (100%) rename src/files-and-videos/{videos => videos-page}/info-sidebar/messages.js (100%) rename src/files-and-videos/{videos => videos-page}/info-sidebar/transcript-item/LanguageSelect.jsx (100%) rename src/files-and-videos/{videos => videos-page}/info-sidebar/transcript-item/Transcript.jsx (100%) rename src/files-and-videos/{videos => videos-page}/info-sidebar/transcript-item/TranscriptMenu.jsx (100%) rename src/files-and-videos/{videos => videos-page}/info-sidebar/transcript-item/index.js (100%) rename src/files-and-videos/{videos => videos-page}/info-sidebar/transcript-item/messages.js (100%) rename src/files-and-videos/{videos => videos-page}/messages.js (100%) rename src/files-and-videos/{videos => videos-page}/transcript-settings/Cielo24Form.jsx (100%) rename src/files-and-videos/{videos => videos-page}/transcript-settings/FormDropdown.jsx (100%) rename src/files-and-videos/{videos => videos-page}/transcript-settings/OrderTranscriptForm.jsx (100%) rename src/files-and-videos/{videos => videos-page}/transcript-settings/ThreePlayMediaForm.jsx (100%) rename src/files-and-videos/{videos => videos-page}/transcript-settings/TranscriptSettings.jsx (97%) rename src/files-and-videos/{videos => videos-page}/transcript-settings/TranscriptSettings.test.jsx (100%) rename src/files-and-videos/{videos => videos-page}/transcript-settings/index.js (100%) rename src/files-and-videos/{videos => videos-page}/transcript-settings/messages.js (100%) diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index 51fe78c2f1..de9e7ed848 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -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-videos/assets'; +import { FilesPage } from './files-and-videos'; import { AdvancedSettings } from './advanced-settings'; import ScheduleAndDetails from './schedule-and-details'; import { GradingSettings } from './grading-settings'; @@ -16,7 +16,7 @@ import CourseTeam from './course-team/CourseTeam'; import { CourseUpdates } from './course-updates'; import CourseExportPage from './export-page/CourseExportPage'; import CourseImportPage from './import-page/CourseImportPage'; -import Videos from './files-and-videos/videos'; +import { VideosPage } from './files-and-videos'; /** * As of this writing, these routes are mounted at a path prefixed with the following: @@ -50,11 +50,11 @@ const CourseAuthoringRoutes = () => { /> } + element={} /> : null} + element={process.env.ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true' ? : null} /> { 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); + }); }); }); diff --git a/src/files-and-videos/assets/AssetThumbnail.jsx b/src/files-and-videos/files-page/AssetThumbnail.jsx similarity index 100% rename from src/files-and-videos/assets/AssetThumbnail.jsx rename to src/files-and-videos/files-page/AssetThumbnail.jsx diff --git a/src/files-and-videos/assets/FileInfoAssetSidebar.jsx b/src/files-and-videos/files-page/FileInfoAssetSidebar.jsx similarity index 100% rename from src/files-and-videos/assets/FileInfoAssetSidebar.jsx rename to src/files-and-videos/files-page/FileInfoAssetSidebar.jsx diff --git a/src/files-and-videos/assets/FilesAndUploads.jsx b/src/files-and-videos/files-page/FilesAndUploads.jsx similarity index 100% rename from src/files-and-videos/assets/FilesAndUploads.jsx rename to src/files-and-videos/files-page/FilesAndUploads.jsx diff --git a/src/files-and-videos/assets/FilesAndUploads.test.jsx b/src/files-and-videos/files-page/FilesAndUploads.test.jsx similarity index 100% rename from src/files-and-videos/assets/FilesAndUploads.test.jsx rename to src/files-and-videos/files-page/FilesAndUploads.test.jsx diff --git a/src/files-and-videos/assets/FilesAndUploadsProvider.jsx b/src/files-and-videos/files-page/FilesAndUploadsProvider.jsx similarity index 100% rename from src/files-and-videos/assets/FilesAndUploadsProvider.jsx rename to src/files-and-videos/files-page/FilesAndUploadsProvider.jsx diff --git a/src/files-and-videos/assets/factories/mockApiResponses.jsx b/src/files-and-videos/files-page/factories/mockApiResponses.jsx similarity index 100% rename from src/files-and-videos/assets/factories/mockApiResponses.jsx rename to src/files-and-videos/files-page/factories/mockApiResponses.jsx diff --git a/src/files-and-videos/assets/index.js b/src/files-and-videos/files-page/index.js similarity index 100% rename from src/files-and-videos/assets/index.js rename to src/files-and-videos/files-page/index.js diff --git a/src/files-and-videos/assets/messages.js b/src/files-and-videos/files-page/messages.js similarity index 100% rename from src/files-and-videos/assets/messages.js rename to src/files-and-videos/files-page/messages.js diff --git a/src/files-and-videos/index.js b/src/files-and-videos/index.js new file mode 100644 index 0000000000..f00b2fcfa6 --- /dev/null +++ b/src/files-and-videos/index.js @@ -0,0 +1,3 @@ +/* eslint-disable import/prefer-default-export */ +export { default as FilesPage } from './files-page'; +export { default as VideosPage } from './videos-page'; \ No newline at end of file diff --git a/src/files-and-videos/videos/VideoThumbnail.jsx b/src/files-and-videos/videos-page/VideoThumbnail.jsx similarity index 94% rename from src/files-and-videos/videos/VideoThumbnail.jsx rename to src/files-and-videos/videos-page/VideoThumbnail.jsx index f06d850a01..0fcb1fb102 100644 --- a/src/files-and-videos/videos/VideoThumbnail.jsx +++ b/src/files-and-videos/videos-page/VideoThumbnail.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import PropTypes from 'prop-types'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { VideoFile } from '@edx/paragon/icons'; @@ -27,6 +27,7 @@ const VideoThumbnail = ({ setSelectedRows: () => {}, setAddOpen: () => false, }); + const [thumbnailError, setThumbnailError] = useState(false); const allowThumbnailUpload = videoImageSettings?.videoImageUploadEnabled; let addThumbnailMessage = 'Add thumbnail'; @@ -38,6 +39,7 @@ const VideoThumbnail = ({ const supportedFiles = videoImageSettings?.supportedFileFormats ? Object.values(videoImageSettings.supportedFileFormats) : null; let isUploaded = false; + switch (status) { case 'Ready': isUploaded = true; @@ -48,18 +50,20 @@ const VideoThumbnail = ({ default: break; } + const showThumbnail = allowThumbnailUpload && thumbnail && isUploaded; return (
{allowThumbnailUpload &&
} - {showThumbnail ? ( + {showThumbnail && !thumbnailError ? (
{intl.formatMessage(messages.thumbnailAltMessage, setThumbnailError(true)} />
) : ( diff --git a/src/files-and-videos/videos/VideoThumbnail.scss b/src/files-and-videos/videos-page/VideoThumbnail.scss similarity index 100% rename from src/files-and-videos/videos/VideoThumbnail.scss rename to src/files-and-videos/videos-page/VideoThumbnail.scss diff --git a/src/files-and-videos/videos/Videos.jsx b/src/files-and-videos/videos-page/Videos.jsx similarity index 97% rename from src/files-and-videos/videos/Videos.jsx rename to src/files-and-videos/videos-page/Videos.jsx index b8fbf0065b..0f948fede9 100644 --- a/src/files-and-videos/videos/Videos.jsx +++ b/src/files-and-videos/videos-page/Videos.jsx @@ -44,7 +44,7 @@ const Videos = ({ intl, }) => { const dispatch = useDispatch(); - const [isTranscriptSettngsOpen, openTranscriptSettings, closeTranscriptSettings] = useToggle(false); + const [isTranscriptSettingsOpen, openTranscriptSettings, closeTranscriptSettings] = useToggle(false); const courseDetails = useModel('courseDetails', courseId); document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.heading)); @@ -187,7 +187,7 @@ const Videos = ({ {isVideoTranscriptEnabled ? ( node); jest.mock('file-saver'); const renderComponent = () => { diff --git a/src/files-and-videos/videos/VideosProvider.jsx b/src/files-and-videos/videos-page/VideosProvider.jsx similarity index 100% rename from src/files-and-videos/videos/VideosProvider.jsx rename to src/files-and-videos/videos-page/VideosProvider.jsx diff --git a/src/files-and-videos/videos/data/api.js b/src/files-and-videos/videos-page/data/api.js similarity index 100% rename from src/files-and-videos/videos/data/api.js rename to src/files-and-videos/videos-page/data/api.js diff --git a/src/files-and-videos/videos/data/api.test.js b/src/files-and-videos/videos-page/data/api.test.js similarity index 100% rename from src/files-and-videos/videos/data/api.test.js rename to src/files-and-videos/videos-page/data/api.test.js diff --git a/src/files-and-videos/videos/data/constants.js b/src/files-and-videos/videos-page/data/constants.js similarity index 100% rename from src/files-and-videos/videos/data/constants.js rename to src/files-and-videos/videos-page/data/constants.js diff --git a/src/files-and-videos/videos/data/slice.js b/src/files-and-videos/videos-page/data/slice.js similarity index 100% rename from src/files-and-videos/videos/data/slice.js rename to src/files-and-videos/videos-page/data/slice.js diff --git a/src/files-and-videos/videos/data/thunks.js b/src/files-and-videos/videos-page/data/thunks.js similarity index 100% rename from src/files-and-videos/videos/data/thunks.js rename to src/files-and-videos/videos-page/data/thunks.js diff --git a/src/files-and-videos/videos/data/utils.js b/src/files-and-videos/videos-page/data/utils.js similarity index 100% rename from src/files-and-videos/videos/data/utils.js rename to src/files-and-videos/videos-page/data/utils.js diff --git a/src/files-and-videos/videos/factories/mockApiResponses.jsx b/src/files-and-videos/videos-page/factories/mockApiResponses.jsx similarity index 100% rename from src/files-and-videos/videos/factories/mockApiResponses.jsx rename to src/files-and-videos/videos-page/factories/mockApiResponses.jsx diff --git a/src/files-and-videos/videos/index.js b/src/files-and-videos/videos-page/index.js similarity index 100% rename from src/files-and-videos/videos/index.js rename to src/files-and-videos/videos-page/index.js diff --git a/src/files-and-videos/videos/info-sidebar/FileInfoVideoSidebar.jsx b/src/files-and-videos/videos-page/info-sidebar/FileInfoVideoSidebar.jsx similarity index 100% rename from src/files-and-videos/videos/info-sidebar/FileInfoVideoSidebar.jsx rename to src/files-and-videos/videos-page/info-sidebar/FileInfoVideoSidebar.jsx diff --git a/src/files-and-videos/videos/info-sidebar/InfoTab.jsx b/src/files-and-videos/videos-page/info-sidebar/InfoTab.jsx similarity index 100% rename from src/files-and-videos/videos/info-sidebar/InfoTab.jsx rename to src/files-and-videos/videos-page/info-sidebar/InfoTab.jsx diff --git a/src/files-and-videos/videos/info-sidebar/TranscriptTab.jsx b/src/files-and-videos/videos-page/info-sidebar/TranscriptTab.jsx similarity index 100% rename from src/files-and-videos/videos/info-sidebar/TranscriptTab.jsx rename to src/files-and-videos/videos-page/info-sidebar/TranscriptTab.jsx diff --git a/src/files-and-videos/videos/info-sidebar/TranscriptTab.test.jsx b/src/files-and-videos/videos-page/info-sidebar/TranscriptTab.test.jsx similarity index 100% rename from src/files-and-videos/videos/info-sidebar/TranscriptTab.test.jsx rename to src/files-and-videos/videos-page/info-sidebar/TranscriptTab.test.jsx diff --git a/src/files-and-videos/videos/info-sidebar/messages.js b/src/files-and-videos/videos-page/info-sidebar/messages.js similarity index 100% rename from src/files-and-videos/videos/info-sidebar/messages.js rename to src/files-and-videos/videos-page/info-sidebar/messages.js diff --git a/src/files-and-videos/videos/info-sidebar/transcript-item/LanguageSelect.jsx b/src/files-and-videos/videos-page/info-sidebar/transcript-item/LanguageSelect.jsx similarity index 100% rename from src/files-and-videos/videos/info-sidebar/transcript-item/LanguageSelect.jsx rename to src/files-and-videos/videos-page/info-sidebar/transcript-item/LanguageSelect.jsx diff --git a/src/files-and-videos/videos/info-sidebar/transcript-item/Transcript.jsx b/src/files-and-videos/videos-page/info-sidebar/transcript-item/Transcript.jsx similarity index 100% rename from src/files-and-videos/videos/info-sidebar/transcript-item/Transcript.jsx rename to src/files-and-videos/videos-page/info-sidebar/transcript-item/Transcript.jsx diff --git a/src/files-and-videos/videos/info-sidebar/transcript-item/TranscriptMenu.jsx b/src/files-and-videos/videos-page/info-sidebar/transcript-item/TranscriptMenu.jsx similarity index 100% rename from src/files-and-videos/videos/info-sidebar/transcript-item/TranscriptMenu.jsx rename to src/files-and-videos/videos-page/info-sidebar/transcript-item/TranscriptMenu.jsx diff --git a/src/files-and-videos/videos/info-sidebar/transcript-item/index.js b/src/files-and-videos/videos-page/info-sidebar/transcript-item/index.js similarity index 100% rename from src/files-and-videos/videos/info-sidebar/transcript-item/index.js rename to src/files-and-videos/videos-page/info-sidebar/transcript-item/index.js diff --git a/src/files-and-videos/videos/info-sidebar/transcript-item/messages.js b/src/files-and-videos/videos-page/info-sidebar/transcript-item/messages.js similarity index 100% rename from src/files-and-videos/videos/info-sidebar/transcript-item/messages.js rename to src/files-and-videos/videos-page/info-sidebar/transcript-item/messages.js diff --git a/src/files-and-videos/videos/messages.js b/src/files-and-videos/videos-page/messages.js similarity index 100% rename from src/files-and-videos/videos/messages.js rename to src/files-and-videos/videos-page/messages.js diff --git a/src/files-and-videos/videos/transcript-settings/Cielo24Form.jsx b/src/files-and-videos/videos-page/transcript-settings/Cielo24Form.jsx similarity index 100% rename from src/files-and-videos/videos/transcript-settings/Cielo24Form.jsx rename to src/files-and-videos/videos-page/transcript-settings/Cielo24Form.jsx diff --git a/src/files-and-videos/videos/transcript-settings/FormDropdown.jsx b/src/files-and-videos/videos-page/transcript-settings/FormDropdown.jsx similarity index 100% rename from src/files-and-videos/videos/transcript-settings/FormDropdown.jsx rename to src/files-and-videos/videos-page/transcript-settings/FormDropdown.jsx diff --git a/src/files-and-videos/videos/transcript-settings/OrderTranscriptForm.jsx b/src/files-and-videos/videos-page/transcript-settings/OrderTranscriptForm.jsx similarity index 100% rename from src/files-and-videos/videos/transcript-settings/OrderTranscriptForm.jsx rename to src/files-and-videos/videos-page/transcript-settings/OrderTranscriptForm.jsx diff --git a/src/files-and-videos/videos/transcript-settings/ThreePlayMediaForm.jsx b/src/files-and-videos/videos-page/transcript-settings/ThreePlayMediaForm.jsx similarity index 100% rename from src/files-and-videos/videos/transcript-settings/ThreePlayMediaForm.jsx rename to src/files-and-videos/videos-page/transcript-settings/ThreePlayMediaForm.jsx diff --git a/src/files-and-videos/videos/transcript-settings/TranscriptSettings.jsx b/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.jsx similarity index 97% rename from src/files-and-videos/videos/transcript-settings/TranscriptSettings.jsx rename to src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.jsx index 842da8055c..ee8484f072 100644 --- a/src/files-and-videos/videos/transcript-settings/TranscriptSettings.jsx +++ b/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.jsx @@ -21,7 +21,7 @@ import { } from '../data/thunks'; const TranscriptSettings = ({ - isTranscriptSettngsOpen, + isTranscriptSettingsOpen, closeTranscriptSettings, courseId, }) => { @@ -50,7 +50,7 @@ const TranscriptSettings = ({
@@ -113,7 +113,7 @@ const TranscriptSettings = ({ TranscriptSettings.propTypes = { closeTranscriptSettings: PropTypes.func.isRequired, - isTranscriptSettngsOpen: PropTypes.bool.isRequired, + isTranscriptSettingsOpen: PropTypes.bool.isRequired, courseId: PropTypes.string.isRequired, }; diff --git a/src/files-and-videos/videos/transcript-settings/TranscriptSettings.test.jsx b/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.test.jsx similarity index 100% rename from src/files-and-videos/videos/transcript-settings/TranscriptSettings.test.jsx rename to src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.test.jsx diff --git a/src/files-and-videos/videos/transcript-settings/index.js b/src/files-and-videos/videos-page/transcript-settings/index.js similarity index 100% rename from src/files-and-videos/videos/transcript-settings/index.js rename to src/files-and-videos/videos-page/transcript-settings/index.js diff --git a/src/files-and-videos/videos/transcript-settings/messages.js b/src/files-and-videos/videos-page/transcript-settings/messages.js similarity index 100% rename from src/files-and-videos/videos/transcript-settings/messages.js rename to src/files-and-videos/videos-page/transcript-settings/messages.js diff --git a/src/index.scss b/src/index.scss index 5d1f7bd4a0..68b228fc7f 100755 --- a/src/index.scss +++ b/src/index.scss @@ -19,5 +19,5 @@ @import "course-updates/CourseUpdates"; @import "export-page/CourseExportPage"; @import "import-page/CourseImportPage"; -@import "files-and-videos/videos/VideoThumbnail.scss"; +@import "files-and-videos/videos-page/VideoThumbnail.scss"; @import "files-and-videos/table-components/GalleryCard" diff --git a/src/store.js b/src/store.js index 99a7265219..69dddbfd32 100644 --- a/src/store.js +++ b/src/store.js @@ -18,7 +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/data/slice'; +import { reducer as videosReducer } from './files-and-videos/videos-page/data/slice'; export default function initializeStore(preloadedState = undefined) { return configureStore({ From 5d56a1212c3c9cb6e92ce95bd9f7bb4c9d592be2 Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Tue, 31 Oct 2023 17:03:20 -0400 Subject: [PATCH 36/46] fix: lint errors --- src/CourseAuthoringRoutes.jsx | 3 +-- src/files-and-videos/index.js | 2 +- src/files-and-videos/videos-page/Videos.test.jsx | 1 - 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index de9e7ed848..7bf5b864ab 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -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 { FilesPage } from './files-and-videos'; +import { FilesPage, VideosPage } from './files-and-videos'; import { AdvancedSettings } from './advanced-settings'; import ScheduleAndDetails from './schedule-and-details'; import { GradingSettings } from './grading-settings'; @@ -16,7 +16,6 @@ import CourseTeam from './course-team/CourseTeam'; import { CourseUpdates } from './course-updates'; import CourseExportPage from './export-page/CourseExportPage'; import CourseImportPage from './import-page/CourseImportPage'; -import { VideosPage } from './files-and-videos'; /** * As of this writing, these routes are mounted at a path prefixed with the following: diff --git a/src/files-and-videos/index.js b/src/files-and-videos/index.js index f00b2fcfa6..aff054a680 100644 --- a/src/files-and-videos/index.js +++ b/src/files-and-videos/index.js @@ -1,3 +1,3 @@ /* eslint-disable import/prefer-default-export */ export { default as FilesPage } from './files-page'; -export { default as VideosPage } from './videos-page'; \ No newline at end of file +export { default as VideosPage } from './videos-page'; diff --git a/src/files-and-videos/videos-page/Videos.test.jsx b/src/files-and-videos/videos-page/Videos.test.jsx index 7f03afa37b..9c2634e07c 100644 --- a/src/files-and-videos/videos-page/Videos.test.jsx +++ b/src/files-and-videos/videos-page/Videos.test.jsx @@ -7,7 +7,6 @@ import { within, } 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'; From 08358179423010bae726ad8f7e0b6fcf0d35202c Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Wed, 1 Nov 2023 11:53:24 -0400 Subject: [PATCH 37/46] fix: errors not clearing on dismiss --- src/files-and-videos/EditFileErrors.jsx | 5 +++++ src/files-and-videos/FileTable.jsx | 12 +++++++----- src/files-and-videos/files-page/FilesAndUploads.jsx | 1 + src/files-and-videos/videos-page/Videos.jsx | 1 + 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/files-and-videos/EditFileErrors.jsx b/src/files-and-videos/EditFileErrors.jsx index 3f92205b82..35304e38d1 100644 --- a/src/files-and-videos/EditFileErrors.jsx +++ b/src/files-and-videos/EditFileErrors.jsx @@ -6,6 +6,7 @@ import { RequestStatus } from '../data/constants'; import messages from './messages'; const EditFileErrors = ({ + resetErrors, errorMessages, addFileStatus, deleteFileStatus, @@ -16,6 +17,7 @@ const EditFileErrors = ({ <> resetErrors({ errorType: 'add' })} isError={addFileStatus === RequestStatus.FAILED} >
    @@ -28,6 +30,7 @@ const EditFileErrors = ({ resetErrors({ errorType: 'delete' })} isError={deleteFileStatus === RequestStatus.FAILED} >
      @@ -40,6 +43,7 @@ const EditFileErrors = ({ resetErrors({ errorType: 'update' })} isError={updateFileStatus === RequestStatus.FAILED} >
        @@ -64,6 +68,7 @@ const EditFileErrors = ({ ); EditFileErrors.propTypes = { + resetErrors: PropTypes.func.isRequired, errorMessages: PropTypes.shape({ add: PropTypes.arrayOf(PropTypes.string).isRequired, delete: PropTypes.arrayOf(PropTypes.string).isRequired, diff --git a/src/files-and-videos/FileTable.jsx b/src/files-and-videos/FileTable.jsx index 31ac0cac8f..80a805e2c4 100644 --- a/src/files-and-videos/FileTable.jsx +++ b/src/files-and-videos/FileTable.jsx @@ -65,15 +65,18 @@ const FileTable = ({ encodingsDownloadUrl, supportedFileFormats, } = data; + useEffect(() => { if (!isEmpty(selectedRows) && Object.keys(selectedRows[0]).length > 0) { - const udpatedRows = []; + const updatedRows = []; selectedRows.forEach(row => { const currentFile = row.original; - const [updatedFile] = files.filter(file => file.id === currentFile.id); - udpatedRows.push({ original: updatedFile }); + if (currentFile) { + const [updatedFile] = files.filter(file => file.id === currentFile?.id); + updatedRows.push({ original: updatedFile }); + } }); - setSelectedRows(udpatedRows); + setSelectedRows(updatedRows); } }, [files]); @@ -102,7 +105,6 @@ const FileTable = ({ handleErrorReset({ errorType: 'delete' }); const fileIdsToDelete = selectedRows.map(row => row.original.id); fileIdsToDelete.forEach(id => handleDeleteFile(id)); - setSelectedRows([]); }; const handleBulkDownload = useCallback(async (selectedFlatRows) => { diff --git a/src/files-and-videos/files-page/FilesAndUploads.jsx b/src/files-and-videos/files-page/FilesAndUploads.jsx index c901b409e3..40b1226db5 100644 --- a/src/files-and-videos/files-page/FilesAndUploads.jsx +++ b/src/files-and-videos/files-page/FilesAndUploads.jsx @@ -145,6 +145,7 @@ const FilesAndUploads = ({
        Date: Wed, 1 Nov 2023 13:33:43 -0400 Subject: [PATCH 38/46] chore: add failure tests --- .../videos-page/Videos.test.jsx | 4 +- .../TranscriptSettings.jsx | 3 +- .../TranscriptSettings.test.jsx | 376 ++++++++++-------- 3 files changed, 213 insertions(+), 170 deletions(-) diff --git a/src/files-and-videos/videos-page/Videos.test.jsx b/src/files-and-videos/videos-page/Videos.test.jsx index 9c2634e07c..24bd308145 100644 --- a/src/files-and-videos/videos-page/Videos.test.jsx +++ b/src/files-and-videos/videos-page/Videos.test.jsx @@ -145,7 +145,7 @@ describe('FilesAndUploads', () => { }); }); - describe('valid assets', () => { + describe('valid videos', () => { beforeEach(async () => { initializeMockApp({ authenticatedUser: { @@ -602,7 +602,7 @@ describe('FilesAndUploads', () => { expect(usageStatus).toEqual(RequestStatus.FAILED); }); - it('multiple asset file fetch failure should show error', async () => { + it('multiple video files fetch failure should show error', async () => { renderComponent(); await mockStore(RequestStatus.SUCCESSFUL); const selectCardButtons = screen.getAllByTestId('datatable-select-column-checkbox-cell'); diff --git a/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.jsx b/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.jsx index ee8484f072..4149ad97dd 100644 --- a/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.jsx +++ b/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.jsx @@ -32,8 +32,9 @@ const TranscriptSettings = ({ transcriptCredentials, videoTranscriptSettings, } = pageSettings; - const { transcriptionPlans } = videoTranscriptSettings; + const { transcriptionPlans } = videoTranscriptSettings || {}; const [transcriptType, setTranscriptType] = useState(activeTranscriptPreferences); + const handleOrderTranscripts = (data, provider) => { const noCredentials = isEmpty(transcriptCredentials) || data.apiKey; dispatch(resetErrors({ errorType: 'transcript' })); diff --git a/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.test.jsx b/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.test.jsx index 750ec0c904..6a38445788 100644 --- a/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.test.jsx +++ b/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.test.jsx @@ -141,7 +141,7 @@ describe('TranscriptSettings', () => { }); }); - describe('has no credentials set', () => { + describe('with no credentials set', () => { beforeEach(async () => { initializeMockApp({ authenticatedUser: { @@ -153,14 +153,15 @@ describe('TranscriptSettings', () => { }); store = initializeStore(initialState); axiosMock = new MockAdapter(getAuthenticatedHttpClient()); - }); - it('should request credentials for Cielo24 and 3Play Media', async () => { 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); @@ -178,22 +179,16 @@ describe('TranscriptSettings', () => { expect(threePlayMediaCredentialMessage).toBeVisible(); }); - it('should handle cielo24 credential update', async () => { - renderComponent(defaultProps); - const orderButton = screen.getByText(messages.orderTranscriptsTitle.defaultMessage); - await act(async () => { - userEvent.click(orderButton); - }); + it('should update cielo24 credentials ', async () => { const cielo24Button = screen.getAllByLabelText('Cielo24 radio')[0]; await act(async () => { userEvent.click(cielo24Button); }); - const updateButton = screen.getByText(messages.updateSettingsLabel.defaultMessage); - - expect(updateButton).toHaveAttribute('disabled'); 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'); @@ -205,6 +200,7 @@ describe('TranscriptSettings', () => { await waitFor(() => { userEvent.click(updateButton); }); + const { transcriptStatus } = store.getState().videos; expect(transcriptStatus).toEqual(RequestStatus.SUCCESSFUL); @@ -214,22 +210,16 @@ describe('TranscriptSettings', () => { expect(screen.getByText(messages.cieloFidelityLabel.defaultMessage)).toBeVisible(); }); - it('should handle 3Play Media credential update', async () => { - renderComponent(defaultProps); - const orderButton = screen.getByText(messages.orderTranscriptsTitle.defaultMessage); - await act(async () => { - userEvent.click(orderButton); - }); + 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); - - expect(updateButton).toHaveAttribute('disabled'); + 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'); @@ -251,7 +241,7 @@ describe('TranscriptSettings', () => { }); }); - describe('has credentials set', () => { + describe('with credentials set', () => { beforeEach(async () => { initializeMockApp({ authenticatedUser: { @@ -275,14 +265,14 @@ describe('TranscriptSettings', () => { }, }); axiosMock = new MockAdapter(getAuthenticatedHttpClient()); - }); - - it('should not show credentials request for Cielo24 and 3Play Media', async () => { 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); @@ -300,153 +290,205 @@ describe('TranscriptSettings', () => { expect(threePlayMediaCredentialMessage).toBeNull(); }); - it('should handle cielo24 preferences update', async () => { - const apiResponse = { - videoSourceLanguage: 'en', - cielo24Turnaround: 'PRIORITY', - cielo24FidelityTypee: 'PREMIUM', - preferredLanguages: ['en'], - provider: 'cielo24', - global: false, - }; - - 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 updateButton = screen.getByText(messages.updateSettingsLabel.defaultMessage); - - expect(updateButton).toHaveAttribute('disabled'); - - 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 handle 3Play Media credential update with english as source language', async () => { - const apiResponse = { - videoSourceLanguage: 'en', - threePlayTurnaround: 'two_hour', - preferredLanguages: ['ar', 'fr'], - provider: '3PlayMedia', - global: false, - }; - - renderComponent(defaultProps); - const orderButton = screen.getByText(messages.orderTranscriptsTitle.defaultMessage); - await act(async () => { - userEvent.click(orderButton); - }); - const threePlayButton = screen.getAllByLabelText('3PlayMedia radio')[0]; - await act(async () => { - userEvent.click(threePlayButton); - }); - const updateButton = screen.getByText(messages.updateSettingsLabel.defaultMessage); - - expect(updateButton).toHaveAttribute('disabled'); - - 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]); - + 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); }); - - 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); - it('should handle 3Play Media credential update with english as source language', async () => { - const apiResponse = { - videoSourceLanguage: 'en', - threePlayTurnaround: 'two_hour', - preferredLanguages: ['ar', 'fr'], - provider: '3PlayMedia', - global: false, - }; - - renderComponent(defaultProps); - const orderButton = screen.getByText(messages.orderTranscriptsTitle.defaultMessage); - await act(async () => { - userEvent.click(orderButton); - }); - const threePlayButton = screen.getAllByLabelText('3PlayMedia radio')[0]; - await act(async () => { - userEvent.click(threePlayButton); - }); - const updateButton = screen.getByText(messages.updateSettingsLabel.defaultMessage); - - expect(updateButton).toHaveAttribute('disabled'); - - 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); + expect(screen.getByText('Failed to update 3PlayMedia transcripts settings.')).toBeVisible(); }); - const { transcriptStatus } = store.getState().videos; - - expect(transcriptStatus).toEqual(RequestStatus.SUCCESSFUL); }); }); }); From 5318160e8a36135889f587988da9c79bca44e9e0 Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Wed, 1 Nov 2023 13:40:47 -0400 Subject: [PATCH 39/46] fix: lint errors --- .../TranscriptSettings.test.jsx | 72 +++++++++---------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.test.jsx b/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.test.jsx index 6a38445788..79af852176 100644 --- a/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.test.jsx +++ b/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.test.jsx @@ -300,7 +300,7 @@ describe('TranscriptSettings', () => { provider: 'cielo24', global: false, }; - + const cielo24Button = screen.getAllByLabelText('Cielo24 radio')[0]; await act(async () => { userEvent.click(cielo24Button); @@ -308,36 +308,36 @@ describe('TranscriptSettings', () => { 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', @@ -353,32 +353,32 @@ describe('TranscriptSettings', () => { 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', @@ -394,30 +394,30 @@ describe('TranscriptSettings', () => { 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]; @@ -427,36 +427,36 @@ describe('TranscriptSettings', () => { 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 () => { @@ -465,26 +465,26 @@ describe('TranscriptSettings', () => { 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(); From 276c95574a06956507cf9ecdce25cad95c369104 Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Wed, 1 Nov 2023 15:36:06 -0400 Subject: [PATCH 40/46] chore: add credential update failure tests --- .../TranscriptSettings.test.jsx | 223 ++++++++++++------ 1 file changed, 155 insertions(+), 68 deletions(-) diff --git a/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.test.jsx b/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.test.jsx index 79af852176..664bc6bbfe 100644 --- a/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.test.jsx +++ b/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.test.jsx @@ -80,34 +80,6 @@ describe('TranscriptSettings', () => { expect(selectableButtons).toBeVisible(); }); - it('should delete transcript preferences', async () => { - 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 updateButton = screen.getByText(messages.updateSettingsLabel.defaultMessage); - - expect(updateButton).toHaveAttribute('disabled'); - - const noneButton = screen.getAllByLabelText('none radio')[0]; - await act(async () => { - userEvent.click(noneButton); - }); - - 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 return to order transcript collapsible', async () => { renderComponent(defaultProps); const orderButton = screen.getByText(messages.orderTranscriptsTitle.defaultMessage); @@ -141,6 +113,61 @@ describe('TranscriptSettings', () => { }); }); + 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({ @@ -179,65 +206,125 @@ describe('TranscriptSettings', () => { expect(threePlayMediaCredentialMessage).toBeVisible(); }); - it('should update cielo24 credentials ', async () => { - const cielo24Button = screen.getAllByLabelText('Cielo24 radio')[0]; - await act(async () => { - userEvent.click(cielo24Button); - }); + 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); + 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'); + await waitFor(() => { + userEvent.type(firstInput, 'apiKey'); + userEvent.type(secondInput, 'username'); - expect(updateButton).not.toHaveAttribute('disabled'); - }); + expect(updateButton).not.toHaveAttribute('disabled'); + }); - axiosMock.onPost(`${getApiBaseUrl()}/transcript_credentials/${courseId}`).reply(200); - await waitFor(() => { - userEvent.click(updateButton); + 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(); }); - const { transcriptStatus } = store.getState().videos; + it('should update 3Play Media credentials', async () => { + const threePlayButton = screen.getAllByLabelText('3PlayMedia radio')[0]; + await act(async () => { + userEvent.click(threePlayButton); + }); - expect(transcriptStatus).toEqual(RequestStatus.SUCCESSFUL); + const updateButton = screen.getByText(messages.updateSettingsLabel.defaultMessage); + const firstInput = screen.getByLabelText(messages.threePlayMediaApiKeyLabel.defaultMessage); + const secondInput = screen.getByLabelText(messages.threePlayMediaApiSecretLabel.defaultMessage); - expect(screen.queryByTestId('cieloCredentialMessage')).toBeNull(); + await waitFor(() => { + userEvent.type(firstInput, 'apiKey'); + userEvent.type(secondInput, 'secretKey'); - expect(screen.getByText(messages.cieloFidelityLabel.defaultMessage)).toBeVisible(); - }); + expect(updateButton).not.toHaveAttribute('disabled'); + }); - it('should update 3Play Media credentials', async () => { - const threePlayButton = screen.getAllByLabelText('3PlayMedia radio')[0]; - await act(async () => { - userEvent.click(threePlayButton); - }); + axiosMock.onPost(`${getApiBaseUrl()}/transcript_credentials/${courseId}`).reply(200); + await waitFor(() => { + userEvent.click(updateButton); + }); + const { transcriptStatus } = store.getState().videos; - const updateButton = screen.getByText(messages.updateSettingsLabel.defaultMessage); - const firstInput = screen.getByLabelText(messages.threePlayMediaApiKeyLabel.defaultMessage); - const secondInput = screen.getByLabelText(messages.threePlayMediaApiSecretLabel.defaultMessage); + expect(transcriptStatus).toEqual(RequestStatus.SUCCESSFUL); - await waitFor(() => { - userEvent.type(firstInput, 'apiKey'); - userEvent.type(secondInput, 'secretKey'); + expect(screen.queryByTestId('threePlayCredentialMessage')).toBeNull(); - expect(updateButton).not.toHaveAttribute('disabled'); + expect(screen.getByText(messages.threePlayMediaTurnaroundLabel.defaultMessage)).toBeVisible(); }); + }); - axiosMock.onPost(`${getApiBaseUrl()}/transcript_credentials/${courseId}`).reply(200); - await waitFor(() => { - userEvent.click(updateButton); + 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(); }); - const { transcriptStatus } = store.getState().videos; - expect(transcriptStatus).toEqual(RequestStatus.SUCCESSFUL); + it('should show error alert on 3PlayMedia credentials update', async () => { + const threePlayButton = screen.getAllByLabelText('3PlayMedia radio')[0]; + await act(async () => { + userEvent.click(threePlayButton); + }); - expect(screen.queryByTestId('threePlayCredentialMessage')).toBeNull(); + 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(screen.getByText(messages.threePlayMediaTurnaroundLabel.defaultMessage)).toBeVisible(); + 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(); + }); }); }); From 83629c8c024732a9f5c20fc627bd3a0c6060c5b2 Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Thu, 2 Nov 2023 10:42:36 -0400 Subject: [PATCH 41/46] chore: increase utils code coverage --- package-lock.json | 32 +++ package.json | 1 + .../videos-page/data/utils.js | 17 +- .../videos-page/data/utils.test.js | 209 ++++++++++++++++++ 4 files changed, 250 insertions(+), 9 deletions(-) create mode 100644 src/files-and-videos/videos-page/data/utils.test.js diff --git a/package-lock.json b/package-lock.json index a63345a8d5..186315e6ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 0d38c011d3..7771d14741 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/files-and-videos/videos-page/data/utils.js b/src/files-and-videos/videos-page/data/utils.js index a1d63a1f8e..288eacb958 100644 --- a/src/files-and-videos/videos-page/data/utils.js +++ b/src/files-and-videos/videos-page/data/utils.js @@ -86,8 +86,8 @@ export const getSupportedFormats = (supportedFileFormats) => { return supportedFormats; }; -/** resampledFile({ canvasUrl, filename, mimeType }) - * resampledFile takes a canvasUrl, filename, and a valid mimeType. The +/** 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 @@ -137,17 +137,14 @@ export const resampleImage = ({ image, filename }) => { 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; }; -const hasValidDimensions = (image) => { - const width = image.naturalWidth; - const height = image.naturalHeight; - const imageAspectRatio = Math.abs(width / height) - ASPECT_RATIO; +export const hasValidDimensions = ({ width, height }) => { + const imageAspectRatio = Math.abs((width / height) - ASPECT_RATIO); - if (width < MIN_HEIGHT || height < MIN_HEIGHT) { + if (width < MIN_WIDTH || height < MIN_HEIGHT) { return false; } if (imageAspectRatio >= ASPECT_RATIO_ERROR_MARGIN) { @@ -168,7 +165,9 @@ export const resampleFile = ({ reader.onload = () => { image.src = reader.result; image.onload = () => { - if (!hasValidDimensions(image)) { + 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 { diff --git a/src/files-and-videos/videos-page/data/utils.test.js b/src/files-and-videos/videos-page/data/utils.test.js new file mode 100644 index 0000000000..5645e8b5a4 --- /dev/null +++ b/src/files-and-videos/videos-page/data/utils.test.js @@ -0,0 +1,209 @@ +import 'jest-canvas-mock'; +import { + hasValidDimensions, + getSupportedFormats, + resampleImage, + createResampledFile, + validateForm, +} 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(); + }); + }); +}); From 4e7902f8850d2d406637a39d97fec191fe433e4f Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Thu, 2 Nov 2023 13:48:03 -0400 Subject: [PATCH 42/46] feat: add disable for invalid transcription plans --- src/files-and-videos/index.scss | 3 ++ .../videos-page/data/utils.js | 21 +++++++- .../videos-page/data/utils.test.js | 50 +++++++++++++++++++ src/files-and-videos/videos-page/index.js | 3 ++ .../transcript-settings/Cielo24Form.jsx | 17 +++---- .../OrderTranscriptForm.jsx | 20 +++++++- .../ThreePlayMediaForm.jsx | 23 ++++----- .../TranscriptSettings.scss | 4 ++ .../TranscriptSettings.test.jsx | 6 +-- .../transcript-settings/messages.js | 8 +++ src/index.scss | 3 +- 11 files changed, 127 insertions(+), 31 deletions(-) create mode 100644 src/files-and-videos/index.scss create mode 100644 src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.scss diff --git a/src/files-and-videos/index.scss b/src/files-and-videos/index.scss new file mode 100644 index 0000000000..c1a22559f5 --- /dev/null +++ b/src/files-and-videos/index.scss @@ -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" \ No newline at end of file diff --git a/src/files-and-videos/videos-page/data/utils.js b/src/files-and-videos/videos-page/data/utils.js index 288eacb958..9676e0649c 100644 --- a/src/files-and-videos/videos-page/data/utils.js +++ b/src/files-and-videos/videos-page/data/utils.js @@ -198,11 +198,28 @@ export const getFidelityOptions = (fidelities) => { }; export const checkCredentials = (transcriptCredentials) => { - const cieloHasCredentials = transcriptCredentials?.cielo24; - const threePlayHasCredentials = transcriptCredentials?.['3PlayMedia']; + 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, diff --git a/src/files-and-videos/videos-page/data/utils.test.js b/src/files-and-videos/videos-page/data/utils.test.js index 5645e8b5a4..2d3708a99d 100644 --- a/src/files-and-videos/videos-page/data/utils.test.js +++ b/src/files-and-videos/videos-page/data/utils.test.js @@ -5,6 +5,7 @@ import { resampleImage, createResampledFile, validateForm, + checkTranscriptionPlans, } from './utils'; describe('getSupportedFormats', () => { @@ -28,6 +29,7 @@ describe('getSupportedFormats', () => { expect(expected).toEqual(actual); }); }); + describe('createResampledFile', () => { it('should return resampled file object', () => { const expected = new File([{ name: 'imageName', size: 20000 }], 'testVALUEVALIDIMAGE'); @@ -40,6 +42,7 @@ describe('createResampledFile', () => { expect(expected).toEqual(actual); }); }); + describe('resampleImage', () => { it('should return filename and file', () => { const resampledFile = new File([{ name: 'testVALUEVALIDIMAGE', size: 20000 }], 'testVALUEVALIDIMAGE'); @@ -51,6 +54,7 @@ describe('resampleImage', () => { expect(actualImage).toEqual(resampledFile); }); }); + describe('checkValidDimensions', () => { it('returns false for images less than min width and min height', () => { const image = { width: 500, height: 281 }; @@ -207,3 +211,49 @@ describe('validateForm', () => { }); }); }); + +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); + }); + }); +}); diff --git a/src/files-and-videos/videos-page/index.js b/src/files-and-videos/videos-page/index.js index b2eadf9d1e..70fd52042b 100644 --- a/src/files-and-videos/videos-page/index.js +++ b/src/files-and-videos/videos-page/index.js @@ -1,3 +1,6 @@ +import TranscriptSettings from './transcript-settings'; import Videos from './Videos'; +import VideoThumbnail from './VideoThumbnail'; export default Videos; +export { TranscriptSettings, VideoThumbnail }; diff --git a/src/files-and-videos/videos-page/transcript-settings/Cielo24Form.jsx b/src/files-and-videos/videos-page/transcript-settings/Cielo24Form.jsx index 99fb373a38..76dad30e44 100644 --- a/src/files-and-videos/videos-page/transcript-settings/Cielo24Form.jsx +++ b/src/files-and-videos/videos-page/transcript-settings/Cielo24Form.jsx @@ -15,16 +15,15 @@ const Cielo24Form = ({ // injected intl, }) => { - 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], - }; - 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 ( diff --git a/src/files-and-videos/videos-page/transcript-settings/OrderTranscriptForm.jsx b/src/files-and-videos/videos-page/transcript-settings/OrderTranscriptForm.jsx index cf54403bf2..87dd5646b7 100644 --- a/src/files-and-videos/videos-page/transcript-settings/OrderTranscriptForm.jsx +++ b/src/files-and-videos/videos-page/transcript-settings/OrderTranscriptForm.jsx @@ -7,7 +7,7 @@ import Cielo24Form from './Cielo24Form'; import ThreePlayMediaForm from './ThreePlayMediaForm'; import { RequestStatus } from '../../../data/constants'; import messages from './messages'; -import { checkCredentials, validateForm } from '../data/utils'; +import { checkCredentials, checkTranscriptionPlans, validateForm } from '../data/utils'; const OrderTranscriptForm = ({ setTranscriptType, @@ -24,6 +24,8 @@ const OrderTranscriptForm = ({ }) => { const [data, setData] = useState(activeTranscriptPreferences || { videoSourceLanguage: '' }); + const [validCieloTranscriptionPlan, validThreePlayTranscriptionPlan] = checkTranscriptionPlans(transcriptionPlans); + let [cieloHasCredentials, threePlayHasCredentials] = checkCredentials(transcriptCredentials); useEffect(() => { [cieloHasCredentials, threePlayHasCredentials] = checkCredentials(transcriptCredentials); @@ -72,13 +74,25 @@ const OrderTranscriptForm = ({ } return ( <> + + + + + +
          {errorMessages.transcript.map(message => ( -
        • +
        • {intl.formatMessage(messages.errorAlertMessage, { message })}
        • ))} @@ -105,6 +119,7 @@ const OrderTranscriptForm = ({ value="Cielo24" aria-label="Cielo24 radio" className="text-center" + disabled={!validCieloTranscriptionPlan && cieloHasCredentials} > @@ -112,6 +127,7 @@ const OrderTranscriptForm = ({ value="3PlayMedia" aria-label="3PlayMedia radio" className="text-center" + disabled={!validThreePlayTranscriptionPlan && threePlayHasCredentials} > diff --git a/src/files-and-videos/videos-page/transcript-settings/ThreePlayMediaForm.jsx b/src/files-and-videos/videos-page/transcript-settings/ThreePlayMediaForm.jsx index 10eda013b3..fdde6f1c25 100644 --- a/src/files-and-videos/videos-page/transcript-settings/ThreePlayMediaForm.jsx +++ b/src/files-and-videos/videos-page/transcript-settings/ThreePlayMediaForm.jsx @@ -21,19 +21,18 @@ const ThreePlayMediaForm = ({ // injected intl, }) => { - 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; - 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 ( diff --git a/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.scss b/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.scss new file mode 100644 index 0000000000..465d640e2b --- /dev/null +++ b/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.scss @@ -0,0 +1,4 @@ +.pgn__selectable_box:disabled, +.pgn__selectable_box[disabled] { + opacity: .5; +} diff --git a/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.test.jsx b/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.test.jsx index 664bc6bbfe..2aef08be5c 100644 --- a/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.test.jsx +++ b/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.test.jsx @@ -20,14 +20,12 @@ import { courseId, initialState, } from '../factories/mockApiResponses'; -import { getApiBaseUrl } from '../../data/api'; +import { getApiBaseUrl } from '../data/api'; import messages from './messages'; import VideosProvider from '../VideosProvider'; -ReactDOM.createPortal = jest.fn(node => node); - const defaultProps = { - isTranscriptSettngsOpen: true, + isTranscriptSettingsOpen: true, closeTranscriptSettings: jest.fn(), courseId, }; diff --git a/src/files-and-videos/videos-page/transcript-settings/messages.js b/src/files-and-videos/videos-page/transcript-settings/messages.js index 880f7dd3fa..ef8817a7e2 100644 --- a/src/files-and-videos/videos-page/transcript-settings/messages.js +++ b/src/files-and-videos/videos-page/transcript-settings/messages.js @@ -6,6 +6,14 @@ const messages = defineMessages({ 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}', diff --git a/src/index.scss b/src/index.scss index 68b228fc7f..3481701d60 100755 --- a/src/index.scss +++ b/src/index.scss @@ -19,5 +19,4 @@ @import "course-updates/CourseUpdates"; @import "export-page/CourseExportPage"; @import "import-page/CourseImportPage"; -@import "files-and-videos/videos-page/VideoThumbnail.scss"; -@import "files-and-videos/table-components/GalleryCard" +@import "files-and-videos"; From c85667a95b969a65dcdd2baf784a235a6187b738 Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Fri, 3 Nov 2023 09:42:47 -0400 Subject: [PATCH 43/46] fix: lint error --- src/files-and-videos/index.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/files-and-videos/index.scss b/src/files-and-videos/index.scss index c1a22559f5..087984e13c 100644 --- a/src/files-and-videos/index.scss +++ b/src/files-and-videos/index.scss @@ -1,3 +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" \ No newline at end of file +@import "files-and-videos/table-components/GalleryCard" From 808ae5066c18430b97b3196f094c03d0c95ba534 Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Fri, 3 Nov 2023 09:54:53 -0400 Subject: [PATCH 44/46] fix: lint error --- .../videos-page/transcript-settings/TranscriptSettings.test.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.test.jsx b/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.test.jsx index 2aef08be5c..9a0eeb1f11 100644 --- a/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.test.jsx +++ b/src/files-and-videos/videos-page/transcript-settings/TranscriptSettings.test.jsx @@ -5,7 +5,6 @@ import { waitFor, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import ReactDOM from 'react-dom'; import { initializeMockApp, } from '@edx/frontend-platform'; From 07eaac6214ae6dab927955587af253468334085b Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Fri, 3 Nov 2023 14:08:01 -0400 Subject: [PATCH 45/46] chore: add tests for transcript actions failures --- .../videos-page/data/thunks.js | 4 +- .../info-sidebar/TranscriptTab.test.jsx | 359 +++++++++++------- 2 files changed, 230 insertions(+), 133 deletions(-) diff --git a/src/files-and-videos/videos-page/data/thunks.js b/src/files-and-videos/videos-page/data/thunks.js index f25e701d1a..7f9aa1a40c 100644 --- a/src/files-and-videos/videos-page/data/thunks.js +++ b/src/files-and-videos/videos-page/data/thunks.js @@ -259,8 +259,8 @@ export function uploadVideoTranscript({ dispatch(updateEditStatus({ editType: 'transcript', status: RequestStatus.SUCCESSFUL })); } catch (error) { - if (error.response) { - const message = error.response.data?.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}.`; diff --git a/src/files-and-videos/videos-page/info-sidebar/TranscriptTab.test.jsx b/src/files-and-videos/videos-page/info-sidebar/TranscriptTab.test.jsx index 03649f7398..94e9cf7037 100644 --- a/src/files-and-videos/videos-page/info-sidebar/TranscriptTab.test.jsx +++ b/src/files-and-videos/videos-page/info-sidebar/TranscriptTab.test.jsx @@ -78,162 +78,259 @@ describe('TranscriptTab', () => { axiosMock = new MockAdapter(getAuthenticatedHttpClient()); }); - 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(); - }); + 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 transcrip row', async () => { - renderComponent(defaultProps); - const addButton = screen.getByText(messages.uploadButtonLabel.defaultMessage); - await act(async () => { fireEvent.click(addButton); }); + 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); }); + const deleteButton = screen.getByLabelText('delete empty transcript'); + await act(async () => { fireEvent.click(deleteButton); }); - expect(screen.getByText(transcriptRowMessages.deleteConfirmationHeader.defaultMessage)).toBeVisible(); + expect(screen.getByText(transcriptRowMessages.deleteConfirmationHeader.defaultMessage)).toBeVisible(); - const confirmButton = screen.getByText(transcriptRowMessages.confirmDeleteLabel.defaultMessage); - await act(async () => { fireEvent.click(confirmButton); }); + const confirmButton = screen.getByText(transcriptRowMessages.confirmDeleteLabel.defaultMessage); + await act(async () => { fireEvent.click(confirmButton); }); + + expect(screen.queryByTestId('transcript-')).toBeNull(); + }); - 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(); + }); + }); }); - it('should upload new transcript', async () => { - renderComponent(defaultProps); - const addButton = screen.getByText(messages.uploadButtonLabel.defaultMessage); - axiosMock.onPost(`${getApiBaseUrl()}/transcript_upload/`).reply(204); - const file = new File(['(⌐□_□)'], 'download.srt', { type: 'text/srt' }); - fireEvent.click(addButton); + describe('with one transcripts preloaded', () => { + const updatedProps = { ...defaultProps, transcripts: ['ar'] }; + beforeEach(() => { + renderComponent(updatedProps); + }); - await act(async () => { - const addFileInput = screen.getByLabelText('file-input'); - expect(addFileInput).toBeInTheDocument(); + it('should contain transcript row', () => { + const addButton = screen.getByText(messages.uploadButtonLabel.defaultMessage); + const transcriptRow = screen.getByTestId('transcript-ar'); + expect(addButton).toBeInTheDocument(); + expect(transcriptRow).toBeInTheDocument(); + }); - userEvent.upload(addFileInput, file); + 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(); + }); }); - const addStatus = store.getState().videos.transcriptStatus; - expect(addStatus).toEqual(RequestStatus.SUCCESSFUL); - }); - it('should contain transcript row', () => { - const updatedProps = { ...defaultProps, transcripts: ['ar'] }; - renderComponent(updatedProps); - const addButton = screen.getByText(messages.uploadButtonLabel.defaultMessage); - const transcriptRow = screen.getByTestId('transcript-ar'); - expect(addButton).toBeInTheDocument(); - expect(transcriptRow).toBeInTheDocument(); + 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(); + }); + }); }); - it('should open delete confirmation modal and handle cancel', async () => { - const updatedProps = { ...defaultProps, transcripts: ['ar'] }; - renderComponent(updatedProps); - const menuButton = screen.getByTestId('ar-transcript-menu'); - await waitFor(() => { - fireEvent.click(menuButton); - }); - const deleteButton = screen.getByText(transcriptRowMessages.deleteTranscript.defaultMessage).closest('a'); - fireEvent.click(deleteButton); + 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); + }); - expect(screen.getByText(transcriptRowMessages.deleteConfirmationHeader.defaultMessage)).toBeVisible(); + const englishOption = screen.getByText('English'); + const arabicOption = screen.getAllByRole('button', { name: 'Arabic' })[0]; + await act(async () => { + expect(arabicOption).toHaveClass('disabled'); + fireEvent.click(englishOption); + }); - const cancelButton = screen.getByText(transcriptRowMessages.cancelDeleteLabel.defaultMessage); - fireEvent.click(cancelButton); + const menuButton = screen.getByTestId('fr-transcript-menu'); + await waitFor(() => { + fireEvent.click(menuButton); + }); + const replaceButton = screen.getByText( + transcriptRowMessages.replaceTranscript.defaultMessage, + ).closest('a'); + fireEvent.click(replaceButton); + }); - expect(screen.queryByText(transcriptRowMessages.deleteConfirmationHeader.defaultMessage)).toBeNull(); - }); + it('should replace transcript', async () => { + axiosMock.onPost(`${getApiBaseUrl()}/transcript_upload/`).reply(204); - it('should open delete confirmation modal and handle delete', async () => { - const updatedProps = { ...defaultProps, transcripts: ['ar'] }; - renderComponent(updatedProps); - const menuButton = screen.getByTestId('ar-transcript-menu'); - await waitFor(() => { - fireEvent.click(menuButton); - }); - const deleteButton = screen.getByText(transcriptRowMessages.deleteTranscript.defaultMessage).closest('a'); - fireEvent.click(deleteButton); - - expect(screen.getByText(transcriptRowMessages.deleteConfirmationHeader.defaultMessage)).toBeVisible(); - - 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; + await act(async () => { + const addFileInput = screen.getAllByLabelText('file-input')[0]; + expect(addFileInput).toBeInTheDocument(); - expect(deleteStatus).toEqual(RequestStatus.SUCCESSFUL); + userEvent.upload(addFileInput, file); + }); + const addStatus = store.getState().videos.transcriptStatus; - expect(screen.queryByText(transcriptRowMessages.deleteConfirmationHeader.defaultMessage)).toBeNull(); - }); + expect(addStatus).toEqual(RequestStatus.SUCCESSFUL); - it('should download transcript', async () => { - const updatedProps = { ...defaultProps, transcripts: ['ar'] }; - renderComponent(updatedProps); - const menuButton = screen.getByTestId('ar-transcript-menu'); - await waitFor(() => { - fireEvent.click(menuButton); - }); - const downloadButton = screen.getByText( - transcriptRowMessages.downloadTranscript.defaultMessage, - ).closest('a'); - 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; + const updatedTranscripts = store.getState().models.videos[defaultProps.id].transcripts; - expect(downloadStatus).toEqual(RequestStatus.SUCCESSFUL); - }); - it('should replace transcript', async () => { - const updatedProps = { ...defaultProps, transcripts: ['fr', 'ar'] }; - renderComponent(updatedProps); - const dropdownButton = screen.getAllByTestId('language-select-dropdown')[0]; - await waitFor(() => { - fireEvent.click(dropdownButton); - }); + expect(updatedTranscripts).toEqual(['ar', 'en']); + }); - const englishOption = screen.getByText('English'); - const arabicOption = screen.getAllByRole('button', { name: 'Arabic' })[0]; - await act(async () => { - expect(arabicOption).toHaveClass('disabled'); - fireEvent.click(englishOption); - }); + it('should show error message', async () => { + axiosMock.onPost(`${getApiBaseUrl()}/transcript_upload/`).reply(404); - const menuButton = screen.getByTestId('ar-transcript-menu'); - await waitFor(() => { - fireEvent.click(menuButton); - }); - const replaceButton = screen.getByText( - transcriptRowMessages.replaceTranscript.defaultMessage, - ).closest('a'); - axiosMock.onPost(`${getApiBaseUrl()}/transcript_upload/`).reply(204); - const file = new File(['(⌐□_□)'], 'download.srt', { type: 'text/srt' }); - - await act(async () => { - fireEvent.click(replaceButton); - const addFileInput = screen.getAllByLabelText('file-input')[0]; - expect(addFileInput).toBeInTheDocument(); - - userEvent.upload(addFileInput, file); - }); - const addStatus = store.getState().videos.transcriptStatus; + await act(async () => { + const addFileInput = screen.getAllByLabelText('file-input')[0]; + expect(addFileInput).toBeInTheDocument(); + + userEvent.upload(addFileInput, file); + }); - expect(addStatus).toEqual(RequestStatus.SUCCESSFUL); + const addStatus = store.getState().videos.transcriptStatus; - const updatedTranscripts = store.getState().models.videos[defaultProps.id].transcripts; + expect(addStatus).toEqual(RequestStatus.FAILED); - expect(updatedTranscripts).toEqual(['ar', 'en']); + expect(screen.getAllByText('Failed to replace fr with en.')[0]).toBeVisible(); + }); + }); }); }); From 4fd19de9e04341f812c696272dd1cb4b60b07819 Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Fri, 3 Nov 2023 17:39:36 -0400 Subject: [PATCH 46/46] feat: update page title --- src/files-and-videos/videos-page/messages.js | 2 +- src/header/messages.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/files-and-videos/videos-page/messages.js b/src/files-and-videos/videos-page/messages.js index 85a212954d..b0dea00c22 100644 --- a/src/files-and-videos/videos-page/messages.js +++ b/src/files-and-videos/videos-page/messages.js @@ -3,7 +3,7 @@ import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages({ heading: { id: 'course-authoring.video-uploads.heading', - defaultMessage: 'Video uploads', + defaultMessage: 'Videos', }, transcriptSettingsButtonLabel: { id: 'course-authoring.video-uploads.transcript-settings.button.toggle', diff --git a/src/header/messages.js b/src/header/messages.js index d2dce3ec27..81b5d96f11 100644 --- a/src/header/messages.js +++ b/src/header/messages.js @@ -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': {