Skip to content
This repository was archived by the owner on Oct 21, 2025. It is now read-only.

Commit 1a26d84

Browse files
authored
feat: Add check to see if assets pre-exist when uploading (#384)
* 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
1 parent 315ab94 commit 1a26d84

File tree

17 files changed

+422
-12
lines changed

17 files changed

+422
-12
lines changed

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ studio instance running in [Devstack](https://github.com/openedx/devstack):
7070
does not exist already.
7171
2. Add `STUDIO_FRONTEND_CONTAINER_URL = 'http://localhost:18011'` to
7272
`cms/envs/private.py`.
73-
3. Reload your Studio server: `make studio-restart`.
73+
3. Restart your Studio container: `make cms-restart-container`.
7474

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

102102
Your devstack Studio should now be using the production studio-frontend files
103103
built by your local checkout.

src/components/AssetsDropZone/AssetsDropZone.test.jsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { courseDetails } from '../../utils/testConstants';
55
import { mountWithIntl } from '../../utils/i18n/enzymeHelper';
66

77
const defaultProps = {
8+
validateAssetsAndUpload: () => {},
89
uploadAssets: () => {},
910
uploadExceedMaxCount: () => {},
1011
uploadExceedMaxSize: () => {},
@@ -70,13 +71,13 @@ describe('<AssetsDropZone />', () => {
7071
wrapper.instance().onDrop([{}, {}], [{}]);
7172
expect(mockUploadInvalidFileType).toBeCalled();
7273
});
73-
it('call uploadAssets() for successful uploads', () => {
74-
const mockUploadAssets = jest.fn();
74+
it('call validateAssetsAndUpload() for approved files', () => {
75+
const mockvalidateAssetsAndUpload = jest.fn();
7576
wrapper.setProps({
76-
uploadAssets: mockUploadAssets,
77+
validateAssetsAndUpload: mockvalidateAssetsAndUpload,
7778
});
7879
wrapper.instance().onDrop([{}, {}], []);
79-
expect(mockUploadAssets).toBeCalled();
80+
expect(mockvalidateAssetsAndUpload).toBeCalled();
8081
});
8182
});
8283

src/components/AssetsDropZone/container.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@ import { connect } from 'react-redux';
22

33
import AssetsDropZone from '.';
44
import {
5-
uploadAssets, uploadExceedMaxSize, uploadExceedMaxCount, uploadInvalidFileType,
5+
validateAssetsAndUpload, uploadAssets, uploadExceedMaxSize, uploadExceedMaxCount, uploadInvalidFileType,
66
} from '../../data/actions/assets';
77

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

1212
const mapDispatchToProps = dispatch => ({
13+
validateAssetsAndUpload: (files, courseDetails) => dispatch(validateAssetsAndUpload(files, courseDetails)),
1314
uploadAssets: (files, courseDetails) => dispatch(uploadAssets(files, courseDetails)),
1415
uploadExceedMaxCount: maxFileCount => dispatch(uploadExceedMaxCount(maxFileCount)),
1516
uploadExceedMaxSize: maxFileSizeMB => dispatch(uploadExceedMaxSize(maxFileSizeMB)),

src/components/AssetsDropZone/index.jsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import PropTypes from 'prop-types';
66
import { Button } from '@edx/paragon';
77
import FontAwesomeStyles from 'font-awesome/css/font-awesome.min.css';
88
import WrappedMessage from '../../utils/i18n/formattedMessageWrapper';
9+
import MAX_FILE_UPLOAD_COUNT from '../../utils/constants';
910
import messages from './displayMessages';
1011
import styles from './AssetsDropZone.scss';
1112

@@ -28,7 +29,7 @@ export default class AssetsDropZone extends React.Component {
2829
this.props.uploadInvalidFileType();
2930
}
3031
} else {
31-
this.props.uploadAssets(acceptedFiles, this.props.courseDetails);
32+
this.props.validateAssetsAndUpload(acceptedFiles, this.props.courseDetails);
3233
}
3334
};
3435

@@ -133,17 +134,16 @@ AssetsDropZone.propTypes = {
133134
}).isRequired,
134135
maxFileCount: PropTypes.number,
135136
maxFileSizeMB: PropTypes.number,
136-
uploadAssets: PropTypes.func.isRequired,
137137
uploadExceedMaxCount: PropTypes.func.isRequired,
138138
uploadExceedMaxSize: PropTypes.func.isRequired,
139139
uploadInvalidFileType: PropTypes.func.isRequired,
140-
140+
validateAssetsAndUpload: PropTypes.func.isRequired,
141141
};
142142

143143
AssetsDropZone.defaultProps = {
144144
acceptedFileTypes: undefined,
145145
buttonRef: () => {},
146146
compactStyle: false,
147-
maxFileCount: 1000,
147+
maxFileCount: MAX_FILE_UPLOAD_COUNT,
148148
maxFileSizeMB: 10,
149149
};

src/components/AssetsPage/index.jsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import WrappedAssetsSearch from '../AssetsSearch/container';
1414
import WrappedAssetsStatusAlert from '../AssetsStatusAlert/container';
1515
import WrappedAssetsResultsCount from '../AssetsResultsCount/container';
1616
import WrappedAssetsClearFiltersButton from '../AssetsClearFiltersButton/container';
17+
import WrappedAssetsUploadConfirm from '../AssetsUploadConfirm/container';
1718
import WrappedMessage from '../../utils/i18n/formattedMessageWrapper';
1819
import messages from './displayMessages';
1920
import styles from './AssetsPage.scss';
@@ -197,6 +198,9 @@ export default class AssetsPage extends React.Component {
197198
</div>
198199
<div className="container">
199200
<div className="row">
201+
<div className="col-12">
202+
<WrappedAssetsUploadConfirm />
203+
</div>
200204
<div className="col-12">
201205
<WrappedAssetsStatusAlert
202206
statusAlertRef={(input) => { this.statusAlertRef = input; }}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import React from 'react';
2+
import { Button, Modal } from '@edx/paragon';
3+
4+
import AssetsUploadConfirm from './index';
5+
import { mountWithIntl } from '../../utils/i18n/enzymeHelper';
6+
import mockQuerySelector from '../../utils/mockQuerySelector';
7+
8+
const defaultProps = {
9+
filesToUpload: [],
10+
uploadAssets: () => { },
11+
clearUploadConfirmProps: () => {},
12+
courseDetails: {},
13+
filenameConflicts: [],
14+
};
15+
16+
const modalIsClosed = (wrapper) => {
17+
expect(wrapper.prop('filenameConflicts')).toEqual([]);
18+
expect(wrapper.state('modalOpen')).toEqual(false);
19+
expect(wrapper.find(Modal).prop('open')).toEqual(false);
20+
};
21+
22+
const modalIsOpen = (wrapper) => {
23+
expect(wrapper.prop('filenameConflicts')).toBeTruthy();
24+
expect(wrapper.state('modalOpen')).toEqual(true);
25+
expect(wrapper.find(Modal).prop('open')).toEqual(true);
26+
};
27+
28+
const errorMessageHasCorrectFiles = (wrapper, files) => {
29+
const filenameConflicts = wrapper.prop('filenameConflicts');
30+
files.forEach((file) => {
31+
expect(filenameConflicts).toContain(file);
32+
});
33+
};
34+
35+
let wrapper;
36+
37+
describe('AssetsUploadConfirm', () => {
38+
beforeEach(() => {
39+
mockQuerySelector.init();
40+
});
41+
afterEach(() => {
42+
mockQuerySelector.reset();
43+
});
44+
45+
describe('renders', () => {
46+
beforeEach(() => {
47+
wrapper = mountWithIntl(
48+
<AssetsUploadConfirm
49+
{...defaultProps}
50+
/>,
51+
);
52+
});
53+
54+
it('closed by default', () => {
55+
modalIsClosed(wrapper);
56+
});
57+
58+
it('open if there is an error message', () => {
59+
wrapper.setProps({
60+
filenameConflicts: ['asset.jpg'],
61+
});
62+
63+
modalIsOpen(wrapper);
64+
errorMessageHasCorrectFiles(wrapper, ['asset.jpg']);
65+
});
66+
});
67+
describe('behaves', () => {
68+
it('Overwrite calls uploadAssets', () => {
69+
const mockUploadAssets = jest.fn();
70+
const filesToUpload = [new File([''], 'file1')];
71+
const courseDetails = {
72+
id: 'course-v1:edX+DemoX+Demo_Course',
73+
};
74+
wrapper.setProps({
75+
filesToUpload,
76+
courseDetails,
77+
uploadAssets: mockUploadAssets,
78+
});
79+
80+
wrapper.find(Button).filterWhere(button => button.text() === 'Overwrite').simulate('click');
81+
expect(mockUploadAssets).toBeCalledWith(filesToUpload, courseDetails);
82+
});
83+
84+
it('clicking cancel button closes the status alert', () => {
85+
wrapper.setProps({
86+
filenameConflicts: ['asset.jpg'],
87+
clearUploadConfirmProps: () => {
88+
wrapper.setProps({
89+
...defaultProps,
90+
});
91+
},
92+
});
93+
94+
const modal = wrapper.find(Modal);
95+
const cancelModalButton = modal.find('button').filterWhere(button => button.text() === 'Cancel');
96+
cancelModalButton.simulate('click');
97+
modalIsClosed(wrapper);
98+
});
99+
});
100+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { connect } from 'react-redux';
2+
3+
import { uploadAssets, clearUploadConfirmProps } from '../../data/actions/assets';
4+
import AssetsUploadConfirm from '.';
5+
6+
const mapStateToProps = state => ({
7+
filesToUpload: state.metadata.filesToUpload,
8+
filenameConflicts: state.metadata.filenameConflicts,
9+
courseDetails: state.studioDetails.course,
10+
});
11+
12+
const mapDispatchToProps = dispatch => ({
13+
uploadAssets: (assets, courseDetails) => dispatch(uploadAssets(assets, courseDetails)),
14+
clearUploadConfirmProps: () => dispatch(clearUploadConfirmProps()),
15+
});
16+
17+
const WrappedAssetsUploadConfirm = connect(
18+
mapStateToProps,
19+
mapDispatchToProps,
20+
)(AssetsUploadConfirm);
21+
22+
export default WrappedAssetsUploadConfirm;
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { defineMessages } from 'react-intl';
2+
3+
const messages = defineMessages({
4+
assetsUploadConfirmMessage: {
5+
id: 'assetsUploadConfirmMessage',
6+
defaultMessage: 'The following files will be overwritten: {listOfFiles}',
7+
description: 'The message displayed in the modal shown when uploading files with pre-existing names',
8+
},
9+
assetsUploadConfirmTitle: {
10+
id: 'assetsUploadConfirmTitle',
11+
defaultMessage: 'Overwrite Files',
12+
description: 'The title of the modal to confirm overwriting the files',
13+
},
14+
assetsUploadConfirmOverwrite: {
15+
id: 'assetsUploadConfirmOverwrite',
16+
defaultMessage: 'Overwrite',
17+
description: 'The message displayed in the button to confirm overwriting the files',
18+
},
19+
assetsUploadConfirmCancel: {
20+
id: 'assetsUploadConfirmCancel',
21+
defaultMessage: 'Cancel',
22+
description: 'The message displayed in the button to confirm cancelling the upload',
23+
},
24+
});
25+
26+
export default messages;
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import React from 'react';
2+
import PropTypes from 'prop-types';
3+
import { Button, Modal, Variant } from '@edx/paragon';
4+
5+
import WrappedMessage from '../../utils/i18n/formattedMessageWrapper';
6+
import messages from './displayMessages';
7+
8+
const defaultState = {
9+
modalOpen: false,
10+
};
11+
const modalWrapperID = 'modalWrapper';
12+
13+
export default class AssetsUploadConfirm extends React.Component {
14+
constructor(props) {
15+
super(props);
16+
this.state = defaultState;
17+
}
18+
19+
componentWillReceiveProps(nextProps) {
20+
const { filenameConflicts } = nextProps;
21+
this.updateAlertOpenState(filenameConflicts);
22+
}
23+
24+
updateAlertOpenState = (filenameConflicts) => {
25+
this.setState({
26+
modalOpen: filenameConflicts.length !== 0,
27+
});
28+
};
29+
30+
uploadFiles = () => {
31+
this.props.uploadAssets(this.props.filesToUpload, this.props.courseDetails);
32+
};
33+
34+
onClose = () => {
35+
this.setState(defaultState);
36+
this.props.clearUploadConfirmProps();
37+
};
38+
39+
render() {
40+
const { uploadFiles } = this;
41+
const { modalOpen } = this.state;
42+
const { filenameConflicts } = this.props;
43+
const listOfFiles = (
44+
<ul>
45+
{ filenameConflicts.sort().map(item => <li key={item}>{item}</li>) }
46+
</ul>
47+
);
48+
const content = (
49+
<WrappedMessage
50+
message={messages.assetsUploadConfirmMessage}
51+
values={{ listOfFiles }}
52+
/>
53+
);
54+
const closeText = (
55+
<WrappedMessage message={messages.assetsUploadConfirmCancel} />
56+
);
57+
const button = (
58+
<Button
59+
buttonType="primary"
60+
label={<WrappedMessage message={messages.assetsUploadConfirmOverwrite} />}
61+
onClick={uploadFiles}
62+
/>
63+
);
64+
65+
return (
66+
<div id={modalWrapperID}>
67+
<Modal
68+
title={<WrappedMessage message={messages.assetsUploadConfirmTitle} />}
69+
open={modalOpen}
70+
body={content}
71+
buttons={[button]}
72+
onClose={this.onClose}
73+
closeText={closeText}
74+
variant={{ status: Variant.status.WARNING }}
75+
parentSelector={`#${modalWrapperID}`}
76+
/>
77+
</div>
78+
);
79+
}
80+
}
81+
82+
AssetsUploadConfirm.propTypes = {
83+
// eslint-disable-next-line react/forbid-prop-types
84+
filesToUpload: PropTypes.arrayOf(PropTypes.object),
85+
uploadAssets: PropTypes.func.isRequired,
86+
clearUploadConfirmProps: PropTypes.func.isRequired,
87+
courseDetails: PropTypes.shape({
88+
lang: PropTypes.string,
89+
url_name: PropTypes.string,
90+
name: PropTypes.string,
91+
display_course_number: PropTypes.string,
92+
num: PropTypes.string,
93+
org: PropTypes.string,
94+
id: PropTypes.string,
95+
revision: PropTypes.string,
96+
}).isRequired,
97+
filenameConflicts: PropTypes.arrayOf(PropTypes.string),
98+
};
99+
100+
AssetsUploadConfirm.defaultProps = {
101+
filesToUpload: [],
102+
filenameConflicts: [],
103+
};

0 commit comments

Comments
 (0)