Ghost/ghost/admin/app/components/modal-import-members.js

237 lines
7.8 KiB
JavaScript
Raw Normal View History

import ModalComponent from 'ghost-admin/components/modal-base';
import ghostPaths from 'ghost-admin/utils/ghost-paths';
import moment from 'moment-timezone';
import unparse from '@tryghost/members-csv/lib/unparse';
import {
AcceptedResponse,
isDataImportError,
isHostLimitError,
isRequestEntityTooLargeError,
isUnsupportedMediaTypeError,
isVersionMismatchError
} from 'ghost-admin/services/ajax';
import {computed} from '@ember/object';
import {htmlSafe} from '@ember/template';
Fixed hosting management screen not loading after sign-in process (#15763) refs https://github.com/TryGhost/Team/issues/2110 - dynamically defined properties on the config service did not have autotracking set up properly if they were accessed in any way before the property was defined, this caused problems in a number of areas because we have both "unauthed" and "authed" sets of config and when not logged in we had parts of the app checking for authed config properties that don't exist until after sign-in and subsequent config re-fetch - renamed `config` service to `configManager` and updated to only contain methods for fetching config data - added a `config` instance initializer that sets up a `TrackedObject` instance with some custom properties/methods and registers it on `config:main` - uses application instance initializer rather than a standard initializer because standard initializers are only called once when setting up the test suite so we'd end up with config leaking across tests - added an `@inject` decorator that when used takes the property name and injects whatever is registered at `${propertyName}:main`, this allows us to use dependency injection for any object rather than just services or controllers - using `application.inject()` in the initializer was initially used but that only works for objects that extend from `EmberObject`, the injections weren't available in native-class glimmer components so this decorator keeps the injection syntax consistent - swapped all `@service config` uses to `@inject config`
2022-11-03 14:14:36 +03:00
import {inject} from 'ghost-admin/decorators/inject';
import {inject as service} from '@ember/service';
export default ModalComponent.extend({
ajax: service(),
notifications: service(),
store: service(),
state: 'INIT',
file: null,
mappingResult: null,
mappingFileData: null,
paramName: 'membersfile',
importResponse: null,
errorMessage: null,
errorHeader: null,
showMappingErrors: false,
showTryAgainButton: true,
// Allowed actions
confirm: () => {},
Fixed hosting management screen not loading after sign-in process (#15763) refs https://github.com/TryGhost/Team/issues/2110 - dynamically defined properties on the config service did not have autotracking set up properly if they were accessed in any way before the property was defined, this caused problems in a number of areas because we have both "unauthed" and "authed" sets of config and when not logged in we had parts of the app checking for authed config properties that don't exist until after sign-in and subsequent config re-fetch - renamed `config` service to `configManager` and updated to only contain methods for fetching config data - added a `config` instance initializer that sets up a `TrackedObject` instance with some custom properties/methods and registers it on `config:main` - uses application instance initializer rather than a standard initializer because standard initializers are only called once when setting up the test suite so we'd end up with config leaking across tests - added an `@inject` decorator that when used takes the property name and injects whatever is registered at `${propertyName}:main`, this allows us to use dependency injection for any object rather than just services or controllers - using `application.inject()` in the initializer was initially used but that only works for objects that extend from `EmberObject`, the injections weren't available in native-class glimmer components so this decorator keeps the injection syntax consistent - swapped all `@service config` uses to `@inject config`
2022-11-03 14:14:36 +03:00
config: inject(),
uploadUrl: computed(function () {
return `${ghostPaths().apiRoot}/members/upload/`;
}),
formData: computed('file', function () {
let formData = new FormData();
formData.append(this.paramName, this.file);
if (this.mappingResult.labels) {
this.mappingResult.labels.forEach((label) => {
formData.append('labels', label.name);
});
}
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;
}),
actions: {
setFile(file) {
this.set('file', file);
this.set('state', 'MAPPING');
},
setMappingResult(mappingResult) {
this.set('mappingResult', mappingResult);
},
setMappingFileData(mappingFileData) {
this.set('mappingFileData', mappingFileData);
},
upload() {
if (this.file && !this.mappingResult.error) {
this.generateRequest();
this.set('showMappingErrors', false);
} else {
this.set('showMappingErrors', true);
}
},
reset() {
this.set('showMappingErrors', false);
this.set('errorMessage', null);
this.set('errorHeader', null);
this.set('file', null);
this.set('mapping', null);
this.set('state', 'INIT');
this.set('showTryAgainButton', true);
},
closeModal() {
if (this.state !== 'UPLOADING') {
this._super(...arguments);
}
},
// noop - we don't want the enter key doing anything
confirm() {}
},
generateRequest() {
let ajax = this.ajax;
let formData = this.formData;
let url = this.uploadUrl;
this.set('state', 'UPLOADING');
ajax.post(url, {
data: formData,
processData: false,
contentType: false,
dataType: 'text'
}).then((importResponse) => {
if (importResponse instanceof AcceptedResponse) {
this.set('state', 'PROCESSING');
} else {
this._uploadSuccess(JSON.parse(importResponse));
this.set('state', 'COMPLETE');
}
}).catch((error) => {
this._uploadError(error);
this.set('state', 'ERROR');
});
},
_uploadSuccess(importResponse) {
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 is too long'
)
.replace(
'Value in [members.subscribed] must be one of true, false, 0 or 1.',
'Value of "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
};
});
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
if (importResponse.meta.import_label) {
this.store.pushPayload({
labels: [importResponse.meta.import_label]
});
}
// invoke the passed in confirm action to refresh member data
// @TODO wtf does confirm mean?
this.confirm({label: importResponse.meta.import_label});
},
_uploadError(error) {
let message;
let header = 'Import error';
if (isVersionMismatchError(error)) {
this.notifications.showAPIError(error);
}
// Handle all the specific errors that we know about
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 (isDataImportError(error, error.payload)) {
message = htmlSafe(error.payload.errors[0].message);
} else if (isHostLimitError(error) && error?.payload?.errors?.[0]?.code === 'EMAIL_VERIFICATION_NEEDED') {
message = htmlSafe(error.payload.errors[0].message);
header = 'Woah there cowboy, that\'s a big list';
this.set('showTryAgainButton', false);
// NOTE: confirm makes sure to refresh the members data in the background
this.confirm();
} else { // Generic fallback error
message = 'An unexpected error occurred, please try again';
console.error(error); // eslint-disable-line
if (error?.payload?.errors?.[0]?.id) {
console.error(`Error ID: ${error.payload.errors[0].id}`); // eslint-disable-line
}
}
this.set('errorMessage', message);
this.set('errorHeader', header);
}
});