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