diff --git a/.env.development b/.env.development index 7b74ec350..32c3d9df1 100644 --- a/.env.development +++ b/.env.development @@ -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 diff --git a/package-lock.json b/package-lock.json index f2eed40cf..51adac5b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index 60505424a..4f13c3845 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index d016c5325..580c174f6 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -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 }) => { {process.env.ENABLE_NEW_FILES_UPLOADS_PAGE === 'true' && ( - + )} diff --git a/src/files-and-uploads/ApiStatusToast.jsx b/src/files-and-uploads/ApiStatusToast.jsx new file mode 100644 index 000000000..4d997b7e0 --- /dev/null +++ b/src/files-and-uploads/ApiStatusToast.jsx @@ -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 ( + + {intl.formatMessage(messages.apiStatusToastMessage, { actionType, selectedRowCount })} + + ); +}; + +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); diff --git a/src/files-and-uploads/FileInfo.jsx b/src/files-and-uploads/FileInfo.jsx new file mode 100644 index 000000000..a1ee502ad --- /dev/null +++ b/src/files-and-uploads/FileInfo.jsx @@ -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 ( + + + + {asset.displayName} + + + +
+
+
+ +
+ +
+ +
+ {asset.dateAdded} +
+ +
+ {/* {asset.fileSize} */} +
+
+ +
+ + + {asset.portableUrl} + + + navigator.clipboard.writeText(asset.portableUrl)} + /> + +
+ +
+ + + {asset.externalUrl} + + + navigator.clipboard.writeText(asset.externalUrl)} + /> + +
+ +
+ +
+ + + +
+
+
+
+ +
+
+
+ ); +}; + +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); diff --git a/src/files-and-uploads/FileInput.jsx b/src/files-and-uploads/FileInput.jsx new file mode 100644 index 000000000..9b0b8510d --- /dev/null +++ b/src/files-and-uploads/FileInput.jsx @@ -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 }) => ( + +); + +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; diff --git a/src/files-and-uploads/FileMenu.jsx b/src/files-and-uploads/FileMenu.jsx new file mode 100644 index 000000000..0bfab16fb --- /dev/null +++ b/src/files-and-uploads/FileMenu.jsx @@ -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, +}) => ( + + + + navigator.clipboard.writeText(portableUrl)} + > + {intl.formatMessage(messages.copyStudioUrlTitle)} + + navigator.clipboard.writeText(externalUrl)} + > + {intl.formatMessage(messages.copyWebUrlTitle)} + + + {intl.formatMessage(messages.downloadTitle)} + + + {locked ? intl.formatMessage(messages.unlockMenuTitle) : intl.formatMessage(messages.lockMenuTitle)} + + + {intl.formatMessage(messages.infoTitle)} + + + + {intl.formatMessage(messages.deleteTitle)} + + + +); + +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); diff --git a/src/files-and-uploads/FileThumbnail.jsx b/src/files-and-uploads/FileThumbnail.jsx new file mode 100644 index 000000000..19eedb795 --- /dev/null +++ b/src/files-and-uploads/FileThumbnail.jsx @@ -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, +}) => ( +
+ {thumbnail ? ( + {`Thumbnail + ) : ( +
+ {wrapperType === 'documents' && } + {wrapperType === 'code' && } + {wrapperType === 'audio' && } + {wrapperType === 'other' && } +
+ )} +
+); + +AssetThumbnail.defaultProps = { + thumbnail: null, +}; +AssetThumbnail.propTypes = { + thumbnail: PropTypes.string, + wrapperType: PropTypes.string.isRequired, + externalUrl: PropTypes.string.isRequired, + displayName: PropTypes.string.isRequired, +}; + +export default AssetThumbnail; diff --git a/src/files-and-uploads/FilesAndUploads.jsx b/src/files-and-uploads/FilesAndUploads.jsx new file mode 100644 index 000000000..4dc007c04 --- /dev/null +++ b/src/files-and-uploads/FilesAndUploads.jsx @@ -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 }) => ( + + ); + + const fileCard = ({ className, original }) => { + if (currentView === defaultVal) { + return ( + + ); + } + return ( + + ); + }; + + if (loadingStatus === RequestStatus.DENIED) { + return ( +
+ +
+ ); + } + + return ( + +
+ + {intl.formatMessage(messages.errorAlertMessage, { message: errorMessages.upload })} + + + {intl.formatMessage(messages.errorAlertMessage, { message: errorMessages.delete })} + + + {intl.formatMessage(messages.errorAlertMessage, { message: errorMessages.lock })} + +
+ {intl.formatMessage(messages.subheading)} +
+
+ +
+ 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 ? ( + + ) : ( +
+ + { currentView === 'card' && } + { currentView === 'list' && } + + + + +
+ )} +
+ +
+
+ ); +}; + +FilesAndUploads.propTypes = { + courseId: PropTypes.string.isRequired, + // injected + intl: intlShape.isRequired, +}; + +export default injectIntl(FilesAndUploads); diff --git a/src/files-and-uploads/FilesAndUploads.test.jsx b/src/files-and-uploads/FilesAndUploads.test.jsx new file mode 100644 index 000000000..081502589 --- /dev/null +++ b/src/files-and-uploads/FilesAndUploads.test.jsx @@ -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( + + + + + , + ); +}; + +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(); + }); + }); + }); +}); diff --git a/src/files-and-uploads/FilesAndUploadsProvider.jsx b/src/files-and-uploads/FilesAndUploadsProvider.jsx new file mode 100644 index 000000000..fee357aae --- /dev/null +++ b/src/files-and-uploads/FilesAndUploadsProvider.jsx @@ -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 ( + + {children} + + ); +}; + +FilesAndUploadsProvider.propTypes = { + courseId: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, +}; + +export default FilesAndUploadsProvider; diff --git a/src/files-and-uploads/data/api.js b/src/files-and-uploads/data/api.js new file mode 100644 index 000000000..d3601bbf2 --- /dev/null +++ b/src/files-and-uploads/data/api.js @@ -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); +} diff --git a/src/files-and-uploads/data/constant.js b/src/files-and-uploads/data/constant.js new file mode 100644 index 000000000..9fe5121f3 --- /dev/null +++ b/src/files-and-uploads/data/constant.js @@ -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; diff --git a/src/files-and-uploads/data/slice.js b/src/files-and-uploads/data/slice.js new file mode 100644 index 000000000..00d470b41 --- /dev/null +++ b/src/files-and-uploads/data/slice.js @@ -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; diff --git a/src/files-and-uploads/data/thunks.js b/src/files-and-uploads/data/thunks.js new file mode 100644 index 000000000..21622e6a5 --- /dev/null +++ b/src/files-and-uploads/data/thunks.js @@ -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 })); + } + }; +} diff --git a/src/files-and-uploads/data/utils.js b/src/files-and-uploads/data/utils.js new file mode 100644 index 000000000..7236bcbc5 --- /dev/null +++ b/src/files-and-uploads/data/utils.js @@ -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; + } +}; diff --git a/src/files-and-uploads/factories/mockApiResponses.jsx b/src/files-and-uploads/factories/mockApiResponses.jsx new file mode 100644 index 000000000..a71b60eeb --- /dev/null +++ b/src/files-and-uploads/factories/mockApiResponses.jsx @@ -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; + } +}; diff --git a/src/files-and-uploads/index.js b/src/files-and-uploads/index.js new file mode 100644 index 000000000..c0b84a009 --- /dev/null +++ b/src/files-and-uploads/index.js @@ -0,0 +1 @@ +export { default } from './FilesAndUploads'; diff --git a/src/files-and-uploads/messages.js b/src/files-and-uploads/messages.js new file mode 100644 index 000000000..a1f7c7bb2 --- /dev/null +++ b/src/files-and-uploads/messages.js @@ -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; diff --git a/src/files-and-uploads/table-components/GalleryCard.jsx b/src/files-and-uploads/table-components/GalleryCard.jsx new file mode 100644 index 000000000..d6ca2c637 --- /dev/null +++ b/src/files-and-uploads/table-components/GalleryCard.jsx @@ -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 ( + <> + + + + + )} + /> + + {original.thumbnail ? ( + + ) : ( + + )} + + {original.displayName} + + + + + {original.wrapperType} + + + + + + ); +}; + +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; diff --git a/src/files-and-uploads/table-components/ListCard.jsx b/src/files-and-uploads/table-components/ListCard.jsx new file mode 100644 index 000000000..d9467aa1f --- /dev/null +++ b/src/files-and-uploads/table-components/ListCard.jsx @@ -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 ( + <> + +
+ {original.thumbnail ? ( + + ) : ( + + )} +
+ + + + {original.displayName} + + + {original.wrapperType} + + + + + + + + +
+ + + ); +}; + +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; diff --git a/src/files-and-uploads/table-components/TableActions.jsx b/src/files-and-uploads/table-components/TableActions.jsx new file mode 100644 index 000000000..db4ccdddf --- /dev/null +++ b/src/files-and-uploads/table-components/TableActions.jsx @@ -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, +}) => ( + <> + + + + + + handleBulkDownload(selectedFlatRows)} + disabled={_.isEmpty(selectedFlatRows)} + > + + + + handleBulkDelete(selectedFlatRows)} + disabled={_.isEmpty(selectedFlatRows)} + > + + + + + + +); + +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); diff --git a/src/files-and-uploads/table-components/index.js b/src/files-and-uploads/table-components/index.js new file mode 100644 index 000000000..9df0da049 --- /dev/null +++ b/src/files-and-uploads/table-components/index.js @@ -0,0 +1,9 @@ +import GalleryCard from './GalleryCard'; +import ListCard from './ListCard'; +import TableActions from './TableActions'; + +export { + TableActions, + GalleryCard, + ListCard, +}; diff --git a/src/store.js b/src/store.js index a3a3dc0f5..095e74dab 100644 --- a/src/store.js +++ b/src/store.js @@ -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,