feat: add file zip on download (#580)

This commit is contained in:
Kristin Aoki
2023-09-05 10:23:47 -04:00
committed by GitHub
parent e50b8c7407
commit ed2eed5110
15 changed files with 396 additions and 63 deletions

6
package-lock.json generated
View File

@@ -24,6 +24,7 @@
"classnames": "2.2.6",
"core-js": "3.8.1",
"email-validator": "2.0.4",
"file-saver": "^2.0.5",
"formik": "2.2.6",
"jszip": "^3.10.1",
"lodash": "4.17.21",
@@ -9885,6 +9886,11 @@
"webpack": "^4.0.0 || ^5.0.0"
}
},
"node_modules/file-saver": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
},
"node_modules/file-selector": {
"version": "0.6.0",
"license": "MIT",

View File

@@ -49,6 +49,7 @@
"classnames": "2.2.6",
"core-js": "3.8.1",
"email-validator": "2.0.4",
"file-saver": "^2.0.5",
"formik": "2.2.6",
"jszip": "^3.10.1",
"lodash": "4.17.21",

View File

@@ -11,6 +11,7 @@ export const RequestStatus = {
FAILED: 'failed',
DENIED: 'denied',
PENDING: 'pending',
CLEAR: 'clear',
};
/**

View File

@@ -1,12 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
export const fileInput = ({
export const useFileInput = ({
onAddFile,
setSelectedRows,
setAddOpen,
}) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const ref = React.useRef();
const click = () => ref.current.click();
const addFile = (e) => {

View File

@@ -12,6 +12,7 @@ const FileMenu = ({
externalUrl,
handleLock,
locked,
onDownload,
openAssetInfo,
openDeleteConfirmation,
portableUrl,
@@ -40,7 +41,7 @@ const FileMenu = ({
>
{intl.formatMessage(messages.copyWebUrlTitle)}
</Dropdown.Item>
<Dropdown.Item href={externalUrl} target="_blank" download>
<Dropdown.Item onClick={onDownload}>
{intl.formatMessage(messages.downloadTitle)}
</Dropdown.Item>
<Dropdown.Item onClick={handleLock}>
@@ -64,6 +65,7 @@ FileMenu.propTypes = {
externalUrl: PropTypes.string.isRequired,
handleLock: PropTypes.func.isRequired,
locked: PropTypes.bool.isRequired,
onDownload: PropTypes.func.isRequired,
openAssetInfo: PropTypes.func.isRequired,
openDeleteConfirmation: PropTypes.func.isRequired,
portableUrl: PropTypes.string.isRequired,

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import isEmpty from 'lodash/isEmpty';
@@ -22,15 +22,17 @@ import {
addAssetFile,
deleteAssetFile,
fetchAssets,
resetErrors,
getUsagePaths,
updateAssetLock,
updateAssetOrder,
fetchAssetDownload,
} from './data/thunks';
import { sortFiles } from './data/utils';
import messages from './messages';
import FileInfo from './FileInfo';
import FileInput, { fileInput } from './FileInput';
import FileInput, { useFileInput } from './FileInput';
import FilesAndUploadsProvider from './FilesAndUploadsProvider';
import {
GalleryCard,
@@ -38,6 +40,7 @@ import {
TableActions,
} from './table-components';
import ApiStatusToast from './ApiStatusToast';
import { clearErrors } from './data/slice';
const FilesAndUploads = ({
courseId,
@@ -72,7 +75,7 @@ const FilesAndUploads = ({
usageStatus: usagePathStatus,
errors: errorMessages,
} = useSelector(state => state.assets);
const fileInputControl = fileInput({
const fileInputControl = useFileInput({
onAddFile: (file) => dispatch(addAssetFile(courseId, file, totalCount)),
setSelectedRows,
setAddOpen,
@@ -95,25 +98,18 @@ const FilesAndUploads = ({
const handleBulkDelete = () => {
closeDeleteConfirmation();
setDeleteOpen();
dispatch(resetErrors({ errorType: 'delete' }));
const assetIdsToDelete = selectedRows.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 handleBulkDownload = useCallback(async (selectedFlatRows) => {
dispatch(resetErrors({ errorType: 'download' }));
dispatch(fetchAssetDownload({ selectedRows: selectedFlatRows, courseId }));
}, []);
const handleLockedAsset = (assetId, locked) => {
dispatch(clearErrors({ errorType: 'lock' }));
dispatch(updateAssetLock({ courseId, assetId, locked }));
};
@@ -123,6 +119,7 @@ const FilesAndUploads = ({
};
const handleOpenAssetInfo = (original) => {
dispatch(resetErrors({ errorType: 'usageMetrics' }));
setSelectedRows([{ original }]);
dispatch(getUsagePaths({ asset: original, courseId, setSelectedRows }));
openAssetInfo();
@@ -146,6 +143,7 @@ const FilesAndUploads = ({
<GalleryCard
{...{
handleLockedAsset,
handleBulkDownload,
handleOpenDeleteConfirmation,
handleOpenAssetInfo,
className,
@@ -158,6 +156,7 @@ const FilesAndUploads = ({
<ListCard
{...{
handleLockedAsset,
handleBulkDownload,
handleOpenDeleteConfirmation,
handleOpenAssetInfo,
className,
@@ -183,8 +182,8 @@ const FilesAndUploads = ({
isError={addAssetStatus === RequestStatus.FAILED}
>
<ul className="p-0">
{errorMessages.upload.map(message => (
<li style={{ listStyle: 'none' }}>
{errorMessages.add.map(message => (
<li key={`add-error-${message}`} style={{ listStyle: 'none' }}>
{intl.formatMessage(messages.errorAlertMessage, { message })}
</li>
))}
@@ -196,7 +195,7 @@ const FilesAndUploads = ({
>
<ul className="p-0">
{errorMessages.delete.map(message => (
<li style={{ listStyle: 'none' }}>
<li key={`delete-error-${message}`} style={{ listStyle: 'none' }}>
{intl.formatMessage(messages.errorAlertMessage, { message })}
</li>
))}
@@ -208,12 +207,16 @@ const FilesAndUploads = ({
>
<ul className="p-0">
{errorMessages.lock.map(message => (
<li style={{ listStyle: 'none' }}>
<li key={`lock-error-${message}`} style={{ listStyle: 'none' }}>
{intl.formatMessage(messages.errorAlertMessage, { message })}
</li>
))}
{errorMessages.download.map(message => (
<li key={`download-error-${message}`} style={{ listStyle: 'none' }}>
{intl.formatMessage(messages.errorAlertMessage, { message })}
</li>
))}
</ul>
</ErrorAlert>
<div className="h2">
<FormattedMessage {...messages.heading} />

View File

@@ -8,6 +8,7 @@ import {
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ReactDOM from 'react-dom';
import { saveAs } from 'file-saver';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
@@ -42,6 +43,7 @@ let axiosMock;
let store;
let file;
ReactDOM.createPortal = jest.fn(node => node);
jest.mock('file-saver');
const renderComponent = () => {
render(
@@ -89,22 +91,27 @@ describe('FilesAndUploads', () => {
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);
@@ -119,10 +126,13 @@ describe('FilesAndUploads', () => {
});
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({
@@ -137,27 +147,39 @@ describe('FilesAndUploads', () => {
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
file = new File(['(⌐□_□)'], 'download.png', { type: 'image/png' });
});
afterEach(() => {
saveAs.mockClear();
});
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();
@@ -171,17 +193,21 @@ describe('FilesAndUploads', () => {
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);
@@ -189,59 +215,113 @@ describe('FilesAndUploads', () => {
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 waitFor(() => {
fireEvent.click(deleteButton);
expect(screen.getByText(messages.deleteConfirmationTitle.defaultMessage)).toBeVisible();
fireEvent.click(screen.getByText(messages.deleteFileButtonLabel.defaultMessage));
expect(screen.queryByText(messages.deleteConfirmationTitle.defaultMessage)).toBeNull();
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();
});
it('download button should be enabled and download single 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 downloadButton = screen.getByText(messages.downloadTitle.defaultMessage).closest('a');
expect(downloadButton).not.toHaveClass('disabled');
fireEvent.click(downloadButton);
expect(saveAs).toHaveBeenCalled();
});
it('download button should be enabled and download multiple selected files', async () => {
renderComponent();
await mockStore(RequestStatus.SUCCESSFUL);
const selectCardButtons = screen.getAllByTestId('datatable-select-column-checkbox-cell');
fireEvent.click(selectCardButtons[0]);
fireEvent.click(selectCardButtons[1]);
const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage);
expect(actionsButton).toBeVisible();
await waitFor(() => {
fireEvent.click(actionsButton);
});
const mockResponseData = { ok: true, blob: () => 'Data' };
const mockFetchResponse = Promise.resolve(mockResponseData);
const downloadButton = screen.getByText(messages.downloadTitle.defaultMessage).closest('a');
expect(downloadButton).not.toHaveClass('disabled');
global.fetch = jest.fn().mockImplementation(() => mockFetchResponse);
fireEvent.click(downloadButton);
expect(fetch).toHaveBeenCalledTimes(2);
});
it('sort button should be enabled and sort files by name', async () => {
renderComponent();
await mockStore(RequestStatus.SUCCESSFUL);
const sortsButton = screen.getByText(messages.sortButtonLabel.defaultMessage);
expect(sortsButton).toBeVisible();
await waitFor(() => {
fireEvent.click(sortsButton);
expect(screen.getByText(messages.sortModalTitleLabel.defaultMessage)).toBeVisible();
});
const sortNameAscendingButton = screen.getByText(messages.sortByNameAscending.defaultMessage);
fireEvent.click(sortNameAscendingButton);
fireEvent.click(screen.getByText(messages.applySortButton.defaultMessage));
expect(screen.queryByText(messages.sortModalTitleLabel.defaultMessage)).toBeNull();
});
it('sort button should be enabled and sort files by file size', async () => {
renderComponent();
await mockStore(RequestStatus.SUCCESSFUL);
const sortsButton = screen.getByText(messages.sortButtonLabel.defaultMessage);
expect(sortsButton).toBeVisible();
await waitFor(() => {
fireEvent.click(sortsButton);
expect(screen.getByText(messages.sortModalTitleLabel.defaultMessage)).toBeVisible();
});
const sortBySizeDescendingButton = screen.getByText(messages.sortBySizeDescending.defaultMessage);
fireEvent.click(sortBySizeDescendingButton);
fireEvent.click(screen.getByText(messages.applySortButton.defaultMessage));
expect(screen.queryByText(messages.sortModalTitleLabel.defaultMessage)).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();
axiosMock.onGet(`${getAssetsUrl(courseId)}mOckID1/usage`).reply(201, { usageLocations: ['subsection - unit / block'] });
await waitFor(() => {
fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle'));
@@ -253,16 +333,19 @@ describe('FilesAndUploads', () => {
}), 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();
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 });
axiosMock.onGet(`${getAssetsUrl(courseId)}mOckID1/usage`).reply(201, { usageLocations: [] });
await waitFor(() => {
@@ -274,6 +357,7 @@ describe('FilesAndUploads', () => {
setSelectedRows: jest.fn(),
}), store.dispatch);
expect(screen.getAllByLabelText('mOckID1')[0]).toBeVisible();
fireEvent.click(screen.getByLabelText('Checkbox'));
executeThunk(updateAssetLock({
courseId,
@@ -282,15 +366,19 @@ describe('FilesAndUploads', () => {
}), store.dispatch);
});
expect(screen.getByText(messages.usageNotInUseMessage.defaultMessage)).toBeVisible();
const updateStatus = store.getState().assets.updatingStatus;
expect(updateStatus).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'));
@@ -304,12 +392,15 @@ describe('FilesAndUploads', () => {
const updateStatus = store.getState().assets.updatingStatus;
expect(updateStatus).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'));
@@ -323,26 +414,48 @@ describe('FilesAndUploads', () => {
const updateStatus = store.getState().assets.updatingStatus;
expect(updateStatus).toEqual(RequestStatus.SUCCESSFUL);
});
it('download button should download 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(() => {
fireEvent.click(within(assetMenuButton).getByLabelText('asset-menu-toggle'));
fireEvent.click(screen.getByText('Download'));
});
expect(saveAs).toHaveBeenCalled();
});
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.getByTestId('open-delete-confirmation-button'));
expect(screen.getByText(messages.deleteConfirmationTitle.defaultMessage)).toBeVisible();
fireEvent.click(screen.getByText(messages.deleteFileButtonLabel.defaultMessage));
expect(screen.queryByText(messages.deleteConfirmationTitle.defaultMessage)).toBeNull();
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.';
@@ -356,8 +469,10 @@ describe('FilesAndUploads', () => {
});
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);
@@ -369,35 +484,45 @@ describe('FilesAndUploads', () => {
});
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.getByTestId('open-delete-confirmation-button'));
expect(screen.getByText(messages.deleteConfirmationTitle.defaultMessage)).toBeVisible();
fireEvent.click(screen.getByText(messages.deleteFileButtonLabel.defaultMessage));
expect(screen.queryByText(messages.deleteConfirmationTitle.defaultMessage)).toBeNull();
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 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'));
@@ -411,12 +536,15 @@ describe('FilesAndUploads', () => {
const { usageStatus } = store.getState().assets;
expect(usageStatus).toEqual(RequestStatus.FAILED);
});
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'));
@@ -429,6 +557,36 @@ describe('FilesAndUploads', () => {
});
const updateStatus = store.getState().assets.updatingStatus;
expect(updateStatus).toEqual(RequestStatus.FAILED);
expect(screen.getByText('Error')).toBeVisible();
});
it('multiple asset file fetch failure should show error', async () => {
renderComponent();
await mockStore(RequestStatus.SUCCESSFUL);
const selectCardButtons = screen.getAllByTestId('datatable-select-column-checkbox-cell');
fireEvent.click(selectCardButtons[0]);
fireEvent.click(selectCardButtons[1]);
const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage);
expect(actionsButton).toBeVisible();
await waitFor(() => {
fireEvent.click(actionsButton);
});
const mockResponseData = { ok: false };
const mockFetchResponse = Promise.resolve(mockResponseData);
const downloadButton = screen.getByText(messages.downloadTitle.defaultMessage).closest('a');
expect(downloadButton).not.toHaveClass('disabled');
global.fetch = jest.fn().mockImplementation(() => mockFetchResponse);
await waitFor(() => {
fireEvent.click(downloadButton);
expect(fetch).toHaveBeenCalledTimes(2);
});
const updateStatus = store.getState().assets.updatingStatus;
expect(updateStatus).toEqual(RequestStatus.FAILED);
expect(screen.getByText('Error')).toBeVisible();
});
});

View File

@@ -19,7 +19,11 @@ const UsageMetricsMessage = ({
<FormattedMessage {...messages.usageNotInUseMessage} />
) : (
<ul className="p-0">
{usageLocations.map((location) => (<li style={{ listStyle: 'none' }}>{location}</li>))}
{usageLocations.map(location => (
<li key={`usage-location-${location}`} style={{ listStyle: 'none' }}>
{location}
</li>
))}
</ul>
);
} else if (usagePathStatus === RequestStatus.FAILED) {

View File

@@ -2,6 +2,9 @@
import { camelCaseObject, ensureConfig, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import JSZip from 'jszip';
import saveAs from 'file-saver';
ensureConfig([
'STUDIO_BASE_URL',
], 'Course Apps API service');
@@ -20,6 +23,62 @@ export async function getAssets(courseId, totalCount) {
.get(`${getAssetsUrl(courseId)}?page_size=${pageCount}`);
return camelCaseObject(data);
}
/**
* Fetch asset file.
* @param {blockId} courseId Course ID for the course to operate on
*/
export async function getDownload(selectedRows, courseId) {
const downloadErrors = [];
if (selectedRows?.length > 1) {
const zip = new JSZip();
const date = new Date().toString();
const folder = zip.folder(`${courseId}-assets-${date}`);
const assetNames = [];
const assetFetcher = await Promise.allSettled(
selectedRows.map(async (row) => {
const asset = row?.original;
try {
assetNames.push(asset.displayName);
const res = await fetch(`${getApiBaseUrl()}/${asset.id}`);
if (!res.ok) {
throw new Error();
}
return res.blob();
} catch (error) {
downloadErrors.push(`Failed to download ${asset?.displayName}.`);
return null;
}
}),
);
const definedAssets = assetFetcher.filter(asset => asset.value !== null);
if (definedAssets.length > 0) {
definedAssets.forEach((assetBlob, index) => {
folder.file(assetNames[index], assetBlob.value, { blob: true });
});
zip.generateAsync({ type: 'blob' }).then(content => {
saveAs(content, `${courseId}-assets-${date}.zip`);
});
}
} else if (selectedRows?.length === 1) {
const asset = selectedRows[0].original;
try {
saveAs(`${getApiBaseUrl()}/${asset.id}`, asset.displayName);
} catch (error) {
downloadErrors.push(`Failed to download ${asset?.displayName}.`);
}
} else {
downloadErrors.push('No files were selected to download');
}
return downloadErrors;
}
/**
* Fetch where asset is used in a course.
* @param {blockId} courseId Course ID for the course to operate on
*/
export async function getAssetUsagePaths({ courseId, assetId }) {
const { data } = await getAuthenticatedHttpClient()
.get(`${getAssetsUrl(courseId)}${assetId}/usage`);
@@ -27,7 +86,7 @@ export async function getAssetUsagePaths({ courseId, assetId }) {
}
/**
* Delete custom page for provided block.
* Delete asset to course.
* @param {blockId} courseId Course ID for the course to operate on
*/
@@ -37,7 +96,7 @@ export async function deleteAsset(courseId, assetId) {
}
/**
* Add custom page for provided block.
* Add asset to course.
* @param {blockId} courseId Course ID for the course to operate on
*/

View File

@@ -0,0 +1,57 @@
import { getDownload } from './api';
import 'file-saver';
jest.mock('file-saver');
describe('api.js', () => {
describe('getDownload', () => {
describe('selectedRows length is undefined or less than zero', () => {
it('should return with no files selected error if selectedRows is empty', async () => {
const expected = ['No files were selected to download'];
const actual = await getDownload([], 'courseId');
expect(actual).toEqual(expected);
});
it('should return with no files selected error if selectedRows is null', async () => {
const expected = ['No files were selected to download'];
const actual = await getDownload(null, 'courseId');
expect(actual).toEqual(expected);
});
});
describe('selectedRows length is greater than one', () => {
it('should not throw error when blob returns null', async () => {
const mockResponseData = { ok: true, blob: () => null };
const mockFetchResponse = Promise.resolve(mockResponseData);
global.fetch = jest.fn().mockImplementation(() => mockFetchResponse);
const expected = [];
const actual = await getDownload([
{ original: { displayName: 'test1' } },
{ original: { displayName: 'test2', id: '2' } },
]);
expect(actual).toEqual(expected);
});
it('should return error if row does not contain .original ancestor', async () => {
const mockResponseData = { ok: true, blob: () => 'data' };
const mockFetchResponse = Promise.resolve(mockResponseData);
global.fetch = jest.fn().mockImplementation(() => mockFetchResponse);
const expected = ['Failed to download undefined.'];
const actual = await getDownload([
{ asset: { displayName: 'test1', id: '1' } },
{ original: { displayName: 'test2', id: '2' } },
]);
expect(actual).toEqual(expected);
});
});
describe('selectedRows length equals one', () => {
it('should return error if row does not contain .original ancestor', async () => {
const mockResponseData = { ok: true, blob: () => 'data' };
const mockFetchResponse = Promise.resolve(mockResponseData);
global.fetch = jest.fn().mockImplementation(() => mockFetchResponse);
const expected = ['Failed to download undefined.'];
const actual = await getDownload([
{ asset: { displayName: 'test1', id: '1' } },
]);
expect(actual).toEqual(expected);
});
});
});
});

View File

@@ -13,9 +13,10 @@ const slice = createSlice({
deletingStatus: '',
usageStatus: '',
errors: {
upload: [],
add: [],
delete: [],
lock: [],
download: [],
usageMetrics: [],
},
totalCount: 0,
@@ -30,14 +31,27 @@ const slice = createSlice({
updateLoadingStatus: (state, { payload }) => {
state.loadingStatus = payload.status;
},
updateUpdatingStatus: (state, { payload }) => {
state.updatingStatus = payload.status;
},
updateAddingStatus: (state, { payload }) => {
state.addingStatus = payload.status;
},
updateDeletingStatus: (state, { payload }) => {
state.deletingStatus = payload.status;
updateEditStatus: (state, { payload }) => {
const { editType, status } = payload;
switch (editType) {
case 'delete':
state.deletingStatus = status;
break;
case 'add':
state.addingStatus = status;
break;
case 'lock':
state.updatingStatus = status;
break;
case 'download':
state.updatingStatus = status;
break;
case 'usageMetrics':
state.usageStatus = status;
break;
default:
break;
}
},
deleteAssetSuccess: (state, { payload }) => {
state.assetIds = state.assetIds.filter(id => id !== payload.assetId);
@@ -45,14 +59,15 @@ 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];
state.errors[error] = [...currentErrorState, message];
},
clearErrors: (state, { payload }) => {
const { error } = payload;
state.errors[error] = [];
},
},
});
@@ -60,13 +75,11 @@ export const {
setAssetIds,
setTotalCount,
updateLoadingStatus,
updateUpdatingStatus,
deleteAssetSuccess,
updateDeletingStatus,
addAssetSuccess,
updateAddingStatus,
updateUsageStatus,
updateErrors,
clearErrors,
updateEditStatus,
} = slice.actions;
export const {

View File

@@ -1,3 +1,4 @@
import { isEmpty } from 'lodash';
import { RequestStatus } from '../../data/constants';
import {
addModel,
@@ -11,18 +12,17 @@ import {
addAsset,
deleteAsset,
updateLockStatus,
getDownload,
} from './api';
import {
setAssetIds,
setTotalCount,
updateLoadingStatus,
updateUpdatingStatus,
deleteAssetSuccess,
updateDeletingStatus,
addAssetSuccess,
updateAddingStatus,
updateErrors,
updateUsageStatus,
clearErrors,
updateEditStatus,
} from './slice';
import { updateFileValues } from './utils';
@@ -61,24 +61,24 @@ export function updateAssetOrder(courseId, assetIds) {
export function deleteAssetFile(courseId, id, totalCount) {
return async (dispatch) => {
dispatch(updateDeletingStatus({ status: RequestStatus.IN_PROGRESS }));
dispatch(updateEditStatus({ editType: 'delete', 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 }));
dispatch(updateEditStatus({ editType: 'delete', status: RequestStatus.SUCCESSFUL }));
} catch (error) {
dispatch(updateErrors({ error: 'delete', message: `Failed to delete file id ${id}.` }));
dispatch(updateDeletingStatus({ status: RequestStatus.FAILED }));
dispatch(updateEditStatus({ editType: 'delete', status: RequestStatus.FAILED }));
}
};
}
export function addAssetFile(courseId, file, totalCount) {
return async (dispatch) => {
dispatch(updateAddingStatus({ status: RequestStatus.IN_PROGRESS }));
dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.IN_PROGRESS }));
try {
const { asset } = await addAsset(courseId, file);
@@ -91,22 +91,22 @@ export function addAssetFile(courseId, file, totalCount) {
assetId: asset.id,
}));
dispatch(setTotalCount({ totalCount: totalCount + 1 }));
dispatch(updateAddingStatus({ courseId, status: RequestStatus.SUCCESSFUL }));
dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.SUCCESSFUL }));
} catch (error) {
if (error.response && error.response.status === 413) {
const message = error.response.data.error;
dispatch(updateErrors({ error: 'upload', message }));
dispatch(updateErrors({ error: 'add', message }));
} else {
dispatch(updateErrors({ error: 'upload', message: `Failed to add ${file.name}.` }));
dispatch(updateErrors({ error: 'add', message: `Failed to add ${file.name}.` }));
}
dispatch(updateAddingStatus({ status: RequestStatus.FAILED }));
dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.FAILED }));
}
};
}
export function updateAssetLock({ assetId, courseId, locked }) {
return async (dispatch) => {
dispatch(updateUpdatingStatus({ status: RequestStatus.IN_PROGRESS }));
dispatch(updateEditStatus({ editType: 'lock', status: RequestStatus.IN_PROGRESS }));
try {
await updateLockStatus({ assetId, courseId, locked });
@@ -117,26 +117,45 @@ export function updateAssetLock({ assetId, courseId, locked }) {
locked,
},
}));
dispatch(updateUpdatingStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(updateEditStatus({ editType: 'lock', status: RequestStatus.SUCCESSFUL }));
} catch (error) {
const lockStatus = locked ? 'lock' : 'unlock';
dispatch(updateErrors({ error: 'lock', message: `Failed to ${lockStatus} file id ${assetId}.` }));
dispatch(updateUpdatingStatus({ status: RequestStatus.FAILED }));
dispatch(updateEditStatus({ editType: 'lock', status: RequestStatus.FAILED }));
}
};
}
export function resetErrors({ errorType }) {
return (dispatch) => { dispatch(clearErrors({ error: errorType })); };
}
export function getUsagePaths({ asset, courseId, setSelectedRows }) {
return async (dispatch) => {
dispatch(updateUsageStatus({ status: RequestStatus.IN_PROGRESS }));
dispatch(updateEditStatus({ editType: 'usageMetrics', status: RequestStatus.IN_PROGRESS }));
try {
const { usageLocations } = await getAssetUsagePaths({ assetId: asset.id, courseId });
setSelectedRows([{ original: { ...asset, usageLocations } }]);
dispatch(updateUsageStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(updateEditStatus({ editType: 'usageMetrics', status: RequestStatus.SUCCESSFUL }));
} catch (error) {
dispatch(updateErrors({ error: 'usageMetrics', message: `Failed to get usage metrics for ${asset.displayName}.` }));
dispatch(updateUsageStatus({ status: RequestStatus.FAILED }));
dispatch(updateEditStatus({ editType: 'usageMetrics', status: RequestStatus.FAILED }));
}
};
}
export function fetchAssetDownload({ selectedRows, courseId }) {
return async (dispatch) => {
dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.IN_PROGRESS }));
const errors = await getDownload(selectedRows, courseId);
if (isEmpty(errors)) {
dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.SUCCESSFUL }));
} else {
errors.forEach(error => {
dispatch(updateErrors({ error: 'download', message: error }));
});
dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.FAILED }));
}
};
}

View File

@@ -15,9 +15,10 @@ export const initialState = {
addingStatus: '',
usageStatus: '',
errors: {
upload: [],
add: [],
delete: [],
lock: [],
download: [],
usageMetrics: [],
},
},

View File

@@ -17,6 +17,7 @@ import { getSrc } from '../data/utils';
const GalleryCard = ({
className,
original,
handleBulkDownload,
handleLockedAsset,
handleOpenDeleteConfirmation,
handleOpenAssetInfo,
@@ -43,6 +44,9 @@ const GalleryCard = ({
portableUrl={original.portableUrl}
iconSrc={MoreVert}
id={original.id}
onDownload={() => handleBulkDownload(
[{ original: { id: original.id, displayName: original.displayName } }],
)}
openDeleteConfirmation={() => handleOpenDeleteConfirmation([{ original }])}
/>
</ActionRow>
@@ -87,6 +91,7 @@ GalleryCard.propTypes = {
id: PropTypes.string.isRequired,
portableUrl: PropTypes.string.isRequired,
}).isRequired,
handleBulkDownload: PropTypes.func.isRequired,
handleLockedAsset: PropTypes.func.isRequired,
handleOpenDeleteConfirmation: PropTypes.func.isRequired,
handleOpenAssetInfo: PropTypes.func.isRequired,

View File

@@ -17,6 +17,7 @@ import { getSrc } from '../data/utils';
const ListCard = ({
className,
original,
handleBulkDownload,
handleLockedAsset,
handleOpenDeleteConfirmation,
handleOpenAssetInfo,
@@ -67,6 +68,9 @@ const ListCard = ({
portableUrl={original.portableUrl}
iconSrc={MoreVert}
id={original.id}
onDownload={() => handleBulkDownload(
[{ original: { id: original.id, displayName: original.displayName } }],
)}
openDeleteConfirmation={() => handleOpenDeleteConfirmation([{ original }])}
/>
</ActionRow>
@@ -89,6 +93,7 @@ ListCard.propTypes = {
id: PropTypes.string.isRequired,
portableUrl: PropTypes.string.isRequired,
}).isRequired,
handleBulkDownload: PropTypes.func.isRequired,
handleLockedAsset: PropTypes.func.isRequired,
handleOpenDeleteConfirmation: PropTypes.func.isRequired,
handleOpenAssetInfo: PropTypes.func.isRequired,