feat: add file size and usage metrics (#573)

This commit is contained in:
Kristin Aoki
2023-08-31 12:21:37 -04:00
committed by GitHub
parent ffae3bd868
commit e50b8c7407
13 changed files with 369 additions and 172 deletions

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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,
},
});

View File

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

View File

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

View File

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