feat: add files and uploads page (#541)
This commit is contained in:
@@ -35,7 +35,7 @@ ENABLE_NEW_EDITOR_PAGES=true
|
||||
ENABLE_NEW_HOME_PAGE = false
|
||||
ENABLE_NEW_COURSE_OUTLINE_PAGE = false
|
||||
ENABLE_NEW_UPDATES_PAGE = false
|
||||
ENABLE_NEW_FILES_UPLOADS_PAGE = false
|
||||
ENABLE_NEW_FILES_UPLOADS_PAGE = true
|
||||
ENABLE_NEW_VIDEO_UPLOAD_PAGE = false
|
||||
ENABLE_NEW_SCHEDULE_DETAILS_PAGE = false
|
||||
ENABLE_NEW_GRADING_PAGE = false
|
||||
|
||||
45
package-lock.json
generated
45
package-lock.json
generated
@@ -14,7 +14,7 @@
|
||||
"@edx/frontend-enterprise-hotjar": "^1.2.1",
|
||||
"@edx/frontend-lib-content-components": "^1.163.1",
|
||||
"@edx/frontend-platform": "4.2.0",
|
||||
"@edx/paragon": "^20.32.0",
|
||||
"@edx/paragon": "^20.45.4",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.28",
|
||||
"@fortawesome/free-brands-svg-icons": "5.11.2",
|
||||
"@fortawesome/free-regular-svg-icons": "5.11.2",
|
||||
@@ -25,6 +25,7 @@
|
||||
"core-js": "3.8.1",
|
||||
"email-validator": "2.0.4",
|
||||
"formik": "2.2.6",
|
||||
"jszip": "^3.10.1",
|
||||
"lodash": "4.17.21",
|
||||
"moment": "2.29.2",
|
||||
"prop-types": "15.7.2",
|
||||
@@ -2527,9 +2528,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@edx/paragon": {
|
||||
"version": "20.35.1",
|
||||
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.35.1.tgz",
|
||||
"integrity": "sha512-hjQlDv4vi9s4asvFG2T8g7kSGwmQvb1IrPYhSGYnysw/UyqKo5ecmVwftNlNdbeFv9jTGJgKRlHHowtVLjQoUA==",
|
||||
"version": "20.45.4",
|
||||
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.45.4.tgz",
|
||||
"integrity": "sha512-ifkkBR4PRGlsFdMwuyYznUMrifyaO9Yy0PyTsP2iD99Pn5ZZMqYOmtvMm8Ek087ABbc/7MIwzxWXMhTvQpNVNw==",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^6.1.1",
|
||||
"@fortawesome/react-fontawesome": "^0.1.18",
|
||||
@@ -7329,7 +7330,6 @@
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cosmiconfig": {
|
||||
@@ -14415,6 +14415,25 @@
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jwt-decode": {
|
||||
"version": "3.1.2",
|
||||
"license": "MIT"
|
||||
@@ -15975,6 +15994,11 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
|
||||
},
|
||||
"node_modules/param-case": {
|
||||
"version": "3.0.4",
|
||||
"dev": true,
|
||||
@@ -17038,7 +17062,6 @@
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prompts": {
|
||||
@@ -18158,7 +18181,6 @@
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
@@ -18172,7 +18194,6 @@
|
||||
},
|
||||
"node_modules/readable-stream/node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
@@ -18695,7 +18716,6 @@
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-json-parse": {
|
||||
@@ -19187,6 +19207,11 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"dev": true,
|
||||
@@ -19996,7 +20021,6 @@
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
@@ -21427,7 +21451,6 @@
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utila": {
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"@edx/frontend-enterprise-hotjar": "^1.2.1",
|
||||
"@edx/frontend-lib-content-components": "^1.163.1",
|
||||
"@edx/frontend-platform": "4.2.0",
|
||||
"@edx/paragon": "^20.32.0",
|
||||
"@edx/paragon": "^20.45.4",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.28",
|
||||
"@fortawesome/free-brands-svg-icons": "5.11.2",
|
||||
"@fortawesome/free-regular-svg-icons": "5.11.2",
|
||||
@@ -50,6 +50,7 @@
|
||||
"core-js": "3.8.1",
|
||||
"email-validator": "2.0.4",
|
||||
"formik": "2.2.6",
|
||||
"jszip": "^3.10.1",
|
||||
"lodash": "4.17.21",
|
||||
"moment": "2.29.2",
|
||||
"prop-types": "15.7.2",
|
||||
|
||||
@@ -9,6 +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 { AdvancedSettings } from './advanced-settings';
|
||||
|
||||
/**
|
||||
@@ -47,7 +48,7 @@ const CourseAuthoringRoutes = ({ courseId }) => {
|
||||
<PageRoute path={`${path}/assets`}>
|
||||
{process.env.ENABLE_NEW_FILES_UPLOADS_PAGE === 'true'
|
||||
&& (
|
||||
<Placeholder />
|
||||
<FilesAndUploads courseId={courseId} />
|
||||
)}
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/videos`}>
|
||||
|
||||
41
src/files-and-uploads/ApiStatusToast.jsx
Normal file
41
src/files-and-uploads/ApiStatusToast.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Toast } from '@edx/paragon';
|
||||
import messages from './messages';
|
||||
|
||||
const ApiStatusToast = ({
|
||||
actionType,
|
||||
selectedRowCount,
|
||||
isOpen,
|
||||
setClose,
|
||||
setSelectedRowCount,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const handleClose = () => {
|
||||
setSelectedRowCount(0);
|
||||
setClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Toast
|
||||
show={isOpen}
|
||||
onClose={handleClose}
|
||||
>
|
||||
{intl.formatMessage(messages.apiStatusToastMessage, { actionType, selectedRowCount })}
|
||||
</Toast>
|
||||
);
|
||||
};
|
||||
|
||||
ApiStatusToast.propTypes = {
|
||||
actionType: PropTypes.string.isRequired,
|
||||
selectedRowCount: PropTypes.number.isRequired,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
setClose: PropTypes.func.isRequired,
|
||||
setSelectedRowCount: PropTypes.func.isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ApiStatusToast);
|
||||
148
src/files-and-uploads/FileInfo.jsx
Normal file
148
src/files-and-uploads/FileInfo.jsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, FormattedMessage, 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 AssetThumbnail from './FileThumbnail';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const FileInfo = ({
|
||||
asset,
|
||||
isOpen,
|
||||
onClose,
|
||||
handleLockedAsset,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const [lockedState, setLockedState] = useState(asset.locked);
|
||||
const handleLock = (e) => {
|
||||
const locked = e.target.checked;
|
||||
setLockedState(locked);
|
||||
handleLockedAsset(asset.id, locked);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalDialog
|
||||
title={asset.displayName}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
hasCloseButton
|
||||
>
|
||||
<ModalDialog.Header>
|
||||
<ModalDialog.Title>
|
||||
{asset.displayName}
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
<ModalDialog.Body className="pt-0 x-small">
|
||||
<hr />
|
||||
<div className="row flex-nowrap m-0 mt-4">
|
||||
<div className="col-7 mr-3">
|
||||
<AssetThumbnail
|
||||
thumbnail={asset.thumbnail}
|
||||
externalUrl={asset.externalUrl}
|
||||
displayName={asset.displayName}
|
||||
wrapperType={asset.wrapperType}
|
||||
/>
|
||||
</div>
|
||||
<Stack>
|
||||
<div className="font-weight-bold">
|
||||
<FormattedMessage {...messages.dateAddedTitle} />
|
||||
</div>
|
||||
{asset.dateAdded}
|
||||
<div className="font-weight-bold mt-3">
|
||||
<FormattedMessage {...messages.fileSizeTitle} />
|
||||
</div>
|
||||
{/* {asset.fileSize} */}
|
||||
<hr />
|
||||
<div className="font-weight-bold mt-3">
|
||||
<FormattedMessage {...messages.studioUrlTitle} />
|
||||
</div>
|
||||
<ActionRow>
|
||||
<Truncate lines={1}>
|
||||
{asset.portableUrl}
|
||||
</Truncate>
|
||||
<ActionRow.Spacer />
|
||||
<IconButton
|
||||
src={ContentCopy}
|
||||
iconAs={Icon}
|
||||
alt={messages.copyStudioUrlTitle.defaultMessage}
|
||||
onClick={() => navigator.clipboard.writeText(asset.portableUrl)}
|
||||
/>
|
||||
</ActionRow>
|
||||
<div className="font-weight-bold mt-3">
|
||||
<FormattedMessage {...messages.webUrlTitle} />
|
||||
</div>
|
||||
<ActionRow>
|
||||
<Truncate lines={1}>
|
||||
{asset.externalUrl}
|
||||
</Truncate>
|
||||
<ActionRow.Spacer />
|
||||
<IconButton
|
||||
src={ContentCopy}
|
||||
iconAs={Icon}
|
||||
alt={messages.copyWebUrlTitle.defaultMessage}
|
||||
onClick={() => navigator.clipboard.writeText(asset.externalUrl)}
|
||||
/>
|
||||
</ActionRow>
|
||||
<hr />
|
||||
<ActionRow>
|
||||
<div className="font-weight-bold">
|
||||
<FormattedMessage {...messages.lockFileTitle} />
|
||||
</div>
|
||||
<IconButtonWithTooltip
|
||||
key="lock-file-info"
|
||||
tooltipPlacement="top"
|
||||
tooltipContent={intl.formatMessage(messages.lockFileTooltipContent)}
|
||||
src={InfoOutline}
|
||||
iconAs={Icon}
|
||||
alt="Info"
|
||||
size="inline"
|
||||
/>
|
||||
<ActionRow.Spacer />
|
||||
<CheckboxControl
|
||||
checked={lockedState}
|
||||
onChange={handleLock}
|
||||
aria-label="Checkbox"
|
||||
/>
|
||||
</ActionRow>
|
||||
</Stack>
|
||||
</div>
|
||||
<div className="row m-0 pt-3 font-weight-bold">
|
||||
<FormattedMessage {...messages.usageTitle} />
|
||||
</div>
|
||||
</ModalDialog.Body>
|
||||
</ModalDialog>
|
||||
);
|
||||
};
|
||||
|
||||
FileInfo.propTypes = {
|
||||
asset: PropTypes.shape({
|
||||
displayName: PropTypes.string.isRequired,
|
||||
wrapperType: PropTypes.string.isRequired,
|
||||
locked: PropTypes.bool.isRequired,
|
||||
externalUrl: PropTypes.string.isRequired,
|
||||
thumbnail: PropTypes.string,
|
||||
id: PropTypes.string.isRequired,
|
||||
portableUrl: PropTypes.string.isRequired,
|
||||
dateAdded: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
handleLockedAsset: PropTypes.func.isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(FileInfo);
|
||||
50
src/files-and-uploads/FileInput.jsx
Normal file
50
src/files-and-uploads/FileInput.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export const fileInput = ({
|
||||
onAddFile,
|
||||
setSelectedRowCount,
|
||||
setAddOpen,
|
||||
}) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const ref = React.useRef();
|
||||
const click = () => ref.current.click();
|
||||
const addFile = (e) => {
|
||||
const { files } = e.target;
|
||||
setSelectedRowCount(files.length);
|
||||
Object.values(files).forEach(file => {
|
||||
onAddFile(file);
|
||||
setAddOpen();
|
||||
});
|
||||
};
|
||||
return {
|
||||
click,
|
||||
addFile,
|
||||
ref,
|
||||
};
|
||||
};
|
||||
|
||||
const FileInput = ({ fileInput: hook }) => (
|
||||
<input
|
||||
aria-label="file-input"
|
||||
className="upload d-none"
|
||||
onChange={hook.addFile}
|
||||
ref={hook.ref}
|
||||
type="file"
|
||||
multiple
|
||||
/>
|
||||
);
|
||||
|
||||
FileInput.propTypes = {
|
||||
fileInput: PropTypes.shape({
|
||||
addFile: PropTypes.func,
|
||||
ref: PropTypes.oneOfType([
|
||||
// Either a function
|
||||
PropTypes.func,
|
||||
// Or the instance of a DOM native element (see the note about SSR)
|
||||
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
|
||||
]),
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default FileInput;
|
||||
73
src/files-and-uploads/FileMenu.jsx
Normal file
73
src/files-and-uploads/FileMenu.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Dropdown,
|
||||
IconButton,
|
||||
Icon,
|
||||
} from '@edx/paragon';
|
||||
import messages from './messages';
|
||||
|
||||
const FileMenu = ({
|
||||
externalUrl,
|
||||
handleDelete,
|
||||
handleLock,
|
||||
locked,
|
||||
openAssetInfo,
|
||||
portableUrl,
|
||||
iconSrc,
|
||||
id,
|
||||
// injected
|
||||
intl,
|
||||
}) => (
|
||||
<Dropdown data-testid={`file-menu-dropdown-${id}`}>
|
||||
<Dropdown.Toggle
|
||||
id={`file-menu-dropdown-${id}`}
|
||||
as={IconButton}
|
||||
src={iconSrc}
|
||||
iconAs={Icon}
|
||||
variant="primary"
|
||||
alt="asset-menu-toggle"
|
||||
/>
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item
|
||||
onClick={() => navigator.clipboard.writeText(portableUrl)}
|
||||
>
|
||||
{intl.formatMessage(messages.copyStudioUrlTitle)}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={() => navigator.clipboard.writeText(externalUrl)}
|
||||
>
|
||||
{intl.formatMessage(messages.copyWebUrlTitle)}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item href={externalUrl} target="_blank" download>
|
||||
{intl.formatMessage(messages.downloadTitle)}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item onClick={handleLock}>
|
||||
{locked ? intl.formatMessage(messages.unlockMenuTitle) : intl.formatMessage(messages.lockMenuTitle)}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item onClick={openAssetInfo}>
|
||||
{intl.formatMessage(messages.infoTitle)}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Divider />
|
||||
<Dropdown.Item onClick={handleDelete}>
|
||||
{intl.formatMessage(messages.deleteTitle)}
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
FileMenu.propTypes = {
|
||||
externalUrl: PropTypes.string.isRequired,
|
||||
handleDelete: PropTypes.func.isRequired,
|
||||
handleLock: PropTypes.func.isRequired,
|
||||
locked: PropTypes.bool.isRequired,
|
||||
openAssetInfo: PropTypes.func.isRequired,
|
||||
portableUrl: PropTypes.string.isRequired,
|
||||
iconSrc: PropTypes.func.isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(FileMenu);
|
||||
44
src/files-and-uploads/FileThumbnail.jsx
Normal file
44
src/files-and-uploads/FileThumbnail.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Icon,
|
||||
Image,
|
||||
} from '@edx/paragon';
|
||||
import {
|
||||
AudioFile,
|
||||
Terminal,
|
||||
// FolderZip,
|
||||
InsertDriveFile,
|
||||
} from '@edx/paragon/icons';
|
||||
|
||||
const AssetThumbnail = ({
|
||||
thumbnail,
|
||||
wrapperType,
|
||||
externalUrl,
|
||||
displayName,
|
||||
}) => (
|
||||
<div className="row justify-content-center">
|
||||
{thumbnail ? (
|
||||
<Image fluid thumbnail src={externalUrl} alt={`Thumbnail of ${displayName}`} />
|
||||
) : (
|
||||
<div className="border rounded p-1">
|
||||
{wrapperType === 'documents' && <Icon src={InsertDriveFile} style={{ height: '48px', width: '48px' }} />}
|
||||
{wrapperType === 'code' && <Icon src={Terminal} style={{ height: '48px', width: '48px' }} />}
|
||||
{wrapperType === 'audio' && <Icon src={AudioFile} style={{ height: '48px', width: '48px' }} />}
|
||||
{wrapperType === 'other' && <Icon src={InsertDriveFile} style={{ height: '48px', width: '48px' }} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
AssetThumbnail.defaultProps = {
|
||||
thumbnail: null,
|
||||
};
|
||||
AssetThumbnail.propTypes = {
|
||||
thumbnail: PropTypes.string,
|
||||
wrapperType: PropTypes.string.isRequired,
|
||||
externalUrl: PropTypes.string.isRequired,
|
||||
displayName: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default AssetThumbnail;
|
||||
270
src/files-and-uploads/FilesAndUploads.jsx
Normal file
270
src/files-and-uploads/FilesAndUploads.jsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import _ from 'lodash';
|
||||
import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
DataTable,
|
||||
TextFilter,
|
||||
CheckboxFilter,
|
||||
Dropzone,
|
||||
CardView,
|
||||
useToggle,
|
||||
} from '@edx/paragon';
|
||||
import Placeholder, { ErrorAlert } from '@edx/frontend-lib-content-components';
|
||||
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import { useModels } from '../generic/model-store';
|
||||
import {
|
||||
addAssetFile,
|
||||
deleteAssetFile,
|
||||
fetchAssets,
|
||||
updateAssetLock,
|
||||
} from './data/thunks';
|
||||
import messages from './messages';
|
||||
|
||||
import FileInput, { fileInput } from './FileInput';
|
||||
import FilesAndUploadsProvider from './FilesAndUploadsProvider';
|
||||
import {
|
||||
GalleryCard,
|
||||
ListCard,
|
||||
TableActions,
|
||||
} from './table-components';
|
||||
import ApiStatusToast from './ApiStatusToast';
|
||||
|
||||
const FilesAndUploads = ({
|
||||
courseId,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const defaultVal = 'card';
|
||||
const columnSizes = { xs: 12, sm: 6, lg: 3 };
|
||||
const [currentView, setCurrentView] = useState(defaultVal);
|
||||
const [isDeleteOpen, setDeleteOpen, setDeleteClose] = useToggle(false);
|
||||
const [isAddOpen, setAddOpen, setAddClose] = useToggle(false);
|
||||
const [selectedRowCount, setSelectedRowCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchAssets(courseId));
|
||||
}, [courseId]);
|
||||
const {
|
||||
totalCount,
|
||||
assetIds,
|
||||
loadingStatus,
|
||||
addingStatus: addAssetStatus,
|
||||
deletingStatus: deleteAssetStatus,
|
||||
savingStatus: saveAssetStatus,
|
||||
} = useSelector(state => state.assets);
|
||||
const errorMessages = useSelector(state => state.assets.errors);
|
||||
const fileInputControl = fileInput({
|
||||
onAddFile: (file) => dispatch(addAssetFile(courseId, file, totalCount)),
|
||||
setSelectedRowCount,
|
||||
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 handleBulkDelete = (selectedFlatRows) => {
|
||||
setSelectedRowCount(selectedFlatRows.length);
|
||||
setDeleteOpen();
|
||||
const assetIdsToDelete = selectedFlatRows.map(row => row.original.id);
|
||||
assetIdsToDelete.forEach(id => dispatch(deleteAssetFile(courseId, id, totalCount)));
|
||||
};
|
||||
|
||||
const handleBulkDownload = (selectedFlatRows) => {
|
||||
selectedFlatRows.forEach(row => {
|
||||
const { externalUrl } = row.original;
|
||||
const link = document.createElement('a');
|
||||
link.target = '_blank';
|
||||
link.download = true;
|
||||
link.href = externalUrl;
|
||||
link.click();
|
||||
});
|
||||
/* ********** TODO ***********
|
||||
* implement a zip file function when there are multiple files
|
||||
*/
|
||||
};
|
||||
|
||||
const handleLockedAsset = (assetId, locked) => {
|
||||
dispatch(updateAssetLock({ courseId, assetId, locked }));
|
||||
};
|
||||
|
||||
const headerActions = ({ selectedFlatRows }) => (
|
||||
<TableActions
|
||||
{...{
|
||||
selectedFlatRows,
|
||||
fileInputControl,
|
||||
handleBulkDelete,
|
||||
handleBulkDownload,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const fileCard = ({ className, original }) => {
|
||||
if (currentView === defaultVal) {
|
||||
return (
|
||||
<GalleryCard
|
||||
{...{
|
||||
handleBulkDelete,
|
||||
handleLockedAsset,
|
||||
className,
|
||||
original,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ListCard
|
||||
{...{
|
||||
handleBulkDelete,
|
||||
handleLockedAsset,
|
||||
className,
|
||||
original,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
if (loadingStatus === RequestStatus.DENIED) {
|
||||
return (
|
||||
<div data-testid="under-construction-placeholder" className="row justify-contnt-center m-6">
|
||||
<Placeholder />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FilesAndUploadsProvider courseId={courseId}>
|
||||
<main className="container p-4 pt-5">
|
||||
<ErrorAlert
|
||||
hideHeading={false}
|
||||
isError={addAssetStatus === RequestStatus.FAILED}
|
||||
>
|
||||
{intl.formatMessage(messages.errorAlertMessage, { message: errorMessages.upload })}
|
||||
</ErrorAlert>
|
||||
<ErrorAlert
|
||||
hideHeading={false}
|
||||
isError={deleteAssetStatus === RequestStatus.FAILED}
|
||||
>
|
||||
{intl.formatMessage(messages.errorAlertMessage, { message: errorMessages.delete })}
|
||||
</ErrorAlert>
|
||||
<ErrorAlert
|
||||
hideHeading={false}
|
||||
isError={saveAssetStatus === RequestStatus.FAILED}
|
||||
>
|
||||
{intl.formatMessage(messages.errorAlertMessage, { message: errorMessages.lock })}
|
||||
</ErrorAlert>
|
||||
<div className="small gray-700">
|
||||
{intl.formatMessage(messages.subheading)}
|
||||
</div>
|
||||
<div className="h2">
|
||||
<FormattedMessage {...messages.heading} />
|
||||
</div>
|
||||
<DataTable
|
||||
isFilterable
|
||||
isLoading={loadingStatus === RequestStatus.IN_PROGRESS}
|
||||
isSortable
|
||||
isSelectable
|
||||
isPaginated
|
||||
defaultColumnValues={{ Filter: TextFilter }}
|
||||
dataViewToggleOptions={{
|
||||
isDataViewToggleEnabled: true,
|
||||
onDataViewToggle: val => setCurrentView(val),
|
||||
defaultActiveStateValue: defaultVal,
|
||||
togglePlacement: 'left',
|
||||
}}
|
||||
initialState={{
|
||||
pageSize: 50,
|
||||
}}
|
||||
tableActions={headerActions}
|
||||
bulkActions={headerActions}
|
||||
columns={[
|
||||
{
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
itemCount={totalCount}
|
||||
pageCount={Math.ceil(totalCount / 50)}
|
||||
data={assets}
|
||||
>
|
||||
{_.isEmpty(assets) && loadingStatus !== RequestStatus.IN_PROGRESS ? (
|
||||
<Dropzone
|
||||
data-testid="files-dropzone"
|
||||
onProcessUpload={handleDropzoneAsset}
|
||||
maxSize={20 * 1048576}
|
||||
errorMessages={{
|
||||
invalidSize: intl.formatMessage(messages.fileSizeError),
|
||||
multipleDragged: 'Dropzone can only upload a single file.',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div data-testid="files-data-table">
|
||||
<DataTable.TableControlBar />
|
||||
{ currentView === 'card' && <CardView CardComponent={fileCard} columnSizes={columnSizes} selectionPlacement="left" skeletonCardCount={4} /> }
|
||||
{ currentView === 'list' && <CardView CardComponent={fileCard} columnSizes={{ xs: 12 }} selectionPlacement="left" skeletonCardCount={4} /> }
|
||||
<DataTable.EmptyTable content={intl.formatMessage(messages.noResultsFoundMessage)} />
|
||||
<DataTable.TableFooter />
|
||||
<ApiStatusToast
|
||||
actionType={intl.formatMessage(messages.apiStatusDeletingAction)}
|
||||
selectedRowCount={selectedRowCount}
|
||||
isOpen={isDeleteOpen}
|
||||
setClose={setDeleteClose}
|
||||
setSelectedRowCount={setSelectedRowCount}
|
||||
/>
|
||||
<ApiStatusToast
|
||||
actionType={intl.formatMessage(messages.apiStatusAddingAction)}
|
||||
selectedRowCount={selectedRowCount}
|
||||
isOpen={isAddOpen}
|
||||
setClose={setAddClose}
|
||||
setSelectedRowCount={setSelectedRowCount}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DataTable>
|
||||
<FileInput fileInput={fileInputControl} />
|
||||
</main>
|
||||
</FilesAndUploadsProvider>
|
||||
);
|
||||
};
|
||||
|
||||
FilesAndUploads.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(FilesAndUploads);
|
||||
363
src/files-and-uploads/FilesAndUploads.test.jsx
Normal file
363
src/files-and-uploads/FilesAndUploads.test.jsx
Normal file
@@ -0,0 +1,363 @@
|
||||
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 FilesAndUploads from './FilesAndUploads';
|
||||
import {
|
||||
generateFetchAssetApiResponse,
|
||||
generateEmptyApiResponse,
|
||||
generateNewAssetApiResponse,
|
||||
getStatusValue,
|
||||
courseId,
|
||||
initialState,
|
||||
} from './factories/mockApiResponses';
|
||||
|
||||
import {
|
||||
fetchAssets,
|
||||
addAssetFile,
|
||||
deleteAssetFile,
|
||||
updateAssetLock,
|
||||
} from './data/thunks';
|
||||
import { getAssetsUrl } from './data/api';
|
||||
import messages from './messages';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
let file;
|
||||
ReactDOM.createPortal = jest.fn(node => node);
|
||||
|
||||
const renderComponent = () => {
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<FilesAndUploads courseId={courseId} />
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
const mockStore = async (
|
||||
status,
|
||||
) => {
|
||||
const fetchAssetsUrl = `${getAssetsUrl(courseId)}?page_size=50`;
|
||||
axiosMock.onGet(fetchAssetsUrl).reply(getStatusValue(status), generateFetchAssetApiResponse());
|
||||
await executeThunk(fetchAssets(courseId), store.dispatch);
|
||||
};
|
||||
|
||||
const emptyMockStore = async (status) => {
|
||||
const fetchAssetsUrl = getAssetsUrl(courseId);
|
||||
axiosMock.onGet(fetchAssetsUrl).reply(getStatusValue(status), generateEmptyApiResponse());
|
||||
await executeThunk(fetchAssets(courseId), store.dispatch);
|
||||
};
|
||||
|
||||
describe('FilesAndUploads', () => {
|
||||
describe('empty state', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore({
|
||||
...initialState,
|
||||
assets: {
|
||||
...initialState.assets,
|
||||
assetIds: [],
|
||||
},
|
||||
models: {},
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
file = new File(['(⌐□_□)'], 'download.png', { type: 'image/png' });
|
||||
});
|
||||
it('should return placeholder component', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.DENIED);
|
||||
expect(screen.getByTestId('under-construction-placeholder')).toBeVisible();
|
||||
});
|
||||
it('should have Files and uploads title', async () => {
|
||||
renderComponent();
|
||||
await emptyMockStore(RequestStatus.SUCCESSFUL);
|
||||
expect(screen.getByText('Files and 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 () => {
|
||||
axiosMock.onPost(getAssetsUrl(courseId)).reply(204, generateNewAssetApiResponse());
|
||||
Object.defineProperty(dropzone, 'files', {
|
||||
value: [file],
|
||||
});
|
||||
fireEvent.drop(dropzone);
|
||||
await executeThunk(addAssetFile(courseId, file, 0), store.dispatch);
|
||||
});
|
||||
const addStatus = store.getState().assets.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.queryByTestId('list-card-mOckID1')).toBeNull();
|
||||
const listButton = screen.getByLabelText('List');
|
||||
await act(async () => {
|
||||
fireEvent.click(listButton);
|
||||
});
|
||||
expect(screen.queryByTestId('grid-card-mOckID1')).toBeNull();
|
||||
expect(screen.getByTestId('list-card-mOckID1')).toBeVisible();
|
||||
});
|
||||
});
|
||||
describe('table actions', () => {
|
||||
it('should upload a single file', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
axiosMock.onPost(getAssetsUrl(courseId)).reply(200, generateNewAssetApiResponse());
|
||||
const addFilesButton = screen.getByLabelText('file-input');
|
||||
await act(async () => {
|
||||
userEvent.upload(addFilesButton, file);
|
||||
await executeThunk(addAssetFile(courseId, file, 1), store.dispatch);
|
||||
});
|
||||
const addStatus = store.getState().assets.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(`${getAssetsUrl(courseId)}mOckID1`).reply(204);
|
||||
await act(async () => {
|
||||
fireEvent.click(deleteButton);
|
||||
await executeThunk(deleteAssetFile(courseId, 'mOckID1', 5), store.dispatch);
|
||||
});
|
||||
const deleteStatus = store.getState().assets.deletingStatus;
|
||||
expect(deleteStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
expect(screen.queryByTestId('grid-card-mOckID1')).toBeNull();
|
||||
});
|
||||
});
|
||||
describe('card menu actions', () => {
|
||||
it('should open asset info', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible();
|
||||
const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1');
|
||||
expect(assetMenuButton).toBeVisible();
|
||||
await waitFor(() => {
|
||||
fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle'));
|
||||
fireEvent.click(screen.getByText('Info'));
|
||||
expect(screen.getAllByLabelText('mOckID1')[0]).toBeVisible();
|
||||
});
|
||||
});
|
||||
it('should open asset info and handle lock checkbox', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible();
|
||||
const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1');
|
||||
expect(assetMenuButton).toBeVisible();
|
||||
axiosMock.onPut(`${getAssetsUrl(courseId)}mOckID1`).reply(201, { locked: false });
|
||||
await waitFor(() => {
|
||||
fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle'));
|
||||
fireEvent.click(screen.getByText('Info'));
|
||||
expect(screen.getAllByLabelText('mOckID1')[0]).toBeVisible();
|
||||
fireEvent.click(screen.getByLabelText('Checkbox'));
|
||||
executeThunk(updateAssetLock({
|
||||
courseId,
|
||||
assetId: 'mOckID1',
|
||||
locked: false,
|
||||
}), store.dispatch);
|
||||
});
|
||||
const saveStatus = store.getState().assets.savingStatus;
|
||||
expect(saveStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
});
|
||||
it('should unlock asset', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible();
|
||||
const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1');
|
||||
expect(assetMenuButton).toBeVisible();
|
||||
await waitFor(() => {
|
||||
axiosMock.onPut(`${getAssetsUrl(courseId)}mOckID1`).reply(201, { locked: false });
|
||||
fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle'));
|
||||
fireEvent.click(screen.getByText('Unlock'));
|
||||
executeThunk(updateAssetLock({
|
||||
courseId,
|
||||
assetId: 'mOckID1',
|
||||
locked: false,
|
||||
}), store.dispatch);
|
||||
});
|
||||
const saveStatus = store.getState().assets.savingStatus;
|
||||
expect(saveStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
});
|
||||
it('should lock asset', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
expect(screen.getByTestId('grid-card-mOckID3')).toBeVisible();
|
||||
const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID3');
|
||||
expect(assetMenuButton).toBeVisible();
|
||||
await waitFor(() => {
|
||||
axiosMock.onPut(`${getAssetsUrl(courseId)}mOckID3`).reply(201, { locked: true });
|
||||
fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle'));
|
||||
fireEvent.click(screen.getByText('Lock'));
|
||||
executeThunk(updateAssetLock({
|
||||
courseId,
|
||||
assetId: 'mOckID3',
|
||||
locked: true,
|
||||
}), store.dispatch);
|
||||
});
|
||||
const saveStatus = store.getState().assets.savingStatus;
|
||||
expect(saveStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
});
|
||||
it('delete button should delete file', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible();
|
||||
const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1');
|
||||
expect(assetMenuButton).toBeVisible();
|
||||
await waitFor(() => {
|
||||
axiosMock.onDelete(`${getAssetsUrl(courseId)}mOckID1`).reply(204);
|
||||
fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle'));
|
||||
fireEvent.click(screen.getByText('Delete'));
|
||||
executeThunk(deleteAssetFile(courseId, 'mOckID1', 5), store.dispatch);
|
||||
});
|
||||
const deleteStatus = store.getState().assets.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 20 MB.';
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
axiosMock.onPost(getAssetsUrl(courseId)).reply(413, { error: errorMessage });
|
||||
const addFilesButton = screen.getByLabelText('file-input');
|
||||
await act(async () => {
|
||||
userEvent.upload(addFilesButton, file);
|
||||
await executeThunk(addAssetFile(courseId, file, 1), store.dispatch);
|
||||
});
|
||||
const addStatus = store.getState().assets.addingStatus;
|
||||
expect(addStatus).toEqual(RequestStatus.FAILED);
|
||||
expect(screen.getByText('Error')).toBeVisible();
|
||||
});
|
||||
it('404 upload should show error', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
axiosMock.onPost(getAssetsUrl(courseId)).reply(404);
|
||||
const addFilesButton = screen.getByLabelText('file-input');
|
||||
await act(async () => {
|
||||
userEvent.upload(addFilesButton, file);
|
||||
await executeThunk(addAssetFile(courseId, file, 1), store.dispatch);
|
||||
});
|
||||
const addStatus = store.getState().assets.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 assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1');
|
||||
expect(assetMenuButton).toBeVisible();
|
||||
await waitFor(() => {
|
||||
axiosMock.onDelete(`${getAssetsUrl(courseId)}mOckID1`).reply(404);
|
||||
fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle'));
|
||||
fireEvent.click(screen.getByText('Delete'));
|
||||
executeThunk(deleteAssetFile(courseId, 'mOckID1', 5), store.dispatch);
|
||||
});
|
||||
const deleteStatus = store.getState().assets.deletingStatus;
|
||||
expect(deleteStatus).toEqual(RequestStatus.FAILED);
|
||||
expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible();
|
||||
expect(screen.getByText('Error')).toBeVisible();
|
||||
});
|
||||
it('404 lock update should show error', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
expect(screen.getByTestId('grid-card-mOckID3')).toBeVisible();
|
||||
const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID3');
|
||||
expect(assetMenuButton).toBeVisible();
|
||||
await waitFor(() => {
|
||||
axiosMock.onPut(`${getAssetsUrl(courseId)}mOckID3`).reply(404);
|
||||
fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle'));
|
||||
fireEvent.click(screen.getByText('Lock'));
|
||||
executeThunk(updateAssetLock({
|
||||
courseId,
|
||||
assetId: 'mOckID3',
|
||||
locked: true,
|
||||
}), store.dispatch);
|
||||
});
|
||||
const saveStatus = store.getState().assets.savingStatus;
|
||||
expect(saveStatus).toEqual(RequestStatus.FAILED);
|
||||
expect(screen.getByText('Error')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
25
src/files-and-uploads/FilesAndUploadsProvider.jsx
Normal file
25
src/files-and-uploads/FilesAndUploadsProvider.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export const FilesAndUploadsContext = React.createContext({});
|
||||
|
||||
const FilesAndUploadsProvider = ({ courseId, children }) => {
|
||||
const contextValue = useMemo(() => ({
|
||||
courseId,
|
||||
path: `/course/${courseId}/assets`,
|
||||
}), []);
|
||||
return (
|
||||
<FilesAndUploadsContext.Provider
|
||||
value={contextValue}
|
||||
>
|
||||
{children}
|
||||
</FilesAndUploadsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
FilesAndUploadsProvider.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export default FilesAndUploadsProvider;
|
||||
58
src/files-and-uploads/data/api.js
Normal file
58
src/files-and-uploads/data/api.js
Normal file
@@ -0,0 +1,58 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { camelCaseObject, ensureConfig, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
ensureConfig([
|
||||
'STUDIO_BASE_URL',
|
||||
], 'Course Apps API service');
|
||||
|
||||
export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
export const getAssetsUrl = (courseId) => `${getApiBaseUrl()}/assets/${courseId}/`;
|
||||
|
||||
/**
|
||||
* Fetches the course custom pages for provided course
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<[{}]>}
|
||||
*/
|
||||
export async function getAssets(courseId, totalCount) {
|
||||
const pageCount = totalCount || 50;
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(`${getAssetsUrl(courseId)}?page_size=${pageCount}`);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete custom page for provided block.
|
||||
* @param {blockId} courseId Course ID for the course to operate on
|
||||
|
||||
*/
|
||||
export async function deleteAsset(courseId, assetId) {
|
||||
await getAuthenticatedHttpClient()
|
||||
.delete(`${getAssetsUrl(courseId)}${assetId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add custom page for provided block.
|
||||
* @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);
|
||||
}
|
||||
38
src/files-and-uploads/data/constant.js
Normal file
38
src/files-and-uploads/data/constant.js
Normal file
@@ -0,0 +1,38 @@
|
||||
const FILES_AND_UPLOAD_TYPE_FILTERS = {
|
||||
images: ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/tiff', 'image/tif', 'image/x-icon',
|
||||
'image/svg+xml', 'image/bmp', 'image/x-ms-bmp', 'image/vnd.microsoft.icon'],
|
||||
documents: [
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.template',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.slideshow',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.template',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.template',
|
||||
'application/msword',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/csv',
|
||||
'application/vnd.ms-excel.sheet.macroEnabled.12',
|
||||
'text/x-tex',
|
||||
'application/x-pdf',
|
||||
'application/vnd.ms-excel.sheet.macroenabled.12',
|
||||
'file/pdf',
|
||||
'image/pdf',
|
||||
'text/csv',
|
||||
'text/pdf',
|
||||
'text/x-sh',
|
||||
'application/pdf',
|
||||
],
|
||||
audio: ['audio/mpeg', 'audio/mp3', 'audio/x-wav', 'audio/ogg', 'audio/wav', 'audio/aac', 'audio/x-m4a',
|
||||
'audio/mp4', 'audio/x-ms-wma'],
|
||||
code: ['application/json', 'text/html', 'text/javascript', 'application/javascript', 'text/css', 'text/x-python',
|
||||
'application/x-java-jnlp-file', 'application/xml', 'application/postscript', 'application/x-javascript',
|
||||
'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'],
|
||||
};
|
||||
|
||||
export default FILES_AND_UPLOAD_TYPE_FILTERS;
|
||||
68
src/files-and-uploads/data/slice.js
Normal file
68
src/files-and-uploads/data/slice.js
Normal file
@@ -0,0 +1,68 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'assets',
|
||||
initialState: {
|
||||
assetIds: [],
|
||||
loadingStatus: RequestStatus.IN_PROGRESS,
|
||||
savingStatus: '',
|
||||
addingStatus: '',
|
||||
deletingStatus: '',
|
||||
errors: {
|
||||
upload: [],
|
||||
delete: [],
|
||||
lock: [],
|
||||
},
|
||||
totalCount: 0,
|
||||
},
|
||||
reducers: {
|
||||
setAssetIds: (state, { payload }) => {
|
||||
state.assetIds = payload.assetIds;
|
||||
},
|
||||
setTotalCount: (state, { payload }) => {
|
||||
state.totalCount = payload.totalCount;
|
||||
},
|
||||
updateLoadingStatus: (state, { payload }) => {
|
||||
state.loadingStatus = payload.status;
|
||||
},
|
||||
updateSavingStatus: (state, { payload }) => {
|
||||
state.savingStatus = payload.status;
|
||||
},
|
||||
updateAddingStatus: (state, { payload }) => {
|
||||
state.addingStatus = payload.status;
|
||||
},
|
||||
updateDeletingStatus: (state, { payload }) => {
|
||||
state.deletingStatus = payload.status;
|
||||
},
|
||||
deleteAssetSuccess: (state, { payload }) => {
|
||||
state.assetIds = state.assetIds.filter(id => id !== payload.assetId);
|
||||
},
|
||||
addAssetSuccess: (state, { payload }) => {
|
||||
state.assetIds = [payload.assetId, ...state.assetIds];
|
||||
},
|
||||
updateErrors: (state, { payload }) => {
|
||||
const { error, message } = payload;
|
||||
const currentErrorState = state.errors[error];
|
||||
state.errors[error] = [...currentErrorState, message];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setAssetIds,
|
||||
setTotalCount,
|
||||
updateLoadingStatus,
|
||||
updateSavingStatus,
|
||||
deleteAssetSuccess,
|
||||
updateDeletingStatus,
|
||||
addAssetSuccess,
|
||||
updateAddingStatus,
|
||||
updateErrors,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
reducer,
|
||||
} = slice;
|
||||
117
src/files-and-uploads/data/thunks.js
Normal file
117
src/files-and-uploads/data/thunks.js
Normal file
@@ -0,0 +1,117 @@
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import {
|
||||
addModel,
|
||||
addModels,
|
||||
removeModel,
|
||||
updateModel,
|
||||
} from '../../generic/model-store';
|
||||
import {
|
||||
getAssets,
|
||||
addAsset,
|
||||
deleteAsset,
|
||||
updateLockStatus,
|
||||
} from './api';
|
||||
import {
|
||||
setAssetIds,
|
||||
setTotalCount,
|
||||
updateLoadingStatus,
|
||||
updateSavingStatus,
|
||||
deleteAssetSuccess,
|
||||
updateDeletingStatus,
|
||||
addAssetSuccess,
|
||||
updateAddingStatus,
|
||||
updateErrors,
|
||||
} from './slice';
|
||||
|
||||
import { getWrapperType } from './utils';
|
||||
|
||||
export function fetchAssets(courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
const { totalCount } = await getAssets(courseId);
|
||||
const { assets } = await getAssets(courseId, totalCount);
|
||||
const assetsWithWraperType = getWrapperType(assets);
|
||||
dispatch(addModels({ modelType: 'assets', models: assetsWithWraperType }));
|
||||
dispatch(setAssetIds({
|
||||
assetIds: assets.map(asset => asset.id),
|
||||
}));
|
||||
dispatch(setTotalCount({ totalCount }));
|
||||
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 deleteAssetFile(courseId, id, totalCount) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateDeletingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
await deleteAsset(courseId, id);
|
||||
dispatch(deleteAssetSuccess({ assetId: id }));
|
||||
dispatch(removeModel({ modelType: 'assets', id }));
|
||||
dispatch(setTotalCount({ totalCount: totalCount - 1 }));
|
||||
dispatch(updateDeletingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
dispatch(updateErrors({ error: 'delete', message: `Failed to delete file id ${id}.` }));
|
||||
dispatch(updateDeletingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function addAssetFile(courseId, file, totalCount) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateAddingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
const { asset } = await addAsset(courseId, file);
|
||||
const [assetsWithWraperType] = getWrapperType([asset]);
|
||||
dispatch(addModel({
|
||||
modelType: 'assets',
|
||||
model: { ...assetsWithWraperType },
|
||||
}));
|
||||
dispatch(addAssetSuccess({
|
||||
assetId: asset.id,
|
||||
}));
|
||||
dispatch(setTotalCount({ totalCount: totalCount + 1 }));
|
||||
dispatch(updateAddingStatus({ courseId, status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
if (error.response && error.response.status === 413) {
|
||||
const message = error.response.data.error;
|
||||
dispatch(updateErrors({ error: 'upload', message }));
|
||||
} else {
|
||||
dispatch(updateErrors({ error: 'upload', message: `Failed to add ${file.name}.` }));
|
||||
}
|
||||
dispatch(updateAddingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function updateAssetLock({ assetId, courseId, locked }) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
await updateLockStatus({ assetId, courseId, locked });
|
||||
dispatch(updateModel({
|
||||
modelType: 'assets',
|
||||
model: {
|
||||
id: assetId,
|
||||
locked,
|
||||
},
|
||||
}));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
const lockStatus = locked ? 'lock' : 'unlock';
|
||||
dispatch(updateErrors({ error: 'lock', message: `Failed to ${lockStatus} file id ${assetId}.` }));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
36
src/files-and-uploads/data/utils.js
Normal file
36
src/files-and-uploads/data/utils.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { InsertDriveFile, Terminal, AudioFile } from '@edx/paragon/icons';
|
||||
import FILES_AND_UPLOAD_TYPE_FILTERS from './constant';
|
||||
|
||||
export const getWrapperType = (assets) => {
|
||||
const assetsWithWraperType = [];
|
||||
assets.forEach(asset => {
|
||||
if (FILES_AND_UPLOAD_TYPE_FILTERS.images.includes(asset.contentType)) {
|
||||
assetsWithWraperType.push({ wrapperType: 'image', ...asset });
|
||||
} else if (FILES_AND_UPLOAD_TYPE_FILTERS.documents.includes(asset.contentType)) {
|
||||
assetsWithWraperType.push({ wrapperType: 'document', ...asset });
|
||||
} else if (FILES_AND_UPLOAD_TYPE_FILTERS.code.includes(asset.contentType)) {
|
||||
assetsWithWraperType.push({ wrapperType: 'code', ...asset });
|
||||
} else if (FILES_AND_UPLOAD_TYPE_FILTERS.audio.includes(asset.contentType)) {
|
||||
assetsWithWraperType.push({ wrapperType: 'audio', ...asset });
|
||||
} else {
|
||||
assetsWithWraperType.push({ wrapperType: 'other', ...asset });
|
||||
}
|
||||
});
|
||||
return assetsWithWraperType;
|
||||
};
|
||||
|
||||
export const getIcon = ({ thumbnail, wrapperType, externalUrl }) => {
|
||||
if (thumbnail) {
|
||||
return externalUrl;
|
||||
}
|
||||
switch (wrapperType) {
|
||||
case 'document':
|
||||
return InsertDriveFile;
|
||||
case 'code':
|
||||
return Terminal;
|
||||
case 'audio':
|
||||
return AudioFile;
|
||||
default:
|
||||
return InsertDriveFile;
|
||||
}
|
||||
};
|
||||
121
src/files-and-uploads/factories/mockApiResponses.jsx
Normal file
121
src/files-and-uploads/factories/mockApiResponses.jsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
|
||||
export const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
|
||||
export const initialState = {
|
||||
courseDetail: {
|
||||
courseId,
|
||||
status: 'sucessful',
|
||||
},
|
||||
assets: {
|
||||
assetIds: ['mOckID1'],
|
||||
loadingStatus: 'successful',
|
||||
savingStatus: '',
|
||||
deletingStatus: '',
|
||||
addingStatus: '',
|
||||
errors: {
|
||||
upload: [],
|
||||
delete: [],
|
||||
lock: [],
|
||||
},
|
||||
},
|
||||
models: {
|
||||
assets: {
|
||||
mOckID1: {
|
||||
id: 'mOckID0',
|
||||
displayName: 'mOckID0',
|
||||
locked: true,
|
||||
externalUrl: 'static_tab_1',
|
||||
portableUrl: '',
|
||||
contentType: 'application/pdf',
|
||||
wrapperType: 'document',
|
||||
dateAdded: '',
|
||||
thumbnail: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const generateFetchAssetApiResponse = () => ({
|
||||
assets: [
|
||||
{
|
||||
id: 'mOckID1',
|
||||
displayName: 'mOckID1',
|
||||
locked: true,
|
||||
externalUrl: 'static_tab_1',
|
||||
portableUrl: '',
|
||||
contentType: 'image/png',
|
||||
dateAdded: '',
|
||||
thumbnail: '/asset',
|
||||
},
|
||||
{
|
||||
id: 'mOckID3',
|
||||
displayName: 'mOckID3',
|
||||
locked: false,
|
||||
externalUrl: 'static_tab_1',
|
||||
portableUrl: '',
|
||||
contentType: 'application/pdf',
|
||||
dateAdded: '',
|
||||
thumbnail: null,
|
||||
},
|
||||
{
|
||||
id: 'mOckID4',
|
||||
displayName: 'mOckID4',
|
||||
locked: false,
|
||||
externalUrl: 'static_tab_1',
|
||||
portableUrl: '',
|
||||
contentType: 'audio/mp3',
|
||||
dateAdded: '',
|
||||
thumbnail: null,
|
||||
},
|
||||
{
|
||||
id: 'mOckID5',
|
||||
displayName: 'mOckID5',
|
||||
locked: false,
|
||||
externalUrl: 'static_tab_1',
|
||||
portableUrl: '',
|
||||
contentType: 'application/json',
|
||||
dateAdded: '',
|
||||
thumbnail: null,
|
||||
},
|
||||
{
|
||||
id: 'mOckID6',
|
||||
displayName: 'mOckID6',
|
||||
locked: false,
|
||||
externalUrl: 'static_tab_1',
|
||||
portableUrl: '',
|
||||
contentType: 'application/octet-stream',
|
||||
dateAdded: '',
|
||||
thumbnail: null,
|
||||
},
|
||||
],
|
||||
totalCount: 50,
|
||||
});
|
||||
|
||||
export const generateEmptyApiResponse = () => ([{
|
||||
assets: [],
|
||||
totalCount: 0,
|
||||
}]);
|
||||
|
||||
export const generateNewAssetApiResponse = () => ({
|
||||
asset: {
|
||||
display_name: 'download.png',
|
||||
content_type: 'image/png',
|
||||
date_added: 'Jul 26, 2023 at 14:07 UTC',
|
||||
url: '/download.png',
|
||||
external_url: 'http://download.png',
|
||||
portable_url: '/static/download.png',
|
||||
thumbnail: '/download.png',
|
||||
locked: false,
|
||||
id: 'mOckID2',
|
||||
},
|
||||
});
|
||||
|
||||
export const getStatusValue = (status) => {
|
||||
switch (status) {
|
||||
case RequestStatus.DENIED:
|
||||
return 403;
|
||||
default:
|
||||
return 200;
|
||||
}
|
||||
};
|
||||
1
src/files-and-uploads/index.js
Normal file
1
src/files-and-uploads/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './FilesAndUploads';
|
||||
106
src/files-and-uploads/messages.js
Normal file
106
src/files-and-uploads/messages.js
Normal file
@@ -0,0 +1,106 @@
|
||||
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)',
|
||||
},
|
||||
apiStatusAddingAction: {
|
||||
id: 'course-authoring.files-and-upload.apiStatus.addingAction.message',
|
||||
defaultMessage: 'Adding',
|
||||
},
|
||||
apiStatusDeletingAction: {
|
||||
id: 'course-authoring.files-and-upload.apiStatus.deletingAction.message',
|
||||
defaultMessage: 'Deleting',
|
||||
},
|
||||
fileSizeError: {
|
||||
id: 'course-authoring.files-and-upload.addFiles.error.fileSize',
|
||||
defaultMessage: 'Uploaded file(s) must be 20 MB or less. Please resize file(s) and try again.',
|
||||
},
|
||||
noResultsFoundMessage: {
|
||||
id: 'course-authoring.files-and-upload.table.noResultsFound.message',
|
||||
defaultMessage: 'No results found',
|
||||
},
|
||||
addFilesButtonLabel: {
|
||||
id: 'course-authoring.files-and-upload.addFiles.button.label',
|
||||
defaultMessage: 'Add files',
|
||||
},
|
||||
actionsButtonLabel: {
|
||||
id: 'course-authoring.files-and-upload.action.button.label',
|
||||
defaultMessage: 'Actions',
|
||||
},
|
||||
errorAlertMessage: {
|
||||
id: 'course-authoring.files-and-upload.errorAlert.message',
|
||||
defaultMessage: '{message}',
|
||||
},
|
||||
dateAddedTitle: {
|
||||
id: 'course-authoring.files-and-uploads.dateAdded.title',
|
||||
defaultMessage: 'Date added',
|
||||
},
|
||||
fileSizeTitle: {
|
||||
id: 'course-authoring.files-and-uploads.fileSize.title',
|
||||
defaultMessage: 'File size',
|
||||
},
|
||||
studioUrlTitle: {
|
||||
id: 'course-authoring.files-and-uploads.studioUrl.title',
|
||||
defaultMessage: 'Studio URL',
|
||||
},
|
||||
webUrlTitle: {
|
||||
id: 'course-authoring.files-and-uploads.webUrl.title',
|
||||
defaultMessage: 'Web URL',
|
||||
},
|
||||
lockFileTitle: {
|
||||
id: 'course-authoring.files-and-uploads.lockFile.title',
|
||||
defaultMessage: 'Lock file',
|
||||
},
|
||||
lockFileTooltipContent: {
|
||||
id: 'course-authoring.files-and-uploads.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.usage.title',
|
||||
defaultMessage: 'Usage',
|
||||
},
|
||||
copyStudioUrlTitle: {
|
||||
id: 'course-authoring.files-and-uploads.cardMenu.copyStudioUrlTitle',
|
||||
defaultMessage: 'Copy Studio Url',
|
||||
},
|
||||
copyWebUrlTitle: {
|
||||
id: 'course-authoring.files-and-uploads.cardMenu.copyWebUrlTitle',
|
||||
defaultMessage: 'Copy Web Url',
|
||||
},
|
||||
downloadTitle: {
|
||||
id: 'course-authoring.files-and-uploads.cardMenu.downloadTitle',
|
||||
defaultMessage: 'Download',
|
||||
},
|
||||
lockMenuTitle: {
|
||||
id: 'course-authoring.files-and-uploads.cardMenu.lockTitle',
|
||||
defaultMessage: 'Lock',
|
||||
},
|
||||
unlockMenuTitle: {
|
||||
id: 'course-authoring.files-and-uploads.cardMenu.unlockTitle',
|
||||
defaultMessage: 'Unlock',
|
||||
},
|
||||
infoTitle: {
|
||||
id: 'course-authoring.files-and-uploads.cardMenu.infoTitle',
|
||||
defaultMessage: 'Info',
|
||||
},
|
||||
deleteTitle: {
|
||||
id: 'course-authoring.files-and-uploads.cardMenu.deleteTitle',
|
||||
defaultMessage: 'Delete',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
101
src/files-and-uploads/table-components/GalleryCard.jsx
Normal file
101
src/files-and-uploads/table-components/GalleryCard.jsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
ActionRow,
|
||||
Icon,
|
||||
Card,
|
||||
useToggle,
|
||||
Chip,
|
||||
Truncate,
|
||||
} from '@edx/paragon';
|
||||
import {
|
||||
MoreVert,
|
||||
} from '@edx/paragon/icons';
|
||||
import FileMenu from '../FileMenu';
|
||||
import FileInfo from '../FileInfo';
|
||||
import { getIcon } from '../data/utils';
|
||||
|
||||
const GalleryCard = ({
|
||||
className,
|
||||
original,
|
||||
handleBulkDelete,
|
||||
handleLockedAsset,
|
||||
}) => {
|
||||
const [isAssetInfoOpen, openAssetInfo, closeAssetinfo] = useToggle(false);
|
||||
const deleteAsset = () => {
|
||||
handleBulkDelete([{ original }]);
|
||||
};
|
||||
const lockAsset = () => {
|
||||
const { locked } = original;
|
||||
handleLockedAsset(original.id, !locked);
|
||||
};
|
||||
const icon = getIcon({
|
||||
thumbnail: original.thumbnail,
|
||||
externalUrl: original.externalUrl,
|
||||
wrapperType: original.wrapperType,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className={className} data-testid={`grid-card-${original.id}`}>
|
||||
<Card.Header
|
||||
actions={(
|
||||
<ActionRow>
|
||||
<FileMenu
|
||||
externalUrl={original.externalUrl}
|
||||
handleDelete={deleteAsset}
|
||||
handleLock={lockAsset}
|
||||
locked={original.locked}
|
||||
openAssetInfo={openAssetInfo}
|
||||
portableUrl={original.portableUrl}
|
||||
iconSrc={MoreVert}
|
||||
id={original.id}
|
||||
/>
|
||||
</ActionRow>
|
||||
)}
|
||||
/>
|
||||
<Card.Section>
|
||||
{original.thumbnail ? (
|
||||
<Card.ImageCap src={original.externalUrl} />
|
||||
) : (
|
||||
<Icon src={icon} style={{ height: '48px', width: '48px' }} />
|
||||
)}
|
||||
<Truncate lines={1} className="font-weight-bold small mt-3">
|
||||
{original.displayName}
|
||||
</Truncate>
|
||||
</Card.Section>
|
||||
<Card.Footer>
|
||||
<Chip>
|
||||
{original.wrapperType}
|
||||
</Chip>
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
<FileInfo
|
||||
asset={original}
|
||||
onClose={closeAssetinfo}
|
||||
isOpen={isAssetInfoOpen}
|
||||
handleLockedAsset={handleLockedAsset}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
GalleryCard.defaultProps = {
|
||||
className: null,
|
||||
};
|
||||
GalleryCard.propTypes = {
|
||||
className: PropTypes.string,
|
||||
original: PropTypes.shape({
|
||||
displayName: PropTypes.string.isRequired,
|
||||
wrapperType: PropTypes.string.isRequired,
|
||||
locked: PropTypes.bool.isRequired,
|
||||
externalUrl: PropTypes.string.isRequired,
|
||||
thumbnail: PropTypes.string,
|
||||
id: PropTypes.string.isRequired,
|
||||
portableUrl: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
handleLockedAsset: PropTypes.func.isRequired,
|
||||
handleBulkDelete: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default GalleryCard;
|
||||
105
src/files-and-uploads/table-components/ListCard.jsx
Normal file
105
src/files-and-uploads/table-components/ListCard.jsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
ActionRow,
|
||||
Icon,
|
||||
Card,
|
||||
useToggle,
|
||||
Chip,
|
||||
Truncate,
|
||||
} from '@edx/paragon';
|
||||
import {
|
||||
MoreVert,
|
||||
} from '@edx/paragon/icons';
|
||||
import FileMenu from '../FileMenu';
|
||||
import FileInfo from '../FileInfo';
|
||||
import { getIcon } from '../data/utils';
|
||||
|
||||
const ListCard = ({
|
||||
className,
|
||||
original,
|
||||
handleBulkDelete,
|
||||
handleLockedAsset,
|
||||
}) => {
|
||||
const [isAssetInfoOpen, openAssetInfo, closeAssetinfo] = useToggle(false);
|
||||
const deleteAsset = () => {
|
||||
handleBulkDelete([{ original }]);
|
||||
};
|
||||
const lockAsset = () => {
|
||||
const { locked } = original;
|
||||
handleLockedAsset(original.id, !locked);
|
||||
};
|
||||
const icon = getIcon({
|
||||
thumbnail: original.thumbnail,
|
||||
externalUrl: original.externalUrl,
|
||||
wrapperType: original.wrapperType,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
className={className}
|
||||
orientation="horizontal"
|
||||
data-testid={`list-card-${original.id}`}
|
||||
>
|
||||
<div className="p-3">
|
||||
{original.thumbnail ? (
|
||||
<Card.ImageCap src={original.externalUrl} />
|
||||
) : (
|
||||
<Icon src={icon} style={{ height: '48px', width: '48px' }} />
|
||||
)}
|
||||
</div>
|
||||
<Card.Body>
|
||||
<Card.Section>
|
||||
<Truncate lines={1} className="font-weight-bold small mt-3">
|
||||
{original.displayName}
|
||||
</Truncate>
|
||||
<Chip className="mt-3">
|
||||
{original.wrapperType}
|
||||
</Chip>
|
||||
</Card.Section>
|
||||
</Card.Body>
|
||||
<Card.Footer>
|
||||
<ActionRow>
|
||||
<FileMenu
|
||||
externalUrl={original.externalUrl}
|
||||
handleDelete={deleteAsset}
|
||||
handleLock={lockAsset}
|
||||
locked={original.locked}
|
||||
openAssetInfo={openAssetInfo}
|
||||
portableUrl={original.portableUrl}
|
||||
iconSrc={MoreVert}
|
||||
id={original.id}
|
||||
/>
|
||||
</ActionRow>
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
<FileInfo
|
||||
asset={original}
|
||||
onClose={closeAssetinfo}
|
||||
isOpen={isAssetInfoOpen}
|
||||
handleLockedAsset={handleLockedAsset}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
handleLockedAsset: PropTypes.func.isRequired,
|
||||
handleBulkDelete: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ListCard;
|
||||
70
src/files-and-uploads/table-components/TableActions.jsx
Normal file
70
src/files-and-uploads/table-components/TableActions.jsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import _ from 'lodash';
|
||||
import { PropTypes } from 'prop-types';
|
||||
import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Dropdown } from '@edx/paragon';
|
||||
import { Add } from '@edx/paragon/icons';
|
||||
import messages from '../messages';
|
||||
|
||||
const TableActions = ({
|
||||
selectedFlatRows,
|
||||
fileInputControl,
|
||||
handleBulkDelete,
|
||||
handleBulkDownload,
|
||||
}) => (
|
||||
<>
|
||||
<Dropdown>
|
||||
<Dropdown.Toggle
|
||||
id="actions-menu-toggle"
|
||||
alt="actions-menu-toggle"
|
||||
variant="outline-primary"
|
||||
>
|
||||
<FormattedMessage {...messages.actionsButtonLabel} />
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item
|
||||
onClick={() => handleBulkDownload(selectedFlatRows)}
|
||||
disabled={_.isEmpty(selectedFlatRows)}
|
||||
>
|
||||
<FormattedMessage {...messages.downloadTitle} />
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Divider />
|
||||
<Dropdown.Item
|
||||
onClick={() => handleBulkDelete(selectedFlatRows)}
|
||||
disabled={_.isEmpty(selectedFlatRows)}
|
||||
>
|
||||
<FormattedMessage {...messages.deleteTitle} />
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
<Button iconBefore={Add} onClick={fileInputControl.click} className="ml-2">
|
||||
<FormattedMessage {...messages.addFilesButtonLabel} />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
TableActions.defaultProps = {
|
||||
selectedFlatRows: null,
|
||||
};
|
||||
TableActions.propTypes = {
|
||||
selectedFlatRows: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
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,
|
||||
}),
|
||||
),
|
||||
fileInputControl: PropTypes.shape({
|
||||
click: PropTypes.func.isRequired,
|
||||
}).isRequired,
|
||||
handleBulkDelete: PropTypes.func.isRequired,
|
||||
handleBulkDownload: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(TableActions);
|
||||
9
src/files-and-uploads/table-components/index.js
Normal file
9
src/files-and-uploads/table-components/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import GalleryCard from './GalleryCard';
|
||||
import ListCard from './ListCard';
|
||||
import TableActions from './TableActions';
|
||||
|
||||
export {
|
||||
TableActions,
|
||||
GalleryCard,
|
||||
ListCard,
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { reducer as pagesAndResourcesReducer } from './pages-and-resources/data/
|
||||
import { reducer as customPagesReducer } from './custom-pages/data/slice';
|
||||
import { reducer as advancedSettingsReducer } from './advanced-settings/data/slice';
|
||||
import { reducer as liveReducer } from './pages-and-resources/live/data/slice';
|
||||
import { reducer as filesReducer } from './files-and-uploads/data/slice';
|
||||
|
||||
export default function initializeStore(preloadedState = undefined) {
|
||||
return configureStore({
|
||||
@@ -14,6 +15,7 @@ export default function initializeStore(preloadedState = undefined) {
|
||||
courseDetail: courseDetailReducer,
|
||||
customPages: customPagesReducer,
|
||||
discussions: discussionsReducer,
|
||||
assets: filesReducer,
|
||||
pagesAndResources: pagesAndResourcesReducer,
|
||||
advancedSettings: advancedSettingsReducer,
|
||||
models: modelsReducer,
|
||||
|
||||
Reference in New Issue
Block a user