mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-29 07:09:48 +03:00
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:
parent
a21be54751
commit
e36b79c940
@ -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>
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
@ -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
|
||||
|
@ -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">
|
||||
|
@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -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 () {
|
||||
|
Loading…
Reference in New Issue
Block a user