diff --git a/package-lock.json b/package-lock.json index bfe7cf8d0..db756f68b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 3524e700d..e0d4688fe 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/data/constants.js b/src/data/constants.js index 42e156253..d91b6bfb5 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -11,6 +11,7 @@ export const RequestStatus = { FAILED: 'failed', DENIED: 'denied', PENDING: 'pending', + CLEAR: 'clear', }; /** diff --git a/src/files-and-uploads/FileInput.jsx b/src/files-and-uploads/FileInput.jsx index b7be691f1..a094cdd15 100644 --- a/src/files-and-uploads/FileInput.jsx +++ b/src/files-and-uploads/FileInput.jsx @@ -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) => { diff --git a/src/files-and-uploads/FileMenu.jsx b/src/files-and-uploads/FileMenu.jsx index 46b9defe1..7d9ac47cd 100644 --- a/src/files-and-uploads/FileMenu.jsx +++ b/src/files-and-uploads/FileMenu.jsx @@ -12,6 +12,7 @@ const FileMenu = ({ externalUrl, handleLock, locked, + onDownload, openAssetInfo, openDeleteConfirmation, portableUrl, @@ -40,7 +41,7 @@ const FileMenu = ({ > {intl.formatMessage(messages.copyWebUrlTitle)} - + {intl.formatMessage(messages.downloadTitle)} @@ -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, diff --git a/src/files-and-uploads/FilesAndUploads.jsx b/src/files-and-uploads/FilesAndUploads.jsx index 12a56b8f8..98070e151 100644 --- a/src/files-and-uploads/FilesAndUploads.jsx +++ b/src/files-and-uploads/FilesAndUploads.jsx @@ -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 = ({
    - {errorMessages.upload.map(message => ( -
  • + {errorMessages.add.map(message => ( +
  • {intl.formatMessage(messages.errorAlertMessage, { message })}
  • ))} @@ -196,7 +195,7 @@ const FilesAndUploads = ({ >
      {errorMessages.delete.map(message => ( -
    • +
    • {intl.formatMessage(messages.errorAlertMessage, { message })}
    • ))} @@ -208,12 +207,16 @@ const FilesAndUploads = ({ >
        {errorMessages.lock.map(message => ( -
      • +
      • + {intl.formatMessage(messages.errorAlertMessage, { message })} +
      • + ))} + {errorMessages.download.map(message => ( +
      • {intl.formatMessage(messages.errorAlertMessage, { message })}
      • ))}
      -
      diff --git a/src/files-and-uploads/FilesAndUploads.test.jsx b/src/files-and-uploads/FilesAndUploads.test.jsx index 38de84b8d..b9ff8a0d8 100644 --- a/src/files-and-uploads/FilesAndUploads.test.jsx +++ b/src/files-and-uploads/FilesAndUploads.test.jsx @@ -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(); }); }); diff --git a/src/files-and-uploads/UsageMetricsMessage.jsx b/src/files-and-uploads/UsageMetricsMessage.jsx index cefb91ccd..fe1d5c97d 100644 --- a/src/files-and-uploads/UsageMetricsMessage.jsx +++ b/src/files-and-uploads/UsageMetricsMessage.jsx @@ -19,7 +19,11 @@ const UsageMetricsMessage = ({ ) : (
        - {usageLocations.map((location) => (
      • {location}
      • ))} + {usageLocations.map(location => ( +
      • + {location} +
      • + ))}
      ); } else if (usagePathStatus === RequestStatus.FAILED) { diff --git a/src/files-and-uploads/data/api.js b/src/files-and-uploads/data/api.js index 06009545d..357e3ff2c 100644 --- a/src/files-and-uploads/data/api.js +++ b/src/files-and-uploads/data/api.js @@ -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 */ diff --git a/src/files-and-uploads/data/api.test.js b/src/files-and-uploads/data/api.test.js new file mode 100644 index 000000000..e852bc4ec --- /dev/null +++ b/src/files-and-uploads/data/api.test.js @@ -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); + }); + }); + }); +}); diff --git a/src/files-and-uploads/data/slice.js b/src/files-and-uploads/data/slice.js index 5f61d3356..a9e8121c6 100644 --- a/src/files-and-uploads/data/slice.js +++ b/src/files-and-uploads/data/slice.js @@ -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 { diff --git a/src/files-and-uploads/data/thunks.js b/src/files-and-uploads/data/thunks.js index 9039b8626..53af42cf0 100644 --- a/src/files-and-uploads/data/thunks.js +++ b/src/files-and-uploads/data/thunks.js @@ -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 })); } }; } diff --git a/src/files-and-uploads/factories/mockApiResponses.jsx b/src/files-and-uploads/factories/mockApiResponses.jsx index 3437fd0fc..6b99e18d6 100644 --- a/src/files-and-uploads/factories/mockApiResponses.jsx +++ b/src/files-and-uploads/factories/mockApiResponses.jsx @@ -15,9 +15,10 @@ export const initialState = { addingStatus: '', usageStatus: '', errors: { - upload: [], + add: [], delete: [], lock: [], + download: [], usageMetrics: [], }, }, diff --git a/src/files-and-uploads/table-components/GalleryCard.jsx b/src/files-and-uploads/table-components/GalleryCard.jsx index dce418381..5728f2b3d 100644 --- a/src/files-and-uploads/table-components/GalleryCard.jsx +++ b/src/files-and-uploads/table-components/GalleryCard.jsx @@ -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 }])} /> @@ -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, diff --git a/src/files-and-uploads/table-components/ListCard.jsx b/src/files-and-uploads/table-components/ListCard.jsx index 639061c94..9a1d38b1b 100644 --- a/src/files-and-uploads/table-components/ListCard.jsx +++ b/src/files-and-uploads/table-components/ListCard.jsx @@ -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 }])} /> @@ -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,