Ghost/ghost/admin/app/components/gh-uploader.js
Kevin Ansfield cb59388c5b 💄🐷 sort-imports eslint rule (#712)
no issue

- adds `eslint-plugin-sort-imports-es6-autofix` dependency
  - implements ESLint's base `sort-imports` rule but has a distinction in that `import {foo} from 'bar';` is considered `multiple` rather than `single`
  - fixes ESLint's autofix behaviour so `eslint --fix` will actually fix the sort order
- updates all unordered import rules by using `eslint --fix`

With the increased number of `import` statements since Ember+ecosystem started moving towards es6 modules I've found it frustrating at times trying to search through randomly ordered import statements. Recently I've been sorting imports manually when I've added new code or touched old code so I thought I'd add an ESLint rule to codify it.
2017-05-29 20:50:03 +02:00

295 lines
8.6 KiB
JavaScript

import Component from 'ember-component';
import EmberObject from 'ember-object';
import ghostPaths from 'ghost-admin/utils/ghost-paths';
import injectService from 'ember-service/inject';
import run from 'ember-runloop';
import {all, task} from 'ember-concurrency';
import {isEmberArray} from 'ember-array/utils';
import {isEmpty} from 'ember-utils';
// TODO: this is designed to be a more re-usable/composable upload component, it
// should be able to replace the duplicated upload logic in:
// - gh-image-uploader
// - gh-file-uploader
// - gh-koenig/cards/card-image
// - gh-koenig/cards/card-markdown
//
// In order to support the above components we'll need to introduce an
// "allowMultiple" attribute so that single-image uploads don't allow multiple
// simultaneous uploads
/**
* Result from a file upload
* @typedef {Object} UploadResult
* @property {string} fileName - file name, eg "my-image.png"
* @property {string} url - url relative to Ghost root,eg "/content/images/2017/05/my-image.png"
*/
const UploadTracker = EmberObject.extend({
file: null,
total: 0,
loaded: 0,
init() {
this.total = this.file && this.file.size || 0;
},
update({loaded, total}) {
this.total = total;
this.loaded = loaded;
}
});
export default Component.extend({
tagName: '',
ajax: injectService(),
// Public attributes
accept: '',
extensions: '',
files: null,
paramName: 'uploadimage', // TODO: is this the best default?
uploadUrl: null,
// Interal attributes
errors: null, // [{fileName: 'x', message: 'y'}, ...]
totalSize: 0,
uploadedSize: 0,
uploadPercentage: 0,
uploadUrls: null, // [{filename: 'x', url: 'y'}],
// Private
_defaultUploadUrl: '/uploads/',
_files: null,
_uploadTrackers: null,
// Closure actions
onCancel() {},
onComplete() {},
onFailed() {},
onStart() {},
onUploadFail() {},
onUploadSuccess() {},
// Optional closure actions
// validate(file) {}
init() {
this._super(...arguments);
this.set('errors', []);
this.set('uploadUrls', []);
this._uploadTrackers = [];
},
didReceiveAttrs() {
this._super(...arguments);
// set up any defaults
if (!this.get('uploadUrl')) {
this.set('uploadUrl', this._defaultUploadUrl);
}
// if we have new files, validate and start an upload
let files = this.get('files');
if (files && files !== this._files) {
if (this.get('_uploadFiles.isRunning')) {
// eslint-disable-next-line
console.error('Adding new files whilst an upload is in progress is not supported.');
}
this._files = files;
// we cancel early if any file fails client-side validation
if (this._validate()) {
this.get('_uploadFiles').perform(files);
}
}
},
_validate() {
let files = this.get('files');
let validate = this.get('validate') || this._defaultValidator.bind(this);
let ok = [];
let errors = [];
// NOTE: for...of loop results in a transpilation that errors in Edge,
// once we drop IE11 support we should be able to use native for...of
for (let i = 0; i < files.length; i++) {
let file = files[i];
let result = validate(file);
if (result === true) {
ok.push(file);
} else {
errors.push({fileName: file.name, message: result});
}
}
if (isEmpty(errors)) {
return true;
}
this.set('errors', errors);
this.onFailed(errors);
return false;
},
// we only check the file extension by default because IE doesn't always
// expose the mime-type, we'll rely on the API for final validation
_defaultValidator(file) {
let extensions = this.get('extensions');
let [, extension] = (/(?:\.([^.]+))?$/).exec(file.name);
// if extensions is falsy exit early and accept all files
if (!extensions) {
return true;
}
if (!isEmberArray(extensions)) {
extensions = extensions.split(',');
}
if (!extension || extensions.indexOf(extension.toLowerCase()) === -1) {
let validExtensions = `.${extensions.join(', .').toUpperCase()}`;
return `The image type you uploaded is not supported. Please use ${validExtensions}`;
}
return true;
},
_uploadFiles: task(function* (files) {
let uploads = [];
this._reset();
this.onStart();
// NOTE: for...of loop results in a transpilation that errors in Edge,
// once we drop IE11 support we should be able to use native for...of
for (let i = 0; i < files.length; i++) {
uploads.push(this.get('_uploadFile').perform(files[i]));
}
// populates this.errors and this.uploadUrls
yield all(uploads);
if (!isEmpty(this.get('errors'))) {
this.onFailed(this.get('errors'));
}
this.onComplete(this.get('uploadUrls'));
}).drop(),
_uploadFile: task(function* (file) {
let ajax = this.get('ajax');
let formData = this._getFormData(file);
let url = `${ghostPaths().apiRoot}${this.get('uploadUrl')}`;
let tracker = new UploadTracker({file});
this.get('_uploadTrackers').pushObject(tracker);
try {
let response = yield ajax.post(url, {
data: formData,
processData: false,
contentType: false,
dataType: 'text',
xhr: () => {
let xhr = new window.XMLHttpRequest();
xhr.upload.addEventListener('progress', (event) => {
run(() => {
tracker.update(event);
this._updateProgress();
});
}, false);
return xhr;
}
});
// force tracker progress to 100% in case we didn't get a final event,
// eg. when using mirage
tracker.update({loaded: file.size, total: file.size});
this._updateProgress();
// TODO: is it safe to assume we'll only get a url back?
let uploadUrl = JSON.parse(response);
let result = {
fileName: file.name,
url: uploadUrl
};
this.get('uploadUrls').pushObject(result);
this.onUploadSuccess(result);
return true;
} catch (error) {
// grab custom error message if present
let message = error.errors && error.errors[0].message;
// fall back to EmberData/ember-ajax default message for error type
if (!message) {
message = error.message;
}
let result = {
fileName: file.name,
message: error.errors[0].message
};
// TODO: check for or expose known error types?
this.get('errors').pushObject(result);
this.onUploadFail(result);
}
}),
// NOTE: this is necessary because the API doesn't accept direct file uploads
_getFormData(file) {
let formData = new FormData();
formData.append(this.get('paramName'), file, file.name);
return formData;
},
// TODO: this was needed because using CPs directly resulted in infrequent updates
// - I think this was because updates were being wrapped up to save
// computation but that hypothesis needs testing
_updateProgress() {
let trackers = this._uploadTrackers;
let totalSize = trackers.reduce((total, tracker) => {
return total + tracker.get('total');
}, 0);
let uploadedSize = trackers.reduce((total, tracker) => {
return total + tracker.get('loaded');
}, 0);
this.set('totalSize', totalSize);
this.set('uploadedSize', uploadedSize);
if (totalSize === 0 || uploadedSize === 0) {
return;
}
let uploadPercentage = Math.round((uploadedSize / totalSize) * 100);
this.set('uploadPercentage', uploadPercentage);
},
_reset() {
this.set('errors', []);
this.set('totalSize', 0);
this.set('uploadedSize', 0);
this.set('uploadPercentage', 0);
this.set('uploadUrls', []);
this._uploadTrackers = [];
},
actions: {
cancel() {
this._reset();
this.onCancel();
}
}
});