0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-02-24 23:48:13 -05:00

Added separate stage to members import popup with support of labels

refs 633ba27f0e

- Import modal is now devided into separate stages with ability to specify labels which will be assigned to every member present in the dataset.
- Also adds explicit "Import" button without automatic import when the CSV file is selected
This commit is contained in:
Nazar Gargol 2020-06-05 22:57:07 +12:00
parent 1d4a2282d7
commit 6bfe8e6490
3 changed files with 425 additions and 20 deletions

View file

@ -3,12 +3,16 @@
{{#if this.response}} {{#if this.response}}
Import result Import result
{{else}} {{else}}
Import members Import {{#if this.filePresent}}<a href="#" {{action "reset"}}><span>(start over)</span></a>{{/if}}
{{/if}} {{/if}}
</h1> </h1>
</header> </header>
<a class="close" href="" role="button" title="Close" {{action "closeModal"}}>{{svg-jar "close"}}<span class="hidden">Close</span></a> <a class="close" href="" role="button" title="Close" {{action "closeModal"}}>{{svg-jar "close"}}<span class="hidden">Close</span></a>
{{#if this.failureMessage}}
<div class="failed bg-red-l1 ba">{{this.failureMessage}}</div>
{{/if}}
<div class="modal-body bg-whitegrey-l2 ba b--whitegrey br3"> <div class="modal-body bg-whitegrey-l2 ba b--whitegrey br3">
{{#if this.response}} {{#if this.response}}
<table class="gh-members-import-results"> <table class="gh-members-import-results">
@ -30,18 +34,37 @@
{{/if}} {{/if}}
</table> </table>
{{else}} {{else}}
<GhFileUploader {{#if this.filePresent}}
@url={{this.uploadUrl}} <GhFormGroup>
@paramName="membersfile" <label for="label-input">Labels</label>
@labelText="Select or drag-and-drop a CSV file." <GhMemberLabelInput @member={{this.labels}} @triggerId="label-input" />
@uploadStarted={{action "uploadStarted"}} </GhFormGroup>
@uploadFinished={{action "uploadFinished"}} {{else}}
@uploadSuccess={{action "uploadSuccess"}} /> <div class="upload-form">
<section class="gh-image-uploader {{this.dragClass}}">
<GhFileInput @multiple={{false}} @alt={{this.labelText}} @action={{action "fileSelected"}} @accept={{this.accept}}>
<div class="description">{{this.labelText}}</div>
</GhFileInput>
</section>
</div>
{{/if}}
{{/if}} {{/if}}
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button {{action "closeModal"}} disabled={{this.closeDisabled}} class="gh-btn" data-test-button="close-import-members"> {{#if this.response}}
<span>{{#if this.response}}Close{{else}}Cancel{{/if}}</span> <button {{action "closeModal"}} disabled={{this.closeDisabled}} class="gh-btn" data-test-button="close-import-members">
</button> <span>Close</span>
</button>
{{else if this.failureMessage}}
<button {{action "closeModal"}} disabled={{this.closeDisabled}} class="gh-btn" data-test-button="close-import-members">
<span>Cancel</span>
</button>
{{else}}
<button {{action "closeModal"}} disabled={{this.closeDisabled}} class="gh-btn" data-test-button="close-import-members">
<span>Cancel</span>
</button>
<button class="gh-btn gh-btn-green" {{action "upload"}} disabled={{this.importDisabled}}><span>Import</span></button>
{{/if}}
</div> </div>

View file

@ -1,33 +1,111 @@
import ModalComponent from 'ghost-admin/components/modal-base'; import ModalComponent from 'ghost-admin/components/modal-base';
import ghostPaths from 'ghost-admin/utils/ghost-paths'; import ghostPaths from 'ghost-admin/utils/ghost-paths';
import {
UnsupportedMediaTypeError,
isRequestEntityTooLargeError,
isUnsupportedMediaTypeError,
isVersionMismatchError
} from 'ghost-admin/services/ajax';
import {computed} from '@ember/object'; 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({ export default ModalComponent.extend({
ajax: service(),
notifications: service(),
labelText: 'Select or drag-and-drop a CSV File', labelText: 'Select or drag-and-drop a CSV File',
dragClass: null,
file: null,
paramName: 'membersfile',
extensions: null,
uploading: false,
uploadPercentage: 0,
response: null, response: null,
closeDisabled: false, failureMessage: null,
labels: null,
// Allowed actions // Allowed actions
confirm: () => {}, confirm: () => {},
filePresent: computed.reads('file'),
closeDisabled: computed.reads('uploading'),
uploadUrl: computed(function () { uploadUrl: computed(function () {
return `${ghostPaths().apiRoot}/members/csv/`; 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: { actions: {
uploadStarted() { fileSelected(fileList, resetInput) {
this.set('closeDisabled', true); let [file] = Array.from(fileList);
let validationResult = this._validate(file);
this.set('file', file);
if (validationResult !== true) {
this._uploadFailed(validationResult);
if (resetInput) {
resetInput();
}
}
}, },
uploadFinished() { reset() {
this.set('closeDisabled', false); this.set('failureMessage', null);
this.set('labels', {labels: []});
this.set('file', null);
this.set('failureMessage', null);
}, },
uploadSuccess(response) { upload() {
this.set('response', response.meta.stats); if (this.file) {
// invoke the passed in confirm action to refresh member data this.generateRequest();
this.confirm(); }
}, },
confirm() { confirm() {
@ -39,5 +117,119 @@ export default ModalComponent.extend({
this._super(...arguments); 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;
} }
}); });

View file

@ -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/);
});
});