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:
parent
1d4a2282d7
commit
6bfe8e6490
3 changed files with 425 additions and 20 deletions
|
@ -3,12 +3,16 @@
|
|||
{{#if this.response}}
|
||||
Import result
|
||||
{{else}}
|
||||
Import members
|
||||
Import {{#if this.filePresent}}<a href="#" {{action "reset"}}><span>(start over)</span></a>{{/if}}
|
||||
{{/if}}
|
||||
</h1>
|
||||
</header>
|
||||
<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">
|
||||
{{#if this.response}}
|
||||
<table class="gh-members-import-results">
|
||||
|
@ -30,18 +34,37 @@
|
|||
{{/if}}
|
||||
</table>
|
||||
{{else}}
|
||||
<GhFileUploader
|
||||
@url={{this.uploadUrl}}
|
||||
@paramName="membersfile"
|
||||
@labelText="Select or drag-and-drop a CSV file."
|
||||
@uploadStarted={{action "uploadStarted"}}
|
||||
@uploadFinished={{action "uploadFinished"}}
|
||||
@uploadSuccess={{action "uploadSuccess"}} />
|
||||
{{#if this.filePresent}}
|
||||
<GhFormGroup>
|
||||
<label for="label-input">Labels</label>
|
||||
<GhMemberLabelInput @member={{this.labels}} @triggerId="label-input" />
|
||||
</GhFormGroup>
|
||||
{{else}}
|
||||
<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}}
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
{{#if this.response}}
|
||||
<button {{action "closeModal"}} disabled={{this.closeDisabled}} class="gh-btn" data-test-button="close-import-members">
|
||||
<span>{{#if this.response}}Close{{else}}Cancel{{/if}}</span>
|
||||
<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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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/);
|
||||
});
|
||||
});
|
Loading…
Add table
Reference in a new issue