f25cc00236
closes https://github.com/TryGhost/Team/issues/2295 Problem: - `<GhUploader>` is not yet converted to an Octane component so it's arguments are not read-only - when a file is selected it sets it's `files` property which in turn updates the tracked `files` property that was passed in, and then again updates it to an empty file list when the input field is cleared - that tracked property was never cleared once the product image was uploaded resulting in a "re-upload" attempt with an empty file list every time the product card was put back into edit mode Fix: - added a guard in `<GhUploader>` so it doesn't try to upload an empty file list if one is passed in as an attribute - added a reset of the tracked `files` property in the product card once the image upload is complete
371 lines
11 KiB
JavaScript
371 lines
11 KiB
JavaScript
import Component from '@ember/component';
|
|
import EmberObject, {get} from '@ember/object';
|
|
import classic from 'ember-classic-decorator';
|
|
import ghostPaths from 'ghost-admin/utils/ghost-paths';
|
|
import {
|
|
ICON_EXTENSIONS,
|
|
ICON_MIME_TYPES,
|
|
IMAGE_EXTENSIONS,
|
|
IMAGE_MIME_TYPES
|
|
} from 'ghost-admin/components/gh-image-uploader';
|
|
import {all, task} from 'ember-concurrency';
|
|
import {isArray} 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
|
|
//
|
|
// 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"
|
|
*/
|
|
|
|
@classic
|
|
class UploadTracker extends EmberObject {
|
|
file = null;
|
|
total = 0;
|
|
loaded = 0;
|
|
|
|
init() {
|
|
super.init(...arguments);
|
|
this.total = this.file && this.file.size || 0;
|
|
}
|
|
|
|
update({loaded, total}) {
|
|
this.total = total;
|
|
this.loaded = loaded;
|
|
}
|
|
}
|
|
|
|
export default Component.extend({
|
|
ajax: service(),
|
|
|
|
tagName: '',
|
|
|
|
// Public attributes
|
|
accept: '',
|
|
extensions: '',
|
|
files: null,
|
|
paramName: 'file',
|
|
paramsHash: null,
|
|
resourceName: 'images',
|
|
uploadUrl: null,
|
|
requestMethod: 'post',
|
|
|
|
// Interal attributes
|
|
errors: null, // [{fileName: 'x', message: 'y'}, ...]
|
|
totalSize: 0,
|
|
uploadedSize: 0,
|
|
uploadPercentage: 0,
|
|
uploadUrls: null, // [{filename: 'x', url: 'y'}],
|
|
|
|
// Private
|
|
_defaultUploadUrl: '/images/upload/',
|
|
_files: null,
|
|
_uploadTrackers: null,
|
|
|
|
// Closure actions
|
|
onCancel() {},
|
|
onComplete() {},
|
|
onFailed() {},
|
|
onStart() {},
|
|
onUploadStart() {},
|
|
onUploadFailure() {},
|
|
onUploadSuccess() {},
|
|
|
|
// Optional closure actions
|
|
// validate(file) {}
|
|
|
|
init() {
|
|
this._super(...arguments);
|
|
this.set('errors', []);
|
|
this.set('uploadUrls', []);
|
|
this._uploadTrackers = [];
|
|
|
|
if (!this.paramsHash) {
|
|
this.set('paramsHash', {purpose: 'image'});
|
|
}
|
|
|
|
this.set('imageExtensions', IMAGE_EXTENSIONS);
|
|
this.set('imageMimeTypes', IMAGE_MIME_TYPES);
|
|
this.set('iconExtensions', ICON_EXTENSIONS);
|
|
this.set('iconMimeTypes', ICON_MIME_TYPES);
|
|
},
|
|
|
|
didReceiveAttrs() {
|
|
this._super(...arguments);
|
|
|
|
// set up any defaults
|
|
if (!this.uploadUrl) {
|
|
this.set('uploadUrl', this._defaultUploadUrl);
|
|
}
|
|
|
|
// if we have new files, validate and start an upload
|
|
if (this.files?.length) {
|
|
this._setFiles(this.files);
|
|
}
|
|
},
|
|
|
|
actions: {
|
|
registerFileInput(input) {
|
|
this.fileInput = input;
|
|
},
|
|
|
|
triggerFileDialog() {
|
|
if (!this.fileInput) {
|
|
// eslint-disable-next-line
|
|
console.error('When using uploader.triggerFileDialog you must call uploader.registerFileInput first');
|
|
return;
|
|
}
|
|
|
|
this.fileInput.click();
|
|
},
|
|
|
|
setFiles(files, resetInput) {
|
|
this._setFiles(files);
|
|
|
|
if (resetInput) {
|
|
this.fileInput = resetInput();
|
|
}
|
|
},
|
|
|
|
cancel() {
|
|
this._reset();
|
|
this.onCancel();
|
|
}
|
|
},
|
|
|
|
_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._uploadFiles.perform(files);
|
|
}
|
|
}
|
|
},
|
|
|
|
_validate() {
|
|
let files = this.files;
|
|
let validate = this.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 += 1) {
|
|
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.extensions;
|
|
let [, extension] = (/(?:\.([^.]+))?$/).exec(file.name);
|
|
|
|
// if extensions is falsy exit early and accept all files
|
|
if (!extensions) {
|
|
return true;
|
|
}
|
|
|
|
if (!isArray(extensions)) {
|
|
extensions = extensions.split(',');
|
|
}
|
|
|
|
if (!extension || extensions.indexOf(extension.toLowerCase()) === -1) {
|
|
let validExtensions = `.${extensions.join(', .').toUpperCase()}`;
|
|
return `The file type you uploaded is not supported. Please use ${validExtensions}`;
|
|
}
|
|
|
|
return true;
|
|
},
|
|
|
|
_uploadFiles: task(function* (files) {
|
|
let uploads = [];
|
|
|
|
this._reset();
|
|
this.onStart(files);
|
|
|
|
// 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 += 1) {
|
|
let file = files[i];
|
|
let tracker = UploadTracker.create({file});
|
|
|
|
this._uploadTrackers.pushObject(tracker);
|
|
uploads.push(this._uploadFile.perform(tracker, file, i));
|
|
}
|
|
|
|
// populates this.errors and this.uploadUrls
|
|
yield all(uploads);
|
|
|
|
if (!isEmpty(this.errors)) {
|
|
this.onFailed(this.errors);
|
|
}
|
|
|
|
this.onComplete(this.uploadUrls);
|
|
}).drop(),
|
|
|
|
// eslint-disable-next-line ghost/ember/order-in-components
|
|
_uploadFile: task(function* (tracker, file, index) {
|
|
let ajax = this.ajax;
|
|
let formData = this._getFormData(file);
|
|
let url = `${ghostPaths().apiRoot}${this.uploadUrl}`;
|
|
let metadata = null;
|
|
|
|
try {
|
|
metadata = yield Promise.resolve(this.onUploadStart(file));
|
|
|
|
let response = yield ajax[this.requestMethod](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();
|
|
|
|
let uploadResponse;
|
|
let responseUrl;
|
|
|
|
try {
|
|
uploadResponse = JSON.parse(response);
|
|
} catch (e) {
|
|
if (!(e instanceof SyntaxError)) {
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
if (uploadResponse) {
|
|
let resource = get(uploadResponse, this.resourceName);
|
|
if (resource && isArray(resource) && resource[0]) {
|
|
responseUrl = get(resource[0], 'url');
|
|
}
|
|
}
|
|
|
|
let result = {
|
|
url: responseUrl,
|
|
fileName: file.name
|
|
};
|
|
|
|
this.uploadUrls[index] = result;
|
|
this.onUploadSuccess(result, metadata);
|
|
|
|
return true;
|
|
} catch (error) {
|
|
// grab custom error message if present
|
|
let message = error.payload && error.payload.errors && error.payload.errors[0].message || '';
|
|
let context = error.payload && error.payload.errors && error.payload.errors[0].context || '';
|
|
|
|
// fall back to EmberData/ember-ajax default message for error type
|
|
if (!message) {
|
|
message = error.message;
|
|
}
|
|
|
|
let result = {
|
|
message,
|
|
context,
|
|
fileName: file.name
|
|
};
|
|
|
|
// TODO: check for or expose known error types?
|
|
this.errors.pushObject(result);
|
|
this.onUploadFailure(result, metadata);
|
|
}
|
|
}).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.paramName, file, file.name);
|
|
|
|
Object.keys(this.paramsHash || {}).forEach((key) => {
|
|
formData.append(key, this.paramsHash[key]);
|
|
});
|
|
|
|
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() {
|
|
if (this.isDestroyed || this.isDestroying) {
|
|
return;
|
|
}
|
|
|
|
let trackers = this._uploadTrackers;
|
|
let totalSize = trackers.reduce((total, tracker) => total + tracker.get('total'), 0);
|
|
let uploadedSize = trackers.reduce((total, tracker) => 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 = [];
|
|
}
|
|
});
|