import ModalComponent from 'ghost-admin/components/modal-base'; import ghostPaths from 'ghost-admin/utils/ghost-paths'; import papaparse from 'papaparse'; import { UnsupportedMediaTypeError, isRequestEntityTooLargeError, isUnsupportedMediaTypeError, isVersionMismatchError } from 'ghost-admin/services/ajax'; import {computed} from '@ember/object'; import {htmlSafe} from '@ember/string'; import {isBlank} from '@ember/utils'; import {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', // import stages, default is "CSV file selection" validating: false, customizing: false, uploading: false, summary: false, dragClass: null, file: null, fileData: null, mapping: null, paramName: 'membersfile', importResponse: null, failureMessage: null, validationErrors: null, uploadErrors: null, labels: null, // 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); if (this.labels.labels.length) { this.labels.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); } } } 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); if (validationResult !== true) { this._validationFailed(validationResult); } 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); } }); } }, reset() { this.set('failureMessage', null); this.set('labels', {labels: []}); 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 }, closeModal() { if (!this.closeDisabled) { 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); } }, generateRequest() { let ajax = this.ajax; let formData = this.formData; let url = this.uploadUrl; this._uploadStarted(); ajax.post(url, { data: formData, processData: false, contentType: false, dataType: 'text' }).then((importResponse) => { this._uploadSuccess(JSON.parse(importResponse)); }).catch((error) => { this._validationFailed(error); }).finally(() => { this._uploadFinished(); }); }, _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'; } }); } this.set('importResponse', importResponse.meta.stats); // insert auto-created import label into store immediately if present // ready for filtering the members list if (importResponse.meta.import_label) { this.store.pushPayload({ labels: [importResponse.meta.import_label] }); } // invoke the passed in confirm action to refresh member data 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) { let message; if (isVersionMismatchError(error)) { this.notifications.showAPIError(error); } if (isUnsupportedMediaTypeError(error)) { message = 'The file type you uploaded is not supported.'; } else if (isRequestEntityTooLargeError(error)) { message = 'The file you uploaded was larger than the maximum file size your server allows.'; } else if (error.payload && error.payload.errors && !isBlank(error.payload.errors[0].message)) { message = htmlSafe(error.payload.errors[0].message); } else { console.error(error); // eslint-disable-line 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; } });