feat: add files and uploads page (#541)

This commit is contained in:
Kristin Aoki
2023-08-04 11:57:44 -04:00
committed by GitHub
parent 7fdf8da8ed
commit b9feb50a2c
25 changed files with 1885 additions and 14 deletions

View File

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

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

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

View File

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

View File

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