From 67b2a159a93001c8b9e5f4175074f5f32ccff113 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Fri, 19 Aug 2016 11:19:30 +0100 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=90=9B=20fix=20infinite=20spinner=20o?= =?UTF-8?q?n=20failed=20save=20through=20SettingsSaveMixin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ghost/admin/app/mixins/settings-save.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ghost/admin/app/mixins/settings-save.js b/ghost/admin/app/mixins/settings-save.js index 351dd5b2e8..248a3f8c76 100644 --- a/ghost/admin/app/mixins/settings-save.js +++ b/ghost/admin/app/mixins/settings-save.js @@ -7,7 +7,7 @@ export default Mixin.create({ save() { this.set('submitting', true); - this.save().then(() => { + this.save().finally(() => { this.set('submitting', false); }); } From 3bfc34231402f251af2672180569b577a6421cd5 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Fri, 19 Aug 2016 12:42:47 +0100 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9C=A8=20add=20fileSelected=20action=20t?= =?UTF-8?q?o=20upload=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no issue - upload components will now trigger a passed-in `fileSelected` action upon file selection - useful when users of the components want to utilise the file object without supplying a custom validation action --- ghost/admin/app/components/gh-file-uploader.js | 1 + ghost/admin/app/components/gh-image-uploader.js | 1 + .../components/gh-file-uploader-test.js | 16 ++++++++++++++++ .../components/gh-image-uploader-test.js | 16 ++++++++++++++++ 4 files changed, 34 insertions(+) diff --git a/ghost/admin/app/components/gh-file-uploader.js b/ghost/admin/app/components/gh-file-uploader.js index 058095c7a2..f813e40652 100644 --- a/ghost/admin/app/components/gh-file-uploader.js +++ b/ghost/admin/app/components/gh-file-uploader.js @@ -179,6 +179,7 @@ export default Component.extend({ let validationResult = this._validate(file); this.set('file', file); + invokeAction(this, 'fileSelected', file); if (validationResult === true) { run.schedule('actions', this, function () { diff --git a/ghost/admin/app/components/gh-image-uploader.js b/ghost/admin/app/components/gh-image-uploader.js index ac52ae089e..b1bf5abb1c 100644 --- a/ghost/admin/app/components/gh-image-uploader.js +++ b/ghost/admin/app/components/gh-image-uploader.js @@ -231,6 +231,7 @@ export default Component.extend({ let validationResult = this._validate(file); this.set('file', file); + invokeAction(this, 'fileSelected', file); if (validationResult === true) { run.schedule('actions', this, function () { diff --git a/ghost/admin/tests/integration/components/gh-file-uploader-test.js b/ghost/admin/tests/integration/components/gh-file-uploader-test.js index d9e79a507d..51d78f9eb1 100644 --- a/ghost/admin/tests/integration/components/gh-file-uploader-test.js +++ b/ghost/admin/tests/integration/components/gh-file-uploader-test.js @@ -131,6 +131,22 @@ describeComponent( }); }); + it('fires fileSelected action on file selection', function (done) { + let fileSelected = sinon.spy(); + this.set('fileSelected', fileSelected); + + stubSuccessfulUpload(server); + + this.render(hbs`{{gh-file-uploader url=uploadUrl fileSelected=(action fileSelected)}}`); + fileUpload(this.$('input[type="file"]'), ['test'], {type: 'text/csv'}); + + wait().then(() => { + expect(fileSelected.calledOnce).to.be.true; + expect(fileSelected.args[0]).to.not.be.blank; + done(); + }); + }); + it('fires uploadStarted action on upload start', function (done) { let uploadStarted = sinon.spy(); this.set('uploadStarted', uploadStarted); diff --git a/ghost/admin/tests/integration/components/gh-image-uploader-test.js b/ghost/admin/tests/integration/components/gh-image-uploader-test.js index 3c52c0c991..d5f0db8a33 100644 --- a/ghost/admin/tests/integration/components/gh-image-uploader-test.js +++ b/ghost/admin/tests/integration/components/gh-image-uploader-test.js @@ -201,6 +201,22 @@ describeComponent( }); }); + it('fires fileSelected action on file selection', function (done) { + let fileSelected = sinon.spy(); + this.set('fileSelected', fileSelected); + + stubSuccessfulUpload(server); + + this.render(hbs`{{gh-image-uploader url=image fileSelected=(action fileSelected) update=(action update)}}`); + fileUpload(this.$('input[type="file"]'), ['test'], {type: 'image/png'}); + + wait().then(() => { + expect(fileSelected.calledOnce).to.be.true; + expect(fileSelected.args[0]).to.not.be.blank; + done(); + }); + }); + it('fires uploadStarted action on upload start', function (done) { let uploadStarted = sinon.spy(); this.set('uploadStarted', uploadStarted); From 0abe447551d278ddefef3166876d8973a190a161 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Wed, 17 Aug 2016 16:01:46 +0100 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=A8=20theme=20management=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refs https://github.com/TryGhost/Ghost/issues/7204, requires https://github.com/TryGhost/Ghost/pull/7209 - replaces theme dropdown with a table - adds theme upload modal - validates theme mime type - prevents upload of `casper.zip` (default Casper theme can't be overwritten) - warns if an upload will overwrite an existing theme - gives option of immediately activating the uploaded theme or closing after successful upload - adds theme activation link/action - adds theme download link/action - adds theme deletion modal - warns about no undo possibility - offers possibility to download theme - modifies mirage config to handle theme changes --- .../admin/app/components/gh-file-uploader.js | 35 +++- ghost/admin/app/components/gh-theme-table.js | 24 +++ .../app/components/modals/delete-theme.js | 21 +++ .../app/components/modals/upload-theme.js | 109 +++++++++++ .../admin/app/controllers/settings/general.js | 86 +++++---- ghost/admin/app/mirage/config.js | 5 + ghost/admin/app/mirage/config/settings.js | 17 ++ ghost/admin/app/mirage/config/themes.js | 27 +++ ghost/admin/app/mirage/fixtures/settings.js | 11 ++ ghost/admin/app/router.js | 4 +- ghost/admin/app/routes/settings/general.js | 19 +- .../routes/settings/general/uploadtheme.js | 14 ++ ghost/admin/app/services/event-bus.js | 14 ++ .../admin/app/styles/components/uploader.css | 1 - ghost/admin/app/styles/layouts/settings.css | 90 +++++++++ .../templates/components/gh-theme-table.hbs | 32 ++++ .../components/modals/delete-theme.hbs | 20 ++ .../components/modals/upload-theme.hbs | 50 +++++ .../admin/app/templates/settings/general.hbs | 57 +++--- .../settings/general/uploadtheme.hbs | 8 + .../tests/acceptance/settings/general-test.js | 164 ++++++++++++++++- .../components/gh-theme-table-test.js | 174 ++++++++++++++++++ .../components/modals/upload-theme-test.js | 30 +++ .../unit/controllers/settings/general-test.js | 38 ---- .../tests/unit/services/event-bus-test.js | 37 ++++ 25 files changed, 983 insertions(+), 104 deletions(-) create mode 100644 ghost/admin/app/components/gh-theme-table.js create mode 100644 ghost/admin/app/components/modals/delete-theme.js create mode 100644 ghost/admin/app/components/modals/upload-theme.js create mode 100644 ghost/admin/app/mirage/config/themes.js create mode 100644 ghost/admin/app/routes/settings/general/uploadtheme.js create mode 100644 ghost/admin/app/services/event-bus.js create mode 100644 ghost/admin/app/templates/components/gh-theme-table.hbs create mode 100644 ghost/admin/app/templates/components/modals/delete-theme.hbs create mode 100644 ghost/admin/app/templates/components/modals/upload-theme.hbs create mode 100644 ghost/admin/app/templates/settings/general/uploadtheme.hbs create mode 100644 ghost/admin/tests/integration/components/gh-theme-table-test.js create mode 100644 ghost/admin/tests/integration/components/modals/upload-theme-test.js create mode 100644 ghost/admin/tests/unit/services/event-bus-test.js diff --git a/ghost/admin/app/components/gh-file-uploader.js b/ghost/admin/app/components/gh-file-uploader.js index f813e40652..75523c8c47 100644 --- a/ghost/admin/app/components/gh-file-uploader.js +++ b/ghost/admin/app/components/gh-file-uploader.js @@ -32,6 +32,7 @@ export default Component.extend({ uploadPercentage: 0, ajax: injectService(), + eventBus: injectService(), notifications: injectService(), formData: computed('file', function () { @@ -57,6 +58,32 @@ export default Component.extend({ return htmlSafe(`width: ${width}`); }), + // we can optionally listen to a named event bus channel so that the upload + // process can be triggered externally + init() { + this._super(...arguments); + let listenTo = this.get('listenTo'); + + if (listenTo) { + this.get('eventBus').subscribe(`${listenTo}:upload`, this, function (file) { + if (file) { + this.set('file', file); + } + this.send('upload'); + }); + } + }, + + willDestroyElement() { + let listenTo = this.get('listenTo'); + + this._super(...arguments); + + if (listenTo) { + this.get('eventBus').unsubscribe(`${listenTo}:upload`); + } + }, + dragOver(event) { if (!event.dataTransfer) { return; @@ -142,7 +169,7 @@ export default Component.extend({ } else if (isRequestEntityTooLargeError(error)) { message = 'The file 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; + message = htmlSafe(error.errors[0].message); } else { message = 'Something went wrong :('; } @@ -190,6 +217,12 @@ export default Component.extend({ } }, + upload() { + if (this.get('file')) { + this.generateRequest(); + } + }, + reset() { this.set('file', null); this.set('uploadPercentage', 0); diff --git a/ghost/admin/app/components/gh-theme-table.js b/ghost/admin/app/components/gh-theme-table.js new file mode 100644 index 0000000000..67eeb947bb --- /dev/null +++ b/ghost/admin/app/components/gh-theme-table.js @@ -0,0 +1,24 @@ +import Component from 'ember-component'; +import computed from 'ember-computed'; + +export default Component.extend({ + + availableThemes: null, + activeTheme: null, + + themes: computed('availableThemes', 'activeTheme', function () { + return this.get('availableThemes').map((t) => { + let theme = {}; + + theme.name = t.name; + theme.label = t.package ? `${t.package.name} - ${t.package.version}` : t.name; + theme.package = t.package; + theme.active = !!t.active; + theme.isDefault = t.name === 'casper'; + theme.isDeletable = !theme.active && !theme.isDefault; + + return theme; + }).sortBy('label'); + }).readOnly() + +}); diff --git a/ghost/admin/app/components/modals/delete-theme.js b/ghost/admin/app/components/modals/delete-theme.js new file mode 100644 index 0000000000..c152e983ca --- /dev/null +++ b/ghost/admin/app/components/modals/delete-theme.js @@ -0,0 +1,21 @@ +import ModalComponent from 'ghost-admin/components/modals/base'; +import {alias} from 'ember-computed'; +import {invokeAction} from 'ember-invoke-action'; + +export default ModalComponent.extend({ + + submitting: false, + + theme: alias('model.theme'), + download: alias('model.download'), + + actions: { + confirm() { + this.set('submitting', true); + + invokeAction(this, 'confirm').finally(() => { + this.send('closeModal'); + }); + } + } +}); diff --git a/ghost/admin/app/components/modals/upload-theme.js b/ghost/admin/app/components/modals/upload-theme.js new file mode 100644 index 0000000000..bb7a4d080f --- /dev/null +++ b/ghost/admin/app/components/modals/upload-theme.js @@ -0,0 +1,109 @@ +import ModalComponent from 'ghost-admin/components/modals/base'; +import computed, {mapBy, or} from 'ember-computed'; +import {invokeAction} from 'ember-invoke-action'; +import ghostPaths from 'ghost-admin/utils/ghost-paths'; +import {UnsupportedMediaTypeError} from 'ghost-admin/services/ajax'; +import {isBlank} from 'ember-utils'; +import run from 'ember-runloop'; +import injectService from 'ember-service/inject'; + +export default ModalComponent.extend({ + + accept: 'application/zip', + availableThemes: null, + closeDisabled: false, + file: null, + theme: false, + displayOverwriteWarning: false, + + eventBus: injectService(), + + hideUploader: or('theme', 'displayOverwriteWarning'), + + uploadUrl: computed(function () { + return `${ghostPaths().apiRoot}/themes/upload/`; + }), + + themeName: computed('theme.{name,package.name}', function () { + let t = this.get('theme'); + + return t.package ? `${t.package.name} - ${t.package.version}` : t.name; + }), + + availableThemeNames: mapBy('model.availableThemes', 'name'), + + fileThemeName: computed('file', function () { + let file = this.get('file'); + return file.name.replace(/\.zip$/, ''); + }), + + canActivateTheme: computed('theme', function () { + let theme = this.get('theme'); + return theme && !theme.active; + }), + + actions: { + validateTheme(file) { + let accept = this.get('accept'); + let themeName = file.name.replace(/\.zip$/, ''); + let availableThemeNames = this.get('availableThemeNames'); + + this.set('file', file); + + if (!isBlank(accept) && file && accept.indexOf(file.type) === -1) { + return new UnsupportedMediaTypeError(); + } + + if (file.name.match(/^casper\.zip$/i)) { + return {errors: [{message: 'Sorry, the default Casper theme cannot be overwritten.
Please rename your zip file and try again.'}]}; + } + + if (!this._allowOverwrite && availableThemeNames.includes(themeName)) { + this.set('displayOverwriteWarning', true); + return false; + } + + return true; + }, + + confirmOverwrite() { + this._allowOverwrite = true; + this.set('displayOverwriteWarning', false); + + // we need to schedule afterRender so that the upload component is + // displayed again in order to subscribe/respond to the event bus + run.schedule('afterRender', this, function () { + this.get('eventBus').publish('themeUploader:upload', this.get('file')); + }); + }, + + uploadStarted() { + this.set('closeDisabled', true); + }, + + uploadFinished() { + this.set('closeDisabled', false); + }, + + uploadSuccess(response) { + this.set('theme', response.themes[0]); + // invoke the passed in confirm action + invokeAction(this, 'model.uploadSuccess', this.get('theme')); + }, + + confirm() { + // noop - we don't want the enter key doing anything + }, + + activate() { + invokeAction(this, 'model.activate', this.get('theme')); + invokeAction(this, 'closeModal'); + }, + + closeModal() { + if (!this.get('closeDisabled')) { + this._super(...arguments); + } + } + } +}); diff --git a/ghost/admin/app/controllers/settings/general.js b/ghost/admin/app/controllers/settings/general.js index 449f10f855..d0917870b5 100644 --- a/ghost/admin/app/controllers/settings/general.js +++ b/ghost/admin/app/controllers/settings/general.js @@ -1,37 +1,29 @@ import Controller from 'ember-controller'; -import computed from 'ember-computed'; +import computed, {notEmpty} from 'ember-computed'; import injectService from 'ember-service/inject'; import observer from 'ember-metal/observer'; import run from 'ember-runloop'; import SettingsSaveMixin from 'ghost-admin/mixins/settings-save'; import randomPassword from 'ghost-admin/utils/random-password'; +import $ from 'jquery'; export default Controller.extend(SettingsSaveMixin, { + availableTimezones: null, + themeToDelete: null, + showUploadLogoModal: false, showUploadCoverModal: false, + showDeleteThemeModal: notEmpty('themeToDelete'), - availableTimezones: null, - - notifications: injectService(), + ajax: injectService(), config: injectService(), + ghostPaths: injectService(), + notifications: injectService(), + session: injectService(), _scratchFacebook: null, _scratchTwitter: null, - selectedTheme: computed('model.activeTheme', 'themes', function () { - let activeTheme = this.get('model.activeTheme'); - let themes = this.get('themes'); - let selectedTheme; - - themes.forEach((theme) => { - if (theme.name === activeTheme) { - selectedTheme = theme; - } - }); - - return selectedTheme; - }), - logoImageSource: computed('model.logo', function () { return this.get('model.logo') || ''; }), @@ -55,21 +47,6 @@ export default Controller.extend(SettingsSaveMixin, { } }), - themes: computed(function () { - return this.get('model.availableThemes').reduce(function (themes, t) { - let theme = {}; - - theme.name = t.name; - theme.label = t.package ? `${t.package.name} - ${t.package.version}` : t.name; - theme.package = t.package; - theme.active = !!t.active; - - themes.push(theme); - - return themes; - }, []); - }).readOnly(), - generatePassword: observer('model.isPrivate', function () { this.get('model.errors').remove('password'); if (this.get('model.isPrivate') && this.get('model.hasDirtyAttributes')) { @@ -77,6 +54,21 @@ export default Controller.extend(SettingsSaveMixin, { } }), + _deleteTheme() { + let theme = this.get('themeToDelete'); + let themeURL = `${this.get('ghostPaths.apiRoot')}/themes/${theme.name}/`; + + if (!theme) { + return; + } + + return this.get('ajax').del(themeURL).then(() => { + this.send('reloadSettings'); + }).catch((error) => { + this.get('notifications').showAPIError(error); + }); + }, + save() { let notifications = this.get('notifications'); let config = this.get('config'); @@ -107,10 +99,38 @@ export default Controller.extend(SettingsSaveMixin, { setTheme(theme) { this.set('model.activeTheme', theme.name); + this.send('save'); }, + + downloadTheme(theme) { + let themeURL = `${this.get('ghostPaths.apiRoot')}/themes/${theme.name}`; + let accessToken = this.get('session.data.authenticated.access_token'); + let downloadURL = `${themeURL}/download/?access_token=${accessToken}`; + let iframe = $('#iframeDownload'); + + if (iframe.length === 0) { + iframe = $('