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:
Rishabh Garg 2020-12-10 01:02:31 +05:30 committed by GitHub
parent 4b51ae8705
commit f068e40723
25 changed files with 783 additions and 770 deletions

View File

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

View File

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

View File

@ -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>

View File

@ -56,6 +56,9 @@ export default Component.extend({
actions: {
setProperty(property, value) {
this.setProperty(property, value);
},
setLabels(labels) {
this.member.set('labels', labels);
}
},

View File

@ -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>

View File

@ -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>

View File

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

View File

@ -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 youll receive a confirmation email as soon as its 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}}
</div>
{{#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>

View File

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

View File

@ -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}}

View File

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

View File

@ -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>

View File

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

View File

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

View File

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

View File

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

View File

@ -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 {

View File

@ -1,4 +1,4 @@
<GhFullscreenModal @modal="import-members"
@confirm={{this.refreshMembers}}
@close={{this.close}}
@modifier="action wide import-members" />
@modifier="action import-members" />

View File

@ -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",

View 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

View 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

View File

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

View File

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

View File

@ -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 () {

View File

@ -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"