Skip to content

Commit

Permalink
feat: Add check to see if assets pre-exist when uploading (#384)
Browse files Browse the repository at this point in the history
* chore: rename studio-* commands to cms-* in README

* feat: Add check to see if assets pre-exist when uploading

* chore: update endpoint used to confirm filename conflicts
  • Loading branch information
mavidser authored Oct 26, 2023
1 parent 315ab94 commit 1a26d84
Show file tree
Hide file tree
Showing 17 changed files with 422 additions and 12 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ studio instance running in [Devstack](https://github.com/openedx/devstack):
does not exist already.
2. Add `STUDIO_FRONTEND_CONTAINER_URL = 'http://localhost:18011'` to
`cms/envs/private.py`.
3. Reload your Studio server: `make studio-restart`.
3. Restart your Studio container: `make cms-restart-container`.

Pages in Studio that have studio-frontend components should now request assets
from your studio-frontend docker container's webpack-dev-server. If you make a
Expand All @@ -91,13 +91,13 @@ your local docker devstack by following these steps:
1. If you have a `cms/envs/private.py` file in your devstack edx-platform
folder, then make sure the line `STUDIO_FRONTEND_CONTAINER_URL =
'http://localhost:18011'` is commented out.
2. Reload your Studio server: `make studio-restart`.
2. Reload your Studio server: `make cms-restart-container`.
3. Run the production build of studio-frontend by running `make shell` and then
`npm run build` inside the docker container.
4. Copy the production files over to your devstack Studio's static assets
folder by running this make command on your host machine in the
studio-frontend folder: `make copy-dist`.
5. Run Studio's static asset pipeline: `make studio-static`.
5. Run Studio's static asset pipeline: `make cms-static`.

Your devstack Studio should now be using the production studio-frontend files
built by your local checkout.
Expand Down
9 changes: 5 additions & 4 deletions src/components/AssetsDropZone/AssetsDropZone.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { courseDetails } from '../../utils/testConstants';
import { mountWithIntl } from '../../utils/i18n/enzymeHelper';

const defaultProps = {
validateAssetsAndUpload: () => {},
uploadAssets: () => {},
uploadExceedMaxCount: () => {},
uploadExceedMaxSize: () => {},
Expand Down Expand Up @@ -70,13 +71,13 @@ describe('<AssetsDropZone />', () => {
wrapper.instance().onDrop([{}, {}], [{}]);
expect(mockUploadInvalidFileType).toBeCalled();
});
it('call uploadAssets() for successful uploads', () => {
const mockUploadAssets = jest.fn();
it('call validateAssetsAndUpload() for approved files', () => {
const mockvalidateAssetsAndUpload = jest.fn();
wrapper.setProps({
uploadAssets: mockUploadAssets,
validateAssetsAndUpload: mockvalidateAssetsAndUpload,
});
wrapper.instance().onDrop([{}, {}], []);
expect(mockUploadAssets).toBeCalled();
expect(mockvalidateAssetsAndUpload).toBeCalled();
});
});

Expand Down
3 changes: 2 additions & 1 deletion src/components/AssetsDropZone/container.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import { connect } from 'react-redux';

import AssetsDropZone from '.';
import {
uploadAssets, uploadExceedMaxSize, uploadExceedMaxCount, uploadInvalidFileType,
validateAssetsAndUpload, uploadAssets, uploadExceedMaxSize, uploadExceedMaxCount, uploadInvalidFileType,
} from '../../data/actions/assets';

const mapStateToProps = state => ({
courseDetails: state.studioDetails.course,
});

const mapDispatchToProps = dispatch => ({
validateAssetsAndUpload: (files, courseDetails) => dispatch(validateAssetsAndUpload(files, courseDetails)),
uploadAssets: (files, courseDetails) => dispatch(uploadAssets(files, courseDetails)),
uploadExceedMaxCount: maxFileCount => dispatch(uploadExceedMaxCount(maxFileCount)),
uploadExceedMaxSize: maxFileSizeMB => dispatch(uploadExceedMaxSize(maxFileSizeMB)),
Expand Down
8 changes: 4 additions & 4 deletions src/components/AssetsDropZone/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import PropTypes from 'prop-types';
import { Button } from '@edx/paragon';
import FontAwesomeStyles from 'font-awesome/css/font-awesome.min.css';
import WrappedMessage from '../../utils/i18n/formattedMessageWrapper';
import MAX_FILE_UPLOAD_COUNT from '../../utils/constants';
import messages from './displayMessages';
import styles from './AssetsDropZone.scss';

Expand All @@ -28,7 +29,7 @@ export default class AssetsDropZone extends React.Component {
this.props.uploadInvalidFileType();
}
} else {
this.props.uploadAssets(acceptedFiles, this.props.courseDetails);
this.props.validateAssetsAndUpload(acceptedFiles, this.props.courseDetails);
}
};

Expand Down Expand Up @@ -133,17 +134,16 @@ AssetsDropZone.propTypes = {
}).isRequired,
maxFileCount: PropTypes.number,
maxFileSizeMB: PropTypes.number,
uploadAssets: PropTypes.func.isRequired,
uploadExceedMaxCount: PropTypes.func.isRequired,
uploadExceedMaxSize: PropTypes.func.isRequired,
uploadInvalidFileType: PropTypes.func.isRequired,

validateAssetsAndUpload: PropTypes.func.isRequired,
};

AssetsDropZone.defaultProps = {
acceptedFileTypes: undefined,
buttonRef: () => {},
compactStyle: false,
maxFileCount: 1000,
maxFileCount: MAX_FILE_UPLOAD_COUNT,
maxFileSizeMB: 10,
};
4 changes: 4 additions & 0 deletions src/components/AssetsPage/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import WrappedAssetsSearch from '../AssetsSearch/container';
import WrappedAssetsStatusAlert from '../AssetsStatusAlert/container';
import WrappedAssetsResultsCount from '../AssetsResultsCount/container';
import WrappedAssetsClearFiltersButton from '../AssetsClearFiltersButton/container';
import WrappedAssetsUploadConfirm from '../AssetsUploadConfirm/container';
import WrappedMessage from '../../utils/i18n/formattedMessageWrapper';
import messages from './displayMessages';
import styles from './AssetsPage.scss';
Expand Down Expand Up @@ -197,6 +198,9 @@ export default class AssetsPage extends React.Component {
</div>
<div className="container">
<div className="row">
<div className="col-12">
<WrappedAssetsUploadConfirm />
</div>
<div className="col-12">
<WrappedAssetsStatusAlert
statusAlertRef={(input) => { this.statusAlertRef = input; }}
Expand Down
100 changes: 100 additions & 0 deletions src/components/AssetsUploadConfirm/AssetsUploadConfirm.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import React from 'react';
import { Button, Modal } from '@edx/paragon';

import AssetsUploadConfirm from './index';
import { mountWithIntl } from '../../utils/i18n/enzymeHelper';
import mockQuerySelector from '../../utils/mockQuerySelector';

const defaultProps = {
filesToUpload: [],
uploadAssets: () => { },
clearUploadConfirmProps: () => {},
courseDetails: {},
filenameConflicts: [],
};

const modalIsClosed = (wrapper) => {
expect(wrapper.prop('filenameConflicts')).toEqual([]);
expect(wrapper.state('modalOpen')).toEqual(false);
expect(wrapper.find(Modal).prop('open')).toEqual(false);
};

const modalIsOpen = (wrapper) => {
expect(wrapper.prop('filenameConflicts')).toBeTruthy();
expect(wrapper.state('modalOpen')).toEqual(true);
expect(wrapper.find(Modal).prop('open')).toEqual(true);
};

const errorMessageHasCorrectFiles = (wrapper, files) => {
const filenameConflicts = wrapper.prop('filenameConflicts');
files.forEach((file) => {
expect(filenameConflicts).toContain(file);
});
};

let wrapper;

describe('AssetsUploadConfirm', () => {
beforeEach(() => {
mockQuerySelector.init();
});
afterEach(() => {
mockQuerySelector.reset();
});

describe('renders', () => {
beforeEach(() => {
wrapper = mountWithIntl(
<AssetsUploadConfirm
{...defaultProps}
/>,
);
});

it('closed by default', () => {
modalIsClosed(wrapper);
});

it('open if there is an error message', () => {
wrapper.setProps({
filenameConflicts: ['asset.jpg'],
});

modalIsOpen(wrapper);
errorMessageHasCorrectFiles(wrapper, ['asset.jpg']);
});
});
describe('behaves', () => {
it('Overwrite calls uploadAssets', () => {
const mockUploadAssets = jest.fn();
const filesToUpload = [new File([''], 'file1')];
const courseDetails = {
id: 'course-v1:edX+DemoX+Demo_Course',
};
wrapper.setProps({
filesToUpload,
courseDetails,
uploadAssets: mockUploadAssets,
});

wrapper.find(Button).filterWhere(button => button.text() === 'Overwrite').simulate('click');
expect(mockUploadAssets).toBeCalledWith(filesToUpload, courseDetails);
});

it('clicking cancel button closes the status alert', () => {
wrapper.setProps({
filenameConflicts: ['asset.jpg'],
clearUploadConfirmProps: () => {
wrapper.setProps({
...defaultProps,
});
},
});

const modal = wrapper.find(Modal);
const cancelModalButton = modal.find('button').filterWhere(button => button.text() === 'Cancel');
cancelModalButton.simulate('click');
modalIsClosed(wrapper);
});
});
});
22 changes: 22 additions & 0 deletions src/components/AssetsUploadConfirm/container.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { connect } from 'react-redux';

import { uploadAssets, clearUploadConfirmProps } from '../../data/actions/assets';
import AssetsUploadConfirm from '.';

const mapStateToProps = state => ({
filesToUpload: state.metadata.filesToUpload,
filenameConflicts: state.metadata.filenameConflicts,
courseDetails: state.studioDetails.course,
});

const mapDispatchToProps = dispatch => ({
uploadAssets: (assets, courseDetails) => dispatch(uploadAssets(assets, courseDetails)),
clearUploadConfirmProps: () => dispatch(clearUploadConfirmProps()),
});

const WrappedAssetsUploadConfirm = connect(
mapStateToProps,
mapDispatchToProps,
)(AssetsUploadConfirm);

export default WrappedAssetsUploadConfirm;
26 changes: 26 additions & 0 deletions src/components/AssetsUploadConfirm/displayMessages.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { defineMessages } from 'react-intl';

const messages = defineMessages({
assetsUploadConfirmMessage: {
id: 'assetsUploadConfirmMessage',
defaultMessage: 'The following files will be overwritten: {listOfFiles}',
description: 'The message displayed in the modal shown when uploading files with pre-existing names',
},
assetsUploadConfirmTitle: {
id: 'assetsUploadConfirmTitle',
defaultMessage: 'Overwrite Files',
description: 'The title of the modal to confirm overwriting the files',
},
assetsUploadConfirmOverwrite: {
id: 'assetsUploadConfirmOverwrite',
defaultMessage: 'Overwrite',
description: 'The message displayed in the button to confirm overwriting the files',
},
assetsUploadConfirmCancel: {
id: 'assetsUploadConfirmCancel',
defaultMessage: 'Cancel',
description: 'The message displayed in the button to confirm cancelling the upload',
},
});

export default messages;
103 changes: 103 additions & 0 deletions src/components/AssetsUploadConfirm/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button, Modal, Variant } from '@edx/paragon';

import WrappedMessage from '../../utils/i18n/formattedMessageWrapper';
import messages from './displayMessages';

const defaultState = {
modalOpen: false,
};
const modalWrapperID = 'modalWrapper';

export default class AssetsUploadConfirm extends React.Component {
constructor(props) {
super(props);
this.state = defaultState;
}

componentWillReceiveProps(nextProps) {
const { filenameConflicts } = nextProps;
this.updateAlertOpenState(filenameConflicts);
}

updateAlertOpenState = (filenameConflicts) => {
this.setState({
modalOpen: filenameConflicts.length !== 0,
});
};

uploadFiles = () => {
this.props.uploadAssets(this.props.filesToUpload, this.props.courseDetails);
};

onClose = () => {
this.setState(defaultState);
this.props.clearUploadConfirmProps();
};

render() {
const { uploadFiles } = this;
const { modalOpen } = this.state;
const { filenameConflicts } = this.props;
const listOfFiles = (
<ul>
{ filenameConflicts.sort().map(item => <li key={item}>{item}</li>) }
</ul>
);
const content = (
<WrappedMessage
message={messages.assetsUploadConfirmMessage}
values={{ listOfFiles }}
/>
);
const closeText = (
<WrappedMessage message={messages.assetsUploadConfirmCancel} />
);
const button = (
<Button
buttonType="primary"
label={<WrappedMessage message={messages.assetsUploadConfirmOverwrite} />}
onClick={uploadFiles}
/>
);

return (
<div id={modalWrapperID}>
<Modal
title={<WrappedMessage message={messages.assetsUploadConfirmTitle} />}
open={modalOpen}
body={content}
buttons={[button]}
onClose={this.onClose}
closeText={closeText}
variant={{ status: Variant.status.WARNING }}
parentSelector={`#${modalWrapperID}`}
/>
</div>
);
}
}

AssetsUploadConfirm.propTypes = {
// eslint-disable-next-line react/forbid-prop-types
filesToUpload: PropTypes.arrayOf(PropTypes.object),
uploadAssets: PropTypes.func.isRequired,
clearUploadConfirmProps: PropTypes.func.isRequired,
courseDetails: PropTypes.shape({
lang: PropTypes.string,
url_name: PropTypes.string,
name: PropTypes.string,
display_course_number: PropTypes.string,
num: PropTypes.string,
org: PropTypes.string,
id: PropTypes.string,
revision: PropTypes.string,
}).isRequired,
filenameConflicts: PropTypes.arrayOf(PropTypes.string),
};

AssetsUploadConfirm.defaultProps = {
filesToUpload: [],
filenameConflicts: [],
};
Loading

0 comments on commit 1a26d84

Please sign in to comment.