diff --git a/src/files-and-videos/files-page/FileValidationModal.jsx b/src/files-and-videos/files-page/FileValidationModal.jsx new file mode 100644 index 0000000000..f3a45deeb3 --- /dev/null +++ b/src/files-and-videos/files-page/FileValidationModal.jsx @@ -0,0 +1,67 @@ +import React, { useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; +import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n'; +import { + ActionRow, + Button, + ModalDialog, + useToggle, +} from '@openedx/paragon'; +import { isEmpty } from 'lodash'; + +import messages from './messages'; + +const FileValidationModal = ({ + handleFileOverwrite, + // injected + intl, +}) => { + const [isOpen, open, close] = useToggle(); + + const { duplicateFiles } = useSelector(state => state.assets); + + useEffect(() => { + if (!isEmpty(duplicateFiles)) { + open(); + } + }, [duplicateFiles]); + + return ( + + + + + + + + +
    + {Object.keys(duplicateFiles).map(file =>
  • {file}
  • )} +
+
+ + + + + + + + +
+ ); +}; + +FileValidationModal.propTypes = { + handleFileOverwrite: PropTypes.func.isRequired, + // injected + intl: intlShape.isRequired, +}; + +export default injectIntl(FileValidationModal); diff --git a/src/files-and-videos/files-page/FilesPage.jsx b/src/files-and-videos/files-page/FilesPage.jsx index 16cf5cce0f..6c3ef01b43 100644 --- a/src/files-and-videos/files-page/FilesPage.jsx +++ b/src/files-and-videos/files-page/FilesPage.jsx @@ -16,6 +16,7 @@ import { getUsagePaths, resetErrors, updateAssetOrder, + validateAssetFiles, } from './data/thunks'; import messages from './messages'; import FilesPageProvider from './FilesPageProvider'; @@ -30,6 +31,7 @@ import { import { getFileSizeToClosestByte } from '../../utils'; import FileThumbnail from './FileThumbnail'; import FileInfoModalSidebar from './FileInfoModalSidebar'; +import FileValidationModal from './FileValidationModal'; const FilesPage = ({ courseId, @@ -55,9 +57,16 @@ const FilesPage = ({ } = useSelector(state => state.assets); const handleErrorReset = (error) => dispatch(resetErrors(error)); - const handleAddFile = (file) => dispatch(addAssetFile(courseId, file)); const handleDeleteFile = (id) => dispatch(deleteAssetFile(courseId, id)); const handleDownloadFile = (selectedRows) => dispatch(fetchAssetDownload({ selectedRows, courseId })); + const handleAddFile = (files) => { + handleErrorReset({ errorType: 'add' }); + dispatch(validateAssetFiles(courseId, files)); + }; + const handleFileOverwrite = (close, files) => { + Object.values(files).forEach(file => dispatch(addAssetFile(courseId, file, true))); + close(); + }; const handleLockFile = (fileId, locked) => { handleErrorReset({ errorType: 'lock' }); dispatch(updateAssetLock({ courseId, assetId: fileId, locked })); @@ -183,24 +192,27 @@ const FilesPage = ({ {loadingStatus !== RequestStatus.FAILED && ( - + <> + + + )} diff --git a/src/files-and-videos/files-page/FilesPage.test.jsx b/src/files-and-videos/files-page/FilesPage.test.jsx index ec15f993f8..beda688193 100644 --- a/src/files-and-videos/files-page/FilesPage.test.jsx +++ b/src/files-and-videos/files-page/FilesPage.test.jsx @@ -10,7 +10,7 @@ import userEvent from '@testing-library/user-event'; import ReactDOM from 'react-dom'; import { saveAs } from 'file-saver'; -import { initializeMockApp } from '@edx/frontend-platform'; +import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform'; import MockAdapter from 'axios-mock-adapter'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { AppProvider } from '@edx/frontend-platform/react'; @@ -36,9 +36,12 @@ import { deleteAssetFile, updateAssetLock, getUsagePaths, + validateAssetFiles, } from './data/thunks'; import { getAssetsUrl } from './data/api'; import messages from '../generic/messages'; +import filesPageMessages from './messages'; +import { updateFileValues } from './data/utils'; let axiosMock; let store; @@ -124,12 +127,13 @@ describe('FilesAndUploads', () => { await emptyMockStore(RequestStatus.SUCCESSFUL); const dropzone = screen.getByTestId('files-dropzone'); await act(async () => { + axiosMock.onGet(`${getAssetsUrl(courseId)}?display_name=download.png&page_size=1`).reply(200, { assets: [] }); axiosMock.onPost(getAssetsUrl(courseId)).reply(204, generateNewAssetApiResponse()); Object.defineProperty(dropzone, 'files', { value: [file], }); fireEvent.drop(dropzone); - await executeThunk(addAssetFile(courseId, file, 0), store.dispatch); + await executeThunk(validateAssetFiles(courseId, [file]), store.dispatch); }); const addStatus = store.getState().assets.addingStatus; expect(addStatus).toEqual(RequestStatus.SUCCESSFUL); @@ -191,19 +195,106 @@ describe('FilesAndUploads', () => { }); describe('table actions', () => { - it('should upload a single file', async () => { - await mockStore(RequestStatus.SUCCESSFUL); - axiosMock.onPost(getAssetsUrl(courseId)).reply(200, generateNewAssetApiResponse()); - let addFilesButton; - await waitFor(() => { - addFilesButton = screen.getByLabelText('file-input'); + describe('upload a single file', () => { + it('should upload without duplication modal', async () => { + await mockStore(RequestStatus.SUCCESSFUL); + axiosMock.onGet(`${getAssetsUrl(courseId)}?display_name=download.png&page_size=1`).reply(200, { assets: [] }); + axiosMock.onPost(getAssetsUrl(courseId)).reply(200, generateNewAssetApiResponse()); + let addFilesButton; + await waitFor(() => { + addFilesButton = screen.getByLabelText('file-input'); + }); + await act(async () => { + userEvent.upload(addFilesButton, file); + await executeThunk(validateAssetFiles(courseId, [file]), store.dispatch); + }); + const addStatus = store.getState().assets.addingStatus; + expect(addStatus).toEqual(RequestStatus.SUCCESSFUL); }); - await act(async () => { - userEvent.upload(addFilesButton, file); - await executeThunk(addAssetFile(courseId, file, 1), store.dispatch); + + it('should show duplicate file modal', async () => { + file = new File(['(⌐□_□)'], 'mOckID6', { type: 'image/png' }); + + await mockStore(RequestStatus.SUCCESSFUL); + axiosMock.onGet( + `${getAssetsUrl(courseId)}?display_name=mOckID6&page_size=1`, + ).reply(200, { assets: [{ display_name: 'mOckID6' }] }); + let addFilesButton; + await waitFor(() => { + addFilesButton = screen.getByLabelText('file-input'); + }); + await act(async () => { + userEvent.upload(addFilesButton, file); + await executeThunk(validateAssetFiles(courseId, [file]), store.dispatch); + }); + expect(screen.getByText(filesPageMessages.overwriteConfirmMessage.defaultMessage)).toBeVisible(); + }); + + it('should overwrite duplicate file', async () => { + file = new File(['(⌐□_□)'], 'mOckID6', { type: 'image/png' }); + + await mockStore(RequestStatus.SUCCESSFUL); + axiosMock.onGet( + `${getAssetsUrl(courseId)}?display_name=mOckID6&page_size=1`, + ).reply(200, { assets: [{ display_name: 'mOckID6' }] }); + const { asset: newDefaultAssetResponse } = generateNewAssetApiResponse(); + const responseData = { + asset: { + ...newDefaultAssetResponse, id: 'mOckID6', + }, + }; + + axiosMock.onPost(getAssetsUrl(courseId)).reply(200, responseData); + let addFilesButton; + await waitFor(() => { + addFilesButton = screen.getByLabelText('file-input'); + }); + await act(async () => { + userEvent.upload(addFilesButton, file); + await executeThunk(validateAssetFiles(courseId, [file]), store.dispatch); + }); + + const overwriteButton = screen.getByText(filesPageMessages.confirmOverwriteButtonLabel.defaultMessage); + await act(async () => { + fireEvent.click(overwriteButton); + }); + + const assetData = store.getState().models.assets.mOckID6; + const { asset: responseAssetData } = responseData; + const [defaultData] = updateFileValues([camelCaseObject(responseAssetData)]); + + expect(screen.queryByText(filesPageMessages.overwriteConfirmMessage.defaultMessage)).toBeNull(); + expect(assetData).toEqual(defaultData); + }); + + it('should keep original file', async () => { + file = new File(['(⌐□_□)'], 'mOckID6', { type: 'image/png' }); + + await mockStore(RequestStatus.SUCCESSFUL); + axiosMock.onGet( + `${getAssetsUrl(courseId)}?display_name=mOckID6&page_size=1`, + ).reply(200, { assets: [{ display_name: 'mOckID6' }] }); + let addFilesButton; + await waitFor(() => { + addFilesButton = screen.getByLabelText('file-input'); + }); + await act(async () => { + userEvent.upload(addFilesButton, file); + await executeThunk(validateAssetFiles(courseId, [file]), store.dispatch); + }); + + const cancelButton = screen.getByText(filesPageMessages.cancelOverwriteButtonLabel.defaultMessage); + await act(async () => { + fireEvent.click(cancelButton); + }); + + const assetData = store.getState().models.assets.mOckID6; + const defaultAssets = generateFetchAssetApiResponse().assets; + const [defaultData] = updateFileValues([defaultAssets[4]]); + + expect(screen.queryByText(filesPageMessages.overwriteConfirmMessage.defaultMessage)).toBeNull(); + expect(assetData).toEqual(defaultData); }); - const addStatus = store.getState().assets.addingStatus; - expect(addStatus).toEqual(RequestStatus.SUCCESSFUL); }); it('should have disabled action buttons', async () => { @@ -503,7 +594,7 @@ describe('FilesAndUploads', () => { it('invalid file size should show error', async () => { const errorMessage = 'File download.png exceeds maximum size of 20 MB.'; await mockStore(RequestStatus.SUCCESSFUL); - + axiosMock.onGet(`${getAssetsUrl(courseId)}?display_name=download.png&page_size=1`).reply(200, { assets: [] }); axiosMock.onPost(getAssetsUrl(courseId)).reply(413, { error: errorMessage }); const addFilesButton = screen.getByLabelText('file-input'); await act(async () => { @@ -515,8 +606,23 @@ describe('FilesAndUploads', () => { expect(screen.getByText('Error')).toBeVisible(); }); + it('404 validation should show error', async () => { + await mockStore(RequestStatus.SUCCESSFUL); + axiosMock.onGet(`${getAssetsUrl(courseId)}?display_name=download.png&page_size=1`).reply(404); + const addFilesButton = screen.getByLabelText('file-input'); + await act(async () => { + userEvent.upload(addFilesButton, file); + await executeThunk(addAssetFile(courseId, file, 1), store.dispatch); + }); + const addStatus = store.getState().assets.addingStatus; + expect(addStatus).toEqual(RequestStatus.FAILED); + + expect(screen.getByText('Error')).toBeVisible(); + }); + it('404 upload should show error', async () => { await mockStore(RequestStatus.SUCCESSFUL); + axiosMock.onGet(`${getAssetsUrl(courseId)}?display_name=download.png&page_size=1`).reply(200, { assets: [] }); axiosMock.onPost(getAssetsUrl(courseId)).reply(404); const addFilesButton = screen.getByLabelText('file-input'); await act(async () => { diff --git a/src/files-and-videos/files-page/data/api.js b/src/files-and-videos/files-page/data/api.js index ded712b836..73140c5f03 100644 --- a/src/files-and-videos/files-page/data/api.js +++ b/src/files-and-videos/files-page/data/api.js @@ -24,6 +24,19 @@ export async function getAssets(courseId, page) { return camelCaseObject(data); } +/** + * Fetches the course custom pages for provided course + * @param {string} courseId + * @returns {Promise<[{}]>} + */ +export async function getAssetDetails({ courseId, filenames, fileCount }) { + const params = new URLSearchParams(filenames.map(filename => ['display_name', filename])); + params.append('page_size', fileCount); + const { data } = await getAuthenticatedHttpClient() + .get(`${getAssetsUrl(courseId)}?${params}`); + return camelCaseObject(data); +} + /** * Fetch asset file. * @param {blockId} courseId Course ID for the course to operate on diff --git a/src/files-and-videos/files-page/data/slice.js b/src/files-and-videos/files-page/data/slice.js index e9bad95817..cb7ad33b7a 100644 --- a/src/files-and-videos/files-page/data/slice.js +++ b/src/files-and-videos/files-page/data/slice.js @@ -9,6 +9,7 @@ const slice = createSlice({ initialState: { assetIds: [], loadingStatus: RequestStatus.IN_PROGRESS, + duplicateFiles: [], updatingStatus: '', addingStatus: '', deletingStatus: '', @@ -64,6 +65,9 @@ const slice = createSlice({ addAssetSuccess: (state, { payload }) => { state.assetIds = [payload.assetId, ...state.assetIds]; }, + updateDuplicateFiles: (state, { payload }) => { + state.duplicateFiles = payload.files; + }, updateErrors: (state, { payload }) => { const { error, message } = payload; if (error === 'loading') { @@ -89,6 +93,7 @@ export const { updateErrors, clearErrors, updateEditStatus, + updateDuplicateFiles, } = slice.actions; export const { diff --git a/src/files-and-videos/files-page/data/thunks.js b/src/files-and-videos/files-page/data/thunks.js index 1aafc604f4..d07c3c6a4b 100644 --- a/src/files-and-videos/files-page/data/thunks.js +++ b/src/files-and-videos/files-page/data/thunks.js @@ -15,6 +15,7 @@ import { deleteAsset, updateLockStatus, getDownload, + getAssetDetails, } from './api'; import { setAssetIds, @@ -25,11 +26,12 @@ import { updateErrors, clearErrors, updateEditStatus, + updateDuplicateFiles, } from './slice'; -import { updateFileValues } from './utils'; +import { getUploadConflicts, updateFileValues } from './utils'; -export function fetchAddtionalAsstets(courseId, totalCount) { +export function fetchAdditionalAssets(courseId, totalCount) { return async (dispatch) => { let remainingAssetCount = totalCount; let page = 1; @@ -66,7 +68,7 @@ export function fetchAssets(courseId) { assetIds: assets.map(asset => asset.id), })); if (totalCount > 50) { - dispatch(fetchAddtionalAsstets(courseId, totalCount - 50)); + dispatch(fetchAdditionalAssets(courseId, totalCount - 50)); } dispatch(updateLoadingStatus({ courseId, status: RequestStatus.SUCCESSFUL })); } catch (error) { @@ -104,7 +106,7 @@ export function deleteAssetFile(courseId, id) { }; } -export function addAssetFile(courseId, file) { +export function addAssetFile(courseId, file, isOverwrite) { return async (dispatch) => { dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.IN_PROGRESS })); @@ -115,9 +117,11 @@ export function addAssetFile(courseId, file) { modelType: 'assets', model: { ...parsedAssets }, })); - dispatch(addAssetSuccess({ - assetId: asset.id, - })); + if (!isOverwrite) { + dispatch(addAssetSuccess({ + assetId: asset.id, + })); + } dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.SUCCESSFUL })); } catch (error) { if (error.response && error.response.status === 413) { @@ -131,6 +135,30 @@ export function addAssetFile(courseId, file) { }; } +export function validateAssetFiles(courseId, files) { + return async (dispatch) => { + dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.IN_PROGRESS })); + dispatch(updateDuplicateFiles({ files: {} })); + + try { + const filenames = []; + files.forEach(file => filenames.push(file.name)); + await getAssetDetails({ courseId, filenames, fileCount: filenames.length }).then(({ assets }) => { + const [conflicts, newFiles] = getUploadConflicts(files, assets); + if (!isEmpty(newFiles)) { + newFiles.forEach(file => dispatch(addAssetFile(courseId, file))); + } + if (!isEmpty(conflicts)) { + dispatch(updateDuplicateFiles({ files: conflicts })); + } + }); + } catch (error) { + files.forEach(file => dispatch(updateErrors({ error: 'add', message: `Failed to validate ${file.name}.` }))); + dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.FAILED })); + } + }; +} + export function updateAssetLock({ assetId, courseId, locked }) { return async (dispatch) => { dispatch(updateEditStatus({ editType: 'lock', status: RequestStatus.IN_PROGRESS })); diff --git a/src/files-and-videos/files-page/data/utils.js b/src/files-and-videos/files-page/data/utils.js index 993875353d..57d4d7aab2 100644 --- a/src/files-and-videos/files-page/data/utils.js +++ b/src/files-and-videos/files-page/data/utils.js @@ -57,3 +57,17 @@ export const getSrc = ({ thumbnail, wrapperType, externalUrl }) => { return InsertDriveFile; } }; + +export const getUploadConflicts = (filesToUpload, assets) => { + const filesFound = assets.map(item => item.displayName); + const conflicts = {}; + const newFiles = []; + filesToUpload.forEach(file => { + if (filesFound.includes(file.name)) { + conflicts[file.name] = file; + } else { + newFiles.push(file); + } + }); + return [conflicts, newFiles]; +}; diff --git a/src/files-and-videos/files-page/factories/mockApiResponses.jsx b/src/files-and-videos/files-page/factories/mockApiResponses.jsx index 0ad2f06409..7c6d1bc5cd 100644 --- a/src/files-and-videos/files-page/factories/mockApiResponses.jsx +++ b/src/files-and-videos/files-page/factories/mockApiResponses.jsx @@ -22,6 +22,7 @@ export const initialState = { usageMetrics: [], loading: '', }, + duplicateFiles: {}, }, models: { assets: { @@ -101,9 +102,9 @@ export const generateFetchAssetApiResponse = () => ({ displayName: 'mOckID6', locked: false, externalUrl: 'static_tab_1', - portableUrl: 'May 17, 2023 at 22:08 UTC', + portableUrl: '', contentType: 'application/octet-stream', - dateAdded: '', + dateAdded: 'May 17, 2023 at 22:08 UTC', thumbnail: null, }, ], diff --git a/src/files-and-videos/files-page/messages.js b/src/files-and-videos/files-page/messages.js index b06b48d45d..646c4d2c58 100644 --- a/src/files-and-videos/files-page/messages.js +++ b/src/files-and-videos/files-page/messages.js @@ -81,6 +81,26 @@ const messages = defineMessages({ id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.otherCheckbox.label', defaultMessage: 'Other', }, + overwriteConfirmMessage: { + id: 'course-authoring.files-and-videos.overwrite.modal.confirmation-message', + defaultMessage: 'Some of the uploaded files already exist in this course. Do you want to overwrite the following files?', + description: 'The message displayed in the modal shown when uploading files with pre-existing names', + }, + overwriteModalTitle: { + id: 'course-authoring.files-and-videos.overwrite.modal.title', + defaultMessage: 'Overwrite files', + description: 'The title of the modal to confirm overwriting the files', + }, + confirmOverwriteButtonLabel: { + id: 'course-authoring.files-and-videos.overwrite.modal.overwrite-button.label', + defaultMessage: 'Overwrite', + description: 'The message displayed in the button to confirm overwriting the files', + }, + cancelOverwriteButtonLabel: { + id: 'course-authoring.files-and-videos.overwrite.modal.cancel-button.label', + defaultMessage: 'Cancel', + description: 'The message displayed in the button to confirm cancelling the upload', + }, }); export default messages; diff --git a/src/files-and-videos/generic/FileInput.jsx b/src/files-and-videos/generic/FileInput.jsx index 1de18b052a..455853722f 100644 --- a/src/files-and-videos/generic/FileInput.jsx +++ b/src/files-and-videos/generic/FileInput.jsx @@ -12,10 +12,9 @@ export const useFileInput = ({ const addFile = (e) => { const { files } = e.target; setSelectedRows(files); - Object.values(files).forEach(file => { - onAddFile(file); - setAddOpen(); - }); + onAddFile(Object.values(files)); + setAddOpen(); + e.target.value = ''; }; return { click, diff --git a/src/files-and-videos/generic/FileTable.jsx b/src/files-and-videos/generic/FileTable.jsx index 06db5c1430..a774029224 100644 --- a/src/files-and-videos/generic/FileTable.jsx +++ b/src/files-and-videos/generic/FileTable.jsx @@ -95,14 +95,14 @@ const FileTable = ({ }, [files]); const fileInputControl = useFileInput({ - onAddFile: (file) => handleAddFile(file), + onAddFile: (uploads) => handleAddFile(uploads), setSelectedRows, setAddOpen, }); const handleDropzoneAsset = ({ fileData, handleError }) => { try { const file = fileData.get('file'); - handleAddFile(file); + handleAddFile([file]); } catch (error) { handleError(error); } diff --git a/src/files-and-videos/videos-page/VideosPage.jsx b/src/files-and-videos/videos-page/VideosPage.jsx index 662577c65c..74cf61d320 100644 --- a/src/files-and-videos/videos-page/VideosPage.jsx +++ b/src/files-and-videos/videos-page/VideosPage.jsx @@ -80,11 +80,14 @@ const VideosPage = ({ const supportedFileFormats = { 'video/*': videoSupportedFileFormats || FILES_AND_UPLOAD_TYPE_FILTERS.video }; - const handleAddFile = (file) => dispatch(addVideoFile(courseId, file, videoIds)); + const handleErrorReset = (error) => dispatch(resetErrors(error)); + const handleAddFile = (files) => { + handleErrorReset({ errorType: 'add' }); + files.forEach(file => dispatch(addVideoFile(courseId, file, videoIds))); + }; const handleDeleteFile = (id) => dispatch(deleteVideoFile(courseId, id)); const handleDownloadFile = (selectedRows) => dispatch(fetchVideoDownload({ selectedRows, courseId })); const handleUsagePaths = (video) => dispatch(getUsagePaths({ video, courseId })); - const handleErrorReset = (error) => dispatch(resetErrors(error)); const handleFileOrder = ({ newFileIdOrder, sortType }) => { dispatch(updateVideoOrder(courseId, newFileIdOrder, sortType)); };