Added field mapping column to members importer gird

no issue

- Adds UI to map imported csv fields to ones accepted by Ghost API
- Includes automatic mapping detection for emails and stripe_customer_ids
This commit is contained in:
Nazar Gargol 2020-07-03 16:54:21 +12:00
parent a21be54751
commit e36b79c940
7 changed files with 199 additions and 24 deletions

View File

@ -0,0 +1,13 @@
<span class="gh-select">
<OneWaySelect @value={{this.mapTo}}
@options={{this.availableFields}}
@optionValuePath="value"
@optionLabelPath="label"
@optionTargetPath="value"
@includeBlank={{true}}
@promptIsSelectable={{true}}
@prompt="Not imported"
@update={{action "updateMapping"}}
/>
{{svg-jar "arrow-down-small"}}
</span>

View File

@ -0,0 +1,29 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {tracked} from '@glimmer/tracking';
const FIELD_MAPPINGS = [
{label: 'email', value: 'email'},
{label: 'name', value: 'name'},
{label: 'note', value: 'note'},
{label: 'subscribed_to_emails', value: 'subscribed_to_emails'},
{label: 'stripe_customer_id', value: 'stripe_customer_id'},
{label: 'complimentary_plan', value: 'complimentary_plan'},
{label: 'labels', value: 'labels'},
{label: 'created_at', value: 'created_at'}
];
export default class extends Component {
@tracked availableFields = FIELD_MAPPINGS;
get mapTo() {
return this.args.mapTo;
}
@action
updateMapping(newMapTo) {
if (this.args.updateMapping) {
this.args.updateMapping(this.args.mapFrom, newMapTo);
}
}
}

View File

@ -1,26 +1,28 @@
<table class="f8 gh-members-import-table ma0">
<thead>
<th class="bb br b--whitegrey"><span class="f-small midgrey ttu fw5">Header</span></th>
<th class="bb br b--whitegrey"><span class="f-small midgrey ttu fw5">Field</span></th>
<th class="bb b--whitegrey">
<div class="flex items-center justify-between">
<span class="f-small midgrey ttu fw5 nudge-top--1">Data</span>
<div class="flex items-center bg-white br2 ml1 nr1 gh-members-import-datanav">
<a href="#" {{action "prev"}} class="pa1 flex items-center justify-center br b--whitegrey" data-test-import-prev>{{svg-jar "arrow-left" class="w3 h3 fill-middarkgrey" }}</a>
<a href="#" {{action "prev"}} class="pa1 flex items-center justify-center br b--whitegrey" data-test-import-prev>{{svg-jar "arrow-left" class="w3 h3 fill-middarkgrey" }}</a>
<a href="#" {{action "next"}} class="pa1 flex items-center justify-center" data-test-import-next>{{svg-jar "arrow-right" class="w3 h3 fill-middarkgrey" }}</a>
</div>
</div>
</th>
<th class="bb b--whitegrey"><span class="f-small midgrey ttu fw5">Import as...</span></th>
</thead>
<tbody>
{{#each-in currentlyDisplayedData as |key value|}}
{{#each currentlyDisplayedData as |row|}}
<tr>
<td class="middarkgrey"><span>{{key}}</span></td>
<td class="middarkgrey {{unless value "empty-cell"}}"><span>{{value}}</span></td>
<td class="middarkgrey"><span>{{row.key}}</span></td>
<td class="middarkgrey {{unless value "empty-cell"}}"><span>{{row.value}}</span></td>
<td><span><GhMembersImportMappingInput @updateMapping={{this.updateMapping}} @mapFrom={{row.key}} @mapTo={{row.mapTo}} /></span></td>
</tr>
{{else}}
<tr>
<td><span>No data</span></td>
</tr>
{{/each-in}}
{{/each}}
</tbody>
</table>

View File

@ -6,11 +6,26 @@ export default class GhMembersImportTable extends Component {
@tracked dataPreviewIndex = 0;
get currentlyDisplayedData() {
if (this.args && this.args.importData) {
return this.args.importData[this.dataPreviewIndex];
let rows = [];
if (this.args && this.args.importData && this.args.mapping && this.args.mapping.mapping) {
let currentRecord = this.args.importData[this.dataPreviewIndex];
for (const [key, value] of Object.entries(currentRecord)) {
rows.push({
key: key,
value: value,
mapTo: this.args.mapping.get(key)
});
}
}
return {};
return rows;
}
@action
updateMapping(mapFrom, mapTo) {
this.args.updateMapping(mapFrom, mapTo);
}
@action

View File

@ -12,12 +12,12 @@
{{#if this.importResponse}}
<div class="modal-body bg-whitegrey-l2 ba b--whitegrey br3 pa4">
<div class="flex items-center">
{{svg-jar "check-circle" class="w4 h4 stroke-green mr2"}}
{{svg-jar "check-circle" class="w4 h4 stroke-green mr2"}}
<p class="ma0 pa0"><span class="fw6">{{this.importResponse.imported.count}}</span> members were added</p>
</div>
{{#if this.importResponse.invalid.count}}
<div class="flex items-start mt2">
{{svg-jar "warning" class="w4 h4 fill-red mr2 nudge-top--3"}}
{{svg-jar "warning" class="w4 h4 fill-red mr2 nudge-top--3"}}
<div>
<p class="ma0 pa0"><span class="fw5">{{this.importResponse.invalid.count}}</span> members were skipped</p>
</div>
@ -44,10 +44,14 @@
{{/if}}
<GhFormGroup>
{{#if this.config.enableDeveloperExperiments}}
<h4 class="fw6 f8 dib mb1">Preview</h4>
<h4 class="fw6 f8 dib mb1">Mapping</h4>
<div class="gh-members-import-scrollarea">
<GhMembersImportTable @importData={{this.fileData}}/>
<GhMembersImportTable
@importData={{this.fileData}}
@mapping={{this.mapping}}
@updateMapping={{action "updateMapping"}}/>
</div>
<p>Match the fields in your uploaded file to Ghost members.</p>
{{else}}
<div class="bg-whitegrey-l2 ba b--whitegrey br3">
<div class="flex flex-column items-center justify-center gh-members-import-file">

View File

@ -12,6 +12,83 @@ import {htmlSafe} from '@ember/string';
import {isBlank} from '@ember/utils';
import {run} from '@ember/runloop';
import {inject as service} from '@ember/service';
import {tracked} from '@glimmer/tracking';
class MembersFieldMapping {
_supportedImportFields = [
'email',
'name',
'note',
'subscribed_to_emails',
'stripe_customer_id',
'complimentary_plan',
'labels',
'created_at'
];
@tracked _mapping = {};
constructor(sampleRecord) {
let importedKeys = Object.keys(sampleRecord);
this._supportedImportFields.forEach((destinaitonField) => {
let matchedImportedKey = importedKeys.find(key => (key === destinaitonField));
if (!matchedImportedKey) {
if (destinaitonField === 'email') {
// scan sample record for any occurances of '@' symbol to autodetect email
for (const [key, value] of Object.entries(sampleRecord)) {
if (value && value.includes('@')) {
matchedImportedKey = key;
break;
}
}
}
if (destinaitonField === 'stripe_customer_id') {
// scan sample record for any occurances of 'cus_' as that's conventional Stripe customer id prefix
for (const [key, value] of Object.entries(sampleRecord)) {
if (value && value.startsWith('cus_')) {
matchedImportedKey = key;
break;
}
}
}
}
if (matchedImportedKey) {
this.set(matchedImportedKey, destinaitonField);
importedKeys = importedKeys.filter(key => (key !== matchedImportedKey));
}
});
}
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;
}
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(),
@ -24,6 +101,7 @@ export default ModalComponent.extend({
dragClass: null,
file: null,
fileData: null,
mapping: null,
paramName: 'membersfile',
uploading: false,
uploadPercentage: 0,
@ -60,6 +138,18 @@ export default ModalComponent.extend({
});
}
// TODO: remove "if" below once import validations are production ready
if (this.config.get('enableDeveloperExperiments')) {
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);
}
}
}
}
return formData;
}),
@ -102,6 +192,7 @@ export default ModalComponent.extend({
worker: true, // NOTE: compare speed and file sizes with/without this flag
complete: async (results) => {
this.set('fileData', results.data);
this.set('mapping', new MembersFieldMapping(results.data[0]));
let result = await this.memberImportValidator.check(results.data);
@ -122,6 +213,7 @@ export default ModalComponent.extend({
this.set('labels', {labels: []});
this.set('file', null);
this.set('fileData', null);
this.set('mapping', null);
this.set('validationErrors', null);
},
@ -139,6 +231,10 @@ export default ModalComponent.extend({
if (!this.closeDisabled) {
this._super(...arguments);
}
},
updateMapping(mapFrom, mapTo) {
this.mapping.updateMapping(mapFrom, mapTo);
}
},

View File

@ -6,6 +6,12 @@ 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`
@ -13,7 +19,7 @@ describe('Integration: Component: gh-members-import-table', function () {
`);
expect(find('table')).to.exist;
expect(findAll('table thead th').length).to.equal(2);
expect(findAll('table thead th').length).to.equal(3);
expect(findAll('table tbody tr').length).to.equal(1);
expect(find('table tbody tr').textContent).to.match(/No data/);
});
@ -23,16 +29,19 @@ describe('Integration: Component: gh-members-import-table', function () {
name: 'Kevin',
email: 'kevin@example.com'
}]);
this.set('mapping', mockMapping);
await render(hbs`
<GhMembersImportTable @importData={{this.importData}} />
<GhMembersImportTable @importData={{this.importData}} @mapping={{this.mapping}}/>
`);
expect(findAll('table tbody tr').length).to.equal(2);
expect(findAll('table tbody tr td')[0].textContent).to.equal('name');
expect(findAll('table tbody tr td')[1].textContent).to.equal('Kevin');
expect(findAll('table tbody tr td')[2].textContent).to.equal('email');
expect(findAll('table tbody tr td')[3].textContent).to.equal('kevin@example.com');
expect(findAll('table tbody tr td')[2].textContent).to.match(/Not imported/);
expect(findAll('table tbody tr td')[3].textContent).to.equal('email');
expect(findAll('table tbody tr td')[4].textContent).to.equal('kevin@example.com');
expect(findAll('table tbody tr td')[5].textContent).to.match(/Not imported/);
});
it('navigates through data when next and previous are clicked', async function () {
@ -43,32 +52,39 @@ describe('Integration: Component: gh-members-import-table', function () {
name: 'Rish',
email: 'rish@example.com'
}]);
this.set('mapping', mockMapping);
await render(hbs`
<GhMembersImportTable @importData={{this.importData}} />
<GhMembersImportTable @importData={{this.importData}} @mapping={{this.mapping}}/>
`);
expect(findAll('table tbody tr').length).to.equal(2);
expect(findAll('table tbody tr td')[0].textContent).to.equal('name');
expect(findAll('table tbody tr td')[1].textContent).to.equal('Kevin');
expect(findAll('table tbody tr td')[2].textContent).to.equal('email');
expect(findAll('table tbody tr td')[3].textContent).to.equal('kevin@example.com');
expect(findAll('table tbody tr td')[2].textContent).to.match(/Not imported/);
expect(findAll('table tbody tr td')[3].textContent).to.equal('email');
expect(findAll('table tbody tr td')[4].textContent).to.equal('kevin@example.com');
expect(findAll('table tbody tr td')[5].textContent).to.match(/Not imported/);
await click('[data-test-import-next]');
expect(findAll('table tbody tr').length).to.equal(2);
expect(findAll('table tbody tr td')[0].textContent).to.equal('name');
expect(findAll('table tbody tr td')[1].textContent).to.equal('Rish');
expect(findAll('table tbody tr td')[2].textContent).to.equal('email');
expect(findAll('table tbody tr td')[3].textContent).to.equal('rish@example.com');
expect(findAll('table tbody tr td')[2].textContent).to.match(/Not imported/);
expect(findAll('table tbody tr td')[3].textContent).to.equal('email');
expect(findAll('table tbody tr td')[4].textContent).to.equal('rish@example.com');
expect(findAll('table tbody tr td')[5].textContent).to.match(/Not imported/);
await click('[data-test-import-prev]');
expect(findAll('table tbody tr').length).to.equal(2);
expect(findAll('table tbody tr td')[0].textContent).to.equal('name');
expect(findAll('table tbody tr td')[1].textContent).to.equal('Kevin');
expect(findAll('table tbody tr td')[2].textContent).to.equal('email');
expect(findAll('table tbody tr td')[3].textContent).to.equal('kevin@example.com');
expect(findAll('table tbody tr td')[2].textContent).to.match(/Not imported/);
expect(findAll('table tbody tr td')[3].textContent).to.equal('email');
expect(findAll('table tbody tr td')[4].textContent).to.equal('kevin@example.com');
expect(findAll('table tbody tr td')[5].textContent).to.match(/Not imported/);
});
it('cannot navigate through data when only one data item is present', async function () {