Skip to content

Commit 1772d17

Browse files
claydiffrientserikjensen
authored andcommitted
Make local file uploads work via drag-n-drop
refs CNVS-28647 Test Plan: - Enable course images feature flag - Go to course settings - Click the "Change Image" button - Drag an image file to the drag-n-drop zone - The file will upload and the modal will close - The course image setting will now show the new image. - Save the course settings - The new image should persist. Change-Id: I9922d3ede6fdd17d7503f8de78035245decfa320 Reviewed-on: https://gerrit.instructure.com/77177 Tested-by: Jenkins Reviewed-by: Matt Zabriskie <mzabriskie@instructure.com> QA-Review: Pierce Arner <pierce@instructure.com> Product-Review: Colleen Palmer <colleen@instructure.com>
1 parent a3342e5 commit 1772d17

14 files changed

Lines changed: 634 additions & 25 deletions

File tree

app/jsx/course_settings/actions.jsx

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
define ([
2-
"axios"
3-
], (axios) => {
2+
'axios',
3+
'i18n!actions',
4+
'./helpers',
5+
'compiled/jquery.rails_flash_notifications'
6+
], (axios, I18n, Helpers) => {
47

58
const Actions = {
69

@@ -22,16 +25,93 @@ define ([
2225
};
2326
},
2427

28+
rejectedUpload(type) {
29+
return {
30+
type: 'REJECTED_UPLOAD',
31+
payload: {
32+
rejectedFiletype: type
33+
}
34+
};
35+
},
36+
37+
errorUploadingImage() {
38+
$.flashError(I18n.t("There was an error uploading the image"));
39+
},
40+
2541
getCourseImage (courseId, ajaxLib = axios) {
2642
return (dispatch, getState) => {
2743
ajaxLib.get(`/api/v1/courses/${courseId}/settings`)
2844
.then((response) => {
2945
dispatch(this.gotCourseImage(response.data.image, courseId));
3046
})
3147
.catch((response) => {
32-
console.error('There is an error');
48+
$.flashError(I18n.t("There was an error retrieving the course image"));
3349
});
3450
};
51+
},
52+
53+
setCourseImageId (imageUrl, imageId) {
54+
return {
55+
type: 'SET_COURSE_IMAGE_ID',
56+
payload: {
57+
imageUrl,
58+
imageId
59+
}
60+
};
61+
},
62+
63+
prepareSetImage (imageUrl, imageId, ajaxLib = axios) {
64+
if (imageUrl) {
65+
return this.setCourseImageId(imageUrl, imageId);
66+
} else {
67+
// In this case the url field was blank so we could either
68+
// recreate it or hit the API to get it. We hit the api
69+
// to be safe.
70+
return (dispatch, getState) => {
71+
ajaxLib.get(`/api/v1/files/${imageId}`)
72+
.then((response) => {
73+
dispatch(this.setCourseImageId(response.data.url, imageId));
74+
})
75+
.catch((response) => {
76+
this.errorUploadingImage();
77+
});
78+
}
79+
}
80+
},
81+
82+
uploadFile (event, courseId, ajaxLib = axios) {
83+
event.preventDefault();
84+
return (dispatch, getState) => {
85+
const type = event.dataTransfer.files[0].type;
86+
const file = event.dataTransfer.files[0];
87+
if (Helpers.isValidImageType(type)) {
88+
const data = {
89+
name: file.name,
90+
size: file.size,
91+
parent_folder_path: 'course_image',
92+
type
93+
};
94+
ajaxLib.post(`/api/v1/courses/${courseId}/files`, data)
95+
.then((response) => {
96+
const formData = Helpers.createFormData(response.data.upload_params);
97+
formData.append('file', file);
98+
ajaxLib.post(response.data.upload_url, formData)
99+
.then((response) => {
100+
dispatch(this.prepareSetImage(response.data.url, response.data.id));
101+
})
102+
.catch((response) => {
103+
this.errorUploadingImage();
104+
});
105+
})
106+
.catch((response) => {
107+
this.errorUploadingImage();
108+
});
109+
} else {
110+
dispatch(this.rejectedUpload(type));
111+
$.flashWarning(I18n.t("'%{type}' is not a valid image type (try jpg, png, or gif)", {type}));
112+
}
113+
};
114+
35115
}
36116
};
37117

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
define([
2+
'react',
3+
'i18n!course_images',
4+
'underscore',
5+
'./UploadArea'
6+
], (React, I18n, _, UploadArea) => {
7+
8+
class CourseImagePicker extends React.Component {
9+
constructor (props) {
10+
super(props);
11+
12+
this.state = {
13+
draggingFile: false
14+
};
15+
16+
this.onDrop = this.onDrop.bind(this);
17+
this.onDragLeave = this.onDragLeave.bind(this);
18+
this.onDragEnter = this.onDragEnter.bind(this);
19+
this.shouldAcceptDrop = this.shouldAcceptDrop.bind(this);
20+
}
21+
22+
onDrop (e) {
23+
this.setState({draggingFile: false});
24+
this.props.handleFileUpload(e, this.props.courseId);
25+
e.preventDefault();
26+
e.stopPropagation();
27+
}
28+
29+
onDragLeave () {
30+
this.setState({draggingFile: false});
31+
}
32+
33+
onDragEnter (e) {
34+
if (this.shouldAcceptDrop(e.dataTransfer)) {
35+
this.setState({draggingFile: true});
36+
e.preventDefault();
37+
e.stopPropagation();
38+
}
39+
}
40+
41+
shouldAcceptDrop (dataTransfer) {
42+
if (dataTransfer) {
43+
return (_.indexOf(dataTransfer.types, 'Files') >= 0);
44+
}
45+
}
46+
47+
render () {
48+
return (
49+
<div className="CourseImagePicker"
50+
onDrop={this.onDrop}
51+
onDragLeave={this.onDragLeave}
52+
onDragOver={this.onDragEnter}
53+
onDragEnter={this.onDragEnter}>
54+
{ this.state.draggingFile ?
55+
<div className="DraggingOverlay">
56+
<div className="DraggingOverlay__Content">
57+
<div className="DraggingOverlay__Icon">
58+
<i className="icon-upload" />
59+
</div>
60+
<div className="DraggingOverlay__Instructions">
61+
{I18n.t('Drop Image')}
62+
</div>
63+
</div>
64+
</div>
65+
:
66+
null
67+
}
68+
<div className="ic-Action-header CourseImagePicker__Header">
69+
<h3 className="ic-Action-header__Heading">{I18n.t('Change Image')}</h3>
70+
<div className="ic-Action-header__Secondary">
71+
<button
72+
className="CourseImagePicker__CloseBtn"
73+
onClick={this.props.handleClose}
74+
type="button"
75+
>
76+
<i className="icon-x" />
77+
<span className="screenreader-only">
78+
{I18n.t('Close')}
79+
</span>
80+
</button>
81+
</div>
82+
</div>
83+
<div className="CourseImagePicker__Content">
84+
<UploadArea />
85+
</div>
86+
</div>
87+
);
88+
}
89+
}
90+
91+
return CourseImagePicker;
92+
93+
});

app/jsx/course_settings/components/CourseImageSelector.jsx

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ define([
22
'react',
33
'react-modal',
44
'i18n!course_images',
5-
'../actions'
6-
], (React, Modal, I18n, Actions) => {
5+
'../actions',
6+
'./CourseImagePicker'
7+
], (React, Modal, I18n, Actions, CourseImagePicker) => {
78

89
class CourseImageSelector extends React.Component {
910

@@ -12,6 +13,7 @@ define([
1213
this.state = props.store.getState();
1314

1415
this.handleChange = this.handleChange.bind(this);
16+
this.handleModalClose = this.handleModalClose.bind(this);
1517
}
1618

1719
componentWillMount () {
@@ -23,6 +25,10 @@ define([
2325
this.setState(this.props.store.getState());
2426
}
2527

28+
handleModalClose () {
29+
this.props.store.dispatch(Actions.setModalVisibility(false));
30+
}
31+
2632
render () {
2733

2834
const styles = {
@@ -31,7 +37,12 @@ define([
3137

3238
return (
3339
<div>
34-
<input ref="hiddenInput" type="hidden" name={this.props.name} value={this.state.courseImage} />
40+
<input
41+
ref="hiddenInput"
42+
type="hidden"
43+
name={this.state.hiddenInputName}
44+
value={this.state.courseImage}
45+
/>
3546
<div
3647
className="CourseImageSelector"
3748
style={(this.state.imageUrl) ? styles : {}}
@@ -47,9 +58,13 @@ define([
4758
<Modal
4859
className="CourseImageSelector__Modal"
4960
isOpen={this.state.showModal}
50-
onRequestClose={() => this.props.store.dispatch(Actions.setModalVisibility(false))}
61+
onRequestClose={this.handleModalClose}
5162
>
52-
Picker will render here :)
63+
<CourseImagePicker
64+
courseId={this.props.courseId}
65+
handleClose={this.handleModalClose}
66+
handleFileUpload={(e, courseId) => this.props.store.dispatch(Actions.uploadFile(e, courseId))}
67+
/>
5368
</Modal>
5469
</div>
5570
);
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
define([
2+
'react',
3+
'i18n!course_images',
4+
'classnames'
5+
], (React, I18n, classnames) => {
6+
7+
class UploadArea extends React.Component {
8+
render () {
9+
return (
10+
<div className="UploadArea">
11+
<div className="UploadArea__Content">
12+
<div className="UploadArea__Icon">
13+
<i className="icon-upload" />
14+
</div>
15+
<div className="UploadArea__Instructions">
16+
<strong>{I18n.t('Drag and drop your image here or browse your computer.')}</strong>
17+
<div className="UploadArea__FileTypes">
18+
{I18n.t('jpg, png, or gif files')}
19+
</div>
20+
</div>
21+
</div>
22+
</div>
23+
);
24+
}
25+
}
26+
27+
return UploadArea;
28+
29+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
define ([], () => {
2+
3+
const Helpers = {
4+
isValidImageType (mimeType) {
5+
switch (mimeType) {
6+
case 'image/jpeg':
7+
case 'image/gif':
8+
case 'image/png':
9+
return true;
10+
break;
11+
default:
12+
return false;
13+
break;
14+
}
15+
},
16+
17+
createFormData (uploadParams) {
18+
const formData = new FormData();
19+
Object.keys(uploadParams).forEach((key) => {
20+
formData.append(key, uploadParams[key]);
21+
});
22+
return formData;
23+
}
24+
};
25+
26+
return Helpers;
27+
28+
});

app/jsx/course_settings/reducer.jsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ define([
1313
state.courseImage = action.payload.imageString;
1414
state.imageUrl = action.payload.imageUrl;
1515
return state;
16+
},
17+
SET_COURSE_IMAGE_ID (state, action) {
18+
state.imageUrl = action.payload.imageUrl;
19+
state.courseImage = action.payload.imageId;
20+
state.showModal = false;
21+
state.hiddenInputName = "course[image_id]"
22+
return state;
1623
}
1724
};
1825

app/jsx/course_settings/store/initialState.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ define([], () => {
33
const initialState = {
44
courseImage: 'abc',
55
imageUrl: '',
6-
showModal: false
6+
showModal: false,
7+
hiddenInputName: ''
78
};
89

910
return initialState;

0 commit comments

Comments
 (0)