feat: add file size and usage metrics (#573)
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {
|
||||
injectIntl,
|
||||
FormattedMessage,
|
||||
@@ -17,37 +18,44 @@ import {
|
||||
CheckboxControl,
|
||||
} from '@edx/paragon';
|
||||
import { ContentCopy, InfoOutline } from '@edx/paragon/icons';
|
||||
import AssetThumbnail from './FileThumbnail';
|
||||
|
||||
import { getFileSizeToClosestByte } from './data/utils';
|
||||
import AssetThumbnail from './FileThumbnail';
|
||||
import messages from './messages';
|
||||
import UsageMetricsMessages from './UsageMetricsMessage';
|
||||
|
||||
const FileInfo = ({
|
||||
asset,
|
||||
isOpen,
|
||||
onClose,
|
||||
handleLockedAsset,
|
||||
usagePathStatus,
|
||||
error,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const [lockedState, setLockedState] = useState(asset.locked);
|
||||
const [lockedState, setLockedState] = useState(asset?.locked);
|
||||
const handleLock = (e) => {
|
||||
const locked = e.target.checked;
|
||||
setLockedState(locked);
|
||||
handleLockedAsset(asset.id, locked);
|
||||
handleLockedAsset(asset?.id, locked);
|
||||
};
|
||||
const fileSize = getFileSizeToClosestByte(asset?.fileSize);
|
||||
|
||||
return (
|
||||
<ModalDialog
|
||||
title={asset.displayName}
|
||||
title={asset?.displayName}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
hasCloseButton
|
||||
data-testid="file-info-modal"
|
||||
>
|
||||
<ModalDialog.Header>
|
||||
<ModalDialog.Title>
|
||||
<div style={{ wordBreak: 'break-word' }}>
|
||||
<Truncate lines={2} className="font-weight-bold small mt-3">
|
||||
{asset.displayName}
|
||||
{asset?.displayName}
|
||||
</Truncate>
|
||||
</div>
|
||||
</ModalDialog.Title>
|
||||
@@ -57,10 +65,10 @@ const FileInfo = ({
|
||||
<div className="row flex-nowrap m-0 mt-4">
|
||||
<div className="col-8 mr-3">
|
||||
<AssetThumbnail
|
||||
thumbnail={asset.thumbnail}
|
||||
externalUrl={asset.externalUrl}
|
||||
displayName={asset.displayName}
|
||||
wrapperType={asset.wrapperType}
|
||||
thumbnail={asset?.thumbnail}
|
||||
externalUrl={asset?.externalUrl}
|
||||
displayName={asset?.displayName}
|
||||
wrapperType={asset?.wrapperType}
|
||||
/>
|
||||
</div>
|
||||
<Stack>
|
||||
@@ -68,7 +76,7 @@ const FileInfo = ({
|
||||
<FormattedMessage {...messages.dateAddedTitle} />
|
||||
</div>
|
||||
<FormattedDate
|
||||
value={asset.dateAdded}
|
||||
value={asset?.dateAdded}
|
||||
year="numeric"
|
||||
month="short"
|
||||
day="2-digit"
|
||||
@@ -78,15 +86,14 @@ const FileInfo = ({
|
||||
<div className="font-weight-bold mt-3">
|
||||
<FormattedMessage {...messages.fileSizeTitle} />
|
||||
</div>
|
||||
{/* {asset.fileSize} */}
|
||||
<hr />
|
||||
<div className="font-weight-bold mt-3">
|
||||
{fileSize}
|
||||
<div className="font-weight-bold border-top mt-3 pt-3">
|
||||
<FormattedMessage {...messages.studioUrlTitle} />
|
||||
</div>
|
||||
<ActionRow>
|
||||
<div style={{ wordBreak: 'break-word' }}>
|
||||
<Truncate lines={1}>
|
||||
{asset.portableUrl}
|
||||
{asset?.portableUrl}
|
||||
</Truncate>
|
||||
</div>
|
||||
<ActionRow.Spacer />
|
||||
@@ -94,7 +101,7 @@ const FileInfo = ({
|
||||
src={ContentCopy}
|
||||
iconAs={Icon}
|
||||
alt={messages.copyStudioUrlTitle.defaultMessage}
|
||||
onClick={() => navigator.clipboard.writeText(asset.portableUrl)}
|
||||
onClick={() => navigator.clipboard.writeText(asset?.portableUrl)}
|
||||
/>
|
||||
</ActionRow>
|
||||
<div className="font-weight-bold mt-3">
|
||||
@@ -103,7 +110,7 @@ const FileInfo = ({
|
||||
<ActionRow>
|
||||
<div style={{ wordBreak: 'break-word' }}>
|
||||
<Truncate lines={1}>
|
||||
{asset.externalUrl}
|
||||
{asset?.externalUrl}
|
||||
</Truncate>
|
||||
</div>
|
||||
<ActionRow.Spacer />
|
||||
@@ -111,11 +118,10 @@ const FileInfo = ({
|
||||
src={ContentCopy}
|
||||
iconAs={Icon}
|
||||
alt={messages.copyWebUrlTitle.defaultMessage}
|
||||
onClick={() => navigator.clipboard.writeText(asset.externalUrl)}
|
||||
onClick={() => navigator.clipboard.writeText(asset?.externalUrl)}
|
||||
/>
|
||||
</ActionRow>
|
||||
<hr />
|
||||
<ActionRow>
|
||||
<ActionRow className=" border-top mt-3 pt-3">
|
||||
<div className="font-weight-bold">
|
||||
<FormattedMessage {...messages.lockFileTitle} />
|
||||
</div>
|
||||
@@ -140,11 +146,11 @@ const FileInfo = ({
|
||||
<div className="row m-0 pt-3 font-weight-bold">
|
||||
<FormattedMessage {...messages.usageTitle} />
|
||||
</div>
|
||||
<UsageMetricsMessages {...{ usageLocations: asset?.usageLocations, usagePathStatus, error }} />
|
||||
</ModalDialog.Body>
|
||||
</ModalDialog>
|
||||
);
|
||||
};
|
||||
|
||||
FileInfo.propTypes = {
|
||||
asset: PropTypes.shape({
|
||||
displayName: PropTypes.string.isRequired,
|
||||
@@ -155,10 +161,14 @@ FileInfo.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
portableUrl: PropTypes.string.isRequired,
|
||||
dateAdded: PropTypes.string.isRequired,
|
||||
fileSize: PropTypes.number.isRequired,
|
||||
usageLocations: PropTypes.arrayOf(PropTypes.string),
|
||||
}).isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
handleLockedAsset: PropTypes.func.isRequired,
|
||||
usagePathStatus: PropTypes.string.isRequired,
|
||||
error: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import _ from 'lodash';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
DataTable,
|
||||
@@ -22,12 +22,14 @@ import {
|
||||
addAssetFile,
|
||||
deleteAssetFile,
|
||||
fetchAssets,
|
||||
getUsagePaths,
|
||||
updateAssetLock,
|
||||
updateAssetOrder,
|
||||
} from './data/thunks';
|
||||
import { sortFiles } from './data/utils';
|
||||
import messages from './messages';
|
||||
|
||||
import FileInfo from './FileInfo';
|
||||
import FileInput, { fileInput } from './FileInput';
|
||||
import FilesAndUploadsProvider from './FilesAndUploadsProvider';
|
||||
import {
|
||||
@@ -52,6 +54,7 @@ const FilesAndUploads = ({
|
||||
};
|
||||
const [currentView, setCurrentView] = useState(defaultVal);
|
||||
const [isDeleteOpen, setDeleteOpen, setDeleteClose] = useToggle(false);
|
||||
const [isAssetInfoOpen, openAssetInfo, closeAssetinfo] = useToggle(false);
|
||||
const [isAddOpen, setAddOpen, setAddClose] = useToggle(false);
|
||||
const [selectedRows, setSelectedRows] = useState([]);
|
||||
const [isDeleteConfirmationOpen, openDeleteConfirmation, closeDeleteConfirmation] = useToggle(false);
|
||||
@@ -65,9 +68,10 @@ const FilesAndUploads = ({
|
||||
loadingStatus,
|
||||
addingStatus: addAssetStatus,
|
||||
deletingStatus: deleteAssetStatus,
|
||||
savingStatus: saveAssetStatus,
|
||||
updatingStatus: updateAssetStatus,
|
||||
usageStatus: usagePathStatus,
|
||||
errors: errorMessages,
|
||||
} = useSelector(state => state.assets);
|
||||
const errorMessages = useSelector(state => state.assets.errors);
|
||||
const fileInputControl = fileInput({
|
||||
onAddFile: (file) => dispatch(addAssetFile(courseId, file, totalCount)),
|
||||
setSelectedRows,
|
||||
@@ -118,6 +122,12 @@ const FilesAndUploads = ({
|
||||
openDeleteConfirmation();
|
||||
};
|
||||
|
||||
const handleOpenAssetInfo = (original) => {
|
||||
setSelectedRows([{ original }]);
|
||||
dispatch(getUsagePaths({ asset: original, courseId, setSelectedRows }));
|
||||
openAssetInfo();
|
||||
};
|
||||
|
||||
const headerActions = ({ selectedFlatRows }) => (
|
||||
<TableActions
|
||||
{...{
|
||||
@@ -137,6 +147,7 @@ const FilesAndUploads = ({
|
||||
{...{
|
||||
handleLockedAsset,
|
||||
handleOpenDeleteConfirmation,
|
||||
handleOpenAssetInfo,
|
||||
className,
|
||||
original,
|
||||
}}
|
||||
@@ -148,6 +159,7 @@ const FilesAndUploads = ({
|
||||
{...{
|
||||
handleLockedAsset,
|
||||
handleOpenDeleteConfirmation,
|
||||
handleOpenAssetInfo,
|
||||
className,
|
||||
original,
|
||||
}}
|
||||
@@ -162,7 +174,6 @@ const FilesAndUploads = ({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FilesAndUploadsProvider courseId={courseId}>
|
||||
<main className="containerpt-5">
|
||||
@@ -171,19 +182,38 @@ const FilesAndUploads = ({
|
||||
hideHeading={false}
|
||||
isError={addAssetStatus === RequestStatus.FAILED}
|
||||
>
|
||||
{intl.formatMessage(messages.errorAlertMessage, { message: errorMessages.upload })}
|
||||
<ul className="p-0">
|
||||
{errorMessages.upload.map(message => (
|
||||
<li style={{ listStyle: 'none' }}>
|
||||
{intl.formatMessage(messages.errorAlertMessage, { message })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ErrorAlert>
|
||||
<ErrorAlert
|
||||
hideHeading={false}
|
||||
isError={deleteAssetStatus === RequestStatus.FAILED}
|
||||
>
|
||||
{intl.formatMessage(messages.errorAlertMessage, { message: errorMessages.delete })}
|
||||
<ul className="p-0">
|
||||
{errorMessages.delete.map(message => (
|
||||
<li style={{ listStyle: 'none' }}>
|
||||
{intl.formatMessage(messages.errorAlertMessage, { message })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ErrorAlert>
|
||||
<ErrorAlert
|
||||
hideHeading={false}
|
||||
isError={saveAssetStatus === RequestStatus.FAILED}
|
||||
isError={updateAssetStatus === RequestStatus.FAILED}
|
||||
>
|
||||
{intl.formatMessage(messages.errorAlertMessage, { message: errorMessages.lock })}
|
||||
<ul className="p-0">
|
||||
{errorMessages.lock.map(message => (
|
||||
<li style={{ listStyle: 'none' }}>
|
||||
{intl.formatMessage(messages.errorAlertMessage, { message })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
</ErrorAlert>
|
||||
<div className="h2">
|
||||
<FormattedMessage {...messages.heading} />
|
||||
@@ -236,28 +266,12 @@ const FilesAndUploads = ({
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
Header: 'Locked',
|
||||
accessor: 'locked',
|
||||
// Filter: CheckboxFilter,
|
||||
// filter: 'exactText',
|
||||
// filterChoices: [
|
||||
// {
|
||||
// name: 'Locked',
|
||||
// value: true,
|
||||
// },
|
||||
// {
|
||||
// name: 'Unlocked',
|
||||
// value: false,
|
||||
// },
|
||||
// ],
|
||||
},
|
||||
]}
|
||||
itemCount={totalCount}
|
||||
pageCount={Math.ceil(totalCount / 50)}
|
||||
data={assets}
|
||||
>
|
||||
{_.isEmpty(assets) && loadingStatus !== RequestStatus.IN_PROGRESS ? (
|
||||
{isEmpty(assets) && loadingStatus !== RequestStatus.IN_PROGRESS ? (
|
||||
<Dropzone
|
||||
data-testid="files-dropzone"
|
||||
onProcessUpload={handleDropzoneAsset}
|
||||
@@ -292,7 +306,16 @@ const FilesAndUploads = ({
|
||||
)}
|
||||
</DataTable>
|
||||
<FileInput fileInput={fileInputControl} />
|
||||
|
||||
{!isEmpty(selectedRows) && (
|
||||
<FileInfo
|
||||
asset={selectedRows[0].original}
|
||||
onClose={closeAssetinfo}
|
||||
isOpen={isAssetInfoOpen}
|
||||
handleLockedAsset={handleLockedAsset}
|
||||
usagePathStatus={usagePathStatus}
|
||||
error={errorMessages.usageMetrics}
|
||||
/>
|
||||
)}
|
||||
<AlertModal
|
||||
title={intl.formatMessage(messages.deleteConfirmationTitle)}
|
||||
isOpen={isDeleteConfirmationOpen}
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
addAssetFile,
|
||||
deleteAssetFile,
|
||||
updateAssetLock,
|
||||
getUsagePaths,
|
||||
} from './data/thunks';
|
||||
import { getAssetsUrl } from './data/api';
|
||||
import messages from './messages';
|
||||
@@ -241,11 +242,20 @@ describe('FilesAndUploads', () => {
|
||||
expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible();
|
||||
const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1');
|
||||
expect(assetMenuButton).toBeVisible();
|
||||
axiosMock.onGet(`${getAssetsUrl(courseId)}mOckID1/usage`).reply(201, { usageLocations: ['subsection - unit / block'] });
|
||||
await waitFor(() => {
|
||||
fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle'));
|
||||
fireEvent.click(screen.getByText('Info'));
|
||||
executeThunk(getUsagePaths({
|
||||
courseId,
|
||||
asset: { id: 'mOckID1', displayName: 'mOckID1' },
|
||||
setSelectedRows: jest.fn(),
|
||||
}), store.dispatch);
|
||||
expect(screen.getAllByLabelText('mOckID1')[0]).toBeVisible();
|
||||
});
|
||||
const { usageStatus } = store.getState().assets;
|
||||
expect(usageStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
expect(screen.getByText('subsection - unit / block')).toBeVisible();
|
||||
});
|
||||
it('should open asset info and handle lock checkbox', async () => {
|
||||
renderComponent();
|
||||
@@ -254,9 +264,15 @@ describe('FilesAndUploads', () => {
|
||||
const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1');
|
||||
expect(assetMenuButton).toBeVisible();
|
||||
axiosMock.onPut(`${getAssetsUrl(courseId)}mOckID1`).reply(201, { locked: false });
|
||||
axiosMock.onGet(`${getAssetsUrl(courseId)}mOckID1/usage`).reply(201, { usageLocations: [] });
|
||||
await waitFor(() => {
|
||||
fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle'));
|
||||
fireEvent.click(screen.getByText('Info'));
|
||||
executeThunk(getUsagePaths({
|
||||
courseId,
|
||||
asset: { id: 'mOckID1', displayName: 'mOckID1' },
|
||||
setSelectedRows: jest.fn(),
|
||||
}), store.dispatch);
|
||||
expect(screen.getAllByLabelText('mOckID1')[0]).toBeVisible();
|
||||
fireEvent.click(screen.getByLabelText('Checkbox'));
|
||||
executeThunk(updateAssetLock({
|
||||
@@ -265,8 +281,9 @@ describe('FilesAndUploads', () => {
|
||||
locked: false,
|
||||
}), store.dispatch);
|
||||
});
|
||||
const saveStatus = store.getState().assets.savingStatus;
|
||||
expect(saveStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
expect(screen.getByText(messages.usageNotInUseMessage.defaultMessage)).toBeVisible();
|
||||
const updateStatus = store.getState().assets.updatingStatus;
|
||||
expect(updateStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
});
|
||||
it('should unlock asset', async () => {
|
||||
renderComponent();
|
||||
@@ -284,8 +301,8 @@ describe('FilesAndUploads', () => {
|
||||
locked: false,
|
||||
}), store.dispatch);
|
||||
});
|
||||
const saveStatus = store.getState().assets.savingStatus;
|
||||
expect(saveStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
const updateStatus = store.getState().assets.updatingStatus;
|
||||
expect(updateStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
});
|
||||
it('should lock asset', async () => {
|
||||
renderComponent();
|
||||
@@ -303,8 +320,8 @@ describe('FilesAndUploads', () => {
|
||||
locked: true,
|
||||
}), store.dispatch);
|
||||
});
|
||||
const saveStatus = store.getState().assets.savingStatus;
|
||||
expect(saveStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
const updateStatus = store.getState().assets.updatingStatus;
|
||||
expect(updateStatus).toEqual(RequestStatus.SUCCESSFUL);
|
||||
});
|
||||
it('delete button should delete file', async () => {
|
||||
renderComponent();
|
||||
@@ -375,6 +392,25 @@ describe('FilesAndUploads', () => {
|
||||
expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible();
|
||||
expect(screen.getByText('Error')).toBeVisible();
|
||||
});
|
||||
it('404 usage path fetch should show error', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
expect(screen.getByTestId('grid-card-mOckID3')).toBeVisible();
|
||||
const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID3');
|
||||
expect(assetMenuButton).toBeVisible();
|
||||
axiosMock.onGet(`${getAssetsUrl(courseId)}mOckID3/usage`).reply(404);
|
||||
await waitFor(() => {
|
||||
fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle'));
|
||||
fireEvent.click(screen.getByText('Info'));
|
||||
executeThunk(getUsagePaths({
|
||||
courseId,
|
||||
asset: { id: 'mOckID3', displayName: 'mOckID3' },
|
||||
setSelectedRows: jest.fn(),
|
||||
}), store.dispatch);
|
||||
});
|
||||
const { usageStatus } = store.getState().assets;
|
||||
expect(usageStatus).toEqual(RequestStatus.FAILED);
|
||||
});
|
||||
it('404 lock update should show error', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
@@ -391,8 +427,8 @@ describe('FilesAndUploads', () => {
|
||||
locked: true,
|
||||
}), store.dispatch);
|
||||
});
|
||||
const saveStatus = store.getState().assets.savingStatus;
|
||||
expect(saveStatus).toEqual(RequestStatus.FAILED);
|
||||
const updateStatus = store.getState().assets.updatingStatus;
|
||||
expect(updateStatus).toEqual(RequestStatus.FAILED);
|
||||
expect(screen.getByText('Error')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
60
src/files-and-uploads/UsageMetricsMessage.jsx
Normal file
60
src/files-and-uploads/UsageMetricsMessage.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Icon, Row, Spinner } from '@edx/paragon';
|
||||
import { ErrorOutline } from '@edx/paragon/icons';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import messages from './messages';
|
||||
|
||||
const UsageMetricsMessage = ({
|
||||
usagePathStatus,
|
||||
usageLocations,
|
||||
error,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
let usageMessage;
|
||||
if (usagePathStatus === RequestStatus.SUCCESSFUL) {
|
||||
usageMessage = isEmpty(usageLocations) ? (
|
||||
<FormattedMessage {...messages.usageNotInUseMessage} />
|
||||
) : (
|
||||
<ul className="p-0">
|
||||
{usageLocations.map((location) => (<li style={{ listStyle: 'none' }}>{location}</li>))}
|
||||
</ul>
|
||||
);
|
||||
} else if (usagePathStatus === RequestStatus.FAILED) {
|
||||
usageMessage = (
|
||||
<Row className="m-0 pt-1">
|
||||
<Icon
|
||||
className="mr-1 text-danger-500"
|
||||
size="sm"
|
||||
src={ErrorOutline}
|
||||
/>
|
||||
{intl.formatMessage(messages.errorAlertMessage, { message: error })}
|
||||
</Row>
|
||||
);
|
||||
} else {
|
||||
usageMessage = (
|
||||
<>
|
||||
<Spinner
|
||||
animation="border"
|
||||
size="sm"
|
||||
className="mie-3"
|
||||
screenReaderText={intl.formatMessage(messages.usageLoadingMessage)}
|
||||
/>
|
||||
<FormattedMessage {...messages.usageLoadingMessage} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
return usageMessage;
|
||||
};
|
||||
|
||||
UsageMetricsMessage.propTypes = {
|
||||
usagePathStatus: PropTypes.string.isRequired,
|
||||
usageLocations: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
error: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(UsageMetricsMessage);
|
||||
@@ -20,6 +20,11 @@ export async function getAssets(courseId, totalCount) {
|
||||
.get(`${getAssetsUrl(courseId)}?page_size=${pageCount}`);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
export async function getAssetUsagePaths({ courseId, assetId }) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(`${getAssetsUrl(courseId)}${assetId}/usage`);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete custom page for provided block.
|
||||
|
||||
@@ -8,13 +8,15 @@ const slice = createSlice({
|
||||
initialState: {
|
||||
assetIds: [],
|
||||
loadingStatus: RequestStatus.IN_PROGRESS,
|
||||
savingStatus: '',
|
||||
updatingStatus: '',
|
||||
addingStatus: '',
|
||||
deletingStatus: '',
|
||||
usageStatus: '',
|
||||
errors: {
|
||||
upload: [],
|
||||
delete: [],
|
||||
lock: [],
|
||||
usageMetrics: [],
|
||||
},
|
||||
totalCount: 0,
|
||||
},
|
||||
@@ -28,8 +30,8 @@ const slice = createSlice({
|
||||
updateLoadingStatus: (state, { payload }) => {
|
||||
state.loadingStatus = payload.status;
|
||||
},
|
||||
updateSavingStatus: (state, { payload }) => {
|
||||
state.savingStatus = payload.status;
|
||||
updateUpdatingStatus: (state, { payload }) => {
|
||||
state.updatingStatus = payload.status;
|
||||
},
|
||||
updateAddingStatus: (state, { payload }) => {
|
||||
state.addingStatus = payload.status;
|
||||
@@ -43,6 +45,9 @@ const slice = createSlice({
|
||||
addAssetSuccess: (state, { payload }) => {
|
||||
state.assetIds = [payload.assetId, ...state.assetIds];
|
||||
},
|
||||
updateUsageStatus: (state, { payload }) => {
|
||||
state.usageStatus = payload.status;
|
||||
},
|
||||
updateErrors: (state, { payload }) => {
|
||||
const { error, message } = payload;
|
||||
const currentErrorState = state.errors[error];
|
||||
@@ -55,11 +60,12 @@ export const {
|
||||
setAssetIds,
|
||||
setTotalCount,
|
||||
updateLoadingStatus,
|
||||
updateSavingStatus,
|
||||
updateUpdatingStatus,
|
||||
deleteAssetSuccess,
|
||||
updateDeletingStatus,
|
||||
addAssetSuccess,
|
||||
updateAddingStatus,
|
||||
updateUsageStatus,
|
||||
updateErrors,
|
||||
} = slice.actions;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '../../generic/model-store';
|
||||
import {
|
||||
getAssets,
|
||||
getAssetUsagePaths,
|
||||
addAsset,
|
||||
deleteAsset,
|
||||
updateLockStatus,
|
||||
@@ -15,12 +16,13 @@ import {
|
||||
setAssetIds,
|
||||
setTotalCount,
|
||||
updateLoadingStatus,
|
||||
updateSavingStatus,
|
||||
updateUpdatingStatus,
|
||||
deleteAssetSuccess,
|
||||
updateDeletingStatus,
|
||||
addAssetSuccess,
|
||||
updateAddingStatus,
|
||||
updateErrors,
|
||||
updateUsageStatus,
|
||||
} from './slice';
|
||||
|
||||
import { updateFileValues } from './utils';
|
||||
@@ -104,7 +106,7 @@ export function addAssetFile(courseId, file, totalCount) {
|
||||
|
||||
export function updateAssetLock({ assetId, courseId, locked }) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
dispatch(updateUpdatingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
await updateLockStatus({ assetId, courseId, locked });
|
||||
@@ -115,11 +117,26 @@ export function updateAssetLock({ assetId, courseId, locked }) {
|
||||
locked,
|
||||
},
|
||||
}));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(updateUpdatingStatus({ 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 }));
|
||||
dispatch(updateUpdatingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getUsagePaths({ asset, courseId, setSelectedRows }) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateUsageStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
const { usageLocations } = await getAssetUsagePaths({ assetId: asset.id, courseId });
|
||||
setSelectedRows([{ original: { ...asset, usageLocations } }]);
|
||||
dispatch(updateUsageStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
dispatch(updateErrors({ error: 'usageMetrics', message: `Failed to get usage metrics for ${asset.displayName}.` }));
|
||||
dispatch(updateUsageStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,7 +24,12 @@ export const updateFileValues = (files) => {
|
||||
const utcDateString = dateAdded.replace(/\bat\b/g, '');
|
||||
const utcDateTime = new Date(utcDateString).toString();
|
||||
|
||||
updatedFiles.push({ ...file, wrapperType, dateAdded: utcDateTime });
|
||||
updatedFiles.push({
|
||||
...file,
|
||||
wrapperType,
|
||||
dateAdded: utcDateTime,
|
||||
usageLocations: [],
|
||||
});
|
||||
});
|
||||
|
||||
return updatedFiles;
|
||||
@@ -46,6 +51,23 @@ export const getSrc = ({ thumbnail, wrapperType, externalUrl }) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const getFileSizeToClosestByte = (fileSize, numberOfDivides = 0) => {
|
||||
if (fileSize > 1000) {
|
||||
const updatedSize = fileSize / 1000;
|
||||
const incrementNumberOfDivides = numberOfDivides + 1;
|
||||
return getFileSizeToClosestByte(updatedSize, incrementNumberOfDivides);
|
||||
}
|
||||
const fileSizeFixedDecimal = Number.parseFloat(fileSize).toFixed(2);
|
||||
switch (numberOfDivides) {
|
||||
case 1:
|
||||
return `${fileSizeFixedDecimal} KB`;
|
||||
case 2:
|
||||
return `${fileSizeFixedDecimal} MB`;
|
||||
default:
|
||||
return `${fileSizeFixedDecimal} B`;
|
||||
}
|
||||
};
|
||||
|
||||
export const sortFiles = (files, sortType) => {
|
||||
const [sort, direction] = sortType.split(',');
|
||||
let sortedFiles;
|
||||
|
||||
21
src/files-and-uploads/data/utils.test.js
Normal file
21
src/files-and-uploads/data/utils.test.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { getFileSizeToClosestByte } from './utils';
|
||||
|
||||
describe('FilesAndUploads utils', () => {
|
||||
describe('getFileSizeToClosestByte', () => {
|
||||
it('should return file size with B for bytes', () => {
|
||||
const expectedSize = '219.00 B';
|
||||
const actualSize = getFileSizeToClosestByte(219);
|
||||
expect(expectedSize).toEqual(actualSize);
|
||||
});
|
||||
it('should return file size with KB for kilobytes', () => {
|
||||
const expectedSize = '21.90 KB';
|
||||
const actualSize = getFileSizeToClosestByte(21900);
|
||||
expect(expectedSize).toEqual(actualSize);
|
||||
});
|
||||
it('should return file size with MB for megabytes', () => {
|
||||
const expectedSize = '2.19 MB';
|
||||
const actualSize = getFileSizeToClosestByte(2190000);
|
||||
expect(expectedSize).toEqual(actualSize);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,13 +10,15 @@ export const initialState = {
|
||||
assets: {
|
||||
assetIds: ['mOckID1'],
|
||||
loadingStatus: 'successful',
|
||||
savingStatus: '',
|
||||
updatingStatus: '',
|
||||
deletingStatus: '',
|
||||
addingStatus: '',
|
||||
usageStatus: '',
|
||||
errors: {
|
||||
upload: [],
|
||||
delete: [],
|
||||
lock: [],
|
||||
usageMetrics: [],
|
||||
},
|
||||
},
|
||||
models: {
|
||||
@@ -31,6 +33,7 @@ export const initialState = {
|
||||
wrapperType: 'document',
|
||||
dateAdded: '',
|
||||
thumbnail: null,
|
||||
fileSize: 1234567,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -47,6 +50,7 @@ export const generateFetchAssetApiResponse = () => ({
|
||||
contentType: 'image/png',
|
||||
dateAdded: '',
|
||||
thumbnail: '/asset',
|
||||
fileSize: 123,
|
||||
},
|
||||
{
|
||||
id: 'mOckID5',
|
||||
@@ -67,6 +71,7 @@ export const generateFetchAssetApiResponse = () => ({
|
||||
contentType: 'application/pdf',
|
||||
dateAdded: 'Aug 17, 2023 at 22:08 UTC',
|
||||
thumbnail: null,
|
||||
fileSize: 1234,
|
||||
},
|
||||
{
|
||||
id: 'mOckID4',
|
||||
@@ -87,6 +92,7 @@ export const generateFetchAssetApiResponse = () => ({
|
||||
contentType: 'application/octet-stream',
|
||||
dateAdded: '',
|
||||
thumbnail: null,
|
||||
fileSize: 0,
|
||||
},
|
||||
{
|
||||
id: 'mOckID6-2',
|
||||
@@ -118,6 +124,7 @@ export const generateNewAssetApiResponse = () => ({
|
||||
thumbnail: '/download.png',
|
||||
locked: false,
|
||||
id: 'mOckID2',
|
||||
fileSize: 1234,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -42,27 +42,27 @@ const messages = defineMessages({
|
||||
defaultMessage: '{message}',
|
||||
},
|
||||
dateAddedTitle: {
|
||||
id: 'course-authoring.files-and-uploads.dateAdded.title',
|
||||
id: 'course-authoring.files-and-uploads.file-info.dateAdded.title',
|
||||
defaultMessage: 'Date added',
|
||||
},
|
||||
fileSizeTitle: {
|
||||
id: 'course-authoring.files-and-uploads.fileSize.title',
|
||||
id: 'course-authoring.files-and-uploads.file-info.fileSize.title',
|
||||
defaultMessage: 'File size',
|
||||
},
|
||||
studioUrlTitle: {
|
||||
id: 'course-authoring.files-and-uploads.studioUrl.title',
|
||||
id: 'course-authoring.files-and-uploads.file-info.studioUrl.title',
|
||||
defaultMessage: 'Studio URL',
|
||||
},
|
||||
webUrlTitle: {
|
||||
id: 'course-authoring.files-and-uploads.webUrl.title',
|
||||
id: 'course-authoring.files-and-uploads.file-info.webUrl.title',
|
||||
defaultMessage: 'Web URL',
|
||||
},
|
||||
lockFileTitle: {
|
||||
id: 'course-authoring.files-and-uploads.lockFile.title',
|
||||
id: 'course-authoring.files-and-uploads.file-info.lockFile.title',
|
||||
defaultMessage: 'Lock file',
|
||||
},
|
||||
lockFileTooltipContent: {
|
||||
id: 'course-authoring.files-and-uploads.lockFile.tooltip.content',
|
||||
id: 'course-authoring.files-and-uploads.file-info.lockFile.tooltip.content',
|
||||
defaultMessage: `By default, anyone can access a file you upload if
|
||||
they know the web URL, even if they are not enrolled in your course.
|
||||
You can prevent outside access to a file by locking the file. When
|
||||
@@ -70,9 +70,17 @@ const messages = defineMessages({
|
||||
in your course and signed in to access the file.`,
|
||||
},
|
||||
usageTitle: {
|
||||
id: 'course-authoring.files-and-uploads.usage.title',
|
||||
id: 'course-authoring.files-and-uploads.file-info.usage.title',
|
||||
defaultMessage: 'Usage',
|
||||
},
|
||||
usageLoadingMessage: {
|
||||
id: 'course-authoring.files-and-uploads.file-info.usage.loading.message',
|
||||
defaultMessage: 'Loading',
|
||||
},
|
||||
usageNotInUseMessage: {
|
||||
id: 'course-authoring.files-and-uploads.file-info.usage.notInUse.message',
|
||||
defaultMessage: 'Currently not in use',
|
||||
},
|
||||
copyStudioUrlTitle: {
|
||||
id: 'course-authoring.files-and-uploads.cardMenu.copyStudioUrlTitle',
|
||||
defaultMessage: 'Copy Studio Url',
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
ActionRow,
|
||||
Icon,
|
||||
Card,
|
||||
useToggle,
|
||||
Chip,
|
||||
Truncate,
|
||||
Image,
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
MoreVert,
|
||||
} from '@edx/paragon/icons';
|
||||
import FileMenu from '../FileMenu';
|
||||
import FileInfo from '../FileInfo';
|
||||
import { getSrc } from '../data/utils';
|
||||
|
||||
const GalleryCard = ({
|
||||
@@ -21,8 +19,8 @@ const GalleryCard = ({
|
||||
original,
|
||||
handleLockedAsset,
|
||||
handleOpenDeleteConfirmation,
|
||||
handleOpenAssetInfo,
|
||||
}) => {
|
||||
const [isAssetInfoOpen, openAssetInfo, closeAssetinfo] = useToggle(false);
|
||||
const lockAsset = () => {
|
||||
const { locked } = original;
|
||||
handleLockedAsset(original.id, !locked);
|
||||
@@ -33,53 +31,45 @@ const GalleryCard = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className={className} data-testid={`grid-card-${original.id}`}>
|
||||
<Card.Header
|
||||
actions={(
|
||||
<ActionRow>
|
||||
<FileMenu
|
||||
externalUrl={original.externalUrl}
|
||||
handleLock={lockAsset}
|
||||
locked={original.locked}
|
||||
openAssetInfo={openAssetInfo}
|
||||
portableUrl={original.portableUrl}
|
||||
iconSrc={MoreVert}
|
||||
id={original.id}
|
||||
openDeleteConfirmation={() => handleOpenDeleteConfirmation([{ original }])}
|
||||
/>
|
||||
</ActionRow>
|
||||
)}
|
||||
/>
|
||||
<Card.Section>
|
||||
<div className="row align-items-center justify-content-center m-0">
|
||||
{original.thumbnail ? (
|
||||
<Image src={src} style={{ height: '76px', width: '135.71px' }} className="border rounded p-1" />
|
||||
) : (
|
||||
<div className="row border justify-content-center align-items-center rounded m-0" style={{ height: '76px', width: '135.71px' }}>
|
||||
<Icon src={src} style={{ height: '48px', width: '48px' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ wordBreak: 'break-word' }}>
|
||||
<Truncate lines={1} className="font-weight-bold small mt-3">
|
||||
{original.displayName}
|
||||
</Truncate>
|
||||
</div>
|
||||
</Card.Section>
|
||||
<Card.Footer>
|
||||
<Chip>
|
||||
{original.wrapperType}
|
||||
</Chip>
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
<FileInfo
|
||||
asset={original}
|
||||
onClose={closeAssetinfo}
|
||||
isOpen={isAssetInfoOpen}
|
||||
handleLockedAsset={handleLockedAsset}
|
||||
<Card className={className} data-testid={`grid-card-${original.id}`}>
|
||||
<Card.Header
|
||||
actions={(
|
||||
<ActionRow>
|
||||
<FileMenu
|
||||
externalUrl={original.externalUrl}
|
||||
handleLock={lockAsset}
|
||||
locked={original.locked}
|
||||
openAssetInfo={() => handleOpenAssetInfo(original)}
|
||||
portableUrl={original.portableUrl}
|
||||
iconSrc={MoreVert}
|
||||
id={original.id}
|
||||
openDeleteConfirmation={() => handleOpenDeleteConfirmation([{ original }])}
|
||||
/>
|
||||
</ActionRow>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
<Card.Section>
|
||||
<div className="row align-items-center justify-content-center m-0">
|
||||
{original.thumbnail ? (
|
||||
<Image src={src} style={{ height: '76px', width: '135.71px' }} className="border rounded p-1" />
|
||||
) : (
|
||||
<div className="row border justify-content-center align-items-center rounded m-0" style={{ height: '76px', width: '135.71px' }}>
|
||||
<Icon src={src} style={{ height: '48px', width: '48px' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ wordBreak: 'break-word' }}>
|
||||
<Truncate lines={1} className="font-weight-bold small mt-3">
|
||||
{original.displayName}
|
||||
</Truncate>
|
||||
</div>
|
||||
</Card.Section>
|
||||
<Card.Footer>
|
||||
<Chip>
|
||||
{original.wrapperType}
|
||||
</Chip>
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -99,6 +89,7 @@ GalleryCard.propTypes = {
|
||||
}).isRequired,
|
||||
handleLockedAsset: PropTypes.func.isRequired,
|
||||
handleOpenDeleteConfirmation: PropTypes.func.isRequired,
|
||||
handleOpenAssetInfo: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default GalleryCard;
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
ActionRow,
|
||||
Icon,
|
||||
Card,
|
||||
useToggle,
|
||||
Chip,
|
||||
Truncate,
|
||||
Image,
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
MoreVert,
|
||||
} from '@edx/paragon/icons';
|
||||
import FileMenu from '../FileMenu';
|
||||
import FileInfo from '../FileInfo';
|
||||
import { getSrc } from '../data/utils';
|
||||
|
||||
const ListCard = ({
|
||||
@@ -21,8 +19,8 @@ const ListCard = ({
|
||||
original,
|
||||
handleLockedAsset,
|
||||
handleOpenDeleteConfirmation,
|
||||
handleOpenAssetInfo,
|
||||
}) => {
|
||||
const [isAssetInfoOpen, openAssetInfo, closeAssetinfo] = useToggle(false);
|
||||
const lockAsset = () => {
|
||||
const { locked } = original;
|
||||
handleLockedAsset(original.id, !locked);
|
||||
@@ -33,55 +31,47 @@ const ListCard = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
className={className}
|
||||
orientation="horizontal"
|
||||
data-testid={`list-card-${original.id}`}
|
||||
>
|
||||
<div className="row align-items-center justify-content-center m-0 p-3">
|
||||
{original.thumbnail ? (
|
||||
<Image src={src} style={{ height: '76px', width: '135.71px' }} className="border rounded p-1" />
|
||||
) : (
|
||||
<div className="row border justify-content-center align-items-center rounded m-0" style={{ height: '76px', width: '135.71px' }}>
|
||||
<Icon src={src} style={{ height: '48px', width: '48px' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Card.Body>
|
||||
<Card.Section>
|
||||
<div style={{ wordBreak: 'break-word' }}>
|
||||
<Truncate lines={1} className="font-weight-bold small mt-3">
|
||||
{original.displayName}
|
||||
</Truncate>
|
||||
</div>
|
||||
<Chip className="mt-3">
|
||||
{original.wrapperType}
|
||||
</Chip>
|
||||
</Card.Section>
|
||||
</Card.Body>
|
||||
<Card.Footer>
|
||||
<ActionRow>
|
||||
<FileMenu
|
||||
externalUrl={original.externalUrl}
|
||||
handleLock={lockAsset}
|
||||
locked={original.locked}
|
||||
openAssetInfo={openAssetInfo}
|
||||
portableUrl={original.portableUrl}
|
||||
iconSrc={MoreVert}
|
||||
id={original.id}
|
||||
openDeleteConfirmation={() => handleOpenDeleteConfirmation([{ original }])}
|
||||
/>
|
||||
</ActionRow>
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
<FileInfo
|
||||
asset={original}
|
||||
onClose={closeAssetinfo}
|
||||
isOpen={isAssetInfoOpen}
|
||||
handleLockedAsset={handleLockedAsset}
|
||||
/>
|
||||
</>
|
||||
<Card
|
||||
className={className}
|
||||
orientation="horizontal"
|
||||
data-testid={`list-card-${original.id}`}
|
||||
>
|
||||
<div className="row align-items-center justify-content-center m-0 p-3">
|
||||
{original.thumbnail ? (
|
||||
<Image src={src} style={{ height: '76px', width: '135.71px' }} className="border rounded p-1" />
|
||||
) : (
|
||||
<div className="row border justify-content-center align-items-center rounded m-0" style={{ height: '76px', width: '135.71px' }}>
|
||||
<Icon src={src} style={{ height: '48px', width: '48px' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Card.Body>
|
||||
<Card.Section>
|
||||
<div style={{ wordBreak: 'break-word' }}>
|
||||
<Truncate lines={1} className="font-weight-bold small mt-3">
|
||||
{original.displayName}
|
||||
</Truncate>
|
||||
</div>
|
||||
<Chip className="mt-3">
|
||||
{original.wrapperType}
|
||||
</Chip>
|
||||
</Card.Section>
|
||||
</Card.Body>
|
||||
<Card.Footer>
|
||||
<ActionRow>
|
||||
<FileMenu
|
||||
externalUrl={original.externalUrl}
|
||||
handleLock={lockAsset}
|
||||
locked={original.locked}
|
||||
openAssetInfo={() => handleOpenAssetInfo(original)}
|
||||
portableUrl={original.portableUrl}
|
||||
iconSrc={MoreVert}
|
||||
id={original.id}
|
||||
openDeleteConfirmation={() => handleOpenDeleteConfirmation([{ original }])}
|
||||
/>
|
||||
</ActionRow>
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -101,6 +91,7 @@ ListCard.propTypes = {
|
||||
}).isRequired,
|
||||
handleLockedAsset: PropTypes.func.isRequired,
|
||||
handleOpenDeleteConfirmation: PropTypes.func.isRequired,
|
||||
handleOpenAssetInfo: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ListCard;
|
||||
|
||||
Reference in New Issue
Block a user