mirror of
https://github.com/TryGhost/Ghost-Admin.git
synced 2023-12-14 02:33:04 +01:00
closes https://github.com/TryGhost/Ghost/issues/9266 - `emberx-file-input` passes a `resetInput` function through to it's action handler but we weren't doing that in our override component. Added the missing functionality and updated all of our handlers to use that instead of doing manual resets - added a `setFiles` action to `{{gh-uploader}}` and yield it for use in block invocations
312 lines
9.1 KiB
JavaScript
312 lines
9.1 KiB
JavaScript
import Component from '@ember/component';
|
|
import EmberObject from '@ember/object';
|
|
import ghostPaths from 'ghost-admin/utils/ghost-paths';
|
|
import {all, task} from 'ember-concurrency';
|
|
import {isArray as isEmberArray} from '@ember/array';
|
|
import {isEmpty} from '@ember/utils';
|
|
import {run} from '@ember/runloop';
|
|
import {inject as service} from '@ember/service';
|
|
|
|
// 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
|
|
|
|
const MAX_SIMULTANEOUS_UPLOADS = 2;
|
|
|
|
/**
|
|
* 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: service(),
|
|
|
|
// 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() {},
|
|
onUploadFailure() {},
|
|
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');
|
|
this._setFiles(files);
|
|
},
|
|
|
|
_setFiles(files) {
|
|
this.set('files', 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++) {
|
|
let file = files[i];
|
|
let tracker = new UploadTracker({file});
|
|
|
|
this.get('_uploadTrackers').pushObject(tracker);
|
|
uploads.push(this.get('_uploadFile').perform(tracker, file, 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* (tracker, file, index) {
|
|
let ajax = this.get('ajax');
|
|
let formData = this._getFormData(file);
|
|
let url = `${ghostPaths().apiRoot}${this.get('uploadUrl')}`;
|
|
|
|
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')[index] = result;
|
|
this.onUploadSuccess(result);
|
|
|
|
return true;
|
|
|
|
} catch (error) {
|
|
// grab custom error message if present
|
|
let message = error.payload.errors && error.payload.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.payload.errors[0].message
|
|
};
|
|
|
|
// TODO: check for or expose known error types?
|
|
this.get('errors').pushObject(result);
|
|
this.onUploadFailure(result);
|
|
}
|
|
}).maxConcurrency(MAX_SIMULTANEOUS_UPLOADS).enqueue(),
|
|
|
|
// 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: {
|
|
setFiles(files, resetInput) {
|
|
this._setFiles(files);
|
|
|
|
if (resetInput) {
|
|
resetInput();
|
|
}
|
|
},
|
|
|
|
cancel() {
|
|
this._reset();
|
|
this.onCancel();
|
|
}
|
|
}
|
|
});
|