Ghost/ghost/admin/app/components/modal-import-members.js
Kevin Ansfield 9bdb25d184
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 11:14:36 +00:00

231 lines
7.5 KiB
JavaScript

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,
isRequestEntityTooLargeError,
isUnsupportedMediaTypeError,
isVersionMismatchError
} from 'ghost-admin/services/ajax';
import {computed} from '@ember/object';
import {htmlSafe} from '@ember/template';
import {inject} from 'ghost-admin/decorators/inject';
import {isBlank} from '@ember/utils';
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: () => {},
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);
}
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);
if (error.payload.errors[0].message.match(/great deliverability/gi)) {
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 {
console.error(error); // eslint-disable-line
message = 'Something went wrong :(';
}
this.set('errorMessage', message);
this.set('errorHeader', header);
}
});