mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-25 20:03:12 +03:00
✨ Added new members CSV importer (#1797)
no refs depends on https://github.com/TryGhost/Ghost/pull/12472 The members CSV importer gets an overhaul and works with new importer module in members service, performing the import in a background job when the import will take too long to complete in a reasonable time and send an email with data on completion. Also includes updated CSV mapping UI and error handling to allow easier import from different type of exports. Co-authored-by: Fabien O'Carroll <fabien@allou.is> Co-authored-by: Peter Zimon <zimo@ghost.org>
This commit is contained in:
parent
4b51ae8705
commit
f068e40723
@ -2,11 +2,12 @@
|
||||
@extra={{hash
|
||||
tokenComponent="gh-token-input/label-token"
|
||||
}}
|
||||
@onChange={{action "updateLabels"}}
|
||||
@onCreate={{action "createLabel"}}
|
||||
@onChange={{this.updateLabels}}
|
||||
@onCreate={{this.createLabel}}
|
||||
@options={{this.availableLabels}}
|
||||
@renderInPlace={{true}}
|
||||
@selected={{selectedLabels}}
|
||||
@showCreateWhen={{action "hideCreateOptionOnMatchingLabel"}}
|
||||
@selected={{this.selectedLabels}}
|
||||
@showCreateWhen={{this.hideCreateOptionOnMatchingLabel}}
|
||||
@triggerId={{this.triggerId}}
|
||||
@disabled={{@disabled}}
|
||||
/>
|
||||
|
@ -1,100 +1,91 @@
|
||||
import Component from '@ember/component';
|
||||
import {computed} from '@ember/object';
|
||||
import Component from '@glimmer/component';
|
||||
import {action} from '@ember/object';
|
||||
import {inject as service} from '@ember/service';
|
||||
import {sort} from '@ember/object/computed';
|
||||
import {tracked} from '@glimmer/tracking';
|
||||
|
||||
export default Component.extend({
|
||||
export default class GhMemberLabelInput extends Component {
|
||||
@service
|
||||
store;
|
||||
|
||||
store: service(),
|
||||
@tracked
|
||||
selectedLabels = [];
|
||||
|
||||
// public attrs
|
||||
member: null,
|
||||
labelName: '',
|
||||
get availableLabels() {
|
||||
return this._availableLabels.toArray().sort((labelA, labelB) => {
|
||||
return labelA.name.localeCompare(labelB.name, undefined, {ignorePunctuation: true});
|
||||
});
|
||||
}
|
||||
|
||||
// internal attrs
|
||||
_availableLabels: null,
|
||||
|
||||
selectedLabels: computed.reads('member.labels'),
|
||||
|
||||
availableLabels: sort('_availableLabels.[]', function (labelA, labelB) {
|
||||
// ignorePunctuation means the # in label names is ignored
|
||||
return labelA.name.localeCompare(labelB.name, undefined, {ignorePunctuation: true});
|
||||
}),
|
||||
|
||||
availableLabelNames: computed('availableLabels.@each.name', function () {
|
||||
return this.availableLabels.map(label => label.name.toLowerCase());
|
||||
}),
|
||||
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
// perform a background query to fetch all users and set `availableLabels`
|
||||
// to a live-query that will be immediately populated with what's in the
|
||||
// store and be updated when the above query returns
|
||||
this.store.query('label', {limit: 'all'});
|
||||
this.set('_availableLabels', this.store.peekAll('label'));
|
||||
},
|
||||
this._availableLabels = this.store.peekAll('label');
|
||||
this.selectedLabels = this.args.labels || [];
|
||||
}
|
||||
|
||||
willDestroyElement() {
|
||||
// NOTE: cleans up labels store in case they were not persisted, this avoids unsaved labels
|
||||
// from appearing on different input instances when unsaved
|
||||
this.get('_availableLabels').forEach((label) => {
|
||||
get availableLabelNames() {
|
||||
return this.availableLabels.map(label => label.name.toLowerCase());
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
this._availableLabels.forEach((label) => {
|
||||
if (label.get('isNew')) {
|
||||
this.store.deleteRecord(label);
|
||||
}
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
actions: {
|
||||
matchLabels(labelName, term) {
|
||||
return labelName.toLowerCase() === term.trim().toLowerCase();
|
||||
},
|
||||
@action
|
||||
hideCreateOptionOnMatchingLabel(term) {
|
||||
return !this.availableLabelNames.includes(term.toLowerCase());
|
||||
}
|
||||
|
||||
hideCreateOptionOnMatchingLabel(term) {
|
||||
return !this.availableLabelNames.includes(term.toLowerCase());
|
||||
},
|
||||
@action
|
||||
updateLabels(newLabels) {
|
||||
let currentLabels = this.selectedLabels;
|
||||
|
||||
updateLabels(newLabels) {
|
||||
let currentLabels = this.get('member.labels');
|
||||
|
||||
// destroy new+unsaved labels that are no longer selected
|
||||
currentLabels.forEach(function (label) {
|
||||
if (!newLabels.includes(label) && label.get('isNew')) {
|
||||
label.destroyRecord();
|
||||
}
|
||||
});
|
||||
|
||||
// update labels
|
||||
return this.set('member.labels', newLabels);
|
||||
},
|
||||
|
||||
createLabel(labelName) {
|
||||
let currentLabels = this.get('member.labels');
|
||||
let currentLabelNames = currentLabels.map(label => label.get('name').toLowerCase());
|
||||
let labelToAdd;
|
||||
|
||||
labelName = labelName.trim();
|
||||
|
||||
// abort if label is already selected
|
||||
if (currentLabelNames.includes(labelName.toLowerCase())) {
|
||||
return;
|
||||
// destroy new+unsaved labels that are no longer selected
|
||||
currentLabels.forEach(function (label) {
|
||||
if (!newLabels.includes(label) && label.get('isNew')) {
|
||||
label.destroyRecord();
|
||||
}
|
||||
});
|
||||
|
||||
// find existing label if there is one
|
||||
labelToAdd = this._findLabelByName(labelName);
|
||||
// update labels
|
||||
this.selectedLabels = newLabels;
|
||||
this.args.onChange(newLabels);
|
||||
}
|
||||
|
||||
// create new label if no match
|
||||
if (!labelToAdd) {
|
||||
labelToAdd = this.store.createRecord('label', {
|
||||
name: labelName
|
||||
});
|
||||
}
|
||||
@action
|
||||
createLabel(labelName) {
|
||||
let currentLabels = this.selectedLabels;
|
||||
let currentLabelNames = currentLabels.map(label => label.get('name').toLowerCase());
|
||||
let labelToAdd;
|
||||
|
||||
// push label onto member relationship
|
||||
return currentLabels.pushObject(labelToAdd);
|
||||
labelName = labelName.trim();
|
||||
|
||||
// abort if label is already selected
|
||||
if (currentLabelNames.includes(labelName.toLowerCase())) {
|
||||
return;
|
||||
}
|
||||
},
|
||||
|
||||
// methods
|
||||
// find existing label if there is one
|
||||
labelToAdd = this._findLabelByName(labelName);
|
||||
|
||||
// create new label if no match
|
||||
if (!labelToAdd) {
|
||||
labelToAdd = this.store.createRecord('label', {
|
||||
name: labelName
|
||||
});
|
||||
}
|
||||
|
||||
// push label onto member relationship
|
||||
currentLabels.pushObject(labelToAdd);
|
||||
this.args.onChange(currentLabels);
|
||||
}
|
||||
|
||||
_findLabelByName(name) {
|
||||
let withMatchingName = function (label) {
|
||||
@ -102,4 +93,4 @@ export default Component.extend({
|
||||
};
|
||||
return this.availableLabels.find(withMatchingName);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -101,7 +101,7 @@
|
||||
<div class="pa5 pt6 pb6">
|
||||
<GhFormGroup>
|
||||
<label for="label-input">Labels</label>
|
||||
<GhMemberLabelInput @member={{this.member}} @triggerId="label-input" />
|
||||
<GhMemberLabelInput @onChange={{action "setLabels"}} @labels={{this.member.labels}} @triggerId="label-input" />
|
||||
</GhFormGroup>
|
||||
<GhFormGroup @errors={{this.member.errors}} @hasValidated={{this.member.hasValidated}} @property="note" @classNames="mb0">
|
||||
<label for="member-note">Note <span class="midgrey-l2 fw4">(not visible to member)</span></label>
|
||||
|
@ -56,6 +56,9 @@ export default Component.extend({
|
||||
actions: {
|
||||
setProperty(property, value) {
|
||||
this.setProperty(property, value);
|
||||
},
|
||||
setLabels(labels) {
|
||||
this.member.set('labels', labels);
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
<span class="gh-select gh-import-member-select {{unless this.mapTo "unmapped"}}">
|
||||
<span class="gh-select gh-import-member-select {{if @disabled "disabled"}} {{unless this.mapTo "unmapped"}}">
|
||||
<OneWaySelect @value={{this.mapTo}}
|
||||
@options={{this.availableFields}}
|
||||
@optionValuePath="value"
|
||||
@ -8,6 +8,7 @@
|
||||
@promptIsSelectable={{true}}
|
||||
@prompt="Not imported"
|
||||
@update={{action "updateMapping"}}
|
||||
@disabled={{@disabled}}
|
||||
/>
|
||||
{{svg-jar "arrow-down-small"}}
|
||||
</span>
|
@ -19,7 +19,7 @@
|
||||
<tr>
|
||||
<td class="middarkgrey table-cell-field"><span>{{row.key}}</span></td>
|
||||
<td class="middarkgrey table-cell-data {{unless row.value "empty-cell"}}"><span>{{row.value}}</span></td>
|
||||
<td><span><GhMembersImportMappingInput @updateMapping={{this.updateMapping}} @mapFrom={{row.key}} @mapTo={{row.mapTo}} /></span></td>
|
||||
<td><span><GhMembersImportMappingInput @updateMapping={{this.updateMapping}} @mapFrom={{row.key}} @mapTo={{row.mapTo}} @disabled={{@disabled}} /></span></td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
|
@ -1,21 +1,77 @@
|
||||
import Component from '@glimmer/component';
|
||||
import {action} from '@ember/object';
|
||||
import {run} from '@ember/runloop';
|
||||
import {inject as service} from '@ember/service';
|
||||
import {tracked} from '@glimmer/tracking';
|
||||
|
||||
class MembersFieldMapping {
|
||||
@tracked _mapping = {};
|
||||
|
||||
constructor(mapping) {
|
||||
if (mapping) {
|
||||
for (const [key, value] of Object.entries(mapping)) {
|
||||
this._mapping[value] = key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get(key) {
|
||||
return this._mapping[key];
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return this._mapping;
|
||||
}
|
||||
|
||||
getKeyByValue(searchedValue) {
|
||||
for (const [key, value] of Object.entries(this._mapping)) {
|
||||
if (value === searchedValue) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
updateMapping(from, to) {
|
||||
for (const key in this._mapping) {
|
||||
if (this.get(key) === to) {
|
||||
this._mapping[key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
this._mapping[from] = to;
|
||||
|
||||
// trigger an update
|
||||
// eslint-disable-next-line no-self-assign
|
||||
this._mapping = this._mapping;
|
||||
}
|
||||
}
|
||||
|
||||
export default class GhMembersImportTable extends Component {
|
||||
@tracked dataPreviewIndex = 0;
|
||||
|
||||
@service memberImportValidator;
|
||||
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
const mapping = this.memberImportValidator.check(this.args.data);
|
||||
this.data = this.args.data;
|
||||
this.mapping = new MembersFieldMapping(mapping);
|
||||
run.schedule('afterRender', () => this.args.setMapping(this.mapping));
|
||||
}
|
||||
|
||||
get currentlyDisplayedData() {
|
||||
let rows = [];
|
||||
|
||||
if (this.args && this.args.importData && this.args.mapping && this.args.mapping.mapping) {
|
||||
let currentRecord = this.args.importData[this.dataPreviewIndex];
|
||||
if (this.data && this.data.length && this.mapping) {
|
||||
let currentRecord = this.data[this.dataPreviewIndex];
|
||||
|
||||
for (const [key, value] of Object.entries(currentRecord)) {
|
||||
rows.push({
|
||||
key: key,
|
||||
value: value,
|
||||
mapTo: this.args.mapping.get(key)
|
||||
mapTo: this.mapping.get(key)
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -24,11 +80,11 @@ export default class GhMembersImportTable extends Component {
|
||||
}
|
||||
|
||||
get hasNextRecord() {
|
||||
return this.args.importData && !!(this.args.importData[this.dataPreviewIndex + 1]);
|
||||
return this.data && !!(this.data[this.dataPreviewIndex + 1]);
|
||||
}
|
||||
|
||||
get hasPrevRecord() {
|
||||
return this.args.importData && !!(this.args.importData[this.dataPreviewIndex - 1]);
|
||||
return this.data && !!(this.data[this.dataPreviewIndex - 1]);
|
||||
}
|
||||
|
||||
get currentRecord() {
|
||||
@ -36,8 +92,8 @@ export default class GhMembersImportTable extends Component {
|
||||
}
|
||||
|
||||
get allRecords() {
|
||||
if (this.args.importData) {
|
||||
return this.args.importData.length;
|
||||
if (this.data) {
|
||||
return this.data;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
@ -45,24 +101,21 @@ export default class GhMembersImportTable extends Component {
|
||||
|
||||
@action
|
||||
updateMapping(mapFrom, mapTo) {
|
||||
this.args.updateMapping(mapFrom, mapTo);
|
||||
this.mapping.updateMapping(mapFrom, mapTo);
|
||||
this.args.setMapping(this.mapping);
|
||||
}
|
||||
|
||||
@action
|
||||
next() {
|
||||
const nextValue = this.dataPreviewIndex + 1;
|
||||
|
||||
if (this.args.importData[nextValue]) {
|
||||
this.dataPreviewIndex = nextValue;
|
||||
if (this.hasNextRecord) {
|
||||
this.dataPreviewIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
prev() {
|
||||
const nextValue = this.dataPreviewIndex - 1;
|
||||
|
||||
if (this.args.importData[nextValue]) {
|
||||
this.dataPreviewIndex = nextValue;
|
||||
if (this.hasPrevRecord) {
|
||||
this.dataPreviewIndex -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,179 +1,161 @@
|
||||
<header class="modal-header" data-test-modal="import-members">
|
||||
<h1>
|
||||
{{#if this.summary}}
|
||||
Import complete{{unless this.importResponse.invalid.count "!"}}
|
||||
{{else}}
|
||||
{{#if this.uploading}}
|
||||
Importing members
|
||||
<div class="gh-member-import-wrapper {{if (or (eq this.state 'MAPPING') (eq this.state 'UPLOADING')) "wide"}}">
|
||||
{{#if (eq this.state 'INIT')}}
|
||||
<header class="modal-header" data-test-modal="import-members">
|
||||
<h1>Import members</h1>
|
||||
</header>
|
||||
{{/if}}
|
||||
|
||||
{{#if (or (eq this.state 'MAPPING') (eq this.state 'UPLOADING'))}}
|
||||
<header class="modal-header" data-test-modal="import-members">
|
||||
<h1>Import members</h1>
|
||||
</header>
|
||||
{{/if}}
|
||||
|
||||
{{#if (eq this.state 'PROCESSING')}}
|
||||
<header class="modal-header icon-center" data-test-modal="import-members">
|
||||
<GhLoadingSpinner />
|
||||
<h1>Import in progress</h1>
|
||||
</header>
|
||||
{{/if}}
|
||||
|
||||
{{#if (eq this.state 'COMPLETE')}}
|
||||
<header class="modal-header icon-center" data-test-modal="import-members">
|
||||
{{#if this.importResponse.errorCount}}
|
||||
{{svg-jar "members-outline" class="gh-member-import-icon-members"}}
|
||||
{{else}}
|
||||
Import members
|
||||
{{svg-jar "confetti" class="gh-member-import-icon-confetti"}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</h1>
|
||||
</header>
|
||||
<a class="close" href="" role="button" title="Close" {{action "closeModal"}}>{{svg-jar "close"}}<span class="hidden">Close</span></a>
|
||||
<h1>Import complete</h1>
|
||||
</header>
|
||||
{{/if}}
|
||||
|
||||
<div class="modal-body">
|
||||
{{#if (and this.filePresent (not this.failureMessage))}}
|
||||
{{#if this.validating}}
|
||||
{{#if validationErrors}}
|
||||
<div class="failed flex items-start gh-members-upload-errorcontainer {{if this.importDisabled "error" "warning"}}">
|
||||
<div class="mr3">
|
||||
{{#if this.importDisabled}}
|
||||
{{svg-jar "warning" class="nudge-top--2 w5 h5 fill-red"}}
|
||||
{{else}}
|
||||
{{svg-jar "warning" class="nudge-top--2 w5 h5 fill-yellow-d1"}}
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="ma0">
|
||||
<p class="ma0 pa0 flex-grow w-100">The CSV contains errors! {{unless this.importDisabled "Some members will not be imported."}}</p>
|
||||
{{#if validationErrors}}
|
||||
<ul class="ma0 pa0 mt4 list bt b--whitegrey">
|
||||
{{#each validationErrors as |error|}}
|
||||
<li class="gh-members-import-errormessage">
|
||||
<span>{{{error.message}}}</span>
|
||||
{{#if error.context}}
|
||||
<p class="gh-members-import-errorcontext">{{{error.context}}}</p>
|
||||
{{/if}}
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="bg-whitegrey-l2 ba b--whitegrey br3 gh-image-uploader gh-members-import-spinner">
|
||||
<GhLoadingSpinner />
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if (eq this.state 'ERROR')}}
|
||||
<header class="modal-header" data-test-modal="import-members">
|
||||
<h1>Import error</h1>
|
||||
</header>
|
||||
{{/if}}
|
||||
|
||||
<a class="close" href="" role="button" title="Close" {{action "closeModal"}}>
|
||||
{{svg-jar "close"}}
|
||||
<span class="hidden">Close</span>
|
||||
</a>
|
||||
|
||||
<div class="modal-body">
|
||||
{{#if (eq this.state 'INIT')}}
|
||||
<ModalImportMembers::CsvFileSelect @setFile={{action "setFile"}} />
|
||||
{{/if}}
|
||||
|
||||
{{#if this.customizing}}
|
||||
{{#if this.uploadErrors}}
|
||||
<div class="failed flex items-start gh-members-upload-errorcontainer {{if this.importDisabled "error" "warning"}}">
|
||||
<div class="mr3">
|
||||
{{svg-jar "warning" class="nudge-top--2 w5 h5 fill-red"}}
|
||||
</div>
|
||||
<div class="ma0">
|
||||
<p class="ma0 pa0 flex-grow w-100">The import contains errors!</p>
|
||||
<ul class="ma0 pa0 mt4 list bt b--whitegrey">
|
||||
{{#each this.uploadErrors as |error|}}
|
||||
<li class="gh-members-import-errormessage">
|
||||
<span>{{{error.message}}}</span>
|
||||
{{#if error.context}}
|
||||
<p class="gh-members-import-errorcontext">{{{error.context}}}</p>
|
||||
{{/if}}
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<GhFormGroup>
|
||||
<div class="gh-members-import-scrollarea">
|
||||
<GhMembersImportTable
|
||||
@importData={{this.fileData}}
|
||||
@mapping={{this.mapping}}
|
||||
@updateMapping={{action "updateMapping"}}/>
|
||||
</div>
|
||||
|
||||
<div class="mt4">
|
||||
<label for="label-input"><span class="fw6 f8 dib mb1">Label these members</span></label>
|
||||
<GhMemberLabelInput @member={{this.labels}} @triggerId="label-input" />
|
||||
</div>
|
||||
</GhFormGroup>
|
||||
{{#if (or (eq this.state 'MAPPING') (eq this.state 'UPLOADING'))}}
|
||||
<ModalImportMembers::CsvFileMapping @file={{this.file}} @setMappingResult={{action "setMappingResult"}} @showErrors={{this.showMappingErrors}} @disabled={{if (eq this.state 'UPLOADING') true false}} />
|
||||
{{/if}}
|
||||
|
||||
{{#if this.uploading}}
|
||||
<div class="bg-whitegrey-l2 ba b--whitegrey br3 gh-image-uploader gh-members-import-spinner">
|
||||
<GhLoadingSpinner />
|
||||
{{#if (eq this.state 'PROCESSING')}}
|
||||
<div class="gh-member-import-resultcontainer">
|
||||
<div class="gh-member-import-result-summary">
|
||||
<p>Your import is being processed, and you’ll receive a confirmation email as soon as it’s complete. Usually this only takes a few minutes, but larger imports may take longer.</p>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if (eq this.state 'COMPLETE')}}
|
||||
<div class="gh-member-import-resultcontainer">
|
||||
{{#unless (eq this.importResponse.importedCount 0)}}
|
||||
<div class="gh-member-import-result-summary">
|
||||
<p>A total of <strong>{{format-number this.importResponse.importedCount}}</strong> {{gh-pluralize this.importResponse.importedCount 'person' without-count=true}} were successfully added or updated in your list of members, and now have access to your site.</p>
|
||||
</div>
|
||||
{{/unless}}
|
||||
{{#if this.importResponse.errorCount}}
|
||||
{{#unless (eq this.importResponse.importedCount 0)}}<hr>{{/unless}}
|
||||
<div class="gh-member-import-result-summary">
|
||||
<p>{{if (eq this.importResponse.importedCount 0) "No members were added. "}}<strong>{{format-number this.importResponse.errorCount}}</strong> {{gh-pluralize this.importResponse.errorCount "member" without-count=true}} {{if (eq this.importResponse.errorCount 1) "was" "were"}} skipped due to the following errors:</p>
|
||||
</div>
|
||||
<div class="gh-member-import-errorlist">
|
||||
{{#each this.importResponse.errorList as |error|}}
|
||||
<p>{{error.message}} ({{error.count}}) </p>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.summary}}
|
||||
<div class="ba b--whitegrey br3 middarkgrey bg-whitegrey-l2">
|
||||
<div class="flex items-start">
|
||||
<div class="w-50 gh-member-import-result-summary">
|
||||
<h2>{{format-number this.importResponse.imported.count}}</h2>
|
||||
<p>{{gh-pluralize this.importResponse.imported.count "Member" without-count=true}} added</p>
|
||||
</div>
|
||||
<div class="bl b--whitegrey w-50 gh-member-import-result-summary">
|
||||
<h2>{{format-number this.importResponse.invalid.count}}</h2>
|
||||
<p>{{gh-pluralize this.importResponse.invalid.count "Error" without-count=true}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#if this.importResponse.invalid.count}}
|
||||
<div class="mt6 gh-members-upload-errorcontainer error">
|
||||
<div class="flex items-start">
|
||||
{{svg-jar "warning" class="w5 h5 fill-red nudge-top--3 mr3"}}
|
||||
<div class="flex-grow w-100">
|
||||
<p class="ma0 pa0">{{format-number this.importResponse.invalid.count}} {{gh-pluralize this.importResponse.invalid.count "member" without-count=true}} were skipped due to the following errors:</p>
|
||||
<ul class="ma0 pa0 mt4 list bt b--whitegrey">
|
||||
{{#each this.importResponse.invalid.errors as |error|}}
|
||||
<li class="gh-members-import-errormessage">{{error.message}} <span class="fw4">({{format-number error.count}})</span></li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{else}}
|
||||
{{#if this.failureMessage}}
|
||||
{{#if (eq this.state 'ERROR')}}
|
||||
<div class="failed flex items-start gh-members-upload-errorcontainer error">
|
||||
<div class="mr2">{{svg-jar "warning" class="nudge-top--2 w4 h4 fill-red"}}</div>
|
||||
<p class="ma0 pa0">{{this.failureMessage}}</p>
|
||||
<p class="ma0 pa0">{{this.errorMessage}}</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="upload-form bg-whitegrey-l2 ba b--whitegrey br3">
|
||||
<section class="gh-image-uploader gh-members-import-uploader {{this.dragClass}}">
|
||||
<GhFileInput @multiple={{false}} @alt={{this.labelText}} @action={{action "fileSelected"}} @accept={{this.accept}}>
|
||||
<div class="flex flex-column items-center">
|
||||
{{svg-jar "upload" class="w9 h9 mb1 stroke-midgrey"}}
|
||||
<div class="description midgrey">{{this.labelText}}</div>
|
||||
</div>
|
||||
</GhFileInput>
|
||||
</section>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer {{unless this.importResponse "modal-footer-spread"}}">
|
||||
{{#if this.importResponse}}
|
||||
<button {{action "closeModal"}} disabled={{this.closeDisabled}} class="gh-btn gh-btn-blue" data-test-button="close-import-members">
|
||||
<span>{{if this.importResponse.invalid.count "Done" "Awesome"}}</span>
|
||||
</button>
|
||||
{{else if (and this.filePresent (not this.failureMessage))}}
|
||||
{{#if this.validating}}
|
||||
{{#if validationErrors}}
|
||||
<button {{action "reset"}} class="gh-btn" data-test-button="close-import-members">
|
||||
<span>Start over</span>
|
||||
<div class="modal-footer modal-footer-spread">
|
||||
{{#if (eq this.state 'INIT')}}
|
||||
<button {{action "closeModal"}} class="gh-btn" data-test-button="close-import-members">
|
||||
<span>Close</span>
|
||||
</button>
|
||||
<a
|
||||
class="gh-btn"
|
||||
href="https://static.ghost.org/v3.0.0/files/member-import-template.csv"
|
||||
target="_blank"
|
||||
>
|
||||
<span>Download sample CSV file</span>
|
||||
</a>
|
||||
{{/if}}
|
||||
|
||||
{{#if (eq this.state 'MAPPING')}}
|
||||
<button {{action "reset"}} class="gh-btn" data-test-button="restart-import-members">
|
||||
<span>Start over</span>
|
||||
</button>
|
||||
<button class="gh-btn gh-btn-green" {{action "upload"}}>
|
||||
{{#if this.mappingResult.membersCount}}
|
||||
<span>Import {{format-number this.mappingResult.membersCount}} {{gh-pluralize this.mappingResult.membersCount 'member' without-count=true}}</span>
|
||||
{{else}}
|
||||
<span>Import members</span>
|
||||
{{/if}}
|
||||
</button>
|
||||
{{/if}}
|
||||
|
||||
{{#if (eq this.state 'UPLOADING')}}
|
||||
<button {{action "reset"}} class="gh-btn disabled" disabled="disabled" data-test-button="restart-import-members">
|
||||
<span>Start over</span>
|
||||
</button>
|
||||
<button class="gh-btn gh-btn-green gh-btn-icon disabled" disabled="disabled" {{action "upload"}}>
|
||||
<span>{{svg-jar "spinner" class="gh-icon-spinner"}} {{this.runningText}}Uploading</span>
|
||||
</button>
|
||||
{{/if}}
|
||||
|
||||
{{#if (eq this.state 'COMPLETE')}}
|
||||
{{#if this.importResponse.errorCount}}
|
||||
<a href="{{this.importResponse.errorCsvUrl}}" download="{{this.importResponse.errorCsvName}}" class="gh-btn" data-test-button="restart-import-members">
|
||||
<span>Download error file</span>
|
||||
</a>
|
||||
<button {{action "closeModal"}} class="gh-btn gh-btn-blue" data-test-button="close-import-members">
|
||||
<span>View members</span>
|
||||
</button>
|
||||
{{else}}
|
||||
<button {{action "reset"}} class="gh-btn" data-test-button="restart-import-members">
|
||||
<span>Upload another file</span>
|
||||
</button>
|
||||
<button {{action "closeModal"}} class="gh-btn gh-btn-blue" data-test-button="close-import-members">
|
||||
<span>View members</span>
|
||||
</button>
|
||||
{{#unless this.importDisabled}}
|
||||
<button class="gh-btn gh-btn-blue" {{action "continueImport"}}>
|
||||
<span>Continue</span>
|
||||
</button>
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
{{#if this.customizing}}
|
||||
<button {{action "reset"}} class="gh-btn" data-test-button="close-import-members">
|
||||
<span>Start over</span>
|
||||
{{#if (eq this.state 'PROCESSING')}}
|
||||
<button {{action "reset"}} class="gh-btn" data-test-button="restart-import-members">
|
||||
<span>Upload another file</span>
|
||||
</button>
|
||||
<button class="gh-btn gh-btn-green" {{action "upload"}} disabled={{this.importDisabled}}>
|
||||
<span>Import{{#if this.fileData.length}} {{format-number this.fileData.length}} {{gh-pluralize this.fileData.length 'member' without-count=true}}{{/if}}</span>
|
||||
<button {{action "closeModal"}} class="gh-btn gh-btn-blue" data-test-button="close-import-members">
|
||||
<span>Got it</span>
|
||||
</button>
|
||||
{{/if}}
|
||||
{{else}}
|
||||
<button {{action "closeModal"}} disabled={{this.closeDisabled}} class="gh-btn" data-test-button="close-import-members">
|
||||
<span>Close</span>
|
||||
</button>
|
||||
<a class="gh-btn" href="https://static.ghost.org/v3.0.0/files/member-import-template.csv" target="_blank">
|
||||
<span>Download sample CSV file</span>
|
||||
</a>
|
||||
{{/if}}
|
||||
|
||||
{{#if (eq this.state 'ERROR')}}
|
||||
<button {{action "reset"}} class="gh-btn" data-test-button="restart-import-members">
|
||||
<span>Try again</span>
|
||||
</button>
|
||||
<button {{action "closeModal"}} class="gh-btn gh-btn-blue" data-test-button="close-import-members">
|
||||
<span>OK</span>
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
@ -1,252 +1,96 @@
|
||||
import ModalComponent from 'ghost-admin/components/modal-base';
|
||||
import ghostPaths from 'ghost-admin/utils/ghost-paths';
|
||||
import papaparse from 'papaparse';
|
||||
import moment from 'moment';
|
||||
import unparse from '@tryghost/members-csv/lib/unparse';
|
||||
import {
|
||||
UnsupportedMediaTypeError,
|
||||
AcceptedResponse,
|
||||
isRequestEntityTooLargeError,
|
||||
isUnsupportedMediaTypeError,
|
||||
isVersionMismatchError
|
||||
} from 'ghost-admin/services/ajax';
|
||||
// eslint-disable-next-line ghost/ember/no-computed-properties-in-native-classes
|
||||
import {computed} from '@ember/object';
|
||||
import {htmlSafe} from '@ember/string';
|
||||
import {isBlank} from '@ember/utils';
|
||||
import {inject as service} from '@ember/service';
|
||||
import {tracked} from '@glimmer/tracking';
|
||||
|
||||
class MembersFieldMapping {
|
||||
@tracked _mapping = {};
|
||||
|
||||
constructor(mapping) {
|
||||
if (mapping) {
|
||||
for (const [key, value] of Object.entries(mapping)) {
|
||||
this.set(value, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
this._mapping[key] = value;
|
||||
|
||||
// trigger an update
|
||||
// eslint-disable-next-line no-self-assign
|
||||
this._mapping = this._mapping;
|
||||
}
|
||||
|
||||
get(key) {
|
||||
return this._mapping[key];
|
||||
}
|
||||
|
||||
get mapping() {
|
||||
return this._mapping;
|
||||
}
|
||||
|
||||
getKeyByValue(searchedValue) {
|
||||
for (const [key, value] of Object.entries(this._mapping)) {
|
||||
if (value === searchedValue) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
updateMapping(from, to) {
|
||||
for (const key in this._mapping) {
|
||||
if (this.get(key) === to) {
|
||||
this.set(key, null);
|
||||
}
|
||||
}
|
||||
|
||||
this.set(from, to);
|
||||
}
|
||||
}
|
||||
|
||||
export default ModalComponent.extend({
|
||||
config: service(),
|
||||
ajax: service(),
|
||||
notifications: service(),
|
||||
memberImportValidator: service(),
|
||||
store: service(),
|
||||
|
||||
labelText: 'Select or drop a CSV file',
|
||||
state: 'INIT',
|
||||
|
||||
// import stages, default is "CSV file selection"
|
||||
validating: false,
|
||||
customizing: false,
|
||||
uploading: false,
|
||||
summary: false,
|
||||
|
||||
dragClass: null,
|
||||
file: null,
|
||||
fileData: null,
|
||||
mapping: null,
|
||||
mappingResult: null,
|
||||
paramName: 'membersfile',
|
||||
importResponse: null,
|
||||
failureMessage: null,
|
||||
validationErrors: null,
|
||||
uploadErrors: null,
|
||||
labels: null,
|
||||
errorMessage: null,
|
||||
showMappingErrors: false,
|
||||
|
||||
// Allowed actions
|
||||
confirm: () => {},
|
||||
|
||||
filePresent: computed.reads('file'),
|
||||
closeDisabled: computed.reads('uploading'),
|
||||
|
||||
uploadUrl: computed(function () {
|
||||
return `${ghostPaths().apiRoot}/members/upload/`;
|
||||
}),
|
||||
|
||||
importDisabled: computed('file', 'validationErrors', function () {
|
||||
const hasEmptyDataFile = this.validationErrors && this.validationErrors.filter(error => error.message.includes('File is empty')).length;
|
||||
return !this.file || !(this._validateFileType(this.file)) || hasEmptyDataFile;
|
||||
}),
|
||||
|
||||
formData: computed('file', function () {
|
||||
let paramName = this.paramName;
|
||||
let file = this.file;
|
||||
let formData = new FormData();
|
||||
|
||||
formData.append(paramName, file);
|
||||
formData.append(this.paramName, this.file);
|
||||
|
||||
if (this.labels.labels.length) {
|
||||
this.labels.labels.forEach((label) => {
|
||||
if (this.mappingResult.labels) {
|
||||
this.mappingResult.labels.forEach((label) => {
|
||||
formData.append('labels', label.name);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.mapping) {
|
||||
for (const key in this.mapping.mapping) {
|
||||
if (this.mapping.get(key)){
|
||||
// reversing mapping direction to match the structure accepted in the API
|
||||
formData.append(`mapping[${this.mapping.get(key)}]`, key);
|
||||
}
|
||||
if (this.mappingResult.mapping) {
|
||||
let mapping = this.mappingResult.mapping.toJSON();
|
||||
for (let [key, val] of Object.entries(mapping)) {
|
||||
formData.append(`mapping[${key}]`, val);
|
||||
}
|
||||
}
|
||||
|
||||
return formData;
|
||||
}),
|
||||
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
|
||||
// NOTE: nested label come from specific "gh-member-label-input" parameters, would be good to refactor
|
||||
this.labels = {labels: []};
|
||||
},
|
||||
|
||||
actions: {
|
||||
fileSelected(fileList) {
|
||||
let [file] = Array.from(fileList);
|
||||
let validationResult = this._validateFileType(file);
|
||||
setFile(file) {
|
||||
this.set('file', file);
|
||||
this.set('state', 'MAPPING');
|
||||
},
|
||||
|
||||
if (validationResult !== true) {
|
||||
this._validationFailed(validationResult);
|
||||
setMappingResult(mappingResult) {
|
||||
this.set('mappingResult', mappingResult);
|
||||
},
|
||||
|
||||
upload() {
|
||||
if (this.file && !this.mappingResult.error) {
|
||||
this.generateRequest();
|
||||
this.set('showMappingErrors', false);
|
||||
} else {
|
||||
this.set('file', file);
|
||||
this.set('failureMessage', null);
|
||||
|
||||
this.set('validating', true);
|
||||
|
||||
papaparse.parse(file, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
worker: true, // NOTE: compare speed and file sizes with/without this flag
|
||||
complete: async (results) => {
|
||||
this.set('fileData', results.data);
|
||||
|
||||
let {validationErrors, mapping} = await this.memberImportValidator.check(results.data);
|
||||
this.set('mapping', new MembersFieldMapping(mapping));
|
||||
|
||||
if (validationErrors.length) {
|
||||
this._importValidationFailed(validationErrors);
|
||||
} else {
|
||||
this.set('validating', false);
|
||||
this.set('customizing', true);
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
this._validationFailed(error);
|
||||
}
|
||||
});
|
||||
this.set('showMappingErrors', true);
|
||||
}
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.set('failureMessage', null);
|
||||
this.set('labels', {labels: []});
|
||||
this.set('showMappingErrors', false);
|
||||
this.set('errorMessage', null);
|
||||
this.set('file', null);
|
||||
this.set('fileData', null);
|
||||
this.set('mapping', null);
|
||||
this.set('validationErrors', null);
|
||||
this.set('uploadErrors', null);
|
||||
|
||||
this.set('validating', false);
|
||||
this.set('customizing', false);
|
||||
this.set('uploading', false);
|
||||
this.set('summary', false);
|
||||
},
|
||||
|
||||
upload() {
|
||||
if (this.file && this.mapping.getKeyByValue('email')) {
|
||||
this.generateRequest();
|
||||
} else {
|
||||
this.set('uploadErrors', [{
|
||||
message: 'Import as "Email" value is missing.',
|
||||
context: 'The CSV import has to have selected import as "Email" field.'
|
||||
}]);
|
||||
}
|
||||
},
|
||||
|
||||
continueImport() {
|
||||
this.set('validating', false);
|
||||
this.set('customizing', true);
|
||||
},
|
||||
|
||||
confirm() {
|
||||
// noop - we don't want the enter key doing anything
|
||||
this.set('state', 'INIT');
|
||||
},
|
||||
|
||||
closeModal() {
|
||||
if (!this.closeDisabled) {
|
||||
if (this.state !== 'UPLOADING') {
|
||||
this._super(...arguments);
|
||||
}
|
||||
},
|
||||
|
||||
updateMapping(mapFrom, mapTo) {
|
||||
this.mapping.updateMapping(mapFrom, mapTo);
|
||||
}
|
||||
},
|
||||
|
||||
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);
|
||||
}
|
||||
// noop - we don't want the enter key doing anything
|
||||
confirm() {}
|
||||
},
|
||||
|
||||
generateRequest() {
|
||||
@ -254,38 +98,81 @@ export default ModalComponent.extend({
|
||||
let formData = this.formData;
|
||||
let url = this.uploadUrl;
|
||||
|
||||
this._uploadStarted();
|
||||
this.set('state', 'UPLOADING');
|
||||
ajax.post(url, {
|
||||
data: formData,
|
||||
processData: false,
|
||||
contentType: false,
|
||||
dataType: 'text'
|
||||
}).then((importResponse) => {
|
||||
this._uploadSuccess(JSON.parse(importResponse));
|
||||
if (importResponse instanceof AcceptedResponse) {
|
||||
this.set('state', 'PROCESSING');
|
||||
} else {
|
||||
this._uploadSuccess(JSON.parse(importResponse));
|
||||
this.set('state', 'COMPLETE');
|
||||
}
|
||||
}).catch((error) => {
|
||||
this._validationFailed(error);
|
||||
}).finally(() => {
|
||||
this._uploadFinished();
|
||||
this._uploadError(error);
|
||||
this.set('state', 'ERROR');
|
||||
});
|
||||
},
|
||||
|
||||
_uploadStarted() {
|
||||
this.set('customizing', false);
|
||||
this.set('uploading', true);
|
||||
},
|
||||
|
||||
_uploadSuccess(importResponse) {
|
||||
if (importResponse.meta.stats.invalid && importResponse.meta.stats.invalid.errors) {
|
||||
importResponse.meta.stats.invalid.errors.forEach((error) => {
|
||||
if (error.message === 'Value in [members.email] cannot be blank.') {
|
||||
error.message = 'Missing email address';
|
||||
} else if (error.message === 'Validation (isEmail) failed for email') {
|
||||
error.message = 'Invalid email address';
|
||||
let importedCount = importResponse.meta.stats.imported;
|
||||
const erroredMembers = importResponse.meta.stats.invalid;
|
||||
let errorCount = erroredMembers.length;
|
||||
const errorList = {};
|
||||
|
||||
const errorsWithFormattedMessages = erroredMembers.map((row) => {
|
||||
const formattedError = row.error
|
||||
.replace(
|
||||
'Value in [members.email] cannot be blank.',
|
||||
'Missing email address'
|
||||
)
|
||||
.replace(
|
||||
'Value in [members.note] exceeds maximum length of 2000 characters.',
|
||||
'"Note" exceeds maximum length of 2000 characters'
|
||||
)
|
||||
.replace(
|
||||
'Value in [members.subscribed] must be one of true, false, 0 or 1.',
|
||||
'Value in "Subscribed to emails" must be "true" or "false"'
|
||||
)
|
||||
.replace(
|
||||
'Validation (isEmail) failed for email',
|
||||
'Invalid email address'
|
||||
)
|
||||
.replace(
|
||||
/No such customer:[^,]*/,
|
||||
'Could not find Stripe customer'
|
||||
);
|
||||
formattedError.split(',').forEach((errorMssg) => {
|
||||
if (errorList[errorMssg]) {
|
||||
errorList[errorMssg].count = errorList[errorMssg].count + 1;
|
||||
} else {
|
||||
errorList[errorMssg] = {
|
||||
message: errorMssg,
|
||||
count: 1
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
return {
|
||||
...row,
|
||||
error: formattedError
|
||||
};
|
||||
});
|
||||
|
||||
this.set('importResponse', importResponse.meta.stats);
|
||||
let errorCsv = unparse(errorsWithFormattedMessages);
|
||||
let errorCsvBlob = new Blob([errorCsv], {type: 'text/csv'});
|
||||
let errorCsvUrl = URL.createObjectURL(errorCsvBlob);
|
||||
let errorCsvName = importResponse.meta.import_label ? `${importResponse.meta.import_label.name} - Errors.csv` : `Import ${moment().format('YYYY-MM-DD HH:mm')} - Errors.csv`;
|
||||
|
||||
this.set('importResponse', {
|
||||
importedCount,
|
||||
errorCount,
|
||||
errorCsvUrl,
|
||||
errorCsvName,
|
||||
errorList: Object.values(errorList)
|
||||
});
|
||||
|
||||
// insert auto-created import label into store immediately if present
|
||||
// ready for filtering the members list
|
||||
@ -296,22 +183,11 @@ export default ModalComponent.extend({
|
||||
}
|
||||
|
||||
// invoke the passed in confirm action to refresh member data
|
||||
// @TODO wtf does confirm mean?
|
||||
this.confirm({label: importResponse.meta.import_label});
|
||||
},
|
||||
|
||||
_uploadFinished() {
|
||||
this.set('uploading', false);
|
||||
|
||||
if (!this.get('failureMessage')) {
|
||||
this.set('summary', true);
|
||||
}
|
||||
},
|
||||
|
||||
_importValidationFailed(errors) {
|
||||
this.set('validationErrors', errors);
|
||||
},
|
||||
|
||||
_validationFailed(error) {
|
||||
_uploadError(error) {
|
||||
let message;
|
||||
|
||||
if (isVersionMismatchError(error)) {
|
||||
@ -329,16 +205,6 @@ export default ModalComponent.extend({
|
||||
message = 'Something went wrong :(';
|
||||
}
|
||||
|
||||
this.set('failureMessage', message);
|
||||
},
|
||||
|
||||
_validateFileType(file) {
|
||||
let [, extension] = (/(?:\.([^.]+))?$/).exec(file.name);
|
||||
|
||||
if (['csv'].indexOf(extension.toLowerCase()) === -1) {
|
||||
return new UnsupportedMediaTypeError();
|
||||
}
|
||||
|
||||
return true;
|
||||
this.set('errorMessage', message);
|
||||
}
|
||||
});
|
||||
|
@ -0,0 +1,25 @@
|
||||
{{#if this.hasFileData}}
|
||||
<GhFormGroup class="gh-members-import-mapping">
|
||||
<div class="gh-members-import-mappingwrapper {{if (and this.error @showErrors) "error"}}">
|
||||
<div class="gh-members-import-scrollarea">
|
||||
<GhMembersImportTable
|
||||
@data={{this.fileData}}
|
||||
@setMapping={{this.setMapping}}
|
||||
@disabled={{@disabled}} />
|
||||
</div>
|
||||
</div>
|
||||
{{#if (and this.error @showErrors)}}
|
||||
<p class="pt2 error">{{this.error.message}}</p>
|
||||
{{/if}}
|
||||
<p class="pt2">If an email address in your CSV matches an existing member, they will be updated with the mapped values.</p>
|
||||
|
||||
<div class="mt6">
|
||||
<label for="label-input"><span class="fw6 f8 dib mb1">Label these members</span></label>
|
||||
<GhMemberLabelInput @onChange={{this.updateLabels}} @disabled={{@disabled}} @triggerId="label-input" />
|
||||
</div>
|
||||
</GhFormGroup>
|
||||
{{else}}
|
||||
<div class="bg-whitegrey-l2 ba b--whitegrey br3 gh-image-uploader gh-members-import-spinner">
|
||||
<GhLoadingSpinner />
|
||||
</div>
|
||||
{{/if}}
|
@ -0,0 +1,72 @@
|
||||
import Component from '@glimmer/component';
|
||||
import MemberImportError from 'ghost-admin/errors/member-import-error';
|
||||
import papaparse from 'papaparse';
|
||||
import {action} from '@ember/object';
|
||||
import {isNone} from '@ember/utils';
|
||||
import {tracked} from '@glimmer/tracking';
|
||||
|
||||
export default class CsvFileMapping extends Component {
|
||||
@tracked
|
||||
error = null;
|
||||
|
||||
@tracked
|
||||
fileData = null;
|
||||
|
||||
mappingResult = {};
|
||||
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
this.parseFileAndGenerateMapping(this.args.file);
|
||||
}
|
||||
|
||||
parseFileAndGenerateMapping(file) {
|
||||
papaparse.parse(file, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
complete: (result) => {
|
||||
if (result.data && result.data.length) {
|
||||
this.fileData = result.data;
|
||||
} else {
|
||||
this.fileData = [];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get hasFileData() {
|
||||
return !isNone(this.fileData);
|
||||
}
|
||||
|
||||
@action
|
||||
setMapping(mapping) {
|
||||
if (this.fileData.length === 0) {
|
||||
this.error = new MemberImportError({
|
||||
message: 'File is empty, nothing to import. Please select a different file.'
|
||||
});
|
||||
} else if (!mapping.getKeyByValue('email')) {
|
||||
this.error = new MemberImportError({
|
||||
message: 'Please map "Email" to one of the fields in the CSV.'
|
||||
});
|
||||
} else {
|
||||
this.error = null;
|
||||
}
|
||||
|
||||
this.mapping = mapping;
|
||||
this.setMappingResult();
|
||||
}
|
||||
|
||||
@action
|
||||
updateLabels(labels) {
|
||||
this.labels = labels;
|
||||
this.setMappingResult();
|
||||
}
|
||||
|
||||
setMappingResult() {
|
||||
this.args.setMappingResult({
|
||||
mapping: this.mapping,
|
||||
labels: this.labels,
|
||||
membersCount: this.fileData?.length,
|
||||
error: this.error
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
{{#if this.error}}
|
||||
<div class="failed flex items-start gh-members-upload-errorcontainer error">
|
||||
<div class="mr2">{{svg-jar "warning" class="nudge-top--2 w4 h4 fill-red"}}</div>
|
||||
<p class="ma0 pa0">{{this.error.message}}</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="upload-form bg-whitegrey-l2 ba b--whitegrey br3">
|
||||
<section class="gh-image-uploader gh-members-import-uploader {{this.dragClass}}"
|
||||
{{on "drop" this.drop}}
|
||||
{{on "dragover" this.dragOver}}
|
||||
{{on "dragleave" this.dragLeave}}
|
||||
>
|
||||
<GhFileInput @multiple={{false}} @alt={{this.labelText}} @action={{this.fileSelected}} @accept={{this.accept}}>
|
||||
<div class="flex flex-column items-center">
|
||||
{{svg-jar "upload" class="w9 h9 mb1 stroke-midgrey"}}
|
||||
<div class="description midgrey">{{this.labelText}}</div>
|
||||
</div>
|
||||
</GhFileInput>
|
||||
</section>
|
||||
</div>
|
@ -0,0 +1,81 @@
|
||||
import Component from '@glimmer/component';
|
||||
import {UnsupportedMediaTypeError} from 'ghost-admin/services/ajax';
|
||||
import {action} from '@ember/object';
|
||||
import {tracked} from '@glimmer/tracking';
|
||||
|
||||
export default class CsvFileSelect extends Component {
|
||||
labelText = 'Select or drop a CSV file'
|
||||
|
||||
@tracked
|
||||
error = null
|
||||
@tracked
|
||||
dragClass = null
|
||||
|
||||
/*
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
assert(this.args.setFile);
|
||||
}
|
||||
*/
|
||||
|
||||
@action
|
||||
fileSelected(fileList) {
|
||||
let [file] = Array.from(fileList);
|
||||
|
||||
try {
|
||||
this._validateFileType(file);
|
||||
this.error = null;
|
||||
} catch (err) {
|
||||
this.error = err;
|
||||
return;
|
||||
}
|
||||
|
||||
this.args.setFile(file);
|
||||
}
|
||||
|
||||
@action
|
||||
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.dragClass = '-drag-over';
|
||||
}
|
||||
|
||||
@action
|
||||
dragLeave(event) {
|
||||
event.preventDefault();
|
||||
this.dragClass = null;
|
||||
}
|
||||
|
||||
@action
|
||||
drop(event) {
|
||||
event.preventDefault();
|
||||
this.dragClass = null;
|
||||
if (event.dataTransfer.files) {
|
||||
this.fileSelected(event.dataTransfer.files);
|
||||
}
|
||||
}
|
||||
|
||||
_validateFileType(file) {
|
||||
let [, extension] = (/(?:\.([^.]+))?$/).exec(file.name);
|
||||
|
||||
if (extension.toLowerCase() !== 'csv') {
|
||||
throw new UnsupportedMediaTypeError({
|
||||
message: 'The file type you uploaded is not supported'
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
@ -144,6 +144,19 @@ export function isEmailError(errorOrStatus, payload) {
|
||||
|
||||
/* end: custom error types */
|
||||
|
||||
export class AcceptedResponse {
|
||||
constructor(data) {
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
export function isAcceptedResponse(errorOrStatus) {
|
||||
if (errorOrStatus === 202) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
let ajaxService = AjaxService.extend({
|
||||
session: service(),
|
||||
|
||||
@ -198,6 +211,8 @@ let ajaxService = AjaxService.extend({
|
||||
return new HostLimitError(payload);
|
||||
} else if (this.isEmailError(status, headers, payload)) {
|
||||
return new EmailError(payload);
|
||||
} else if (this.isAcceptedResponse(status)) {
|
||||
return new AcceptedResponse(payload);
|
||||
}
|
||||
|
||||
let isGhostRequest = GHOST_REQUEST.test(request.url);
|
||||
@ -264,6 +279,10 @@ let ajaxService = AjaxService.extend({
|
||||
|
||||
isEmailError(status, headers, payload) {
|
||||
return isEmailError(status, payload);
|
||||
},
|
||||
|
||||
isAcceptedResponse(status) {
|
||||
return isAcceptedResponse(status);
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -1,8 +1,5 @@
|
||||
import MemberImportError from 'ghost-admin/errors/member-import-error';
|
||||
import Service, {inject as service} from '@ember/service';
|
||||
import validator from 'validator';
|
||||
import {formatNumber} from 'ghost-admin/helpers/format-number';
|
||||
import {ghPluralize} from 'ghost-admin/helpers/gh-pluralize';
|
||||
import {isEmpty} from '@ember/utils';
|
||||
|
||||
export default Service.extend({
|
||||
@ -10,69 +7,10 @@ export default Service.extend({
|
||||
membersUtils: service(),
|
||||
ghostPaths: service(),
|
||||
|
||||
async check(data) {
|
||||
if (!data || !data.length) {
|
||||
return {
|
||||
validationErrors: [new MemberImportError({
|
||||
message: 'File is empty, nothing to import. Please select a different file.'
|
||||
})]
|
||||
};
|
||||
}
|
||||
|
||||
check(data) {
|
||||
let sampledData = this._sampleData(data);
|
||||
let mapping = this._detectDataTypes(sampledData);
|
||||
|
||||
let validationErrors = [];
|
||||
|
||||
const hasStripeIds = !!mapping.stripe_customer_id;
|
||||
const hasEmails = !!mapping.email;
|
||||
|
||||
if (hasStripeIds) {
|
||||
// check can be done on whole set as it won't be too slow
|
||||
const {totalCount, duplicateCount} = this._checkStripeIds(data, mapping);
|
||||
|
||||
if (!this.membersUtils.isStripeEnabled) {
|
||||
validationErrors.push(new MemberImportError({
|
||||
message: `Missing Stripe connection`,
|
||||
context: `${ghPluralize(totalCount, 'Stripe customer')} won't be imported. You need to <a href="#/settings/labs">connect to Stripe</a> to import Stripe customers.`,
|
||||
type: 'warning'
|
||||
}));
|
||||
} else {
|
||||
let stripeSeverValidation = await this._checkStripeServer(sampledData, mapping);
|
||||
if (stripeSeverValidation !== true) {
|
||||
validationErrors.push(new MemberImportError({
|
||||
message: 'Wrong Stripe account',
|
||||
context: `The CSV contains Stripe customers from a different Stripe account. These members will not be imported. Make sure you're connected to the correct <a href="#/settings/labs">Stripe account</a>.`,
|
||||
type: 'warning'
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (duplicateCount) {
|
||||
validationErrors.push(new MemberImportError({
|
||||
message: `Duplicate Stripe ID <span class="fw4">(${formatNumber(duplicateCount)})</span>`,
|
||||
type: 'warning'
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasEmails) {
|
||||
validationErrors.push(new MemberImportError({
|
||||
message: 'No email addresses found in the uploaded CSV.'
|
||||
}));
|
||||
} else {
|
||||
// check can be done on whole set as it won't be too slow
|
||||
const {emptyCount} = this._checkEmails(data, mapping);
|
||||
|
||||
if (emptyCount) {
|
||||
validationErrors.push(new MemberImportError({
|
||||
message: `Missing email address <span class="fw4">(${formatNumber(emptyCount)})</span>`,
|
||||
type: 'warning'
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return {validationErrors, mapping};
|
||||
return mapping;
|
||||
},
|
||||
|
||||
/**
|
||||
@ -141,15 +79,12 @@ export default Service.extend({
|
||||
'name',
|
||||
'note',
|
||||
'subscribed_to_emails',
|
||||
'stripe_customer_id',
|
||||
'complimentary_plan',
|
||||
'labels',
|
||||
'created_at'
|
||||
];
|
||||
|
||||
const autoDetectedTypes = [
|
||||
'email',
|
||||
'stripe_customer_id'
|
||||
'email'
|
||||
];
|
||||
|
||||
let mapping = {};
|
||||
@ -167,11 +102,6 @@ export default Service.extend({
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!mapping.stripe_customer_id && value && value.startsWith && value.startsWith('cus_')) {
|
||||
mapping.stripe_customer_id = key;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!mapping.name && /name/.test(key)) {
|
||||
mapping.name = key;
|
||||
continue;
|
||||
@ -186,99 +116,5 @@ export default Service.extend({
|
||||
}
|
||||
|
||||
return mapping;
|
||||
},
|
||||
|
||||
_containsRecordsWithStripeId(validatedSet) {
|
||||
let memberWithStripeId = validatedSet.find(m => !!(m.stripe_customer_id));
|
||||
return !!memberWithStripeId;
|
||||
},
|
||||
|
||||
_checkEmails(validatedSet, mapping) {
|
||||
let emptyCount = 0;
|
||||
|
||||
validatedSet.forEach((member) => {
|
||||
let emailValue = member[mapping.email];
|
||||
if (!emailValue) {
|
||||
emptyCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return {emptyCount};
|
||||
},
|
||||
|
||||
_countStripeRecors(validatedSet, mapping) {
|
||||
let count = 0;
|
||||
|
||||
validatedSet.forEach((member) => {
|
||||
if (!isEmpty(member[mapping.stripe_customer_id])) {
|
||||
count += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return count;
|
||||
},
|
||||
|
||||
_checkStripeIds(validatedSet, mapping) {
|
||||
let totalCount = 0;
|
||||
let duplicateCount = 0;
|
||||
|
||||
validatedSet.reduce((acc, member) => {
|
||||
let stripeCustomerIdValue = member[mapping.stripe_customer_id];
|
||||
|
||||
if (stripeCustomerIdValue && stripeCustomerIdValue !== 'undefined') {
|
||||
totalCount += 1;
|
||||
|
||||
if (acc[stripeCustomerIdValue]) {
|
||||
acc[stripeCustomerIdValue] += 1;
|
||||
duplicateCount += 1;
|
||||
} else {
|
||||
acc[stripeCustomerIdValue] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return {totalCount, duplicateCount};
|
||||
},
|
||||
|
||||
_checkContainsStripeIDs(validatedSet) {
|
||||
let result = true;
|
||||
|
||||
if (!this.membersUtils.isStripeEnabled) {
|
||||
validatedSet.forEach((member) => {
|
||||
if (member.stripe_customer_id) {
|
||||
result = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
async _checkStripeServer(validatedSet, mapping) {
|
||||
const url = this.ghostPaths.get('url').api('members/upload/validate');
|
||||
const mappedValidatedSet = validatedSet.map((entry) => {
|
||||
return {
|
||||
stripe_customer_id: entry[mapping.stripe_customer_id]
|
||||
};
|
||||
});
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await this.ajax.post(url, {
|
||||
data: {
|
||||
members: mappedValidatedSet
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (response.errors) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
@ -131,6 +131,31 @@
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.modal-header.icon-center {
|
||||
padding-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-height: 124px;
|
||||
}
|
||||
|
||||
.modal-header.icon-center svg {
|
||||
width: 66px;
|
||||
height: 66px;
|
||||
}
|
||||
|
||||
.modal-header.icon-center h1 {
|
||||
margin: 20px 0 8px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.modal-header.icon-center .gh-loading-content {
|
||||
position: relative;
|
||||
padding: 8px 0;
|
||||
height: 62px;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
position: relative;
|
||||
}
|
||||
|
@ -527,7 +527,39 @@ textarea.gh-member-details-textarea {
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
.fullscreen-modal-import-members {
|
||||
max-width: 640px;
|
||||
max-width: unset !important;
|
||||
}
|
||||
|
||||
.gh-member-import-wrapper {
|
||||
width: 420px;
|
||||
}
|
||||
|
||||
.gh-member-import-wrapper.wide {
|
||||
width: 580px;
|
||||
}
|
||||
|
||||
.gh-member-import-wrapper .gh-btn.disabled,
|
||||
.gh-member-import-wrapper .gh-btn.disabled:hover {
|
||||
cursor: auto !important;
|
||||
opacity: 0.6 !important;
|
||||
}
|
||||
|
||||
.gh-member-import-wrapper .gh-btn.disabled span,
|
||||
.gh-member-import-wrapper .gh-btn.disabled span:hover {
|
||||
cursor: auto !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gh-member-import-wrapper .gh-token-input .ember-power-select-trigger[aria-disabled=true],
|
||||
.gh-member-import-wrapper .gh-token-input .ember-power-select-trigger-multiple-input:disabled {
|
||||
background: var(--whitegrey-l2);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.gh-member-import-wrapper,
|
||||
.gh-member-import-wrapper.wide {
|
||||
width: calc(100vw - 128px);
|
||||
}
|
||||
}
|
||||
|
||||
.gh-members-import-uploader {
|
||||
@ -545,6 +577,7 @@ textarea.gh-member-details-textarea {
|
||||
min-height: 182px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: -20px;
|
||||
}
|
||||
|
||||
.gh-members-import-spinner .gh-loading-content {
|
||||
@ -595,6 +628,27 @@ p.gh-members-import-errorcontext {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.gh-members-import-mapping .error {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.gh-members-import-mappingwrapper.error {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.gh-members-import-mappingwrapper.error::before {
|
||||
display: block;
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border: 1px solid red;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gh-members-import-scrollarea {
|
||||
position: relative;
|
||||
max-height: calc(100vh - 350px - 12vw);
|
||||
@ -778,8 +832,12 @@ p.gh-members-import-errordetail:first-of-type {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.gh-member-import-resultcontainer {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.gh-member-import-result-summary {
|
||||
padding: 20px;
|
||||
flex-basis: 50%;
|
||||
}
|
||||
|
||||
.gh-member-import-result-summary h2 {
|
||||
@ -790,9 +848,42 @@ p.gh-members-import-errordetail:first-of-type {
|
||||
}
|
||||
|
||||
.gh-member-import-result-summary p {
|
||||
margin: -2px 0 0;
|
||||
color: var(--darkgrey);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: var(--midlightgrey);
|
||||
line-height: 1.6em;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.gh-member-import-result-summary p strong {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.gh-member-import-errorlist {
|
||||
width: 100%;
|
||||
margin: 8px 0 28px;
|
||||
}
|
||||
|
||||
.gh-member-import-errorlist h4 {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid var(--whitegrey);
|
||||
padding-bottom: 8px;
|
||||
margin-top: 0px;
|
||||
color: var(--midgrey);
|
||||
}
|
||||
|
||||
.gh-member-import-errorlist p {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--midlightgrey-d2);
|
||||
padding: 0;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.gh-member-import-resultcontainer hr {
|
||||
margin: 24px -32px;
|
||||
border-color: var(--whitegrey);
|
||||
}
|
||||
|
||||
.gh-member-import-nodata span {
|
||||
@ -803,6 +894,26 @@ p.gh-members-import-errordetail:first-of-type {
|
||||
color: var(--midgrey);
|
||||
}
|
||||
|
||||
.gh-member-import-icon-members {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.gh-member-import-icon-members path,
|
||||
.gh-member-import-icon-members circle {
|
||||
stroke-width: 0.85px;
|
||||
}
|
||||
|
||||
.gh-member-import-icon-confetti {
|
||||
color: var(--purple);
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.gh-member-import-icon-confetti path,
|
||||
.gh-member-import-icon-confetti circle,
|
||||
.gh-member-import-icon-confetti ellipse {
|
||||
stroke-width: 0.85px;
|
||||
}
|
||||
|
||||
/* Fixing Firefox's select padding */
|
||||
@-moz-document url-prefix() {
|
||||
.gh-import-member-select select {
|
||||
|
@ -1,4 +1,4 @@
|
||||
<GhFullscreenModal @modal="import-members"
|
||||
@confirm={{this.refreshMembers}}
|
||||
@close={{this.close}}
|
||||
@modifier="action wide import-members" />
|
||||
@modifier="action import-members" />
|
||||
|
@ -33,6 +33,7 @@
|
||||
"@tryghost/helpers": "1.1.34",
|
||||
"@tryghost/kg-clean-basic-html": "1.0.10",
|
||||
"@tryghost/kg-parser-plugins": "1.0.10",
|
||||
"@tryghost/members-csv": "0.4.0",
|
||||
"@tryghost/mobiledoc-kit": "0.12.5-ghost.1",
|
||||
"@tryghost/string": "0.1.14",
|
||||
"@tryghost/timezone-data": "0.2.32",
|
||||
|
1
ghost/admin/public/assets/icons/confetti.svg
Normal file
1
ghost/admin/public/assets/icons/confetti.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg id="Regular" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs><style>.cls-1{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.5px;}</style></defs><title>party-confetti</title><ellipse class="cls-1" cx="11.531" cy="12.469" rx="2.625" ry="5.25" transform="translate(-5.44 11.806) rotate(-45)"/><path class="cls-1" d="M7.4,9.7.888,21.121a1.5,1.5,0,0,0,1.991,1.991L14.3,16.605"/><path class="cls-1" d="M15.773,7.7a.375.375,0,0,1,0,.531"/><path class="cls-1" d="M15.243,7.7a.375.375,0,0,1,.53,0"/><path class="cls-1" d="M15.243,8.227a.377.377,0,0,1,0-.531"/><path class="cls-1" d="M15.773,8.227a.375.375,0,0,1-.53,0"/><path class="cls-1" d="M20.016,3.454a.374.374,0,0,1,0,.53"/><path class="cls-1" d="M19.486,3.454a.374.374,0,0,1,.53,0"/><path class="cls-1" d="M19.486,3.984a.374.374,0,0,1,0-.53"/><path class="cls-1" d="M20.016,3.984a.375.375,0,0,1-.53,0"/><path class="cls-1" d="M20.016,14.06a.375.375,0,0,1,0,.531"/><path class="cls-1" d="M19.486,14.06a.375.375,0,0,1,.53,0"/><path class="cls-1" d="M19.486,14.591a.375.375,0,0,1,0-.531"/><path class="cls-1" d="M20.016,14.591a.375.375,0,0,1-.53,0"/><path class="cls-1" d="M8.349,4.514a.377.377,0,0,1,0,.531"/><path class="cls-1" d="M7.819,4.514a.375.375,0,0,1,.53,0"/><path class="cls-1" d="M7.819,5.045a.375.375,0,0,1,0-.531"/><path class="cls-1" d="M8.349,5.045a.375.375,0,0,1-.53,0"/><path class="cls-1" d="M12.857.75a13.836,13.836,0,0,1-.531,5.62"/><line class="cls-1" x1="16.569" y1="2.128" x2="16.039" y2="4.779"/><path class="cls-1" d="M23.25,11.143a13.836,13.836,0,0,0-5.62.531"/><line class="cls-1" x1="21.872" y1="7.431" x2="19.221" y2="7.961"/></svg>
|
After Width: | Height: | Size: 1.6 KiB |
5
ghost/admin/public/assets/icons/members-outline.svg
Normal file
5
ghost/admin/public/assets/icons/members-outline.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg id="Regular" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs><style>.cls-1{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:1px;}</style></defs><title>multiple-users-1</title>
|
||||
<g class="g3"><circle class="cls-1" cx="4.5" cy="6" r="2.25"/><path class="cls-1" d="M4.5,9.75A3.75,3.75,0,0,0,.75,13.5v2.25h1.5l.75,6H6"/></g>
|
||||
<g class="g2"><circle class="cls-1" cx="19.5" cy="6" r="2.25"/><path class="cls-1" d="M19.5,9.75a3.75,3.75,0,0,1,3.75,3.75v2.25h-1.5l-.75,6H18"/></g>
|
||||
<g class="g1"><circle class="cls-1" cx="12" cy="3.75" r="3"/><path class="cls-1" d="M17.25,13.5a5.25,5.25,0,0,0-10.5,0v2.25H9l.75,7.5h4.5l.75-7.5h2.25Z"/></g>
|
||||
</svg>
|
After Width: | Height: | Size: 702 B |
@ -1,38 +1,21 @@
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
import {click, find, findAll, render} from '@ember/test-helpers';
|
||||
import {click, findAll, render} from '@ember/test-helpers';
|
||||
import {describe, it} from 'mocha';
|
||||
import {expect} from 'chai';
|
||||
import {setupRenderingTest} from 'ember-mocha';
|
||||
|
||||
describe('Integration: Component: gh-members-import-table', function () {
|
||||
setupRenderingTest();
|
||||
const mockMapping = {
|
||||
mapping: {},
|
||||
get: (key) => {
|
||||
return key;
|
||||
}
|
||||
};
|
||||
|
||||
it('renders empty without data', async function () {
|
||||
await render(hbs`
|
||||
<GhMembersImportTable />
|
||||
`);
|
||||
|
||||
expect(find('table')).to.exist;
|
||||
expect(findAll('table thead th').length).to.equal(3);
|
||||
expect(findAll('table tbody tr').length).to.equal(1);
|
||||
expect(find('table tbody').textContent).to.match(/No data/);
|
||||
});
|
||||
|
||||
it('renders members data with all the properties', async function () {
|
||||
this.set('importData', [{
|
||||
name: 'Kevin',
|
||||
email: 'kevin@example.com'
|
||||
}]);
|
||||
this.set('mapping', mockMapping);
|
||||
this.set('setMapping', () => {});
|
||||
|
||||
await render(hbs`
|
||||
<GhMembersImportTable @importData={{this.importData}} @mapping={{this.mapping}}/>
|
||||
<GhMembersImportTable @data={{this.importData}} @setMapping={{this.setMapping}}/>
|
||||
`);
|
||||
|
||||
expect(findAll('table tbody tr').length).to.equal(2);
|
||||
@ -52,10 +35,10 @@ describe('Integration: Component: gh-members-import-table', function () {
|
||||
name: 'Rish',
|
||||
email: 'rish@example.com'
|
||||
}]);
|
||||
this.set('mapping', mockMapping);
|
||||
this.set('setMapping', () => {});
|
||||
|
||||
await render(hbs`
|
||||
<GhMembersImportTable @importData={{this.importData}} @mapping={{this.mapping}}/>
|
||||
<GhMembersImportTable @data={{this.importData}} @setMapping={{this.setMapping}}/>
|
||||
`);
|
||||
|
||||
expect(findAll('table tbody tr').length).to.equal(2);
|
||||
|
@ -1,13 +1,11 @@
|
||||
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, waitFor} from '@ember/test-helpers';
|
||||
import {click, find, findAll, render, waitFor} 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({
|
||||
@ -178,34 +176,16 @@ describe('Integration: Component: modal-import-members-test', function () {
|
||||
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);
|
||||
stubFailedUpload(server, 415);
|
||||
|
||||
await render(hbs`{{modal-import-members}}`);
|
||||
|
||||
await fileUpload('input[type="file"]', ['membersfile'], {name: 'test.txt'});
|
||||
await fileUpload('input[type="file"]', ['name,email\r\nmembername,memberemail@example.com'], {name: 'test.csv'});
|
||||
|
||||
// Wait for async CSV parsing to finish
|
||||
await waitFor('table', {timeout: 50});
|
||||
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/);
|
||||
|
@ -25,75 +25,14 @@ describe('Integration: Service: member-import-validator', function () {
|
||||
it('checks correct data without Stripe customer', async function () {
|
||||
let service = this.owner.lookup('service:member-import-validator');
|
||||
|
||||
const {validationErrors, mapping} = await service.check([{
|
||||
const mapping = await service.check([{
|
||||
name: 'Rish',
|
||||
email: 'validemail@example.com'
|
||||
}]);
|
||||
|
||||
expect(validationErrors.length).to.equal(0);
|
||||
expect(mapping.email).to.equal('email');
|
||||
});
|
||||
|
||||
it('returns validation error when no data is provided', async function () {
|
||||
let service = this.owner.lookup('service:member-import-validator');
|
||||
|
||||
const {validationErrors} = await service.check([]);
|
||||
|
||||
expect(validationErrors.length).to.equal(1);
|
||||
expect(validationErrors[0].message).to.equal('File is empty, nothing to import. Please select a different file.');
|
||||
});
|
||||
|
||||
it('returns validation error for data with stripe_customer_id but no connected Stripe', async function () {
|
||||
this.owner.register('service:membersUtils', Service.extend({
|
||||
isStripeEnabled: false
|
||||
}));
|
||||
|
||||
let service = this.owner.lookup('service:member-import-validator');
|
||||
|
||||
const {validationErrors, mapping} = await service.check([{
|
||||
name: 'Kevin',
|
||||
email: 'goodeamil@example.com',
|
||||
stripe_customer_id: 'cus_XXXX'
|
||||
}]);
|
||||
|
||||
expect(validationErrors.length).to.equal(1);
|
||||
expect(validationErrors[0].message).to.equal('Missing Stripe connection');
|
||||
expect(mapping.name).to.equal('name');
|
||||
expect(mapping.email).to.equal('email');
|
||||
expect(mapping.stripe_customer_id).to.equal('stripe_customer_id');
|
||||
});
|
||||
|
||||
it('returns validation error for no valid emails', async function () {
|
||||
this.owner.register('service:membersUtils', Service.extend({
|
||||
isStripeEnabled: false
|
||||
}));
|
||||
|
||||
let service = this.owner.lookup('service:member-import-validator');
|
||||
|
||||
const {validationErrors} = await service.check([{
|
||||
email: 'invalid_email'
|
||||
}]);
|
||||
|
||||
expect(validationErrors.length).to.equal(1);
|
||||
expect(validationErrors[0].message).to.equal('No email addresses found in the uploaded CSV.');
|
||||
});
|
||||
|
||||
it('ignores validation for invalid email', async function () {
|
||||
this.owner.register('service:membersUtils', Service.extend({
|
||||
isStripeEnabled: false
|
||||
}));
|
||||
|
||||
let service = this.owner.lookup('service:member-import-validator');
|
||||
|
||||
const {validationErrors} = await service.check([{
|
||||
email: 'invalid_email'
|
||||
}, {
|
||||
email: 'email@example.com'
|
||||
}]);
|
||||
|
||||
expect(validationErrors.length).to.equal(0);
|
||||
});
|
||||
|
||||
describe('data sampling method', function () {
|
||||
it('returns whole data set when sampled size is less then default 30', async function () {
|
||||
this.owner.register('service:membersUtils', Service.extend({
|
||||
@ -205,7 +144,6 @@ describe('Integration: Service: member-import-validator', function () {
|
||||
}]);
|
||||
|
||||
expect(result.email).to.equal('correo_electronico');
|
||||
expect(result.stripe_customer_id).to.equal('stripe_id');
|
||||
});
|
||||
|
||||
it('correctly detects variation of "name" mapping', async function () {
|
||||
|
@ -1514,6 +1514,13 @@
|
||||
dependencies:
|
||||
"@tryghost/kg-clean-basic-html" "^1.0.10"
|
||||
|
||||
"@tryghost/members-csv@0.4.0":
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/members-csv/-/members-csv-0.4.0.tgz#cd91d1ad4ca3c317cb9c3dbfd27c5414004f901b"
|
||||
integrity sha512-GotwC8geHK7A8qBrXDNU+P9tIBOCYqwqZp2I/60BYgBJT6wEUpxU8KezluZ6fuHsk1fKIBRUk9k5+qi83sQI3A==
|
||||
dependencies:
|
||||
papaparse "5.3.0"
|
||||
|
||||
"@tryghost/mobiledoc-kit@0.12.5-ghost.1":
|
||||
version "0.12.5-ghost.1"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/mobiledoc-kit/-/mobiledoc-kit-0.12.5-ghost.1.tgz#f79b0f9a9b93eb100fd3dc1c02b343d5d334f4e0"
|
||||
@ -4131,6 +4138,11 @@ caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000844, caniuse-lite@^1.0.30001164, can
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001165.tgz#32955490d2f60290bb186bb754f2981917fa744f"
|
||||
integrity sha512-8cEsSMwXfx7lWSUMA2s08z9dIgsnR5NAqjXP23stdsU3AUWkCr/rr4s4OFtHXn5XXr6+7kam3QFVoYyXNPdJPA==
|
||||
|
||||
caniuse-lite@^1.0.30001161:
|
||||
version "1.0.30001162"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001162.tgz#9f83aad1f42539ce9aab58bb177598f2f8e22ec6"
|
||||
integrity sha512-E9FktFxaNnp4ky3ucIGzEXLM+Knzlpuq1oN1sFAU0KeayygabGTmOsndpo8QrL4D9pcThlf4D2pUKaDxPCUmVw==
|
||||
|
||||
capture-exit@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/capture-exit/-/capture-exit-2.0.0.tgz#fb953bfaebeb781f62898239dabb426d08a509a4"
|
||||
@ -12718,6 +12730,13 @@ semver@^7.0.0, semver@^7.1.3, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4:
|
||||
dependencies:
|
||||
lru-cache "^6.0.0"
|
||||
|
||||
semver@^7.3.4:
|
||||
version "7.3.4"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.4.tgz#27aaa7d2e4ca76452f98d3add093a72c943edc97"
|
||||
integrity sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==
|
||||
dependencies:
|
||||
lru-cache "^6.0.0"
|
||||
|
||||
send@0.17.1:
|
||||
version "0.17.1"
|
||||
resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8"
|
||||
|
Loading…
Reference in New Issue
Block a user