streamline image uploads in settings/general (#702)

refs TryGhost/Ghost#8455

- ensure `uploadUrls` and `errors` are cleared in `gh-uploader` when new uploads are started
- yield `isUploading` in `gh-uploader` component
- replace image upload modals in settings/general with in-page uploads
This commit is contained in:
Kevin Ansfield 2017-05-23 09:50:04 +01:00 committed by Katharina Irrgang
parent e8ff4ac1dd
commit 8d66430c2a
10 changed files with 456 additions and 74 deletions

View File

@ -18,6 +18,13 @@ import run from 'ember-runloop';
// "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,
@ -153,6 +160,7 @@ export default Component.extend({
_uploadFiles: task(function* (files) {
let uploads = [];
this._reset();
this.onStart();
// NOTE: for...of loop results in a transpilation that errors in Edge,
@ -239,7 +247,7 @@ export default Component.extend({
// 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);
formData.append(this.get('paramName'), file, file.name);
return formData;
},
@ -269,10 +277,11 @@ export default Component.extend({
},
_reset() {
this.set('errors', null);
this.set('errors', []);
this.set('totalSize', 0);
this.set('uploadedSize', 0);
this.set('uploadPercentage', 0);
this.set('uploadUrls', []);
this._uploadTrackers = [];
},

View File

@ -5,25 +5,26 @@ import observer from 'ember-metal/observer';
import run from 'ember-runloop';
import randomPassword from 'ghost-admin/utils/random-password';
import {task} from 'ember-concurrency';
import {
IMAGE_MIME_TYPES,
IMAGE_EXTENSIONS
} from 'ghost-admin/components/gh-image-uploader';
export default Controller.extend({
availableTimezones: null,
showUploadLogoModal: false,
showUploadCoverModal: false,
showUploadIconModal: false,
config: injectService(),
ghostPaths: injectService(),
notifications: injectService(),
session: injectService(),
availableTimezones: null,
iconExtensions: ['ico', 'png'],
iconMimeTypes: 'image/png,image/x-icon',
imageExtensions: IMAGE_EXTENSIONS,
imageMimeTypes: IMAGE_MIME_TYPES,
_scratchFacebook: null,
_scratchTwitter: null,
iconMimeTypes: 'image/png,image/x-icon',
iconExtensions: ['ico', 'png'],
isDatedPermalinks: computed('model.permalinks', {
set(key, value) {
this.set('model.permalinks', value ? '/:year/:month/:day/:slug/' : '/:slug/');
@ -89,16 +90,48 @@ export default Controller.extend({
this.set('model.activeTimezone', timezone.name);
},
toggleUploadCoverModal() {
this.toggleProperty('showUploadCoverModal');
removeImage(image) {
// setting `null` here will error as the server treats it as "null"
this.get('model').set(image, '');
},
toggleUploadLogoModal() {
this.toggleProperty('showUploadLogoModal');
/**
* Opens a file selection dialog - Triggered by "Upload Image" buttons,
* searches for the hidden file input within the .gh-setting element
* containing the clicked button then simulates a click
* @param {MouseEvent} event - MouseEvent fired by the button click
*/
triggerFileDialog(event) {
let fileInput = event.target
.closest('.gh-setting')
.querySelector('input[type="file"]');
if (fileInput) {
let click = new MouseEvent('click', {
view: window,
bubbles: true,
cancelable: true
});
// reset file input value before clicking so that the same image
// can be selected again
fileInput.value = '';
// simulate click to open file dialog
fileInput.dispatchEvent(click);
}
},
toggleUploadIconModal() {
this.toggleProperty('showUploadIconModal');
/**
* Fired after an image upload completes
* @param {string} property - Property name to be set on `this.model`
* @param {UploadResult[]} results - Array of UploadResult objects
* @return {string} The URL that was set on `this.model.property`
*/
imageUploaded(property, results) {
if (results[0]) {
return this.get('model').set(property, results[0].url);
}
},
validateFacebookUrl() {

View File

@ -33,6 +33,14 @@
letter-spacing: 0.3px;
}
.gh-setting-error {
margin-top: 1em;
line-height: 1.3em;
color: var(--red);
font-weight: 200;
letter-spacing: 0.3px;
}
.gh-setting-action {
flex-shrink: 0;
margin: 1px 0 0 0;
@ -40,6 +48,10 @@
/* Images */
.gh-setting-action-smallimg {
position: relative;
}
.gh-setting-action-smallimg img {
height: 50px;
width: auto;
@ -57,6 +69,36 @@
cursor: pointer;
}
.gh-setting-action-smallimg-delete,
.gh-setting-action-largeimg-delete {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
color: var(--midgrey);
text-decoration: none;
font-size: 13px;
line-height: 10px;
}
.gh-setting-action-smallimg-delete:hover,
.gh-setting-action-largeimg-delete:hover {
color: var(--red);
text-decoration: underline;
}
.gh-setting-action .gh-progress-container {
width: 113px;
height: 100%;
}
.gh-setting-action .gh-progress-container-progress {
width: 100%;
}
.gh-setting-action .gh-progress-bar {
height: 9px;
}
/* Checkboxes */

View File

@ -1,6 +1,7 @@
{{yield (hash
progressBar=(component "gh-progress-bar" percentage=uploadPercentage)
files=files
errors=errors
cancel=(action "cancel")
errors=errors
files=files
isUploading=_uploadFiles.isRunning
progressBar=(component "gh-progress-bar" percentage=uploadPercentage)
)}}

View File

@ -52,65 +52,111 @@
</div>
<div class="gh-setting-header">Publication identity</div>
<div class="gh-setting">
<div class="gh-setting" data-test-setting="icon">
{{#gh-uploader
files=iconUpload
extensions=iconExtensions
uploadUrl="/uploads/icon/"
onComplete=(action "imageUploaded" "icon")
as |uploader|
}}
<div class="gh-setting-content">
<div class="gh-setting-title">Publication icon</div>
<div class="gh-setting-desc">A square, social icon used in the UI of your publication, at least 60x60px</div>
{{#if uploader.errors}}
{{#each uploader.errors as |error|}}
<div class="gh-setting-error" data-test-error="icon">{{error.message}}</div>
{{/each}}
{{/if}}
</div>
<div class="gh-setting-action gh-setting-action-smallimg">
{{#if model.icon}}
<img class="blog-icon" src="{{model.icon}}" alt="icon" role="button" {{action "toggleUploadIconModal"}}>
<img class="blog-icon" src="{{model.icon}}" alt="icon" data-test-icon-img>
<button type="button" class="gh-setting-action-smallimg-delete" {{action "removeImage" "icon"}} data-test-delete-image="icon">
<span>delete</span>
</button>
{{else if uploader.isUploading}}
{{uploader.progressBar}}
{{else}}
<button type="button" class="gh-btn" {{action "toggleUploadIconModal"}}><span>Upload Image</span></button>
{{/if}}
{{#if showUploadIconModal}}
{{gh-fullscreen-modal "upload-image"
model=(hash model=model imageProperty="icon" accept=iconMimeTypes extensions=iconExtensions uploadUrl="/uploads/icon/")
close=(action "toggleUploadIconModal")
modifier="action wide"}}
<button type="button" class="gh-btn" onClick={{action "triggerFileDialog"}} data-test-image-upload-btn="icon">
<span>Upload Image</span>
</button>
{{/if}}
<div style="display:none">
{{gh-file-input multiple=false action=(action (mut iconUpload)) accept=iconMimeTypes data-test-file-input="icon"}}
</div>
</div>
{{/gh-uploader}}
</div>
<div class="gh-setting">
<div class="gh-setting" data-test-setting="logo">
{{#gh-uploader
files=logoUpload
extensions=imageExtensions
onComplete=(action "imageUploaded" "logo")
as |uploader|
}}
<div class="gh-setting-content">
<div class="gh-setting-title">Publication logo</div>
<div class="gh-setting-desc">The primary logo for your brand displayed across your theme, should be transparent and at least 600px x 72px</div>
{{#if uploader.errors}}
{{#each uploader.errors as |error|}}
<div class="gh-setting-error" data-test-error="logo">{{error.message}}</div>
{{/each}}
{{/if}}
</div>
<div class="gh-setting-action gh-setting-action-smallimg">
{{#if model.logo}}
<img class="blog-logo" src="{{model.logo}}" alt="logo" role="button" {{action "toggleUploadLogoModal"}}>
<img class="blog-logo" src="{{model.logo}}" alt="logo" data-test-logo-img>
<button type="button" class="gh-setting-action-smallimg-delete" {{action "removeImage" "logo"}} data-test-delete-image="logo">
<span>delete</span>
</button>
{{else if uploader.isUploading}}
{{uploader.progressBar}}
{{else}}
<button type="button" class="gh-btn" {{action "toggleUploadLogoModal"}}><span>Upload Image</span></button>
{{/if}}
{{#if showUploadLogoModal}}
{{gh-fullscreen-modal "upload-image"
model=(hash model=model imageProperty="logo")
close=(action "toggleUploadLogoModal")
modifier="action wide"}}
<button type="button" class="gh-btn" onClick={{action "triggerFileDialog"}} data-test-image-upload-btn="logo">
<span>Upload Image</span>
</button>
{{/if}}
<div style="display:none">
{{gh-file-input multiple=false action=(action (mut logoUpload)) accept=imageMimeTypes data-test-file-input="logo"}}
</div>
</div>
{{/gh-uploader}}
</div>
<div class="gh-setting">
<div class="gh-setting" data-test-setting="coverImage">
{{#gh-uploader
files=coverImageUpload
extensions=imageExtensions
onComplete=(action "imageUploaded" "coverImage")
as |uploader|
}}
<div class="gh-setting-content">
<div class="gh-setting-title">Publication cover</div>
<div class="gh-setting-desc">An optional large background image for your site</div>
{{#if uploader.errors}}
{{#each uploader.errors as |error|}}
<div class="gh-setting-error" data-test-error="coverImage">{{error.message}}</div>
{{/each}}
{{/if}}
</div>
<div class="gh-setting-action gh-setting-action-largeimg">
{{#if model.coverImage}}
<img class="blog-cover" src="{{model.coverImage}}" alt="cover photo" role="button" {{action "toggleUploadCoverModal"}}>
<img class="blog-cover" src="{{model.coverImage}}" alt="cover photo" data-test-cover-img>
<button type="button" class="gh-setting-action-largeimg-delete" {{action "removeImage" "coverImage"}} data-test-delete-image="coverImage">
<span>delete</span>
</button>
{{else if uploader.isUploading}}
{{uploader.progressBar}}
{{else}}
<button type="button" class="gh-btn" {{action "toggleUploadCoverModal"}}><span>Upload Image</span></button>
{{/if}}
{{#if showUploadCoverModal}}
{{gh-fullscreen-modal "upload-image"
model=(hash model=model imageProperty="coverImage")
close=(action "toggleUploadCoverModal")
modifier="action wide"}}
<button type="button" class="gh-btn" onClick={{action "triggerFileDialog"}} data-test-image-upload-btn="coverImage">
<span>Upload Image</span>
</button>
{{/if}}
<div style="display:none">
{{gh-file-input multiple=false action=(action (mut coverImageUpload)) accept=imageMimeTypes data-test-file-input="coverImage"}}
</div>
</div>
{{/gh-uploader}}
</div>
<div class="gh-setting-header">Social accounts</div>

View File

@ -8,6 +8,7 @@ import mockSlugs from './config/slugs';
import mockSubscribers from './config/subscribers';
import mockTags from './config/tags';
import mockThemes from './config/themes';
import mockUploads from './config/uploads';
import mockUsers from './config/users';
// import {versionMismatchResponse} from 'utils';
@ -48,13 +49,14 @@ export function testConfig() {
mockSubscribers(this);
mockTags(this);
mockThemes(this);
mockUploads(this);
mockUsers(this);
/* Notifications -------------------------------------------------------- */
this.get('/notifications/');
/* Apps - Slack Test Notification --------------------------------------------------------- */
/* Apps - Slack Test Notification --------------------------------------- */
this.post('/slack/test', function () {
return {};

View File

@ -0,0 +1,17 @@
const fileUploadResponse = function (db, {requestBody}) {
let [file] = requestBody.getAll('uploadimage');
let now = new Date();
let year = now.getFullYear();
let month = `${now.getMonth()}`;
if (month.length === 1) {
month = `0${month}`;
}
return `"/content/images/${year}/${month}/${file.name}"`;
};
export default function mockUploads(server) {
server.post('/uploads/', fileUploadResponse, 200, {timing: 100});
server.post('/uploads/icon/', fileUploadResponse, 200, {timing: 100});
}

View File

@ -12,6 +12,9 @@ import startApp from '../../helpers/start-app';
import destroyApp from '../../helpers/destroy-app';
import {invalidateSession, authenticateSession} from 'ghost-admin/tests/helpers/ember-simple-auth';
import ctrlOrCmd from 'ghost-admin/utils/ctrl-or-cmd';
import wait from 'ember-test-helpers/wait';
import run from 'ember-runloop';
import mockUploads from '../../../mirage/config/uploads';
describe('Acceptance: Settings - General', function () {
let application;
@ -59,7 +62,7 @@ describe('Acceptance: Settings - General', function () {
return authenticateSession(application);
});
it('it renders, shows image uploader modals', async function () {
it('it renders, handles image uploads', async function () {
await visit('/settings/general');
// has correct url
@ -72,42 +75,230 @@ describe('Acceptance: Settings - General', function () {
expect($('.gh-nav-settings-general').hasClass('active'), 'highlights nav menu item')
.to.be.true;
expect(find(testSelector('save-button')).text().trim(), 'save button text').to.equal('Save settings');
expect(
find(testSelector('save-button')).text().trim(),
'save button text'
).to.equal('Save settings');
expect(find(testSelector('dated-permalinks-checkbox')).prop('checked'), 'date permalinks checkbox').to.be.false;
expect(
find(testSelector('dated-permalinks-checkbox')).prop('checked'),
'date permalinks checkbox'
).to.be.false;
await click(testSelector('toggle-pub-info'));
await fillIn(testSelector('title-input'), 'New Blog Title');
await click(testSelector('save-button'));
expect(document.title, 'page title').to.equal('Settings - General - New Blog Title');
await click('.blog-logo');
expect(find('.fullscreen-modal .modal-content .gh-image-uploader').length, 'modal selector').to.equal(1);
// blog icon upload
// -------------------------------------------------------------- //
await click('.fullscreen-modal .modal-content .gh-image-uploader .image-cancel');
expect(find(testSelector('file-input-description')).text()).to.equal('Upload an image');
// has fixture icon
expect(
find(testSelector('icon-img')).attr('src'),
'initial icon src'
).to.equal('/content/images/2014/Feb/favicon.ico');
// click cancel button
await click('.fullscreen-modal .modal-footer .gh-btn');
expect(find('.fullscreen-modal').length).to.equal(0);
// delete removes icon + shows button
await click(testSelector('delete-image', 'icon'));
expect(
find(testSelector('icon-img')),
'icon img after removal'
).to.not.exist;
expect(
find(testSelector('image-upload-btn', 'icon')),
'icon upload button after removal'
).to.exist;
await click('.blog-icon');
expect(find('.fullscreen-modal .modal-content .gh-image-uploader').length, 'modal selector').to.equal(1);
// select file
fileUpload(
testSelector('file-input', 'icon'),
['test'],
{name: 'pub-icon.ico', type: 'image/x-icon'}
);
await click('.fullscreen-modal .modal-content .gh-image-uploader .image-cancel');
expect(find(testSelector('file-input-description')).text()).to.equal('Upload an image');
// check progress bar exists during upload
run.later(() => {
expect(
find(`${testSelector('setting', 'icon')} ${testSelector('progress-bar')}`),
'icon upload progress bar'
).to.exist;
}, 50);
// click cancel button
await click('.fullscreen-modal .modal-footer .gh-btn');
expect(find('.fullscreen-modal').length).to.equal(0);
// wait for upload to finish and check image is shown
await wait();
expect(
find(testSelector('icon-img')).attr('src'),
'icon img after upload'
).to.match(/pub-icon\.ico$/);
expect(
find(testSelector('image-upload-btn', 'icon')),
'icon upload button after upload'
).to.not.exist;
await click('.blog-cover');
expect(find('.fullscreen-modal .modal-content .gh-image-uploader').length, 'modal selector').to.equal(1);
// failed upload shows error
server.post('/uploads/icon/', function () {
return {
errors: [{
errorType: 'ValidationError',
message: 'Wrong icon size'
}]
};
}, 422);
await click(testSelector('delete-image', 'icon'));
await fileUpload(
testSelector('file-input', 'icon'),
['test'],
{name: 'pub-icon.ico', type: 'image/x-icon'}
);
expect(
find(testSelector('error', 'icon')).text().trim(),
'failed icon upload message'
).to.equal('Wrong icon size');
await click(testSelector('modal-accept-button'));
expect(find('.fullscreen-modal').length).to.equal(0);
// reset upload endpoints
mockUploads(server);
// blog logo upload
// -------------------------------------------------------------- //
// has fixture icon
expect(
find(testSelector('logo-img')).attr('src'),
'initial logo src'
).to.equal('/content/images/2013/Nov/logo.png');
// delete removes logo + shows button
await click(testSelector('delete-image', 'logo'));
expect(
find(testSelector('logo-img')),
'logo img after removal'
).to.not.exist;
expect(
find(testSelector('image-upload-btn', 'logo')),
'logo upload button after removal'
).to.exist;
// select file
fileUpload(
testSelector('file-input', 'logo'),
['test'],
{name: 'pub-logo.png', type: 'image/png'}
);
// check progress bar exists during upload
run.later(() => {
expect(
find(`${testSelector('setting', 'logo')} ${testSelector('progress-bar')}`),
'logo upload progress bar'
).to.exist;
}, 50);
// wait for upload to finish and check image is shown
await wait();
expect(
find(testSelector('logo-img')).attr('src'),
'logo img after upload'
).to.match(/pub-logo\.png$/);
expect(
find(testSelector('image-upload-btn', 'logo')),
'logo upload button after upload'
).to.not.exist;
// failed upload shows error
server.post('/uploads/', function () {
return {
errors: [{
errorType: 'ValidationError',
message: 'Wrong logo size'
}]
};
}, 422);
await click(testSelector('delete-image', 'logo'));
await fileUpload(
testSelector('file-input', 'logo'),
['test'],
{name: 'pub-logo.png', type: 'image/png'}
);
expect(
find(testSelector('error', 'logo')).text().trim(),
'failed logo upload message'
).to.equal('Wrong logo size');
// reset upload endpoints
mockUploads(server);
// blog cover upload
// -------------------------------------------------------------- //
// has fixture icon
expect(
find(testSelector('cover-img')).attr('src'),
'initial coverImage src'
).to.equal('/content/images/2014/Feb/cover.jpg');
// delete removes coverImage + shows button
await click(testSelector('delete-image', 'coverImage'));
expect(
find(testSelector('coverImage-img')),
'coverImage img after removal'
).to.not.exist;
expect(
find(testSelector('image-upload-btn', 'coverImage')),
'coverImage upload button after removal'
).to.exist;
// select file
fileUpload(
testSelector('file-input', 'coverImage'),
['test'],
{name: 'pub-coverImage.png', type: 'image/png'}
);
// check progress bar exists during upload
run.later(() => {
expect(
find(`${testSelector('setting', 'coverImage')} ${testSelector('progress-bar')}`),
'coverImage upload progress bar'
).to.exist;
}, 50);
// wait for upload to finish and check image is shown
await wait();
expect(
find(testSelector('cover-img')).attr('src'),
'coverImage img after upload'
).to.match(/pub-coverImage\.png$/);
expect(
find(testSelector('image-upload-btn', 'coverImage')),
'coverImage upload button after upload'
).to.not.exist;
// failed upload shows error
server.post('/uploads/', function () {
return {
errors: [{
errorType: 'ValidationError',
message: 'Wrong coverImage size'
}]
};
}, 422);
await click(testSelector('delete-image', 'coverImage'));
await fileUpload(
testSelector('file-input', 'coverImage'),
['test'],
{name: 'pub-coverImage.png', type: 'image/png'}
);
expect(
find(testSelector('error', 'coverImage')).text().trim(),
'failed coverImage upload message'
).to.equal('Wrong coverImage size');
// reset upload endpoints
mockUploads(server);
// CMD-S shortcut works
// -------------------------------------------------------------- //
await fillIn(testSelector('title-input'), 'CMD-S Test');
await triggerEvent('.gh-app', 'keydown', {
keyCode: 83, // s

View File

@ -30,6 +30,6 @@ export default Test.registerAsyncHelper('fileUpload', function(app, selector, co
return triggerEvent(
selector,
'change',
{foor: 'bar', testingFiles: [file]}
{testingFiles: [file]}
);
});

View File

@ -118,6 +118,26 @@ describe('Integration: Component: gh-uploader', function() {
expect(result[1].url).to.equal('/content/images/test.png');
});
it('onComplete only passes results for last upload', async function () {
this.set('uploadsFinished', sinon.spy());
this.render(hbs`{{#gh-uploader files=files onComplete=(action uploadsFinished)}}{{/gh-uploader}}`);
this.set('files', [
createFile(['test'], {name: 'file1.png'})
]);
await wait();
this.set('files', [
createFile(['test'], {name: 'file2.png'})
]);
await wait();
let [results] = this.get('uploadsFinished').getCall(1).args;
expect(results.length).to.equal(1);
expect(results[0].fileName).to.equal('file2.png');
});
it('doesn\'t allow new files to be set whilst uploading', async function () {
let errorSpy = sinon.spy(console, 'error');
stubSuccessfulUpload(server, 100);
@ -142,6 +162,27 @@ describe('Integration: Component: gh-uploader', function() {
errorSpy.restore();
});
it('yields isUploading whilst upload is in progress', async function () {
stubSuccessfulUpload(server, 200);
this.render(hbs`
{{#gh-uploader files=files as |uploader|}}
{{#if uploader.isUploading}}
<div class="is-uploading-test"></div>
{{/if}}
{{/gh-uploader}}`);
this.set('files', [createFile(), createFile()]);
run.later(() => {
expect(find('.is-uploading-test')).to.exist;
}, 100);
await wait();
expect(find('.is-uploading-test')).to.not.exist;
});
it('yields progressBar component with total upload progress', async function () {
stubSuccessfulUpload(server, 200);