diff --git a/ghost/admin/app/components/gh-uploader.js b/ghost/admin/app/components/gh-uploader.js index 62cd691a62..00d3a93210 100644 --- a/ghost/admin/app/components/gh-uploader.js +++ b/ghost/admin/app/components/gh-uploader.js @@ -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 = []; }, diff --git a/ghost/admin/app/controllers/settings/general.js b/ghost/admin/app/controllers/settings/general.js index e2a19990e6..80a0bb5057 100644 --- a/ghost/admin/app/controllers/settings/general.js +++ b/ghost/admin/app/controllers/settings/general.js @@ -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() { diff --git a/ghost/admin/app/styles/layouts/settings.css b/ghost/admin/app/styles/layouts/settings.css index 8f84e6eac0..377a337332 100644 --- a/ghost/admin/app/styles/layouts/settings.css +++ b/ghost/admin/app/styles/layouts/settings.css @@ -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 */ diff --git a/ghost/admin/app/templates/components/gh-uploader.hbs b/ghost/admin/app/templates/components/gh-uploader.hbs index 5725cd698e..564b02ae1f 100644 --- a/ghost/admin/app/templates/components/gh-uploader.hbs +++ b/ghost/admin/app/templates/components/gh-uploader.hbs @@ -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) )}} diff --git a/ghost/admin/app/templates/settings/general.hbs b/ghost/admin/app/templates/settings/general.hbs index ee0470648a..8e0c6da53f 100644 --- a/ghost/admin/app/templates/settings/general.hbs +++ b/ghost/admin/app/templates/settings/general.hbs @@ -52,65 +52,111 @@
Publication identity
-
+
+ {{#gh-uploader + files=iconUpload + extensions=iconExtensions + uploadUrl="/uploads/icon/" + onComplete=(action "imageUploaded" "icon") + as |uploader| + }}
Publication icon
A square, social icon used in the UI of your publication, at least 60x60px
+ {{#if uploader.errors}} + {{#each uploader.errors as |error|}} +
{{error.message}}
+ {{/each}} + {{/if}}
{{#if model.icon}} - icon + icon + + {{else if uploader.isUploading}} + {{uploader.progressBar}} {{else}} - - {{/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"}} + {{/if}} +
+ {{gh-file-input multiple=false action=(action (mut iconUpload)) accept=iconMimeTypes data-test-file-input="icon"}} +
+ {{/gh-uploader}}
-
+
+ {{#gh-uploader + files=logoUpload + extensions=imageExtensions + onComplete=(action "imageUploaded" "logo") + as |uploader| + }}
Publication logo
The primary logo for your brand displayed across your theme, should be transparent and at least 600px x 72px
+ {{#if uploader.errors}} + {{#each uploader.errors as |error|}} +
{{error.message}}
+ {{/each}} + {{/if}}
{{#if model.logo}} - + + + {{else if uploader.isUploading}} + {{uploader.progressBar}} {{else}} - - {{/if}} - - {{#if showUploadLogoModal}} - {{gh-fullscreen-modal "upload-image" - model=(hash model=model imageProperty="logo") - close=(action "toggleUploadLogoModal") - modifier="action wide"}} + {{/if}} +
+ {{gh-file-input multiple=false action=(action (mut logoUpload)) accept=imageMimeTypes data-test-file-input="logo"}} +
+ {{/gh-uploader}}
-
+
+ {{#gh-uploader + files=coverImageUpload + extensions=imageExtensions + onComplete=(action "imageUploaded" "coverImage") + as |uploader| + }}
Publication cover
An optional large background image for your site
+ {{#if uploader.errors}} + {{#each uploader.errors as |error|}} +
{{error.message}}
+ {{/each}} + {{/if}}
{{#if model.coverImage}} - cover photo + cover photo + + {{else if uploader.isUploading}} + {{uploader.progressBar}} {{else}} - - {{/if}} - - {{#if showUploadCoverModal}} - {{gh-fullscreen-modal "upload-image" - model=(hash model=model imageProperty="coverImage") - close=(action "toggleUploadCoverModal") - modifier="action wide"}} + {{/if}} +
+ {{gh-file-input multiple=false action=(action (mut coverImageUpload)) accept=imageMimeTypes data-test-file-input="coverImage"}} +
+ {{/gh-uploader}}
Social accounts
diff --git a/ghost/admin/mirage/config.js b/ghost/admin/mirage/config.js index f866c74bc7..6af9e6db83 100644 --- a/ghost/admin/mirage/config.js +++ b/ghost/admin/mirage/config.js @@ -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 {}; diff --git a/ghost/admin/mirage/config/uploads.js b/ghost/admin/mirage/config/uploads.js new file mode 100644 index 0000000000..911123ebf6 --- /dev/null +++ b/ghost/admin/mirage/config/uploads.js @@ -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}); +} diff --git a/ghost/admin/tests/acceptance/settings/general-test.js b/ghost/admin/tests/acceptance/settings/general-test.js index d7d367c095..10a87ec562 100644 --- a/ghost/admin/tests/acceptance/settings/general-test.js +++ b/ghost/admin/tests/acceptance/settings/general-test.js @@ -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 diff --git a/ghost/admin/tests/helpers/file-upload.js b/ghost/admin/tests/helpers/file-upload.js index dca3c1b922..3d31aa2ad0 100644 --- a/ghost/admin/tests/helpers/file-upload.js +++ b/ghost/admin/tests/helpers/file-upload.js @@ -30,6 +30,6 @@ export default Test.registerAsyncHelper('fileUpload', function(app, selector, co return triggerEvent( selector, 'change', - {foor: 'bar', testingFiles: [file]} + {testingFiles: [file]} ); }); diff --git a/ghost/admin/tests/integration/components/gh-uploader-test.js b/ghost/admin/tests/integration/components/gh-uploader-test.js index 8ce78137c8..40588cc61d 100644 --- a/ghost/admin/tests/integration/components/gh-uploader-test.js +++ b/ghost/admin/tests/integration/components/gh-uploader-test.js @@ -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}} +
+ {{/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);