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"}} +{{#if this.failureMessage}} +
{{this.failureMessage}}
+{{/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/); + }); +});