diff --git a/ghost/admin/app/components/modal-import-members.hbs b/ghost/admin/app/components/modal-import-members.hbs
index 3ab31b0de0..4916cc3ccc 100644
--- a/ghost/admin/app/components/modal-import-members.hbs
+++ b/ghost/admin/app/components/modal-import-members.hbs
@@ -3,12 +3,16 @@
{{#if this.response}}
Import result
{{else}}
- Import members
+ Import {{#if this.filePresent}}(start over){{/if}}
{{/if}}
{{svg-jar "close"}}Close
+{{#if this.failureMessage}}
+
{{#if this.response}}
@@ -30,18 +34,37 @@
{{/if}}
{{else}}
-
+ {{#if this.filePresent}}
+
+
+
+
+ {{else}}
+
+ {{/if}}
{{/if}}
diff --git a/ghost/admin/app/components/modal-import-members.js b/ghost/admin/app/components/modal-import-members.js
index aa18bbffc2..1194b9f6d3 100644
--- a/ghost/admin/app/components/modal-import-members.js
+++ b/ghost/admin/app/components/modal-import-members.js
@@ -1,33 +1,111 @@
import ModalComponent from 'ghost-admin/components/modal-base';
import ghostPaths from 'ghost-admin/utils/ghost-paths';
+import {
+ UnsupportedMediaTypeError,
+ isRequestEntityTooLargeError,
+ isUnsupportedMediaTypeError,
+ isVersionMismatchError
+} from 'ghost-admin/services/ajax';
import {computed} from '@ember/object';
+import {htmlSafe} from '@ember/string';
+import {isBlank} from '@ember/utils';
+import {run} from '@ember/runloop';
+import {inject as service} from '@ember/service';
export default ModalComponent.extend({
+ ajax: service(),
+ notifications: service(),
+
labelText: 'Select or drag-and-drop a CSV File',
+ dragClass: null,
+ file: null,
+ paramName: 'membersfile',
+ extensions: null,
+ uploading: false,
+ uploadPercentage: 0,
response: null,
- closeDisabled: false,
+ failureMessage: null,
+ labels: null,
// Allowed actions
confirm: () => {},
+ filePresent: computed.reads('file'),
+ closeDisabled: computed.reads('uploading'),
+
uploadUrl: computed(function () {
return `${ghostPaths().apiRoot}/members/csv/`;
}),
+ importDisabled: computed('file', function () {
+ return !this.file || !(this._validate(this.file));
+ }),
+
+ formData: computed('file', function () {
+ let paramName = this.paramName;
+ let file = this.file;
+ let formData = new FormData();
+
+ formData.append(paramName, file);
+
+ if (this.labels.labels.length) {
+ this.labels.labels.forEach((label) => {
+ formData.append('labels', label.name);
+ });
+ }
+
+ return formData;
+ }),
+
+ progressStyle: computed('uploadPercentage', function () {
+ let percentage = this.uploadPercentage;
+ let width = '';
+
+ if (percentage > 0) {
+ width = `${percentage}%`;
+ } else {
+ width = '0';
+ }
+
+ return htmlSafe(`width: ${width}`);
+ }),
+
+ init() {
+ this._super(...arguments);
+ this.extensions = ['csv'];
+
+ // NOTE: nested label come from specific "gh-member-label-input" parameters, would be good to refactor
+ this.labels = {labels: []};
+ },
+
actions: {
- uploadStarted() {
- this.set('closeDisabled', true);
+ fileSelected(fileList, resetInput) {
+ let [file] = Array.from(fileList);
+ let validationResult = this._validate(file);
+
+ this.set('file', file);
+
+ if (validationResult !== true) {
+ this._uploadFailed(validationResult);
+
+ if (resetInput) {
+ resetInput();
+ }
+ }
},
- uploadFinished() {
- this.set('closeDisabled', false);
+ reset() {
+ this.set('failureMessage', null);
+ this.set('labels', {labels: []});
+ this.set('file', null);
+ this.set('failureMessage', null);
},
- uploadSuccess(response) {
- this.set('response', response.meta.stats);
- // invoke the passed in confirm action to refresh member data
- this.confirm();
+ upload() {
+ if (this.file) {
+ this.generateRequest();
+ }
},
confirm() {
@@ -39,5 +117,119 @@ export default ModalComponent.extend({
this._super(...arguments);
}
}
+ },
+
+ dragOver(event) {
+ if (!event.dataTransfer) {
+ return;
+ }
+
+ // this is needed to work around inconsistencies with dropping files
+ // from Chrome's downloads bar
+ if (navigator.userAgent.indexOf('Chrome') > -1) {
+ let eA = event.dataTransfer.effectAllowed;
+ event.dataTransfer.dropEffect = (eA === 'move' || eA === 'linkMove') ? 'move' : 'copy';
+ }
+
+ event.stopPropagation();
+ event.preventDefault();
+
+ this.set('dragClass', '-drag-over');
+ },
+
+ dragLeave(event) {
+ event.preventDefault();
+ this.set('dragClass', null);
+ },
+
+ drop(event) {
+ event.preventDefault();
+ this.set('dragClass', null);
+ if (event.dataTransfer.files) {
+ this.send('fileSelected', event.dataTransfer.files);
+ }
+ },
+
+ generateRequest() {
+ let ajax = this.ajax;
+ let formData = this.formData;
+ let url = this.uploadUrl;
+
+ this._uploadStarted();
+ ajax.post(url, {
+ data: formData,
+ processData: false,
+ contentType: false,
+ dataType: 'text',
+ xhr: () => {
+ let xhr = new window.XMLHttpRequest();
+
+ xhr.upload.addEventListener('progress', (event) => {
+ this._uploadProgress(event);
+ }, false);
+
+ return xhr;
+ }
+ }).then((response) => {
+ this._uploadSuccess(JSON.parse(response));
+ }).catch((error) => {
+ this._uploadFailed(error);
+ }).finally(() => {
+ this._uploadFinished();
+ });
+ },
+
+ _uploadStarted() {
+ this.set('uploading', true);
+ },
+
+ _uploadProgress(event) {
+ if (event.lengthComputable) {
+ run(() => {
+ let percentage = Math.round((event.loaded / event.total) * 100);
+ this.set('uploadPercentage', percentage);
+ });
+ }
+ },
+
+ _uploadSuccess(response) {
+ this.set('response', response.meta.stats);
+ // invoke the passed in confirm action to refresh member data
+ this.confirm();
+ },
+
+ _uploadFinished() {
+ this.set('uploading', false);
+ },
+
+ _uploadFailed(error) {
+ let message;
+
+ if (isVersionMismatchError(error)) {
+ this.notifications.showAPIError(error);
+ }
+
+ if (isUnsupportedMediaTypeError(error)) {
+ message = 'The file type you uploaded is not supported.';
+ } else if (isRequestEntityTooLargeError(error)) {
+ message = 'The file you uploaded was larger than the maximum file size your server allows.';
+ } else if (error.payload && error.payload.errors && !isBlank(error.payload.errors[0].message)) {
+ message = htmlSafe(error.payload.errors[0].message);
+ } else {
+ message = 'Something went wrong :(';
+ }
+
+ this.set('failureMessage', message);
+ },
+
+ _validate(file) {
+ let [, extension] = (/(?:\.([^.]+))?$/).exec(file.name);
+ let extensions = this.extensions;
+
+ if (!extension || extensions.indexOf(extension.toLowerCase()) === -1) {
+ return new UnsupportedMediaTypeError();
+ }
+
+ return true;
}
});
diff --git a/ghost/admin/tests/integration/components/modal-import-members-test.js b/ghost/admin/tests/integration/components/modal-import-members-test.js
new file mode 100644
index 0000000000..903f5a604f
--- /dev/null
+++ b/ghost/admin/tests/integration/components/modal-import-members-test.js
@@ -0,0 +1,190 @@
+import $ from 'jquery';
+import Pretender from 'pretender';
+import Service from '@ember/service';
+import hbs from 'htmlbars-inline-precompile';
+import sinon from 'sinon';
+import {click, find, findAll, render, settled, triggerEvent} from '@ember/test-helpers';
+import {describe, it} from 'mocha';
+import {expect} from 'chai';
+import {fileUpload} from '../../helpers/file-upload';
+import {run} from '@ember/runloop';
+import {setupRenderingTest} from 'ember-mocha';
+
+const notificationsStub = Service.extend({
+ showAPIError() {
+ // noop - to be stubbed
+ }
+});
+
+const stubSuccessfulUpload = function (server, delay = 0) {
+ server.post('/ghost/api/v3/admin/members/csv/', function () {
+ return [200, {'Content-Type': 'application/json'}, '{"url":"/content/images/test.png"}'];
+ }, delay);
+};
+
+const stubFailedUpload = function (server, code, error, delay = 0) {
+ server.post('/ghost/api/v3/admin/members/csv/', function () {
+ return [code, {'Content-Type': 'application/json'}, JSON.stringify({
+ errors: [{
+ type: error,
+ message: `Error: ${error}`
+ }]
+ })];
+ }, delay);
+};
+
+describe('Integration: Component: modal-import-members-test', function () {
+ setupRenderingTest();
+
+ let server;
+
+ beforeEach(function () {
+ server = new Pretender();
+ this.set('uploadUrl', '/ghost/api/v3/admin/members/csv/');
+
+ this.owner.register('service:notifications', notificationsStub);
+ });
+
+ afterEach(function () {
+ server.shutdown();
+ });
+
+ it('renders', async function () {
+ await render(hbs`{{modal-import-members}}`);
+
+ expect(find('h1').textContent.trim(), 'default header')
+ .to.equal('Import');
+ expect(find('.description').textContent.trim(), 'upload label')
+ .to.equal('Select or drag-and-drop a CSV File');
+ });
+
+ it('generates request to supplied endpoint', async function () {
+ stubSuccessfulUpload(server);
+
+ await render(hbs`{{modal-import-members}}`);
+ await fileUpload('input[type="file"]', ['membersfile'], {name: 'test.csv'});
+
+ expect(find('label').textContent.trim(), 'labels label')
+ .to.equal('Labels');
+ expect(find('.gh-btn-green').textContent).to.equal('Import');
+
+ await click('.gh-btn-green');
+
+ expect(server.handledRequests.length).to.equal(1);
+ expect(server.handledRequests[0].url).to.equal('/ghost/api/v3/admin/members/csv/');
+ });
+
+ it('displays server error', async function () {
+ stubFailedUpload(server, 415, 'UnsupportedMediaTypeError');
+ await render(hbs`{{modal-import-members}}`);
+ await fileUpload('input[type="file"]', ['membersfile'], {name: 'test.csv'});
+ await click('.gh-btn-green');
+
+ expect(findAll('.failed').length, 'error message is displayed').to.equal(1);
+ expect(find('.failed').textContent).to.match(/The file type you uploaded is not supported/);
+ });
+
+ it('displays file too large for server error', async function () {
+ stubFailedUpload(server, 413, 'RequestEntityTooLargeError');
+ await render(hbs`{{modal-import-members}}`);
+ await fileUpload('input[type="file"]', ['membersfile'], {name: 'test.csv'});
+ await click('.gh-btn-green');
+
+ expect(findAll('.failed').length, 'error message is displayed').to.equal(1);
+ expect(find('.failed').textContent).to.match(/The file you uploaded was larger/);
+ });
+
+ it('handles file too large error directly from the web server', async function () {
+ server.post('/ghost/api/v3/admin/members/csv/', function () {
+ return [413, {}, ''];
+ });
+ await render(hbs`{{modal-import-members}}`);
+ await fileUpload('input[type="file"]', ['membersfile'], {name: 'test.csv'});
+ await click('.gh-btn-green');
+
+ expect(findAll('.failed').length, 'error message is displayed').to.equal(1);
+ expect(find('.failed').textContent).to.match(/The file you uploaded was larger/);
+ });
+
+ it('displays other server-side error with message', async function () {
+ stubFailedUpload(server, 400, 'UnknownError');
+ await render(hbs`{{modal-import-members}}`);
+ await fileUpload('input[type="file"]', ['membersfile'], {name: 'test.csv'});
+ await click('.gh-btn-green');
+
+ expect(findAll('.failed').length, 'error message is displayed').to.equal(1);
+ expect(find('.failed').textContent).to.match(/Error: UnknownError/);
+ });
+
+ it('handles unknown failure', async function () {
+ server.post('/ghost/api/v3/admin/members/csv/', function () {
+ return [500, {'Content-Type': 'application/json'}, ''];
+ });
+ await render(hbs`{{modal-import-members}}`);
+ await fileUpload('input[type="file"]', ['membersfile'], {name: 'test.csv'});
+ await click('.gh-btn-green');
+
+ expect(findAll('.failed').length, 'error message is displayed').to.equal(1);
+ expect(find('.failed').textContent).to.match(/Something went wrong/);
+ });
+
+ it('triggers notifications.showAPIError for VersionMismatchError', async function () {
+ let showAPIError = sinon.spy();
+ let notifications = this.owner.lookup('service:notifications');
+ notifications.set('showAPIError', showAPIError);
+
+ stubFailedUpload(server, 400, 'VersionMismatchError');
+
+ await render(hbs`{{modal-import-members}}`);
+ await fileUpload('input[type="file"]', ['membersfile'], {name: 'test.csv'});
+ await click('.gh-btn-green');
+
+ expect(showAPIError.calledOnce).to.be.true;
+ });
+
+ it('doesn\'t trigger notifications.showAPIError for other errors', async function () {
+ let showAPIError = sinon.spy();
+ let notifications = this.owner.lookup('service:notifications');
+ notifications.set('showAPIError', showAPIError);
+
+ stubFailedUpload(server, 400, 'UnknownError');
+ await render(hbs`{{modal-import-members}}`);
+ await fileUpload('input[type="file"]', ['membersfile'], {name: 'test.csv'});
+ await click('.gh-btn-green');
+
+ expect(showAPIError.called).to.be.false;
+ });
+
+ it('handles drag over/leave', async function () {
+ await render(hbs`{{modal-import-members}}`);
+
+ run(() => {
+ // eslint-disable-next-line new-cap
+ let dragover = $.Event('dragover', {
+ dataTransfer: {
+ files: []
+ }
+ });
+ $(find('.gh-image-uploader')).trigger(dragover);
+ });
+
+ await settled();
+
+ expect(find('.gh-image-uploader').classList.contains('-drag-over'), 'has drag-over class').to.be.true;
+
+ await triggerEvent('.gh-image-uploader', 'dragleave');
+
+ expect(find('.gh-image-uploader').classList.contains('-drag-over'), 'has drag-over class').to.be.false;
+ });
+
+ it('validates extension by default', async function () {
+ stubSuccessfulUpload(server);
+
+ await render(hbs`{{modal-import-members}}`);
+
+ await fileUpload('input[type="file"]', ['membersfile'], {name: 'test.txt'});
+
+ expect(findAll('.failed').length, 'error message is displayed').to.equal(1);
+ expect(find('.failed').textContent).to.match(/The file type you uploaded is not supported/);
+ });
+});