Merge pull request #6651 from kevinansfield/uploader-js-must-die

Replace jQuery-based uploader.js with ember components
This commit is contained in:
Hannah Wolfe 2016-04-14 16:57:57 +01:00
commit a2a825bfe9
31 changed files with 1265 additions and 595 deletions

View File

@ -1,257 +0,0 @@
import Ember from 'ember';
import ghostPaths from 'ghost/utils/ghost-paths';
const {$} = Ember;
let Ghost = ghostPaths();
let UploadUi = function ($dropzone, settings) {
let $url = '<div class="js-url"><input class="url js-upload-url gh-input" type="url" placeholder="http://"/></div>';
let $cancel = '<a class="image-cancel icon-trash js-cancel" title="Delete"><span class="hidden">Delete</span></a>';
let $progress = $('<div />', {
class: 'js-upload-progress progress progress-success active',
role: 'progressbar',
'aria-valuemin': '0',
'aria-valuemax': '100'
}).append($('<div />', {
class: 'js-upload-progress-bar bar',
style: 'width:0%'
}));
$.extend(this, {
complete: (result) => {
let showImage = (width, height) => {
$dropzone.find('img.js-upload-target').attr({width, height}).css({display: 'block'});
$dropzone.find('.fileupload-loading').remove();
$dropzone.css({height: 'auto'});
$dropzone.delay(250).animate({opacity: 100}, 1000, () => {
$('.js-button-accept').prop('disabled', false);
this.init();
});
};
let animateDropzone = ($img) => {
$dropzone.animate({opacity: 0}, 250, () => {
$dropzone.removeClass('image-uploader').addClass('pre-image-uploader');
this.removeExtras();
$dropzone.animate({height: $img.height()}, 250, () => {
showImage($img.width(), $img.height());
});
});
};
let preLoadImage = () => {
let $img = $dropzone.find('img.js-upload-target')
.attr({src: '', width: 'auto', height: 'auto'});
$progress.animate({opacity: 0}, 250, () => {
$dropzone.find('span.media').after(`<img class="fileupload-loading" src="${Ghost.subdir}/ghost/img/loadingcat.gif" />`);
});
$img.one('load', () => {
$dropzone.trigger('uploadsuccess', [result]);
animateDropzone($img);
}).attr('src', result);
};
preLoadImage();
},
bindFileUpload() {
$dropzone.find('.js-fileupload').fileupload().fileupload('option', {
url: `${Ghost.apiRoot}/uploads/`,
add(e, data) {
/*jshint unused:false*/
$('.js-button-accept').prop('disabled', true);
$dropzone.find('.js-fileupload').removeClass('right');
$dropzone.find('.js-url').remove();
$progress.find('.js-upload-progress-bar').removeClass('fail');
$dropzone.trigger('uploadstart', [$dropzone.attr('id')]);
$dropzone.find('span.media, div.description, a.image-url, a.image-webcam')
.animate({opacity: 0}, 250, () => {
$dropzone.find('div.description').hide().css({opacity: 100});
if (settings.progressbar) {
$dropzone.find('div.js-fail').after($progress);
$progress.animate({opacity: 100}, 250);
}
data.submit();
});
},
dropZone: settings.fileStorage ? $dropzone : null,
progressall(e, data) {
/*jshint unused:false*/
let progress = parseInt(data.loaded / data.total * 100, 10);
if (settings.progressbar) {
$dropzone.trigger('uploadprogress', [progress, data]);
$progress.find('.js-upload-progress-bar').css('width', `${progress}%`);
}
},
fail: (e, data) => {
/*jshint unused:false*/
$('.js-button-accept').prop('disabled', false);
$dropzone.trigger('uploadfailure', [data.result]);
$dropzone.find('.js-upload-progress-bar').addClass('fail');
if (data.jqXHR.status === 413) {
$dropzone.find('div.js-fail').text('The image you uploaded was larger than the maximum file size your server allows.');
} else if (data.jqXHR.status === 415) {
$dropzone.find('div.js-fail').text('The image type you uploaded is not supported. Please use .PNG, .JPG, .GIF, .SVG.');
} else {
$dropzone.find('div.js-fail').text('Something went wrong :(');
}
$dropzone.find('div.js-fail, button.js-fail').fadeIn(1500);
$dropzone.find('button.js-fail').on('click', () => {
$dropzone.css({minHeight: 0});
$dropzone.find('div.description').show();
this.removeExtras();
this.init();
});
},
done: (e, data) => {
/*jshint unused:false*/
this.complete(data.result);
}
});
},
buildExtras() {
if (!$dropzone.find('span.media')[0]) {
$dropzone.prepend('<span class="media"><span class="hidden">Image Upload</span></span>');
}
if (!$dropzone.find('div.description')[0]) {
$dropzone.append('<div class="description">Add image</div>');
}
if (!$dropzone.find('div.js-fail')[0]) {
$dropzone.append('<div class="js-fail failed" style="display: none">Something went wrong :(</div>');
}
if (!$dropzone.find('button.js-fail')[0]) {
$dropzone.append('<button class="js-fail btn btn-green" style="display: none">Try Again</button>');
}
if (!$dropzone.find('a.image-url')[0]) {
$dropzone.append('<a class="image-url" title="Add image from URL"><i class="icon-link"><span class="hidden">URL</span></i></a>');
}
// if (!$dropzone.find('a.image-webcam')[0]) {
// $dropzone.append('<a class="image-webcam" title="Add image from webcam"><span class="hidden">Webcam</span></a>');
// }
},
removeExtras() {
$dropzone.find('span.media, div.js-upload-progress, a.image-url, a.image-upload, a.image-webcam, div.js-fail, button.js-fail, a.js-cancel, button.js-button-accept').remove();
},
initWithDropzone() {
// This is the start point if no image exists
$dropzone.find('img.js-upload-target').css({display: 'none'});
$dropzone.find('div.description').show();
$dropzone.removeClass('pre-image-uploader image-uploader-url').addClass('image-uploader');
this.removeExtras();
this.buildExtras();
this.bindFileUpload();
if (!settings.fileStorage) {
this.initUrl();
return;
}
$dropzone.find('a.image-url').on('click', () => {
this.initUrl();
});
},
initUrl() {
this.removeExtras();
$dropzone.addClass('image-uploader-url').removeClass('pre-image-uploader');
$dropzone.find('.js-fileupload').addClass('right');
$dropzone.find('.js-cancel').on('click', () => {
$dropzone.find('.js-url').remove();
$dropzone.find('.js-fileupload').removeClass('right');
$dropzone.trigger('imagecleared');
this.removeExtras();
this.initWithDropzone();
});
if (!$dropzone.find('.js-url')[0]) {
$dropzone.find('div.description').before($url);
}
if (settings.editor) {
$dropzone.find('div.js-url').append('<button class="btn btn-blue js-button-accept gh-input">Save</button>');
$dropzone.find('div.description').hide();
}
$dropzone.find('.js-button-accept').on('click', () => {
let val = $dropzone.find('.js-upload-url').val();
$dropzone.find('div.description').hide();
$dropzone.find('.js-fileupload').removeClass('right');
$dropzone.find('.js-url').remove();
if (val === '') {
$dropzone.trigger('uploadsuccess', 'http://');
this.initWithDropzone();
} else {
this.complete(val);
}
});
// Only show the toggle icon if there is a dropzone mode to go back to
if (settings.fileStorage !== false) {
$dropzone.append('<a class="image-upload icon-photos" title="Add image"><span class="hidden">Upload</span></a>');
}
$dropzone.find('a.image-upload').on('click', () => {
$dropzone.find('.js-url').remove();
$dropzone.find('.js-fileupload').removeClass('right');
this.initWithDropzone();
});
},
initWithImage() {
// This is the start point if an image already exists
this.removeExtras();
$dropzone.removeClass('image-uploader image-uploader-url').addClass('pre-image-uploader');
$dropzone.find('div.description').hide();
$dropzone.find('img.js-upload-target').show();
$dropzone.append($cancel);
$dropzone.find('.js-cancel').on('click', () => {
$dropzone.find('img.js-upload-target').attr({src: ''});
$dropzone.find('div.description').show();
$dropzone.trigger('imagecleared');
$dropzone.trigger('uploadsuccess', 'http://');
this.initWithDropzone();
});
},
init() {
let imageTarget = $dropzone.find('img.js-upload-target');
// First check if field image is defined by checking for js-upload-target class
if (!imageTarget[0]) {
// This ensures there is an image we can hook into to display uploaded image
$dropzone.prepend('<img class="js-upload-target" style="display: none" src="" />');
}
$('.js-button-accept').prop('disabled', false);
if (imageTarget.attr('src') === '' || imageTarget.attr('src') === undefined) {
this.initWithDropzone();
} else {
this.initWithImage();
}
},
reset() {
$dropzone.find('.js-url').remove();
$dropzone.find('.js-fileupload').removeClass('right');
this.removeExtras();
this.initWithDropzone();
}
});
};
export default function (options) {
let settings = $.extend({
progressbar: true,
editor: false,
fileStorage: true
}, options);
return this.each(function () {
let $dropzone = $(this);
let ui = new UploadUi($dropzone, settings);
$(this).attr('data-uploaderui', true);
this.uploaderUi = ui;
ui.init();
});
}

View File

@ -1,23 +1,25 @@
import Ember from 'ember'; import Ember from 'ember';
import uploader from 'ghost/assets/lib/uploader';
const { const {
$, $,
Component, Component,
inject: {service}, run,
run uuid
} = Ember; } = Ember;
export default Component.extend({ export default Component.extend({
config: service(),
_scrollWrapper: null, _scrollWrapper: null,
init() {
this._super(...arguments);
this.set('imageUploadComponents', Ember.A([]));
},
didInsertElement() { didInsertElement() {
this._super(...arguments); this._super(...arguments);
this._scrollWrapper = this.$().closest('.entry-preview-content'); this._scrollWrapper = this.$().closest('.entry-preview-content');
this.adjustScrollPosition(this.get('scrollPosition')); this.adjustScrollPosition(this.get('scrollPosition'));
run.scheduleOnce('afterRender', this, this.dropzoneHandler); run.scheduleOnce('afterRender', this, this.registerImageUploadComponents);
}, },
didReceiveAttrs(attrs) { didReceiveAttrs(attrs) {
@ -32,7 +34,14 @@ export default Component.extend({
} }
if (attrs.newAttrs.markdown.value !== attrs.oldAttrs.markdown.value) { if (attrs.newAttrs.markdown.value !== attrs.oldAttrs.markdown.value) {
run.scheduleOnce('afterRender', this, this.dropzoneHandler); // we need to clear the rendered components as we are unable to
// retain a reliable reference for the component's position in the
// document
// TODO: it may be possible to extract the dropzones and use the
// image src as a key, re-connecting any that match and
// dropping/re-rendering any unknown/no-source instances
this.set('imageUploadComponents', Ember.A([]));
run.scheduleOnce('afterRender', this, this.registerImageUploadComponents);
} }
}, },
@ -44,22 +53,38 @@ export default Component.extend({
} }
}, },
dropzoneHandler() { registerImageUploadComponents() {
let dropzones = $('.js-drop-zone[data-uploaderui!="true"]'); let dropzones = $('.js-drop-zone');
if (dropzones.length) { dropzones.each((i, el) => {
uploader.call(dropzones, { let id = uuid();
editor: true, let destinationElementId = `image-uploader-${id}`;
fileStorage: this.get('config.fileStorage') let src = $(el).find('.js-upload-target').attr('src');
let imageUpload = Ember.Object.create({
destinationElementId,
id,
src,
index: i
}); });
dropzones.on('uploadstart', run.bind(this, 'sendAction', 'uploadStarted')); el.id = destinationElementId;
dropzones.on('uploadfailure', run.bind(this, 'sendAction', 'uploadFinished')); $(el).empty();
dropzones.on('uploadsuccess', run.bind(this, 'sendAction', 'uploadFinished')); $(el).removeClass('image-uploader');
dropzones.on('uploadsuccess', run.bind(this, 'sendAction', 'uploadSuccess'));
// Set the current height so we can listen run.schedule('afterRender', () => {
this.sendAction('updateHeight', this.$().height()); this.get('imageUploadComponents').pushObject(imageUpload);
});
});
},
actions: {
updateImageSrc(index, url) {
this.attrs.updateImageSrc(index, url);
},
updateHeight() {
this.attrs.updateHeight(this.$().height());
} }
} }
}); });

View File

@ -100,18 +100,18 @@ export default Component.extend(ShortcutsMixin, {
// Match the uploaded file to a line in the editor, and update that line with a path reference // Match the uploaded file to a line in the editor, and update that line with a path reference
// ensuring that everything ends up in the correct place and format. // ensuring that everything ends up in the correct place and format.
handleImgUpload(e, resultSrc) { handleImgUpload(imageIndex, newSrc) {
let editor = this.get('editor'); let editor = this.get('editor');
let editorValue = editor.getValue(); let editorValue = editor.getValue();
let replacement = imageManager.getSrcRange(editorValue, e.target); let replacement = imageManager.getSrcRange(editorValue, imageIndex);
let cursorPosition; let cursorPosition;
if (replacement) { if (replacement) {
cursorPosition = replacement.start + resultSrc.length + 1; cursorPosition = replacement.start + newSrc.length + 1;
if (replacement.needsParens) { if (replacement.needsParens) {
resultSrc = `(${resultSrc})`; newSrc = `(${newSrc})`;
} }
editor.replaceSelection(resultSrc, replacement.start, replacement.end, cursorPosition); editor.replaceSelection(newSrc, replacement.start, replacement.end, cursorPosition);
} }
}, },

View File

@ -0,0 +1,39 @@
import Ember from 'ember';
const {
Component
} = Ember;
export default Component.extend({
actions: {
update() {
if (typeof this.attrs.update === 'function') {
this.attrs.update(...arguments);
}
},
onInput() {
if (typeof this.attrs.onInput === 'function') {
this.attrs.onInput(...arguments);
}
},
uploadStarted() {
if (typeof this.attrs.uploadStarted === 'function') {
this.attrs.uploadStarted(...arguments);
}
},
uploadFinished() {
if (typeof this.attrs.uploadFinished === 'function') {
this.attrs.uploadFinished(...arguments);
}
},
formChanged() {
if (typeof this.attrs.formChanged === 'function') {
this.attrs.formChanged(...arguments);
}
}
}
});

View File

@ -0,0 +1,217 @@
import Ember from 'ember';
import ghostPaths from 'ghost/utils/ghost-paths';
import {RequestEntityTooLargeError, UnsupportedMediaTypeError} from 'ghost/services/ajax';
const {
Component,
computed,
inject: {service},
isBlank,
run
} = Ember;
export default Component.extend({
tagName: 'section',
classNames: ['gh-image-uploader'],
classNameBindings: ['dragClass'],
image: null,
text: 'Upload an image',
saveButton: true,
dragClass: null,
failureMessage: null,
file: null,
formType: 'upload',
url: null,
uploadPercentage: 0,
ajax: service(),
config: service(),
session: service(),
// TODO: this wouldn't be necessary if the server could accept direct
// file uploads
formData: computed('file', function () {
let file = this.get('file');
let formData = new FormData();
formData.append('uploadimage', file);
return formData;
}),
progressStyle: computed('uploadPercentage', function () {
let percentage = this.get('uploadPercentage');
let width = '';
if (percentage > 0) {
width = `${percentage}%`;
} else {
width = '0';
}
return Ember.String.htmlSafe(`width: ${width}`);
}),
canShowUploadForm: computed('config.fileStorage', function () {
return this.get('config.fileStorage') !== false;
}),
showUploadForm: computed('formType', function () {
let canShowUploadForm = this.get('canShowUploadForm');
let formType = this.get('formType');
return formType === 'upload' && canShowUploadForm;
}),
didReceiveAttrs() {
let image = this.get('image');
this.set('url', image);
},
dragOver(event) {
let showUploadForm = this.get('showUploadForm');
event.preventDefault();
if (showUploadForm) {
this.set('dragClass', '--drag-over');
}
},
dragLeave(event) {
let showUploadForm = this.get('showUploadForm');
event.preventDefault();
if (showUploadForm) {
this.set('dragClass', null);
}
},
drop(event) {
let showUploadForm = this.get('showUploadForm');
event.preventDefault();
this.set('dragClass', null);
if (showUploadForm) {
if (event.dataTransfer.files) {
this.send('fileSelected', event.dataTransfer.files);
}
}
},
uploadStarted() {
if (typeof this.attrs.uploadStarted === 'function') {
this.attrs.uploadStarted();
}
},
uploadProgress(event) {
if (event.lengthComputable) {
run(() => {
let percentage = Math.round((event.loaded / event.total) * 100);
this.set('uploadPercentage', percentage);
});
}
},
uploadFinished() {
if (typeof this.attrs.uploadFinished === 'function') {
this.attrs.uploadFinished();
}
},
uploadSuccess(response) {
this.set('url', response);
this.send('saveUrl');
this.send('reset');
},
uploadFailed(error) {
let message;
if (error instanceof UnsupportedMediaTypeError) {
message = 'The image type you uploaded is not supported. Please use .PNG, .JPG, .GIF, .SVG.';
} else if (error instanceof RequestEntityTooLargeError) {
message = 'The image you uploaded was larger than the maximum file size your server allows.';
} else if (error.errors && !isBlank(error.errors[0].message)) {
message = error.errors[0].message;
} else {
message = 'Something went wrong :(';
}
this.set('failureMessage', message);
},
generateRequest() {
let ajax = this.get('ajax');
let formData = this.get('formData');
let url = `${ghostPaths().apiRoot}/uploads/`;
this.uploadStarted();
ajax.post(url, {
data: formData,
processData: false,
contentType: false,
dataType: 'text',
xhr: () => {
let xhr = new window.XMLHttpRequest();
xhr.upload.addEventListener('progress', (event) => {
this.uploadProgress(event);
}, false);
return xhr;
}
}).then((response) => {
let url = JSON.parse(response);
this.uploadSuccess(url);
}).catch((error) => {
this.uploadFailed(error);
}).finally(() => {
this.uploadFinished();
});
},
actions: {
fileSelected(fileList) {
this.set('file', fileList[0]);
run.schedule('actions', this, function () {
this.generateRequest();
});
},
onInput(url) {
this.set('url', url);
if (typeof this.attrs.onInput === 'function') {
this.attrs.onInput(url);
}
},
reset() {
this.set('file', null);
this.set('uploadPercentage', 0);
},
switchForm(formType) {
this.set('formType', formType);
if (typeof this.attrs.formChanged === 'function') {
run.scheduleOnce('afterRender', this, function () {
this.attrs.formChanged(formType);
});
}
},
saveUrl() {
let url = this.get('url');
this.attrs.update(url);
}
}
});

View File

@ -119,10 +119,6 @@ export default Component.extend({
this.get('setProperty')('image', ''); this.get('setProperty')('image', '');
}, },
setUploaderReference() {
// noop
},
openMeta() { openMeta() {
this.set('isViewingSubview', true); this.set('isViewingSubview', true);
}, },

View File

@ -1,88 +0,0 @@
import Ember from 'ember';
import uploader from 'ghost/assets/lib/uploader';
const {
Component,
computed,
get,
inject: {service},
isEmpty,
run
} = Ember;
export default Component.extend({
classNames: ['image-uploader', 'js-post-image-upload'],
config: service(),
imageSource: computed('image', function () {
return this.get('image') || '';
}),
// removes event listeners from the uploader
removeListeners() {
let $this = this.$();
$this.off();
$this.find('.js-cancel').off();
},
// NOTE: because the uploader is sometimes in the same place in the DOM
// between transitions Glimmer will re-use the existing elements including
// those that arealready decorated by jQuery. The following works around
// situations where the image is changed without a full teardown/rebuild
didReceiveAttrs(attrs) {
let oldValue = attrs.oldAttrs && get(attrs.oldAttrs, 'image.value');
let newValue = attrs.newAttrs && get(attrs.newAttrs, 'image.value');
this._super(...arguments);
// always reset when we receive a blank image
// - handles navigating to populated image from blank image
if (isEmpty(newValue) && !isEmpty(oldValue)) {
this.$()[0].uploaderUi.reset();
}
// re-init if we receive a new image
// - handles back button navigating from blank image to populated image
// - handles navigating between populated images
if (!isEmpty(newValue) && this.$()) {
this.$('.js-upload-target').attr('src', '');
this.$()[0].uploaderUi.reset();
this.$()[0].uploaderUi.initWithImage();
}
},
didInsertElement() {
this._super(...arguments);
this.send('initUploader');
},
willDestroyElement() {
this._super(...arguments);
this.removeListeners();
},
actions: {
initUploader() {
let el = this.$();
let ref = uploader.call(el, {
editor: true,
fileStorage: this.get('config.fileStorage')
});
el.on('uploadsuccess', (event, result) => {
if (result && result !== '' && result !== 'http://') {
run(this, function () {
this.sendAction('uploaded', result);
});
}
});
el.on('imagecleared', run.bind(this, 'sendAction', 'canceled'));
this.sendAction('initUploader', ref);
}
}
});

View File

@ -1,6 +1,5 @@
import Ember from 'ember'; import Ember from 'ember';
import ModalComponent from 'ghost/components/modals/base'; import ModalComponent from 'ghost/components/modals/base';
import upload from 'ghost/assets/lib/uploader';
import cajaSanitizers from 'ghost/utils/caja-sanitizers'; import cajaSanitizers from 'ghost/utils/caja-sanitizers';
const { const {
@ -10,14 +9,16 @@ const {
} = Ember; } = Ember;
export default ModalComponent.extend({ export default ModalComponent.extend({
acceptEncoding: 'image/*',
model: null, model: null,
submitting: false, submitting: false,
url: '',
newUrl: '',
config: service(), config: service(),
notifications: service(), notifications: service(),
imageUrl: computed('model.model', 'model.imageProperty', { image: computed('model.model', 'model.imageProperty', {
get() { get() {
let imageProperty = this.get('model.imageProperty'); let imageProperty = this.get('model.imageProperty');
@ -32,51 +33,62 @@ export default ModalComponent.extend({
} }
}), }),
didInsertElement() { didReceiveAttrs() {
this._super(...arguments); let image = this.get('image');
upload.call(this.$('.js-drop-zone'), { this.set('url', image);
fileStorage: this.get('config.fileStorage') this.set('newUrl', image);
});
}, },
// TODO: should validation be handled in the gh-image-uploader component?
// pro - consistency everywhere, simplification here
// con - difficult if the "save" is happening externally as it does here
//
// maybe it should be handled at the model level?
// - automatically present everywhere
// - file uploads should always result in valid urls so it should only
// affect the url input form
keyDown() { keyDown() {
this._setErrorState(false); this._setErrorState(false);
}, },
_setErrorState(state) { _setErrorState(state) {
if (state) { if (state) {
this.$('.js-upload-url').addClass('error'); this.$('.url').addClass('error');
} else { } else {
this.$('.js-upload-url').removeClass('error'); this.$('.url').removeClass('error');
} }
}, },
_setImageProperty() { _validateUrl(url) {
let value; if (!isEmpty(url) && !cajaSanitizers.url(url)) {
this._setErrorState(true);
if (this.$('.js-upload-url').val()) { return {message: 'Image URI is not valid'};
value = this.$('.js-upload-url').val();
if (!isEmpty(value) && !cajaSanitizers.url(value)) {
this._setErrorState(true);
return {message: 'Image URI is not valid'};
}
} else {
value = this.$('.js-upload-target').attr('src');
} }
this.set('imageUrl', value);
return true; return true;
}, },
// end validation
actions: { actions: {
fileUploaded(url) {
this.set('url', url);
this.set('newUrl', url);
},
removeImage() {
this.set('url', '');
this.set('newUrl', '');
},
confirm() { confirm() {
let model = this.get('model.model'); let model = this.get('model.model');
let newUrl = this.get('newUrl');
let result = this._validateUrl(newUrl);
let notifications = this.get('notifications'); let notifications = this.get('notifications');
let result = this._setImageProperty();
if (!result.message) { if (result === true) {
this.set('submitting', true); this.set('submitting', true);
this.set('image', newUrl);
model.save().catch((err) => { model.save().catch((err) => {
notifications.showAPIError(err, {key: 'image.upload'}); notifications.showAPIError(err, {key: 'image.upload'});

View File

@ -24,7 +24,6 @@ export default Controller.extend(SettingsMenuMixin, {
debounceId: null, debounceId: null,
lastPromise: null, lastPromise: null,
selectedAuthor: null, selectedAuthor: null,
uploaderReference: null,
application: controller(), application: controller(),
config: service(), config: service(),
@ -408,14 +407,6 @@ export default Controller.extend(SettingsMenuMixin, {
}); });
}, },
resetUploader() {
let uploader = this.get('uploaderReference');
if (uploader && uploader[0]) {
uploader[0].uploaderUi.reset();
}
},
resetPubDate() { resetPubDate() {
this.set('publishedAtValue', ''); this.set('publishedAtValue', '');
}, },

View File

@ -131,6 +131,7 @@ export default Mixin.create({
$textarea.focus(); $textarea.focus();
// Tell the editor it has changed, as programmatic replacements won't trigger this automatically // Tell the editor it has changed, as programmatic replacements won't trigger this automatically
this._elementValueDidChange();
this.sendAction('onChange'); this.sendAction('onChange');
} }
}); });

View File

@ -32,8 +32,7 @@ export default AuthenticatedRoute.extend(base, {
// from previous posts // from previous posts
psm.removeObserver('titleScratch', psm, 'titleObserver'); psm.removeObserver('titleScratch', psm, 'titleObserver');
// Ensure that the PSM Image Uploader and Publish Date selector resets // Ensure that the PSM Publish Date selector resets
psm.send('resetUploader');
psm.send('resetPubDate'); psm.send('resetPubDate');
this._super(...arguments); this._super(...arguments);

View File

@ -1,8 +1,17 @@
import Ember from 'ember'; import Ember from 'ember';
import AjaxService from 'ember-ajax/services/ajax'; import AjaxService from 'ember-ajax/services/ajax';
import {AjaxError} from 'ember-ajax/errors';
const {inject, computed} = Ember; const {inject, computed} = Ember;
export function RequestEntityTooLargeError(errors) {
AjaxError.call(this, errors, 'Request was rejected because it\'s larger than the maximum file size the server allows');
}
export function UnsupportedMediaTypeError(errors) {
AjaxError.call(this, errors, 'Request was rejected because it contains an unknown or unsupported file type.');
}
export default AjaxService.extend({ export default AjaxService.extend({
session: inject.service(), session: inject.service(),
@ -22,11 +31,29 @@ export default AjaxService.extend({
} }
}), }),
handleResponse(status, headers, payload) {
if (this.isRequestEntityTooLarge(status, headers, payload)) {
return new RequestEntityTooLargeError(payload.errors);
} else if (this.isUnsupportedMediaType(status, headers, payload)) {
return new UnsupportedMediaTypeError(payload.errors);
}
return this._super(...arguments);
},
normalizeErrorResponse(status, headers, payload) { normalizeErrorResponse(status, headers, payload) {
if (payload && typeof payload === 'object') { if (payload && typeof payload === 'object') {
payload.errors = payload.error || payload.errors || payload.message || undefined; payload.errors = payload.error || payload.errors || payload.message || undefined;
} }
return this._super(status, headers, payload); return this._super(status, headers, payload);
},
isRequestEntityTooLarge(status/*, headers, payload */) {
return status === 413;
},
isUnsupportedMediaType(status/*, headers, payload */) {
return status === 415;
} }
}); });

View File

@ -61,8 +61,7 @@
/* The modal /* The modal
/* ---------------------------------------------------------- */ /* ---------------------------------------------------------- */
.fullscreen-modal .image-uploader, .fullscreen-modal .gh-image-uploader {
.fullscreen-modal .pre-image-uploader {
margin: 0; margin: 0;
} }

View File

@ -102,18 +102,25 @@
padding: 0 24px 24px; padding: 0 24px 24px;
} }
.settings-menu-content .image-uploader { .settings-menu-content .gh-image-uploader {
margin: 0 0 1.6rem 0; margin: 0 0 1.6rem 0;
} }
.settings-menu-content .image-uploader .description { .settings-menu-content .gh-image-uploader .description {
font-size: 1.4rem; font-size: 1.4rem;
} }
.settings-menu-content .image-uploader.image-uploader-url { .settings-menu-content .gh-image-uploader form {
padding: 35px 45px; padding: 35px 45px;
} }
.settings-menu-content .gh-image-uploader.--with-image {
margin-top: 0;
min-height: 50px;
max-height: 250px;
width: auto;
}
.settings-menu-content textarea { .settings-menu-content textarea {
height: 108px; height: 108px;
} }
@ -138,13 +145,6 @@
margin-top: 3rem; margin-top: 3rem;
} }
.settings-menu-content .pre-image-uploader {
margin-top: 0;
min-height: 50px;
max-height: 250px;
width: auto;
}
.settings-menu-content .word-count { .settings-menu-content .word-count {
font-weight: bold; font-weight: bold;
} }

View File

@ -1,162 +1,38 @@
/* Image Uploader /* Image Uploader
/* ---------------------------------------------------------- */ /* ---------------------------------------------------------- */
.image-uploader { .gh-image-uploader {
position: relative; position: relative;
display: flex;
flex-direction: column;
align-items: center;
overflow: hidden; overflow: hidden;
margin: 1.6em 0; margin: 1.6em 0;
padding: 55px 60px; min-height: 130px;
width: 100%; width: 100%;
height: auto;
background: #f6f7f8; background: #f6f7f8;
border-radius: 4px; border-radius: 4px;
color: #808284; color: #808284;
text-align: center; text-align: center;
} }
.image-uploader .description { .gh-image-uploader.--drag-over {
font-size: 1.6rem; border: 2px solid;
} }
.image-uploader a { .gh-image-uploader.--with-image {
color: var(--midgrey);
text-decoration: none;
}
.image-uploader a:hover {
color: var(--darkgrey);
}
.image-uploader .image-upload,
.image-uploader .image-url {
position: absolute;
bottom: 0;
left: 0;
display: block;
padding: 10px;
color: var(--midgrey);
text-decoration: none;
font-size: 14px;
line-height: 12px;
}
.image-uploader .image-upload:hover,
.image-uploader .image-url:hover {
cursor: pointer;
}
.image-uploader .btn-green {
position: relative;
z-index: 700;
display: inline-block;
margin-top: 10px;
color: #fff;
}
.image-uploader input.main {
position: absolute;
right: 0;
margin: 0;
font-size: 23px;
opacity: 0;
cursor: pointer;
transform: scale(14);
transform-origin: right;
direction: ltr;
}
.image-uploader input.main.right {
right: 9999px;
height: 0;
}
.image-uploader input.url {
margin: 0 0 10px 0;
padding: 9px 7px;
outline: 0;
background: #fff;
vertical-align: middle;
font: -webkit-small-control;
font-size: 1.4rem;
}
.image-uploader input.url + .btn.btn-blue {
color: #fff;
}
.image-uploader .progress {
position: relative;
top: 50%;
display: block;
overflow: hidden;
background: linear-gradient(to bottom, #f5f5f5, #f9f9f9);
border-radius: 12px;
box-shadow: rgba(0, 0, 0, 0.1) 0 1px 2px inset;
}
.image-uploader .fileupload-loading {
top: 50%;
display: block;
margin: 0 auto;
width: 35px;
height: 28px;
background-size: contain;
}
.image-uploader .failed {
position: relative;
top: -40px;
font-size: 16px;
}
.image-uploader .bar {
height: 12px;
background: var(--blue);
}
.image-uploader .bar.fail {
background: var(--red);
}
/* Pre-Image-Uploader // TODO: RENAME
/* ---------------------------------------------------------- */
.pre-image-uploader {
position: relative;
overflow: hidden;
margin: 1.6em 0;
min-height: 46px;
height: auto;
background: rgba(0, 0, 0, 0.1); background: rgba(0, 0, 0, 0.1);
border-radius: 2px; border-radius: 2px;
color: var(--midgrey);
} }
.pre-image-uploader input { .gh-image-uploader img {
position: absolute;
left: 9999px;
opacity: 0;
}
.pre-image-uploader a {
z-index: 10000;
color: var(--midgrey);
text-decoration: none;
}
.pre-image-uploader a:hover {
color: var(--darkgrey);
}
.pre-image-uploader img {
display: block; display: block;
margin: 0 auto; margin: 0 auto;
max-width: 100%; max-width: 100%;
line-height: 0; line-height: 0;
} }
.pre-image-uploader .image-cancel { .gh-image-uploader .image-cancel {
position: absolute; position: absolute;
top: 10px; top: 10px;
right: 10px; right: 10px;
@ -172,8 +48,115 @@
line-height: 10px; line-height: 10px;
} }
.pre-image-uploader .image-cancel:hover { .gh-image-uploader .upload-form {
flex-grow: 1;
display: flex;
flex-direction: row;
}
.gh-image-uploader .x-file-input {
flex-grow: 1;
display: flex;
}
.gh-image-uploader .x-file-input label {
flex-grow: 1;
display: flex;
align-items: center;
outline: none;
}
.gh-image-uploader .description {
width: 100%;
text-align: center;
font-size: 1.6rem;
}
.gh-image-uploader .image-upload,
.gh-image-uploader .image-url {
position: absolute;
bottom: 0;
left: 0;
display: block;
padding: 10px;
color: var(--midgrey);
text-decoration: none;
font-size: 14px;
line-height: 12px;
}
.gh-image-uploader a {
color: var(--midgrey);
text-decoration: none;
}
.gh-image-uploader a:hover {
color: var(--darkgrey);
}
.gh-image-uploader .image-upload:hover,
.gh-image-uploader .image-url:hover {
cursor: pointer;
}
.gh-image-uploader form {
padding: 55px 60px;
width: 100%;
}
.gh-image-uploader input.url {
margin: 0 0 10px 0;
padding: 9px 7px;
outline: 0;
background: #fff;
vertical-align: middle;
font: -webkit-small-control;
font-size: 1.4rem;
}
.gh-image-uploader input.url + .btn.btn-blue {
color: #fff;
}
.gh-image-uploader .image-cancel:hover {
background: var(--red); background: var(--red);
color: #fff; color: #fff;
cursor: pointer; cursor: pointer;
} }
.gh-image-uploader .progress-container {
flex-grow: 1;
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
}
.gh-image-uploader .progress {
overflow: hidden;
margin: 0 auto;
width: 60%;
background: linear-gradient(to bottom, #f5f5f5, #f9f9f9);
border-radius: 12px;
box-shadow: rgba(0, 0, 0, 0.1) 0 1px 2px inset;
}
.gh-image-uploader .failed {
margin: 1em 2em;
font-size: 16px;
}
.gh-image-uploader .bar {
height: 12px;
background: var(--blue);
}
.gh-image-uploader .bar.fail {
width: 100% !important;
background: var(--red);
}
/* Try Again button */
.gh-image-uploader .btn-green:last-child {
margin-top: 1em;
margin-bottom: 3em;
}

View File

@ -1 +1,14 @@
{{gh-format-markdown markdown}} {{gh-format-markdown markdown}}
{{#each imageUploadComponents as |uploader|}}
{{#ember-wormhole to=uploader.destinationElementId}}
{{gh-image-uploader-with-preview
image=uploader.src
text="Upload an image"
update=(action "updateImageSrc" uploader.index)
remove=(action "updateImageSrc" uploader.index "")
uploadStarted=uploadStarted
uploadFinished=uploadFinished
formChanged=(action "updateHeight")}}
{{/ember-wormhole}}
{{/each}}

View File

@ -38,7 +38,7 @@
updateHeight=(action "updateHeight") updateHeight=(action "updateHeight")
uploadStarted=(action "disableEditor") uploadStarted=(action "disableEditor")
uploadFinished=(action "enableEditor") uploadFinished=(action "enableEditor")
uploadSuccess=(action "handleImgUpload")}} updateImageSrc=(action "handleImgUpload")}}
</section> </section>
</section> </section>

View File

@ -0,0 +1,16 @@
{{#if image}}
<div class="gh-image-uploader --with-image">
<div><img src={{image}}></div>
<a class="image-cancel icon-trash" title="Delete" {{action remove}}>
<span class="hidden">Delete</span>
</a>
</div>
{{else}}
{{gh-image-uploader
text=text
update=(action 'update')
onInput=(action 'onInput')
uploadStarted=(action 'uploadStarted')
uploadFinished=(action 'uploadFinished')
formChanged=(action 'formChanged')}}
{{/if}}

View File

@ -0,0 +1,43 @@
{{#if file}}
{{!-- Upload in progress! --}}
{{#if failureMessage}}
<div class="failed">{{failureMessage}}</div>
{{/if}}
<div class="progress-container">
<div class="progress">
<div class="bar {{if failureMessage "fail"}}" style={{progressStyle}}></div>
</div>
</div>
{{#if failureMessage}}
<button class="btn btn-green" {{action "reset"}}>Try Again</button>
{{/if}}
{{else}}
{{#if showUploadForm}}
{{!-- file selection/drag-n-drop --}}
<div class="upload-form">
{{#x-file-input multiple=false alt=text action=(action 'fileSelected') accept="image/gif,image/jpg,image/jpeg,image/png,image/svg+xml"}}
<div class="description">{{text}}</div>
{{/x-file-input}}
</div>
<a class="image-url" {{action 'switchForm' 'url-input'}}>
<i class="icon-link"><span class="hidden">URL</span></i>
</a>
{{else}}
{{!-- URL input --}}
<form class="url-form">
{{one-way-input class="url gh-input" placeholder="http://" value=url update=(action "onInput") onenter=(action "saveUrl")}}
{{#if saveButton}}
<button class="btn btn-blue gh-input" {{action 'saveUrl'}}>Save</button>
{{else}}
<div class="description">{{text}}</div>
{{/if}}
</form>
{{#if canShowUploadForm}}
<a class="image-upload icon-photos" title="Add image" {{action 'switchForm' 'upload'}}>
<span class="hidden">Upload</span>
</a>
{{/if}}
{{/if}}
{{/if}}

View File

@ -9,7 +9,11 @@
{{/if}} {{/if}}
</div> </div>
<div class="settings-menu-content"> <div class="settings-menu-content">
{{gh-uploader uploaded="setCoverImage" canceled="clearCoverImage" description="Add tag image" image=tag.image initUploader="setUploaderReference" tagName="section"}} {{gh-image-uploader-with-preview
image=tag.image
text="Add tag image"
update=(action "setCoverImage")
remove=(action "clearCoverImage")}}
<form> <form>
{{#gh-form-group errors=tag.errors hasValidated=tag.hasValidated property="name"}} {{#gh-form-group errors=tag.errors hasValidated=tag.hasValidated property="name"}}
<label for="tag-name">Name</label> <label for="tag-name">Name</label>

View File

@ -1,3 +0,0 @@
<img class="js-upload-target" src="{{imageSource}}" />
<div class="description">{{description}}<strong></strong></div>
<input data-url="upload" class="gh-input js-fileupload main fileupload" type="file" name="uploadimage">

View File

@ -1,8 +1,17 @@
<div class="modal-body"> <div class="modal-body">
<section class="js-drop-zone"> {{#if url}}
<img class="js-upload-target" src="{{imageUrl}}" alt="logo"> <div class="gh-image-uploader --with-image">
<input data-url="upload" class="js-fileupload main" type="file" name="uploadimage" accept="{{acceptEncoding}}"> <div><img src={{url}}></div>
</section> <a class="image-cancel icon-trash" title="Delete" {{action 'removeImage'}}>
<span class="hidden">Delete</span>
</a>
</div>
{{else}}
{{gh-image-uploader image=newUrl
saveButton=false
update=(action 'fileUploaded')
onInput=(action (mut newUrl))}}
{{/if}}
</div> </div>
<div class="modal-footer"> <div class="modal-footer">

View File

@ -6,7 +6,11 @@
<button class="close icon-x settings-menu-header-action" {{action "closeMenus"}}><span class="hidden">Close</span></button> <button class="close icon-x settings-menu-header-action" {{action "closeMenus"}}><span class="hidden">Close</span></button>
</div> </div>
<div class="settings-menu-content"> <div class="settings-menu-content">
{{gh-uploader uploaded="setCoverImage" canceled="clearCoverImage" description="Add post image" image=model.image uploaderReference=uploaderReference tagName="section"}} {{gh-image-uploader-with-preview
image=model.image
text="Add post image"
update=(action "setCoverImage")
remove=(action "clearCoverImage")}}
<form> <form>
<div class="form-group"> <div class="form-group">
<label for="url">Post URL</label> <label for="url">Post URL</label>

View File

@ -12,23 +12,9 @@ function parse(stringToParse) {
return images; return images;
} }
// Loop through all dropzones in the preview and find which one was the target of the upload
function getZoneIndex(element) {
let zones = document.querySelectorAll('.js-entry-preview .js-drop-zone');
for (let i = 0; i < zones.length; i += 1) {
if (zones.item(i) === element) {
return i;
}
}
return -1;
}
// Figure out the start and end of the selection range for the src in the markdown, so we know what to replace // Figure out the start and end of the selection range for the src in the markdown, so we know what to replace
function getSrcRange(content, element) { function getSrcRange(content, index) {
let images = parse(content); let images = parse(content);
let index = getZoneIndex(element);
let replacement = {}; let replacement = {};
if (index > -1) { if (index > -1) {

View File

@ -42,6 +42,7 @@
"ember-export-application-global": "1.0.5", "ember-export-application-global": "1.0.5",
"ember-load-initializers": "0.5.1", "ember-load-initializers": "0.5.1",
"ember-myth": "0.1.1", "ember-myth": "0.1.1",
"ember-one-way-controls": "0.5.3",
"ember-resolver": "2.0.3", "ember-resolver": "2.0.3",
"ember-route-action-helper": "0.3.0", "ember-route-action-helper": "0.3.0",
"ember-simple-auth": "1.0.1", "ember-simple-auth": "1.0.1",
@ -49,6 +50,8 @@
"ember-sortable": "1.7.0", "ember-sortable": "1.7.0",
"ember-suave": "2.0.1", "ember-suave": "2.0.1",
"ember-watson": "0.7.0", "ember-watson": "0.7.0",
"ember-wormhole": "0.3.5",
"emberx-file-input": "1.0.0",
"fs-extra": "0.16.3", "fs-extra": "0.16.3",
"glob": "^4.0.5", "glob": "^4.0.5",
"liquid-fire": "0.23.0", "liquid-fire": "0.23.0",

View File

@ -92,13 +92,13 @@ describe('Acceptance: Settings - General', function () {
click('.blog-logo'); click('.blog-logo');
andThen(() => { andThen(() => {
expect(find('.fullscreen-modal .modal-content .js-drop-zone').length, 'modal selector').to.equal(1); expect(find('.fullscreen-modal .modal-content .gh-image-uploader').length, 'modal selector').to.equal(1);
}); });
click('.fullscreen-modal .modal-content .js-drop-zone .js-cancel'); click('.fullscreen-modal .modal-content .gh-image-uploader .image-cancel');
andThen(() => { andThen(() => {
expect(find('.fullscreen-modal .modal-content .js-drop-zone .description').text()).to.equal('Add image'); expect(find('.fullscreen-modal .modal-content .gh-image-uploader .description').text()).to.equal('Upload an image');
}); });
// click cancel button // click cancel button
@ -111,7 +111,7 @@ describe('Acceptance: Settings - General', function () {
click('.blog-cover'); click('.blog-cover');
andThen(() => { andThen(() => {
expect(find('.fullscreen-modal .modal-content .js-drop-zone').length, 'modal selector').to.equal(1); expect(find('.fullscreen-modal .modal-content .gh-image-uploader').length, 'modal selector').to.equal(1);
}); });
click('.fullscreen-modal .modal-footer .js-button-accept'); click('.fullscreen-modal .modal-footer .js-button-accept');

View File

@ -0,0 +1,239 @@
/* jshint expr:true */
import Ember from 'ember';
import sinon from 'sinon';
import { expect } from 'chai';
import {
describeComponent,
it
} from 'ember-mocha';
import hbs from 'htmlbars-inline-precompile';
import Pretender from 'pretender';
import wait from 'ember-test-helpers/wait';
const {run} = Ember;
const keyCodes = {
enter: 13
};
const configStub = Ember.Service.extend({
fileStorage: true
});
const sessionStub = Ember.Service.extend({
isAuthenticated: false,
authorize(authorizer, block) {
if (this.get('isAuthenticated')) {
block('Authorization', 'Bearer token');
}
}
});
const stubSuccessfulUpload = function (server, delay = 0) {
server.post('/ghost/api/v0.1/uploads/', function () {
return [200, {'Content-Type': 'application/json'}, '"/content/images/test.png"'];
}, delay);
};
const stubFailedUpload = function (server, code, error, delay = 0) {
server.post('/ghost/api/v0.1/uploads/', function () {
return [code, {'Content-Type': 'application/json'}, JSON.stringify({
errors: [{
errorType: error,
message: `Error: ${error}`
}]
})];
}, delay);
};
describeComponent(
'gh-image-upload',
'Integration: Component: gh-image-uploader',
{
integration: true
},
function() {
let server;
beforeEach(function () {
this.register('service:config', configStub);
this.register('service:session', sessionStub);
this.inject.service('config', {as: 'configService'});
this.inject.service('session', {as: 'sessionService'});
this.set('update', function () {});
server = new Pretender();
});
afterEach(function () {
server.shutdown();
});
it('renders', function() {
this.set('image', 'http://example.com/test.png');
this.render(hbs`{{gh-image-uploader image=image}}`);
expect(this.$()).to.have.length(1);
});
it('defaults to upload form', function () {
this.render(hbs`{{gh-image-uploader image=image}}`);
expect(this.$('input[type="file"]').length).to.equal(1);
});
it('defaults to url form with no filestorage config', function () {
this.set('configService.fileStorage', false);
this.render(hbs`{{gh-image-uploader image=image}}`);
expect(this.$('input[type="file"]').length).to.equal(0);
expect(this.$('input[type="text"].url').length).to.equal(1);
});
it('can switch between form types', function () {
this.render(hbs`{{gh-image-uploader image=image}}`);
expect(this.$('input[type="file"]').length).to.equal(1);
expect(this.$('input[type="text"].url').length).to.equal(0);
this.$('a.image-url').click();
expect(this.$('input[type="file"]').length, 'upload form is visible after switch to url form')
.to.equal(0);
expect(this.$('input[type="text"].url').length, 'url form is visible after switch to url form')
.to.equal(1);
this.$('a.image-upload').click();
expect(this.$('input[type="file"]').length, 'upload form is visible after switch to upload form')
.to.equal(1);
expect(this.$('input[type="text"].url').length, 'url form is visible after switch to upload form')
.to.equal(0);
});
it('triggers formChanged action when switching between forms', function () {
let formChanged = sinon.spy();
this.set('formChanged', formChanged);
this.render(hbs`{{gh-image-uploader image=image formChanged=(action formChanged)}}`);
this.$('a.image-url').click();
this.$('a.image-upload').click();
expect(formChanged.calledTwice).to.be.true;
expect(formChanged.firstCall.args[0]).to.equal('url-input');
expect(formChanged.secondCall.args[0]).to.equal('upload');
});
describe('file uploads', function () {
it('renders form with supplied text', function () {
this.render(hbs`{{gh-image-uploader image=image text="text test"}}`);
expect(this.$('.description').text().trim()).to.equal('text test');
});
it('generates request to correct endpoint', function (done) {
stubSuccessfulUpload(server);
this.render(hbs`{{gh-image-uploader image=image update=(action update)}}`);
this.$('input[type="file"]').trigger('change');
wait().then(() => {
expect(server.handledRequests.length).to.equal(1);
expect(server.handledRequests[0].url).to.equal('/ghost/api/v0.1/uploads/');
expect(server.handledRequests[0].requestHeaders.Authorization).to.be.undefined;
done();
});
});
it('adds authentication headers to request', function (done) {
stubSuccessfulUpload(server);
this.get('sessionService').set('isAuthenticated', true);
this.render(hbs`{{gh-image-uploader image=image update=(action update)}}`);
this.$('input[type="file"]').trigger('change');
wait().then(() => {
let [request] = server.handledRequests;
expect(request.requestHeaders.Authorization).to.equal('Bearer token');
done();
});
});
it('handles drag over/leave', function () {
stubSuccessfulUpload(server);
this.render(hbs`{{gh-image-uploader image=image update=(action update)}}`);
run(() => {
this.$('.gh-image-uploader').trigger('dragover');
});
expect(this.$('.gh-image-uploader').hasClass('--drag-over'), 'has drag-over class').to.be.true;
run(() => {
this.$('.gh-image-uploader').trigger('dragleave');
});
expect(this.$('.gh-image-uploader').hasClass('--drag-over'), 'has drag-over class').to.be.false;
});
});
describe('URL input', function () {
beforeEach(function () {
this.set('configService.fileStorage', false);
});
it('displays save button by default', function () {
this.set('image', 'http://example.com/test.png');
this.render(hbs`{{gh-image-uploader image=image text="text test"}}`);
expect(this.$('button').length).to.equal(1);
expect(this.$('input[type="text"]').val()).to.equal('http://example.com/test.png');
});
it('can render without a save button', function () {
this.render(hbs`{{gh-image-uploader image=image saveButton=false text="text test"}}`);
expect(this.$('button').length).to.equal(0);
expect(this.$('.description').text().trim()).to.equal('text test');
});
it('fires update action when save button clicked', function () {
let update = sinon.spy();
this.set('update', update);
this.render(hbs`{{gh-image-uploader image=image update=(action update)}}`);
this.$('input[type="text"]').val('saved url');
this.$('input[type="text"]').change();
this.$('button.btn-blue').click();
expect(update.calledOnce).to.be.true;
expect(update.firstCall.args[0]).to.equal('saved url');
});
it('fires onInput action when typing URL', function () {
let onInput = sinon.spy();
this.set('onInput', onInput);
this.render(hbs`{{gh-image-uploader image=image onInput=(action onInput)}}`);
this.$('input[type="text"]').val('input url');
this.$('input[type="text"]').change();
expect(onInput.calledOnce).to.be.true;
expect(onInput.firstCall.args[0]).to.equal('input url');
});
it('saves on enter key', function () {
let update = sinon.spy();
this.set('update', update);
this.render(hbs`{{gh-image-uploader image=image update=(action update)}}`);
this.$('input[type="text"]').val('saved url');
this.$('input[type="text"]').change();
this.$('input[type="text"]').trigger(
$.Event('keyup', {keyCode: keyCodes.enter, which: keyCodes.enter})
);
expect(update.calledOnce).to.be.true;
expect(update.firstCall.args[0]).to.equal('saved url');
});
});
}
);

View File

@ -0,0 +1,48 @@
/* jshint expr:true */
import { expect } from 'chai';
import {
describeComponent,
it
} from 'ember-mocha';
import hbs from 'htmlbars-inline-precompile';
import Ember from 'ember';
import sinon from 'sinon';
const {run} = Ember;
describeComponent(
'gh-image-uploader-with-preview',
'Integration: Component: gh-image-uploader-with-preview',
{
integration: true
},
function() {
it('renders image if provided', function() {
this.set('image', 'http://example.com/test.png');
this.render(hbs`{{gh-image-uploader-with-preview image=image}}`);
expect(this.$('.gh-image-uploader.--with-image').length).to.equal(1);
expect(this.$('img').attr('src')).to.equal('http://example.com/test.png');
});
it('renders upload form when no image provided', function () {
this.render(hbs`{{gh-image-uploader-with-preview image=image}}`);
expect(this.$('input[type="file"]').length).to.equal(1);
});
it('triggers remove action when delete icon is clicked', function () {
let remove = sinon.spy();
this.set('remove', remove);
this.set('image', 'http://example.com/test.png');
this.render(hbs`{{gh-image-uploader-with-preview image=image remove=remove}}`);
run(() => {
this.$('.icon-trash').click();
});
expect(remove.calledOnce).to.be.true;
});
}
);

View File

@ -75,7 +75,7 @@ describeComponent(
{{gh-tag-settings-form tag=tag setProperty=(action 'setProperty')}} {{gh-tag-settings-form tag=tag setProperty=(action 'setProperty')}}
`); `);
expect(this.$('.image-uploader').length, 'displays image uploader').to.equal(1); expect(this.$('.gh-image-uploader').length, 'displays image uploader').to.equal(1);
expect(this.$('input[name="name"]').val(), 'name field value').to.equal('Test'); expect(this.$('input[name="name"]').val(), 'name field value').to.equal('Test');
expect(this.$('input[name="slug"]').val(), 'slug field value').to.equal('test'); expect(this.$('input[name="slug"]').val(), 'slug field value').to.equal('test');
expect(this.$('textarea[name="description"]').val(), 'description field value').to.equal('Description.'); expect(this.$('textarea[name="description"]').val(), 'description field value').to.equal('Description.');

View File

@ -5,11 +5,12 @@ import {
} from 'ember-mocha'; } from 'ember-mocha';
import Pretender from 'pretender'; import Pretender from 'pretender';
import {AjaxError, UnauthorizedError} from 'ember-ajax/errors'; import {AjaxError, UnauthorizedError} from 'ember-ajax/errors';
import {RequestEntityTooLargeError, UnsupportedMediaTypeError} from 'ghost/services/ajax';
function stubAjaxEndpoint(server, response) { function stubAjaxEndpoint(server, response = {}, code = 500) {
server.get('/test/', function () { server.get('/test/', function () {
return [ return [
500, code,
{'Content-Type': 'application/json'}, {'Content-Type': 'application/json'},
JSON.stringify(response) JSON.stringify(response)
]; ];
@ -89,13 +90,7 @@ describeModule(
}); });
it('returns known error object for built-in errors', function (done) { it('returns known error object for built-in errors', function (done) {
server.get('/test/', function () { stubAjaxEndpoint(server, '', 401);
return [
401,
{'Content-Type': 'application/json'},
''
];
});
let ajax = this.subject(); let ajax = this.subject();
@ -106,5 +101,31 @@ describeModule(
done(); done();
}); });
}); });
it('returns RequestEntityTooLargeError object for 413 errors', function (done) {
stubAjaxEndpoint(server, {}, 413);
let ajax = this.subject();
ajax.request('/test/').then(() => {
expect(false).to.be.true;
}).catch((error) => {
expect(error).to.be.instanceOf(RequestEntityTooLargeError);
done();
});
});
it('returns UnsupportedMediaTypeError object for 415 errors', function (done) {
stubAjaxEndpoint(server, {}, 415);
let ajax = this.subject();
ajax.request('/test/').then(() => {
expect(false).to.be.true;
}).catch((error) => {
expect(error).to.be.instanceOf(UnsupportedMediaTypeError);
done();
});
});
} }
); );

View File

@ -0,0 +1,343 @@
/* jshint expr:true */
/* global Blob */
import { expect } from 'chai';
import {
describeComponent,
it
} from 'ember-mocha';
import Ember from 'ember';
import sinon from 'sinon';
import Pretender from 'pretender';
import wait from 'ember-test-helpers/wait';
const {run} = Ember;
const createFile = function (content = ['test'], options = {}) {
let {
name,
type,
lastModifiedDate
} = options;
let file = new Blob(content, {type: type ? type : 'text/plain'});
file.name = name ? name : 'text.txt';
return file;
};
const stubSuccessfulUpload = function (server, delay = 0) {
server.post('/ghost/api/v0.1/uploads/', function () {
return [200, {'Content-Type': 'application/json'}, '"/content/images/test.png"'];
}, delay);
};
const stubFailedUpload = function (server, code, error, delay = 0) {
server.post('/ghost/api/v0.1/uploads/', function () {
return [code, {'Content-Type': 'application/json'}, JSON.stringify({
errors: [{
errorType: error,
message: `Error: ${error}`
}]
})];
}, delay);
};
describeComponent(
'gh-image-uploader',
'Unit: Component: gh-image-uploader',
{
needs: [
'service:config',
'service:session',
'service:ajax',
'component:x-file-input',
'component:one-way-input'
],
unit: true
},
function() {
let server;
beforeEach(function () {
server = new Pretender();
});
afterEach(function () {
server.shutdown();
});
it('renders', function() {
// creates the component instance
let component = this.subject();
// renders the component on the page
this.render();
expect(component).to.be.ok;
expect(this.$()).to.have.length(1);
});
it('fires update action on successful upload', function (done) {
let component = this.subject();
let update = sinon.spy();
let file = createFile();
stubSuccessfulUpload(server);
this.render();
component.attrs.update = update;
run(() => {
component.send('fileSelected', [file]);
});
wait().then(() => {
expect(update.calledOnce).to.be.true;
expect(update.firstCall.args[0]).to.equal('/content/images/test.png');
done();
});
});
it('fires uploadStarted action on upload start', function (done) {
let component = this.subject();
let uploadStarted = sinon.spy();
let file = createFile();
stubSuccessfulUpload(server);
this.render();
component.attrs.update = () => {};
component.attrs.uploadStarted = uploadStarted;
run(() => {
component.send('fileSelected', [file]);
});
wait().then(() => {
expect(uploadStarted.calledOnce).to.be.true;
done();
});
});
it('fires uploadFinished action on successful upload', function (done) {
let component = this.subject();
let uploadFinished = sinon.spy();
let file = createFile();
stubSuccessfulUpload(server);
this.render();
component.attrs.update = () => {};
component.attrs.uploadFinished = uploadFinished;
run(() => {
component.send('fileSelected', [file]);
});
wait().then(() => {
expect(uploadFinished.calledOnce).to.be.true;
done();
});
});
it('fires uploadFinished action on failed upload', function (done) {
let component = this.subject();
let uploadFinished = sinon.spy();
let file = createFile();
stubFailedUpload(server);
this.render();
component.attrs.update = () => {};
component.attrs.uploadFinished = uploadFinished;
run(() => {
component.send('fileSelected', [file]);
});
wait().then(() => {
expect(uploadFinished.calledOnce).to.be.true;
done();
});
});
it('displays invalid file type error', function (done) {
let component = this.subject();
let file = createFile();
stubFailedUpload(server, 415, 'UnsupportedMediaTypeError');
this.render();
component.attrs.update = () => {};
run(() => {
component.send('fileSelected', [file]);
});
wait().then(() => {
expect(this.$('.failed').length, 'error message is displayed').to.equal(1);
expect(this.$('.failed').text()).to.match(/The image type you uploaded is not supported/);
expect(this.$('.btn-green').length, 'reset button is displayed').to.equal(1);
expect(this.$('.btn-green').text()).to.equal('Try Again');
done();
});
});
it('displays file too large for server error', function (done) {
let component = this.subject();
let file = createFile();
stubFailedUpload(server, 413, 'RequestEntityTooLargeError');
this.render();
component.attrs.update = () => {};
run(() => {
component.send('fileSelected', [file]);
});
wait().then(() => {
expect(this.$('.failed').length, 'error message is displayed').to.equal(1);
expect(this.$('.failed').text()).to.match(/The image you uploaded was larger/);
done();
});
});
it('handles file too large error directly from the web server', function (done) {
let component = this.subject();
let file = createFile();
server.post('/ghost/api/v0.1/uploads/', function () {
return [413, {}, ''];
});
this.render();
component.attrs.update = () => {};
run(() => {
component.send('fileSelected', [file]);
});
wait().then(() => {
expect(this.$('.failed').length, 'error message is displayed').to.equal(1);
expect(this.$('.failed').text()).to.match(/The image you uploaded was larger/);
done();
});
});
it('displays other server-side error with message', function (done) {
let component = this.subject();
let file = createFile();
stubFailedUpload(server, 400, 'UnknownError');
this.render();
component.attrs.update = () => {};
run(() => {
component.send('fileSelected', [file]);
});
wait().then(() => {
expect(this.$('.failed').length, 'error message is displayed').to.equal(1);
expect(this.$('.failed').text()).to.match(/Error: UnknownError/);
done();
});
});
it('handles unknown failure', function (done) {
let component = this.subject();
let file = createFile();
server.post('/ghost/api/v0.1/uploads/', function () {
return [500, {'Content-Type': 'application/json'}, ''];
});
this.render();
component.attrs.update = () => {};
run(() => {
component.send('fileSelected', [file]);
});
wait().then(() => {
expect(this.$('.failed').length, 'error message is displayed').to.equal(1);
expect(this.$('.failed').text()).to.match(/Something went wrong/);
done();
});
});
it('can be reset after a failed upload', function (done) {
let component = this.subject();
let file = createFile();
stubFailedUpload(server, 400, 'UnknownError');
this.render();
component.attrs.update = () => {};
run(() => {
component.send('fileSelected', [file]);
});
wait().then(() => {
run(() => {
this.$('.btn-green').click();
});
});
wait().then(() => {
expect(this.$('input[type="file"]').length).to.equal(1);
done();
});
});
it('displays upload progress', function (done) {
let component = this.subject();
let file = createFile();
// pretender fires a progress event every 50ms
stubSuccessfulUpload(server, 150);
this.render();
component.attrs.update = () => {};
component.attrs.uploadFinished = done;
run(() => {
component.send('fileSelected', [file]);
});
// after 75ms we should have had one progress event
run.later(this, function () {
expect(this.$('.progress .bar').length).to.equal(1);
let [_, percentageWidth] = this.$('.progress .bar').attr('style').match(/width: (\d+)%?/);
expect(percentageWidth).to.be.above(0);
expect(percentageWidth).to.be.below(100);
}, 75);
});
it('triggers file upload on file drop', function (done) {
let component = this.subject();
let file = createFile();
let update = sinon.spy();
let drop = Ember.$.Event('drop', {
dataTransfer: {
files: [file]
}
});
stubSuccessfulUpload(server);
this.render();
component.attrs.update = update;
run(() => {
this.$().trigger(drop);
});
wait().then(() => {
expect(update.calledOnce).to.be.true;
expect(update.firstCall.args[0]).to.equal('/content/images/test.png');
done();
});
});
}
);