From ac16e6504c443b428b90e7f7491e99e071addfe3 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Thu, 11 Jan 2018 17:43:23 +0000 Subject: [PATCH] ESLint: Consistent ember property/method ordering no issue - https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/order-in-components.md - https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/order-in-controllers.md - https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/order-in-routes.md --- app/components/gh-activating-list-item.js | 12 +- app/components/gh-alert.js | 8 +- app/components/gh-alerts.js | 6 +- app/components/gh-blog-url.js | 4 +- app/components/gh-cm-editor.js | 32 +- app/components/gh-download-count.js | 10 +- app/components/gh-dropdown-button.js | 4 +- app/components/gh-dropdown.js | 38 +- app/components/gh-editor-post-status.js | 5 +- app/components/gh-editor.js | 90 ++--- app/components/gh-feature-flag.js | 18 +- app/components/gh-file-upload.js | 16 +- app/components/gh-file-uploader.js | 84 ++-- app/components/gh-fullscreen-modal.js | 3 +- app/components/gh-image-uploader.js | 88 ++--- app/components/gh-loading-spinner.js | 10 +- app/components/gh-markdown-editor.js | 366 +++++++++--------- app/components/gh-menu-toggle.js | 7 +- app/components/gh-nav-menu.js | 16 +- app/components/gh-navitem.js | 20 +- app/components/gh-notification.js | 4 +- app/components/gh-notifications.js | 4 +- app/components/gh-post-settings-menu.js | 121 +++--- app/components/gh-posts-list-item.js | 8 +- app/components/gh-profile-image.js | 74 ++-- app/components/gh-psm-template-select.js | 14 +- app/components/gh-publishmenu-draft.js | 12 +- app/components/gh-publishmenu.js | 74 ++-- app/components/gh-search-input-trigger.js | 24 +- app/components/gh-search-input.js | 138 ++++--- app/components/gh-simplemde.js | 40 +- app/components/gh-tag-settings-form.js | 41 +- .../gh-tags-management-container.js | 24 +- app/components/gh-task-button.js | 12 +- app/components/gh-textarea.js | 24 +- app/components/gh-timezone-select.js | 4 +- app/components/gh-tour-item.js | 28 +- app/components/gh-uploader.js | 35 +- app/components/gh-url-preview.js | 4 +- app/components/gh-user-active.js | 4 +- app/components/gh-user-invited.js | 6 +- app/components/modal-base.js | 40 +- app/components/modal-delete-all.js | 14 +- app/components/modal-delete-post.js | 15 +- app/components/modal-delete-subscriber.js | 10 +- app/components/modal-delete-tag.js | 14 +- app/components/modal-delete-theme.js | 14 +- app/components/modal-delete-user.js | 14 +- app/components/modal-invite-new-user.js | 28 +- app/components/modal-new-subscriber.js | 26 +- app/components/modal-re-authenticate.js | 22 +- app/components/modal-suspend-user.js | 14 +- app/components/modal-theme-warnings.js | 6 +- app/components/modal-transfer-owner.js | 14 +- app/components/modal-unsuspend-user.js | 14 +- app/components/modal-upload-image.js | 48 +-- app/components/modal-upload-theme.js | 8 +- app/controllers/error.js | 2 +- app/controllers/posts.js | 17 +- app/controllers/reset.js | 26 +- app/controllers/settings/apps/amp.js | 32 +- app/controllers/settings/apps/slack.js | 84 ++-- app/controllers/settings/apps/unsplash.js | 38 +- app/controllers/settings/code-injection.js | 24 +- app/controllers/settings/design.js | 118 +++--- app/controllers/settings/general.js | 78 ++-- app/controllers/settings/labs.js | 156 ++++---- app/controllers/settings/tags.js | 23 +- app/controllers/settings/tags/tag.js | 33 +- app/controllers/setup/three.js | 85 ++-- app/controllers/setup/two.js | 36 +- app/controllers/signin.js | 36 +- app/controllers/signup.js | 30 +- app/controllers/subscribers.js | 25 +- app/controllers/team/index.js | 13 +- app/controllers/team/user.js | 232 +++++------ app/routes/about.js | 6 +- app/routes/application.js | 68 ++-- app/routes/posts.js | 79 ++-- app/routes/reset.js | 4 +- app/routes/settings/apps/slack.js | 4 +- app/routes/settings/code-injection.js | 4 +- app/routes/settings/tags.js | 57 +-- app/routes/setup.js | 8 +- app/routes/signout.js | 4 +- app/routes/signup.js | 4 +- app/routes/subscribers.js | 4 +- app/routes/subscribers/new.js | 10 +- app/routes/team/user.js | 8 +- .../addon/components/cards/card-html.js | 16 +- .../addon/components/cards/card-image.js | 84 ++-- .../addon/components/cards/card-markdown.js | 146 +++---- lib/gh-koenig/addon/components/gh-koenig.js | 223 ++++++----- .../addon/components/koenig-slash-menu.js | 48 +-- .../addon/components/koenig-title-input.js | 96 ++--- .../addon/components/koenig-toolbar-button.js | 8 +- .../addon/components/koenig-toolbar.js | 106 ++--- .../components/gh-post-settings-menu-test.js | 14 +- 98 files changed, 1957 insertions(+), 1965 deletions(-) diff --git a/app/components/gh-activating-list-item.js b/app/components/gh-activating-list-item.js index 4370e9ade..ae565939f 100644 --- a/app/components/gh-activating-list-item.js +++ b/app/components/gh-activating-list-item.js @@ -2,14 +2,10 @@ import Component from '@ember/component'; import {schedule} from '@ember/runloop'; export default Component.extend({ - tagName: 'li', - classNameBindings: ['active'], active: false, + classNameBindings: ['active'], linkClasses: null, - - click() { - this.$('a').blur(); - }, + tagName: 'li', actions: { setActive(value) { @@ -17,5 +13,9 @@ export default Component.extend({ this.set('active', value); }); } + }, + + click() { + this.$('a').blur(); } }); diff --git a/app/components/gh-alert.js b/app/components/gh-alert.js index 5cc428711..2a14bd5f4 100644 --- a/app/components/gh-alert.js +++ b/app/components/gh-alert.js @@ -3,12 +3,12 @@ import {computed} from '@ember/object'; import {inject as service} from '@ember/service'; export default Component.extend({ - tagName: 'article', - classNames: ['gh-alert'], - classNameBindings: ['typeClass'], - notifications: service(), + classNameBindings: ['typeClass'], + classNames: ['gh-alert'], + tagName: 'article', + typeClass: computed('message.type', function () { let type = this.get('message.type'); let classes = ''; diff --git a/app/components/gh-alerts.js b/app/components/gh-alerts.js index 18df4f52f..6afbfb9bf 100644 --- a/app/components/gh-alerts.js +++ b/app/components/gh-alerts.js @@ -3,10 +3,10 @@ import {alias} from '@ember/object/computed'; import {inject as service} from '@ember/service'; export default Component.extend({ - tagName: 'aside', - classNames: 'gh-alerts', - notifications: service(), + classNames: 'gh-alerts', + tagName: 'aside', + messages: alias('notifications.alerts') }); diff --git a/app/components/gh-blog-url.js b/app/components/gh-blog-url.js index c5d374d6b..0ca91bafd 100644 --- a/app/components/gh-blog-url.js +++ b/app/components/gh-blog-url.js @@ -2,7 +2,7 @@ import Component from '@ember/component'; import {inject as service} from '@ember/service'; export default Component.extend({ - tagName: '', + config: service(), - config: service() + tagName: '' }); diff --git a/app/components/gh-cm-editor.js b/app/components/gh-cm-editor.js index 78a5573b1..921bd8a65 100644 --- a/app/components/gh-cm-editor.js +++ b/app/components/gh-cm-editor.js @@ -9,9 +9,10 @@ import {inject as service} from '@ember/service'; import {task} from 'ember-concurrency'; const CmEditorComponent = Component.extend(InvokeActionMixin, { + lazyLoader: service(), + classNameBindings: ['isFocused:focus'], - _value: boundOneWay('value'), // make sure a value exists isFocused: false, isInitializingCodemirror: true, @@ -22,8 +23,7 @@ const CmEditorComponent = Component.extend(InvokeActionMixin, { theme: 'xq-light', _editor: null, // reference to CodeMirror editor - - lazyLoader: service(), + _value: boundOneWay('value'), // make sure a value exists didReceiveAttrs() { if (this.get('value') === null || undefined) { @@ -36,6 +36,19 @@ const CmEditorComponent = Component.extend(InvokeActionMixin, { this.get('initCodeMirror').perform(); }, + willDestroyElement() { + this._super(...arguments); + + // Ensure the editor exists before trying to destroy it. This fixes + // an error that occurs if codemirror hasn't finished loading before + // the component is destroyed. + if (this._editor) { + let editor = this._editor.getWrapperElement(); + editor.parentNode.removeChild(editor); + this._editor = null; + } + }, + actions: { updateFromTextarea(value) { this.invokeAction('update', value); @@ -108,19 +121,6 @@ const CmEditorComponent = Component.extend(InvokeActionMixin, { _blur(/* codeMirror, event */) { this.set('isFocused', false); - }, - - willDestroyElement() { - this._super(...arguments); - - // Ensure the editor exists before trying to destroy it. This fixes - // an error that occurs if codemirror hasn't finished loading before - // the component is destroyed. - if (this._editor) { - let editor = this._editor.getWrapperElement(); - editor.parentNode.removeChild(editor); - this._editor = null; - } } }); diff --git a/app/components/gh-download-count.js b/app/components/gh-download-count.js index 59f793585..09e292c46 100644 --- a/app/components/gh-download-count.js +++ b/app/components/gh-download-count.js @@ -10,6 +10,10 @@ export default Component.extend({ tagName: '', count: '', + didInsertElement() { + this.get('_poll').perform(); + }, + _poll: task(function* () { let url = this.get('ghostPaths.count'); let pattern = /(-?\d+)(\d{3})/; @@ -31,9 +35,5 @@ export default Component.extend({ } catch (e) { // no-op - we don't want to create noise for a failed download count } - }), - - didInsertElement() { - this.get('_poll').perform(); - } + }) }); diff --git a/app/components/gh-dropdown-button.js b/app/components/gh-dropdown-button.js index eff8f1e73..7ac9221d3 100644 --- a/app/components/gh-dropdown-button.js +++ b/app/components/gh-dropdown-button.js @@ -3,6 +3,8 @@ import DropdownMixin from 'ghost-admin/mixins/dropdown-mixin'; import {inject as service} from '@ember/service'; export default Component.extend(DropdownMixin, { + dropdown: service(), + tagName: 'button', attributeBindings: ['href', 'role'], role: 'button', @@ -10,8 +12,6 @@ export default Component.extend(DropdownMixin, { // matches with the dropdown this button toggles dropdownName: null, - dropdown: service(), - // Notify dropdown service this dropdown should be toggled click(event) { this._super(event); diff --git a/app/components/gh-dropdown.js b/app/components/gh-dropdown.js index 21b2f583f..329dbffdd 100644 --- a/app/components/gh-dropdown.js +++ b/app/components/gh-dropdown.js @@ -5,6 +5,8 @@ import {run} from '@ember/runloop'; import {inject as service} from '@ember/service'; export default Component.extend(DropdownMixin, { + dropdown: service(), + classNames: 'dropdown', classNameBindings: ['fadeIn:fade-in-scale:fade-out', 'isOpen:open:closed'], @@ -22,7 +24,23 @@ export default Component.extend(DropdownMixin, { return this.get('isOpen') && !this.get('closing'); }), - dropdown: service(), + didInsertElement() { + let dropdownService = this.get('dropdown'); + + this._super(...arguments); + + dropdownService.on('close', this, this.close); + dropdownService.on('toggle', this, this.toggle); + }, + + willDestroyElement() { + let dropdownService = this.get('dropdown'); + + this._super(...arguments); + + dropdownService.off('close', this, this.close); + dropdownService.off('toggle', this, this.toggle); + }, open() { this.set('isOpen', true); @@ -74,23 +92,5 @@ export default Component.extend(DropdownMixin, { if (this.get('closeOnClick')) { return this.close(); } - }, - - didInsertElement() { - let dropdownService = this.get('dropdown'); - - this._super(...arguments); - - dropdownService.on('close', this, this.close); - dropdownService.on('toggle', this, this.toggle); - }, - - willDestroyElement() { - let dropdownService = this.get('dropdown'); - - this._super(...arguments); - - dropdownService.off('close', this, this.close); - dropdownService.off('toggle', this, this.toggle); } }); diff --git a/app/components/gh-editor-post-status.js b/app/components/gh-editor-post-status.js index 6a87da396..dda7cee4b 100644 --- a/app/components/gh-editor-post-status.js +++ b/app/components/gh-editor-post-status.js @@ -6,14 +6,15 @@ import {task, timeout} from 'ember-concurrency'; export default Component.extend({ post: null, - isNew: reads('post.isNew'), - isScheduled: reads('post.isScheduled'), isSaving: false, 'data-test-editor-post-status': true, _isSaving: false, + isNew: reads('post.isNew'), + isScheduled: reads('post.isScheduled'), + isPublished: computed('post.{isPublished,pastScheduledTime}', function () { let isPublished = this.get('post.isPublished'); let pastScheduledTime = this.get('post.pastScheduledTime'); diff --git a/app/components/gh-editor.js b/app/components/gh-editor.js index fb7f26b3e..37a9ee8b6 100644 --- a/app/components/gh-editor.js +++ b/app/components/gh-editor.js @@ -43,12 +43,6 @@ export default Component.extend({ }; }, - didInsertElement() { - this._super(...arguments); - window.addEventListener('resize', this._onResizeHandler); - this._setHeaderClass(); - }, - didReceiveAttrs() { let navIsClosed = this.get('navIsClosed'); @@ -59,6 +53,51 @@ export default Component.extend({ this._navIsClosed = navIsClosed; }, + didInsertElement() { + this._super(...arguments); + window.addEventListener('resize', this._onResizeHandler); + this._setHeaderClass(); + }, + + willDestroyElement() { + this._super(...arguments); + window.removeEventListener('resize', this._onResizeHandler); + }, + + actions: { + toggleFullScreen(isFullScreen) { + this.set('isFullScreen', isFullScreen); + this.get('ui').set('isFullScreen', isFullScreen); + run.scheduleOnce('afterRender', this, this._setHeaderClass); + }, + + togglePreview(isPreview) { + this.set('isPreview', isPreview); + }, + + toggleSplitScreen(isSplitScreen) { + this.set('isSplitScreen', isSplitScreen); + run.scheduleOnce('afterRender', this, this._setHeaderClass); + }, + + uploadImages(fileList, resetInput) { + // convert FileList to an array so that resetting the input doesn't + // clear the file references before upload actions can be triggered + let files = Array.from(fileList); + this.set('droppedFiles', files); + resetInput(); + }, + + uploadComplete(uploads) { + this.set('uploadedImageUrls', uploads.mapBy('url')); + this.set('droppedFiles', null); + }, + + uploadCancelled() { + this.set('droppedFiles', null); + } + }, + _setHeaderClass() { let $editorTitle = this.$('.gh-editor-title'); let smallHeaderClass = 'gh-editor-header-small'; @@ -134,44 +173,5 @@ export default Component.extend({ if (event.dataTransfer.files) { this.set('droppedFiles', event.dataTransfer.files); } - }, - - willDestroyElement() { - this._super(...arguments); - window.removeEventListener('resize', this._onResizeHandler); - }, - - actions: { - toggleFullScreen(isFullScreen) { - this.set('isFullScreen', isFullScreen); - this.get('ui').set('isFullScreen', isFullScreen); - run.scheduleOnce('afterRender', this, this._setHeaderClass); - }, - - togglePreview(isPreview) { - this.set('isPreview', isPreview); - }, - - toggleSplitScreen(isSplitScreen) { - this.set('isSplitScreen', isSplitScreen); - run.scheduleOnce('afterRender', this, this._setHeaderClass); - }, - - uploadImages(fileList, resetInput) { - // convert FileList to an array so that resetting the input doesn't - // clear the file references before upload actions can be triggered - let files = Array.from(fileList); - this.set('droppedFiles', files); - resetInput(); - }, - - uploadComplete(uploads) { - this.set('uploadedImageUrls', uploads.mapBy('url')); - this.set('droppedFiles', null); - }, - - uploadCancelled() { - this.set('droppedFiles', null); - } } }); diff --git a/app/components/gh-feature-flag.js b/app/components/gh-feature-flag.js index 6553da405..d62e5d1f0 100644 --- a/app/components/gh-feature-flag.js +++ b/app/components/gh-feature-flag.js @@ -3,19 +3,13 @@ import {computed} from '@ember/object'; import {inject as service} from '@ember/service'; const FeatureFlagComponent = Component.extend({ + feature: service(), + tagName: 'label', classNames: 'checkbox', attributeBindings: ['for'], _flagValue: null, - feature: service(), - - init() { - this._super(...arguments); - - this.set('_flagValue', this.get(`feature.${this.get('flag')}`)); - }, - value: computed('_flagValue', { get() { return this.get('_flagValue'); @@ -31,7 +25,13 @@ const FeatureFlagComponent = Component.extend({ name: computed('flag', function () { return `labs[${this.get('flag')}]`; - }) + }), + + init() { + this._super(...arguments); + + this.set('_flagValue', this.get(`feature.${this.get('flag')}`)); + } }); FeatureFlagComponent.reopenClass({ diff --git a/app/components/gh-file-upload.js b/app/components/gh-file-upload.js index b6ae42276..2c169e623 100644 --- a/app/components/gh-file-upload.js +++ b/app/components/gh-file-upload.js @@ -7,18 +7,12 @@ export default Component.extend({ uploadButtonText: 'Text', uploadButtonDisabled: true, + shouldResetForm: true, + // closure actions onUpload() {}, onAdd() {}, - shouldResetForm: true, - - change(event) { - this.set('uploadButtonDisabled', false); - this.onAdd(); - this._file = event.target.files[0]; - }, - actions: { upload() { if (!this.get('uploadButtonDisabled') && this._file) { @@ -33,5 +27,11 @@ export default Component.extend({ this.$().closest('form')[0].reset(); } } + }, + + change(event) { + this.set('uploadButtonDisabled', false); + this.onAdd(); + this._file = event.target.files[0]; } }); diff --git a/app/components/gh-file-uploader.js b/app/components/gh-file-uploader.js index edd929735..8c001d189 100644 --- a/app/components/gh-file-uploader.js +++ b/app/components/gh-file-uploader.js @@ -19,6 +19,10 @@ const DEFAULTS = { }; export default Component.extend({ + ajax: service(), + eventBus: service(), + notifications: service(), + tagName: 'section', classNames: ['gh-image-uploader'], classNameBindings: ['dragClass'], @@ -37,10 +41,6 @@ export default Component.extend({ failureMessage: null, uploadPercentage: 0, - ajax: service(), - eventBus: service(), - notifications: service(), - formData: computed('file', function () { let paramName = this.get('paramName'); let file = this.get('file'); @@ -102,6 +102,44 @@ export default Component.extend({ } }, + actions: { + fileSelected(fileList, resetInput) { + let [file] = Array.from(fileList); + let validationResult = this._validate(file); + + this.set('file', file); + invokeAction(this, 'fileSelected', file); + + if (validationResult === true) { + run.schedule('actions', this, function () { + this.generateRequest(); + + if (resetInput) { + resetInput(); + } + }); + } else { + this._uploadFailed(validationResult); + + if (resetInput) { + resetInput(); + } + } + }, + + upload() { + if (this.get('file')) { + this.generateRequest(); + } + }, + + reset() { + this.set('file', null); + this.set('uploadPercentage', 0); + this.set('failureMessage', null); + } + }, + dragOver(event) { if (!event.dataTransfer) { return; @@ -215,43 +253,5 @@ export default Component.extend({ } return true; - }, - - actions: { - fileSelected(fileList, resetInput) { - let [file] = Array.from(fileList); - let validationResult = this._validate(file); - - this.set('file', file); - invokeAction(this, 'fileSelected', file); - - if (validationResult === true) { - run.schedule('actions', this, function () { - this.generateRequest(); - - if (resetInput) { - resetInput(); - } - }); - } else { - this._uploadFailed(validationResult); - - if (resetInput) { - resetInput(); - } - } - }, - - upload() { - if (this.get('file')) { - this.generateRequest(); - } - }, - - reset() { - this.set('file', null); - this.set('uploadPercentage', 0); - this.set('failureMessage', null); - } } }); diff --git a/app/components/gh-fullscreen-modal.js b/app/components/gh-fullscreen-modal.js index c4e1554c0..5e3d7b002 100644 --- a/app/components/gh-fullscreen-modal.js +++ b/app/components/gh-fullscreen-modal.js @@ -8,12 +8,11 @@ import {run} from '@ember/runloop'; import {inject as service} from '@ember/service'; const FullScreenModalComponent = Component.extend({ + dropdown: service(), model: null, modifier: null, - dropdown: service(), - modalPath: computed('modal', function () { return `modal-${this.get('modal') || 'unknown'}`; }), diff --git a/app/components/gh-image-uploader.js b/app/components/gh-image-uploader.js index e3bb45e52..b37d94c1c 100644 --- a/app/components/gh-image-uploader.js +++ b/app/components/gh-image-uploader.js @@ -93,6 +93,50 @@ export default Component.extend({ } }, + actions: { + fileSelected(fileList, resetInput) { + // can't use array destructuring here as FileList is not a strict + // array and fails in Safari + // eslint-disable-next-line ember-suave/prefer-destructuring + let file = fileList[0]; + let validationResult = this._validate(file); + + this.set('file', file); + invokeAction(this, 'fileSelected', file); + + if (validationResult === true) { + run.schedule('actions', this, function () { + this.generateRequest(); + + if (resetInput) { + resetInput(); + } + }); + } else { + this._uploadFailed(validationResult); + + if (resetInput) { + resetInput(); + } + } + }, + + addUnsplashPhoto(photo) { + this.set('url', photo.urls.regular); + this.send('saveUrl'); + }, + + reset() { + this.set('file', null); + this.set('uploadPercentage', 0); + }, + + saveUrl() { + let url = this.get('url'); + invokeAction(this, 'update', url); + } + }, + dragOver(event) { if (!event.dataTransfer) { return; @@ -228,49 +272,5 @@ export default Component.extend({ } return true; - }, - - actions: { - fileSelected(fileList, resetInput) { - // can't use array destructuring here as FileList is not a strict - // array and fails in Safari - // eslint-disable-next-line ember-suave/prefer-destructuring - let file = fileList[0]; - let validationResult = this._validate(file); - - this.set('file', file); - invokeAction(this, 'fileSelected', file); - - if (validationResult === true) { - run.schedule('actions', this, function () { - this.generateRequest(); - - if (resetInput) { - resetInput(); - } - }); - } else { - this._uploadFailed(validationResult); - - if (resetInput) { - resetInput(); - } - } - }, - - addUnsplashPhoto(photo) { - this.set('url', photo.urls.regular); - this.send('saveUrl'); - }, - - reset() { - this.set('file', null); - this.set('uploadPercentage', 0); - }, - - saveUrl() { - let url = this.get('url'); - invokeAction(this, 'update', url); - } } }); diff --git a/app/components/gh-loading-spinner.js b/app/components/gh-loading-spinner.js index 1bf782f7c..bf4a312a3 100644 --- a/app/components/gh-loading-spinner.js +++ b/app/components/gh-loading-spinner.js @@ -9,12 +9,12 @@ export default Component.extend({ // prevents unnecessary flash of spinner slowLoadTimeout: 200, + didInsertElement() { + this.get('startSpinnerTimeout').perform(); + }, + startSpinnerTimeout: task(function* () { yield timeout(this.get('slowLoadTimeout')); this.set('showSpinner', true); - }), - - didInsertElement() { - this.get('startSpinnerTimeout').perform(); - } + }) }); diff --git a/app/components/gh-markdown-editor.js b/app/components/gh-markdown-editor.js index 854750e2e..7876a99d8 100644 --- a/app/components/gh-markdown-editor.js +++ b/app/components/gh-markdown-editor.js @@ -49,13 +49,6 @@ export default Component.extend(ShortcutsMixin, { shortcuts: null, - // Closure actions - onChange() {}, - onFullScreenToggle() {}, - onImageFilesSelected() {}, - onPreviewToggle() {}, - onSplitScreenToggle() {}, - // Internal attributes markdown: null, @@ -71,6 +64,13 @@ export default Component.extend(ShortcutsMixin, { _toolbar: null, _uploadedImageUrls: null, + // Closure actions + onChange() {}, + onFullScreenToggle() {}, + onImageFilesSelected() {}, + onPreviewToggle() {}, + onSplitScreenToggle() {}, + simpleMDEOptions: computed('options', function () { let options = this.get('options') || {}; let defaultOptions = { @@ -247,6 +247,182 @@ export default Component.extend(ShortcutsMixin, { } }, + actions: { + // put the markdown into a new mobiledoc card, trigger external update + updateMarkdown(markdown) { + let mobiledoc = copy(BLANK_DOC, true); + mobiledoc.cards[0][1].markdown = markdown; + this.onChange(mobiledoc); + }, + + // store a reference to the simplemde editor so that we can handle + // focusing and image uploads + setEditor(editor) { + this._editor = editor; + + // disable CodeMirror's drag/drop handling as we want to handle that + // in the parent gh-editor component + this._editor.codemirror.setOption('dragDrop', false); + + // default to spellchecker being off + this._editor.codemirror.setOption('mode', 'gfm'); + + // add non-breaking space as a special char + this._editor.codemirror.setOption('specialChars', /[\u0000-\u001f\u007f-\u009f\u00ad\u061c\u200b-\u200f\u2028\u2029\ufeff\xa0]/g); + + // HACK: move the toolbar & status bar elements outside of the + // editor container so that they can be aligned in fixed positions + let container = this.$().closest('.gh-editor').find('.gh-editor-footer'); + this._toolbar = this.$('.editor-toolbar'); + this._statusbar = this.$('.editor-statusbar'); + this._toolbar.appendTo(container); + this._statusbar.appendTo(container); + + this._updateButtonState(); + }, + + // used by the title input when the TAB or ENTER keys are pressed + focusEditor(position = 'bottom') { + this._editor.codemirror.focus(); + + if (position === 'bottom') { + this._editor.codemirror.execCommand('goDocEnd'); + } else if (position === 'top') { + this._editor.codemirror.execCommand('goDocStart'); + } + + return false; + }, + + // HACK FIXME (PLEASE): + // - clicking toolbar buttons will cause the editor to lose focus + // - this is painful because we often want to know if the editor has focus + // so that we can insert images and so on in the correct place + // - the blur event will always fire before the button action is triggered 😞 + // - to work around this we track focus state manually and set it to false + // after an arbitrary period that's long enough to allow the button action + // to trigger first + // - this _may_ well have unknown issues due to browser differences, + // variations in performance, moon cycles, sun spots, or cosmic rays + // - here be 🐲 + // - (please let it work 🙏) + updateFocusState(focused) { + if (focused) { + this._editorFocused = true; + } else { + run.later(this, function () { + this._editorFocused = false; + }, 100); + } + }, + + openImageFileDialog() { + let captureSelection = this._editor.codemirror.hasFocus(); + this._openImageFileDialog({captureSelection}); + }, + + toggleUnsplash() { + if (this.get('_showUnsplash')) { + return this.toggleProperty('_showUnsplash'); + } + + // capture current selection before it's lost by clicking toolbar btn + if (this._editorFocused) { + this._imageInsertSelection = { + anchor: this._editor.codemirror.getCursor('anchor'), + head: this._editor.codemirror.getCursor('head') + }; + } + + this.toggleProperty('_showUnsplash'); + }, + + insertUnsplashPhoto(photo) { + let image = { + alt: photo.description || '', + url: photo.urls.regular, + credit: `Photo by [${photo.user.name}](${photo.user.links.html}?utm_source=ghost&utm_medium=referral&utm_campaign=api-credit) / [Unsplash](https://unsplash.com/?utm_source=ghost&utm_medium=referral&utm_campaign=api-credit)` + }; + + this._insertImages([image]); + }, + + togglePreview() { + this._togglePreview(); + }, + + toggleFullScreen() { + let isFullScreen = !this.get('_isFullScreen'); + + this.set('_isFullScreen', isFullScreen); + this._updateButtonState(); + this.onFullScreenToggle(isFullScreen); + + // leave split screen when exiting full screen mode + if (!isFullScreen && this.get('_isSplitScreen')) { + this.send('toggleSplitScreen'); + } + }, + + toggleSplitScreen() { + let isSplitScreen = !this.get('_isSplitScreen'); + let previewButton = this._editor.toolbarElements.preview; + + this.set('_isSplitScreen', isSplitScreen); + this._updateButtonState(); + + // set up the preview rendering and scroll sync + // afterRender is needed so that necessary components have been + // added/removed and editor pane length has settled + if (isSplitScreen) { + // disable the normal SimpleMDE preview if it's active + if (this._editor.isPreviewActive()) { + let preview = this._editor.toolbar.find(button => button.name === 'preview'); + + preview.action(this._editor); + } + + if (previewButton) { + previewButton.classList.add('disabled'); + } + + run.scheduleOnce('afterRender', this, this._connectSplitPreview); + } else { + if (previewButton) { + previewButton.classList.remove('disabled'); + } + + run.scheduleOnce('afterRender', this, this._disconnectSplitPreview); + } + + this.onSplitScreenToggle(isSplitScreen); + + // go fullscreen when entering split screen mode + this.send('toggleFullScreen'); + }, + + toggleSpellcheck() { + this._toggleSpellcheck(); + }, + + toggleHemingway() { + this._toggleHemingway(); + }, + + toggleMarkdownHelp() { + this.toggleProperty('showMarkdownHelp'); + }, + + // put the toolbar/statusbar elements back so that SimpleMDE doesn't throw + // errors when it tries to remove them + destroyEditor() { + let container = this.$('.gh-markdown-editor-pane'); + this._toolbar.appendTo(container); + this._statusbar.appendTo(container); + this._editor = null; + } + }, + _preventBodyScroll() { this._preventBodyScrollId = window.requestAnimationFrame(() => { let body = document.querySelector('body'); @@ -479,181 +655,5 @@ export default Component.extend(ShortcutsMixin, { htmlSafe(notificationText), {key: 'editor.hemingwaymode'} ); - }, - - actions: { - // put the markdown into a new mobiledoc card, trigger external update - updateMarkdown(markdown) { - let mobiledoc = copy(BLANK_DOC, true); - mobiledoc.cards[0][1].markdown = markdown; - this.onChange(mobiledoc); - }, - - // store a reference to the simplemde editor so that we can handle - // focusing and image uploads - setEditor(editor) { - this._editor = editor; - - // disable CodeMirror's drag/drop handling as we want to handle that - // in the parent gh-editor component - this._editor.codemirror.setOption('dragDrop', false); - - // default to spellchecker being off - this._editor.codemirror.setOption('mode', 'gfm'); - - // add non-breaking space as a special char - this._editor.codemirror.setOption('specialChars', /[\u0000-\u001f\u007f-\u009f\u00ad\u061c\u200b-\u200f\u2028\u2029\ufeff\xa0]/g); - - // HACK: move the toolbar & status bar elements outside of the - // editor container so that they can be aligned in fixed positions - let container = this.$().closest('.gh-editor').find('.gh-editor-footer'); - this._toolbar = this.$('.editor-toolbar'); - this._statusbar = this.$('.editor-statusbar'); - this._toolbar.appendTo(container); - this._statusbar.appendTo(container); - - this._updateButtonState(); - }, - - // used by the title input when the TAB or ENTER keys are pressed - focusEditor(position = 'bottom') { - this._editor.codemirror.focus(); - - if (position === 'bottom') { - this._editor.codemirror.execCommand('goDocEnd'); - } else if (position === 'top') { - this._editor.codemirror.execCommand('goDocStart'); - } - - return false; - }, - - // HACK FIXME (PLEASE): - // - clicking toolbar buttons will cause the editor to lose focus - // - this is painful because we often want to know if the editor has focus - // so that we can insert images and so on in the correct place - // - the blur event will always fire before the button action is triggered 😞 - // - to work around this we track focus state manually and set it to false - // after an arbitrary period that's long enough to allow the button action - // to trigger first - // - this _may_ well have unknown issues due to browser differences, - // variations in performance, moon cycles, sun spots, or cosmic rays - // - here be 🐲 - // - (please let it work 🙏) - updateFocusState(focused) { - if (focused) { - this._editorFocused = true; - } else { - run.later(this, function () { - this._editorFocused = false; - }, 100); - } - }, - - openImageFileDialog() { - let captureSelection = this._editor.codemirror.hasFocus(); - this._openImageFileDialog({captureSelection}); - }, - - toggleUnsplash() { - if (this.get('_showUnsplash')) { - return this.toggleProperty('_showUnsplash'); - } - - // capture current selection before it's lost by clicking toolbar btn - if (this._editorFocused) { - this._imageInsertSelection = { - anchor: this._editor.codemirror.getCursor('anchor'), - head: this._editor.codemirror.getCursor('head') - }; - } - - this.toggleProperty('_showUnsplash'); - }, - - insertUnsplashPhoto(photo) { - let image = { - alt: photo.description || '', - url: photo.urls.regular, - credit: `Photo by [${photo.user.name}](${photo.user.links.html}?utm_source=ghost&utm_medium=referral&utm_campaign=api-credit) / [Unsplash](https://unsplash.com/?utm_source=ghost&utm_medium=referral&utm_campaign=api-credit)` - }; - - this._insertImages([image]); - }, - - togglePreview() { - this._togglePreview(); - }, - - toggleFullScreen() { - let isFullScreen = !this.get('_isFullScreen'); - - this.set('_isFullScreen', isFullScreen); - this._updateButtonState(); - this.onFullScreenToggle(isFullScreen); - - // leave split screen when exiting full screen mode - if (!isFullScreen && this.get('_isSplitScreen')) { - this.send('toggleSplitScreen'); - } - }, - - toggleSplitScreen() { - let isSplitScreen = !this.get('_isSplitScreen'); - let previewButton = this._editor.toolbarElements.preview; - - this.set('_isSplitScreen', isSplitScreen); - this._updateButtonState(); - - // set up the preview rendering and scroll sync - // afterRender is needed so that necessary components have been - // added/removed and editor pane length has settled - if (isSplitScreen) { - // disable the normal SimpleMDE preview if it's active - if (this._editor.isPreviewActive()) { - let preview = this._editor.toolbar.find(button => button.name === 'preview'); - - preview.action(this._editor); - } - - if (previewButton) { - previewButton.classList.add('disabled'); - } - - run.scheduleOnce('afterRender', this, this._connectSplitPreview); - } else { - if (previewButton) { - previewButton.classList.remove('disabled'); - } - - run.scheduleOnce('afterRender', this, this._disconnectSplitPreview); - } - - this.onSplitScreenToggle(isSplitScreen); - - // go fullscreen when entering split screen mode - this.send('toggleFullScreen'); - }, - - toggleSpellcheck() { - this._toggleSpellcheck(); - }, - - toggleHemingway() { - this._toggleHemingway(); - }, - - toggleMarkdownHelp() { - this.toggleProperty('showMarkdownHelp'); - }, - - // put the toolbar/statusbar elements back so that SimpleMDE doesn't throw - // errors when it tries to remove them - destroyEditor() { - let container = this.$('.gh-markdown-editor-pane'); - this._toolbar.appendTo(container); - this._statusbar.appendTo(container); - this._editor = null; - } } }); diff --git a/app/components/gh-menu-toggle.js b/app/components/gh-menu-toggle.js index 9d4207642..1773f7b4b 100644 --- a/app/components/gh-menu-toggle.js +++ b/app/components/gh-menu-toggle.js @@ -14,16 +14,17 @@ import {inject as service} from '@ember/service'; closes the mobile menu */ export default Component.extend({ - classNames: ['gh-menu-toggle'], - mediaQueries: service(), - isMobile: reads('mediaQueries.isMobile'), + + classNames: ['gh-menu-toggle'], maximise: false, // closure actions desktopAction() {}, mobileAction() {}, + isMobile: reads('mediaQueries.isMobile'), + iconClass: computed('maximise', 'isMobile', function () { if (this.get('maximise') && !this.get('isMobile')) { return 'icon-maximise'; diff --git a/app/components/gh-nav-menu.js b/app/components/gh-nav-menu.js index 2be7d4afb..9ff24a721 100644 --- a/app/components/gh-nav-menu.js +++ b/app/components/gh-nav-menu.js @@ -19,14 +19,6 @@ export default Component.extend({ open: false, iconStyle: '', - // the menu has a rendering issue (#8307) when the the world is reloaded - // during an import which we have worked around by not binding the icon - // style directly. However we still need to keep track of changing icons - // so that we can refresh when a new icon is uploaded - didReceiveAttrs() { - this._setIconStyle(); - }, - showMenuExtension: computed('config.clientExtensions.menu', 'session.user.isOwner', function () { return this.get('config.clientExtensions.menu') && this.get('session.user.isOwner'); }), @@ -39,6 +31,14 @@ export default Component.extend({ return this.get('config.clientExtensions.script') && this.get('session.user.isOwner'); }), + // the menu has a rendering issue (#8307) when the the world is reloaded + // during an import which we have worked around by not binding the icon + // style directly. However we still need to keep track of changing icons + // so that we can refresh when a new icon is uploaded + didReceiveAttrs() { + this._setIconStyle(); + }, + // equivalent to "left: auto; right: -20px" userDropdownPosition(trigger, dropdown) { let {horizontalPosition, verticalPosition, style} = calculatePosition(...arguments); diff --git a/app/components/gh-navitem.js b/app/components/gh-navitem.js index 5bc4c5a94..c6888abfd 100644 --- a/app/components/gh-navitem.js +++ b/app/components/gh-navitem.js @@ -18,16 +18,6 @@ export default Component.extend(ValidationState, { } }), - keyPress(event) { - // enter key - if (event.keyCode === 13 && this.get('navItem.isNew')) { - event.preventDefault(); - run.scheduleOnce('actions', this, function () { - this.send('addItem'); - }); - } - }, - actions: { addItem() { let action = this.get('addItem'); @@ -64,5 +54,15 @@ export default Component.extend(ValidationState, { clearUrlErrors() { this.get('navItem.errors').remove('url'); } + }, + + keyPress(event) { + // enter key + if (event.keyCode === 13 && this.get('navItem.isNew')) { + event.preventDefault(); + run.scheduleOnce('actions', this, function () { + this.send('addItem'); + }); + } } }); diff --git a/app/components/gh-notification.js b/app/components/gh-notification.js index c78d48f28..5b3d93d8d 100644 --- a/app/components/gh-notification.js +++ b/app/components/gh-notification.js @@ -3,14 +3,14 @@ import {computed} from '@ember/object'; import {inject as service} from '@ember/service'; export default Component.extend({ + notifications: service(), + tagName: 'article', classNames: ['gh-notification', 'gh-notification-passive'], classNameBindings: ['typeClass'], message: null, - notifications: service(), - typeClass: computed('message.type', function () { let type = this.get('message.type'); let classes = ''; diff --git a/app/components/gh-notifications.js b/app/components/gh-notifications.js index cb0b33fd8..f07a7501b 100644 --- a/app/components/gh-notifications.js +++ b/app/components/gh-notifications.js @@ -3,10 +3,10 @@ import {alias} from '@ember/object/computed'; import {inject as service} from '@ember/service'; export default Component.extend({ + notifications: service(), + tagName: 'aside', classNames: 'gh-notifications', - notifications: service(), - messages: alias('notifications.notifications') }); diff --git a/app/components/gh-post-settings-menu.js b/app/components/gh-post-settings-menu.js index 41afee7ad..c495493ef 100644 --- a/app/components/gh-post-settings-menu.js +++ b/app/components/gh-post-settings-menu.js @@ -13,9 +13,6 @@ import {task, timeout} from 'ember-concurrency'; const PSM_ANIMATION_LENGTH = 400; export default Component.extend(SettingsMenuMixin, { - selectedAuthor: null, - authors: null, - store: service(), config: service(), ghostPaths: service(), @@ -25,7 +22,12 @@ export default Component.extend(SettingsMenuMixin, { settings: service(), ui: service(), + authors: null, post: null, + selectedAuthor: null, + + _showSettingsMenu: false, + _showThrobbers: false, customExcerptScratch: alias('post.customExcerptScratch'), codeinjectionFootScratch: alias('post.codeinjectionFootScratch'), @@ -46,8 +48,49 @@ export default Component.extend(SettingsMenuMixin, { twitterImage: or('post.twitterImage', 'post.featureImage'), twitterTitle: or('twitterTitleScratch', 'seoTitle'), - _showSettingsMenu: false, - _showThrobbers: false, + twitterImageStyle: computed('twitterImage', function () { + let image = this.get('twitterImage'); + return htmlSafe(`background-image: url(${image})`); + }), + + facebookImageStyle: computed('facebookImage', function () { + let image = this.get('facebookImage'); + return htmlSafe(`background-image: url(${image})`); + }), + + seoDescription: computed('post.scratch', 'metaDescriptionScratch', function () { + let metaDescription = this.get('metaDescriptionScratch') || ''; + let mobiledoc = this.get('post.scratch'); + let markdown = mobiledoc.cards && mobiledoc.cards[0][1].markdown; + let placeholder; + + if (metaDescription) { + placeholder = metaDescription; + } else { + let div = document.createElement('div'); + div.innerHTML = formatMarkdown(markdown, false); + + // Strip HTML + placeholder = div.textContent; + // Replace new lines and trim + placeholder = placeholder.replace(/\n+/g, ' ').trim(); + } + + return placeholder; + }), + + seoURL: computed('post.slug', 'config.blogUrl', function () { + let blogUrl = this.get('config.blogUrl'); + let seoSlug = this.get('post.slug') ? this.get('post.slug') : ''; + let seoURL = `${blogUrl}/${seoSlug}`; + + // only append a slash to the URL if the slug exists + if (seoSlug) { + seoURL += '/'; + } + + return seoURL; + }), init() { this._super(...arguments); @@ -93,62 +136,6 @@ export default Component.extend(SettingsMenuMixin, { this._showSettingsMenu = this.get('showSettingsMenu'); }, - twitterImageStyle: computed('twitterImage', function () { - let image = this.get('twitterImage'); - return htmlSafe(`background-image: url(${image})`); - }), - - facebookImageStyle: computed('facebookImage', function () { - let image = this.get('facebookImage'); - return htmlSafe(`background-image: url(${image})`); - }), - - showThrobbers: task(function* () { - yield timeout(PSM_ANIMATION_LENGTH); - this.set('_showThrobbers', true); - }).restartable(), - - seoDescription: computed('post.scratch', 'metaDescriptionScratch', function () { - let metaDescription = this.get('metaDescriptionScratch') || ''; - let mobiledoc = this.get('post.scratch'); - let markdown = mobiledoc.cards && mobiledoc.cards[0][1].markdown; - let placeholder; - - if (metaDescription) { - placeholder = metaDescription; - } else { - let div = document.createElement('div'); - div.innerHTML = formatMarkdown(markdown, false); - - // Strip HTML - placeholder = div.textContent; - // Replace new lines and trim - placeholder = placeholder.replace(/\n+/g, ' ').trim(); - } - - return placeholder; - }), - - seoURL: computed('post.slug', 'config.blogUrl', function () { - let blogUrl = this.get('config.blogUrl'); - let seoSlug = this.get('post.slug') ? this.get('post.slug') : ''; - let seoURL = `${blogUrl}/${seoSlug}`; - - // only append a slash to the URL if the slug exists - if (seoSlug) { - seoURL += '/'; - } - - return seoURL; - }), - - showError(error) { - // TODO: remove null check once ValidationEngine has been removed - if (error) { - this.get('notifications').showAPIError(error); - } - }, - actions: { showSubview(subview) { this._super(...arguments); @@ -526,5 +513,17 @@ export default Component.extend(SettingsMenuMixin, { this.get('deletePost')(); } } + }, + + showThrobbers: task(function* () { + yield timeout(PSM_ANIMATION_LENGTH); + this.set('_showThrobbers', true); + }).restartable(), + + showError(error) { + // TODO: remove null check once ValidationEngine has been removed + if (error) { + this.get('notifications').showAPIError(error); + } } }); diff --git a/app/components/gh-posts-list-item.js b/app/components/gh-posts-list-item.js index 088dbcf44..d9d243a4f 100644 --- a/app/components/gh-posts-list-item.js +++ b/app/components/gh-posts-list-item.js @@ -19,16 +19,16 @@ export default Component.extend({ post: null, active: false, + // closure actions + onClick() {}, + onDoubleClick() {}, + isFeatured: alias('post.featured'), isPage: alias('post.page'), isDraft: equal('post.status', 'draft'), isPublished: equal('post.status', 'published'), isScheduled: equal('post.status', 'scheduled'), - // closure actions - onClick() {}, - onDoubleClick() {}, - authorName: computed('post.author.{name,email}', function () { return this.get('post.author.name') || this.get('post.author.email'); }), diff --git a/app/components/gh-profile-image.js b/app/components/gh-profile-image.js index 3b4b9ca1b..9328da4bf 100644 --- a/app/components/gh-profile-image.js +++ b/app/components/gh-profile-image.js @@ -22,6 +22,9 @@ const ANIMATION_TIMEOUT = 1000; * @property {String} imageBackground String containing the background-image css property with the gravatar url */ export default Component.extend({ + config: service(), + ghostPaths: service(), + email: '', size: 180, debounce: 300, @@ -29,17 +32,14 @@ export default Component.extend({ imageFile: null, hasUploadedImage: false, + _defaultImageUrl: '', + // closure actions setImage() {}, - config: service(), - ghostPaths: service(), - placeholderStyle: htmlSafe('background-image: url()'), avatarStyle: htmlSafe('display: none'), - _defaultImageUrl: '', - init() { this._super(...arguments); @@ -55,6 +55,38 @@ export default Component.extend({ } }, + actions: { + imageSelected(fileList, resetInput) { + // eslint-disable-next-line + let imageFile = fileList[0]; + + if (imageFile) { + let reader = new FileReader(); + + this.set('imageFile', imageFile); + this.setImage(imageFile); + + reader.addEventListener('load', () => { + let dataURL = reader.result; + this.set('previewDataURL', dataURL); + }, false); + + reader.readAsDataURL(imageFile); + } + + resetInput(); + }, + + openFileDialog(event) { + // simulate click to open file dialog + // using jQuery because IE11 doesn't support MouseEvent + $(event.target) + .closest('figure') + .find('input[type="file"]') + .click(); + } + }, + dragOver(event) { if (!event.dataTransfer) { return; @@ -128,37 +160,5 @@ export default Component.extend({ action(data); } } - }, - - actions: { - imageSelected(fileList, resetInput) { - // eslint-disable-next-line - let imageFile = fileList[0]; - - if (imageFile) { - let reader = new FileReader(); - - this.set('imageFile', imageFile); - this.setImage(imageFile); - - reader.addEventListener('load', () => { - let dataURL = reader.result; - this.set('previewDataURL', dataURL); - }, false); - - reader.readAsDataURL(imageFile); - } - - resetInput(); - }, - - openFileDialog(event) { - // simulate click to open file dialog - // using jQuery because IE11 doesn't support MouseEvent - $(event.target) - .closest('figure') - .find('input[type="file"]') - .click(); - } } }); diff --git a/app/components/gh-psm-template-select.js b/app/components/gh-psm-template-select.js index e8da9f644..952c5106f 100644 --- a/app/components/gh-psm-template-select.js +++ b/app/components/gh-psm-template-select.js @@ -53,6 +53,12 @@ export default Component.extend({ this.get('loadActiveTheme').perform(); }, + actions: { + selectTemplate(template) { + this.onTemplateSelect(template.filename); + } + }, + // tasks loadActiveTheme: task(function* () { let store = this.get('store'); @@ -65,11 +71,5 @@ export default Component.extend({ let activeTheme = themes.filterBy('active', true).get('firstObject'); this.set('activeTheme', activeTheme); - }), - - actions: { - selectTemplate(template) { - this.onTemplateSelect(template.filename); - } - } + }) }); diff --git a/app/components/gh-publishmenu-draft.js b/app/components/gh-publishmenu-draft.js index 1af9e1c62..b9a8d9611 100644 --- a/app/components/gh-publishmenu-draft.js +++ b/app/components/gh-publishmenu-draft.js @@ -18,12 +18,6 @@ export default Component.extend({ this.send('setSaveType', 'publish'); }, - // API only accepts dates at least 2 mins in the future, default the - // scheduled date 5 mins in the future to avoid immediate validation errors - _getMinDate() { - return moment.utc().add(5, 'minutes'); - }, - actions: { setSaveType(type) { if (this.get('saveType') !== type) { @@ -70,5 +64,11 @@ export default Component.extend({ post.set('publishedAtBlogTime', time); return post.validate(); } + }, + + // API only accepts dates at least 2 mins in the future, default the + // scheduled date 5 mins in the future to avoid immediate validation errors + _getMinDate() { + return moment.utc().add(5, 'minutes'); } }); diff --git a/app/components/gh-publishmenu.js b/app/components/gh-publishmenu.js index ee78eea94..7013c72c0 100644 --- a/app/components/gh-publishmenu.js +++ b/app/components/gh-publishmenu.js @@ -11,8 +11,10 @@ export default Component.extend({ classNames: 'gh-publishmenu', post: null, saveTask: null, + runningText: null, _publishedAtBlogTZ: null, + _previousStatus: null, isClosing: null, @@ -60,8 +62,6 @@ export default Component.extend({ return runningText || 'Publishing'; }), - runningText: null, - buttonText: computed('postState', 'saveType', function () { let saveType = this.get('saveType'); let postState = this.get('postState'); @@ -102,42 +102,6 @@ export default Component.extend({ return buttonText; }), - save: task(function* () { - // runningText needs to be declared before the other states change during the - // save action. - this.set('runningText', this.get('_runningText')); - this.set('_previousStatus', this.get('post.status')); - this.get('setSaveType')(this.get('saveType')); - - try { - // validate publishedAtBlog first to avoid an alert for displayed errors - yield this.get('post').validate({property: 'publishedAtBlog'}); - - // actual save will show alert for other failed validations - let post = yield this.get('saveTask').perform(); - - this._cachePublishedAtBlogTZ(); - return post; - } catch (error) { - // re-throw if we don't have a validation error - if (error) { - throw error; - } - } - }), - - _previousStatus: null, - - _cachePublishedAtBlogTZ() { - this._publishedAtBlogTZ = this.get('post.publishedAtBlogTZ'); - }, - - // when closing the menu we reset the publishedAtBlogTZ date so that the - // unsaved changes made to the scheduled date aren't reflected in the PSM - _resetPublishedAtBlogTZ() { - this.get('post').set('publishedAtBlogTZ', this._publishedAtBlogTZ); - }, - actions: { setSaveType(saveType) { let post = this.get('post'); @@ -183,5 +147,39 @@ export default Component.extend({ return true; } + }, + + save: task(function* () { + // runningText needs to be declared before the other states change during the + // save action. + this.set('runningText', this.get('_runningText')); + this.set('_previousStatus', this.get('post.status')); + this.get('setSaveType')(this.get('saveType')); + + try { + // validate publishedAtBlog first to avoid an alert for displayed errors + yield this.get('post').validate({property: 'publishedAtBlog'}); + + // actual save will show alert for other failed validations + let post = yield this.get('saveTask').perform(); + + this._cachePublishedAtBlogTZ(); + return post; + } catch (error) { + // re-throw if we don't have a validation error + if (error) { + throw error; + } + } + }), + + _cachePublishedAtBlogTZ() { + this._publishedAtBlogTZ = this.get('post.publishedAtBlogTZ'); + }, + + // when closing the menu we reset the publishedAtBlogTZ date so that the + // unsaved changes made to the scheduled date aren't reflected in the PSM + _resetPublishedAtBlogTZ() { + this.get('post').set('publishedAtBlogTZ', this._publishedAtBlogTZ); } }); diff --git a/app/components/gh-search-input-trigger.js b/app/components/gh-search-input-trigger.js index 9ad47c779..e28f4cfe4 100644 --- a/app/components/gh-search-input-trigger.js +++ b/app/components/gh-search-input-trigger.js @@ -2,18 +2,6 @@ import Component from '@ember/component'; import {isBlank} from '@ember/utils'; export default Component.extend({ - open() { - this.get('select.actions').open(); - }, - - close() { - this.get('select.actions').close(); - }, - - _focusInput() { - this.$('input')[0].focus(); - }, - actions: { captureMouseDown(e) { e.stopPropagation(); @@ -50,5 +38,17 @@ export default Component.extend({ e.stopPropagation(); } } + }, + + open() { + this.get('select.actions').open(); + }, + + close() { + this.get('select.actions').close(); + }, + + _focusInput() { + this.$('input')[0].focus(); } }); diff --git a/app/components/gh-search-input.js b/app/components/gh-search-input.js index e81978dce..be36fed87 100644 --- a/app/components/gh-search-input.js +++ b/app/components/gh-search-input.js @@ -22,29 +22,87 @@ export function computedGroup(category) { } export default Component.extend({ + store: service('store'), + router: service('router'), + ajax: service(), + notifications: service(), - selection: null, content: null, - isLoading: false, - contentExpiry: 10 * 1000, contentExpiresAt: false, + contentExpiry: 10000, currentSearch: '', + isLoading: false, + selection: null, posts: computedGroup('Stories'), pages: computedGroup('Pages'), users: computedGroup('Users'), tags: computedGroup('Tags'), - _store: service('store'), - router: service('router'), - ajax: service(), - notifications: service(), + groupedContent: computed('posts', 'pages', 'users', 'tags', function () { + let groups = []; + + if (!isEmpty(this.get('posts'))) { + groups.pushObject({groupName: 'Stories', options: this.get('posts')}); + } + + if (!isEmpty(this.get('pages'))) { + groups.pushObject({groupName: 'Pages', options: this.get('pages')}); + } + + if (!isEmpty(this.get('users'))) { + groups.pushObject({groupName: 'Users', options: this.get('users')}); + } + + if (!isEmpty(this.get('tags'))) { + groups.pushObject({groupName: 'Tags', options: this.get('tags')}); + } + + return groups; + }), init() { this._super(...arguments); this.content = []; }, + actions: { + openSelected(selected) { + if (!selected) { + return; + } + + if (selected.category === 'Stories' || selected.category === 'Pages') { + let id = selected.id.replace('post.', ''); + this.get('router').transitionTo('editor.edit', id); + } + + if (selected.category === 'Users') { + let id = selected.id.replace('user.', ''); + this.get('router').transitionTo('team.user', id); + } + + if (selected.category === 'Tags') { + let id = selected.id.replace('tag.', ''); + this.get('router').transitionTo('settings.tags.tag', id); + } + }, + + onFocus() { + this._setKeymasterScope(); + }, + + onBlur() { + this._resetKeymasterScope(); + }, + + search(term) { + return new RSVP.Promise((resolve, reject) => { + run.debounce(this, this._performSearch, term, resolve, reject, 200); + }); + } + }, + refreshContent() { let promises = []; let now = new Date(); @@ -67,30 +125,8 @@ export default Component.extend({ }); }, - groupedContent: computed('posts', 'pages', 'users', 'tags', function () { - let groups = []; - - if (!isEmpty(this.get('posts'))) { - groups.pushObject({groupName: 'Stories', options: this.get('posts')}); - } - - if (!isEmpty(this.get('pages'))) { - groups.pushObject({groupName: 'Pages', options: this.get('pages')}); - } - - if (!isEmpty(this.get('users'))) { - groups.pushObject({groupName: 'Users', options: this.get('users')}); - } - - if (!isEmpty(this.get('tags'))) { - groups.pushObject({groupName: 'Tags', options: this.get('tags')}); - } - - return groups; - }), - _loadPosts() { - let store = this.get('_store'); + let store = this.get('store'); let postsUrl = `${store.adapterFor('post').urlForQuery({}, 'post')}/`; let postsQuery = {fields: 'id,title,page', limit: 'all', status: 'all', staticPages: 'all'}; let content = this.get('content'); @@ -107,7 +143,7 @@ export default Component.extend({ }, _loadUsers() { - let store = this.get('_store'); + let store = this.get('store'); let usersUrl = `${store.adapterFor('user').urlForQuery({}, 'user')}/`; let usersQuery = {fields: 'name,slug', limit: 'all'}; let content = this.get('content'); @@ -124,7 +160,7 @@ export default Component.extend({ }, _loadTags() { - let store = this.get('_store'); + let store = this.get('store'); let tagsUrl = `${store.adapterFor('tag').urlForQuery({}, 'tag')}/`; let tagsQuery = {fields: 'name,slug', limit: 'all'}; let content = this.get('content'); @@ -163,43 +199,5 @@ export default Component.extend({ willDestroy() { this._super(...arguments); this._resetKeymasterScope(); - }, - - actions: { - openSelected(selected) { - if (!selected) { - return; - } - - if (selected.category === 'Stories' || selected.category === 'Pages') { - let id = selected.id.replace('post.', ''); - this.get('router').transitionTo('editor.edit', id); - } - - if (selected.category === 'Users') { - let id = selected.id.replace('user.', ''); - this.get('router').transitionTo('team.user', id); - } - - if (selected.category === 'Tags') { - let id = selected.id.replace('tag.', ''); - this.get('router').transitionTo('settings.tags.tag', id); - } - }, - - onFocus() { - this._setKeymasterScope(); - }, - - onBlur() { - this._resetKeymasterScope(); - }, - - search(term) { - return new RSVP.Promise((resolve, reject) => { - run.debounce(this, this._performSearch, term, resolve, reject, 200); - }); - } } - }); diff --git a/app/components/gh-simplemde.js b/app/components/gh-simplemde.js index 293f9fdd8..cb6f66421 100644 --- a/app/components/gh-simplemde.js +++ b/app/components/gh-simplemde.js @@ -13,14 +13,14 @@ export default TextArea.extend({ value: null, placeholder: '', + // Private + _editor: null, + // Closure actions onChange() {}, onEditorInit() {}, onEditorDestroy() {}, - // Private - _editor: null, - // default SimpleMDE options, see docs for available config: // https://github.com/sparksuite/simplemde-markdown-editor#configuration defaultOptions: computed(function () { @@ -40,6 +40,23 @@ export default TextArea.extend({ } }, + // update the editor when the value property changes from the outside + didReceiveAttrs() { + this._super(...arguments); + + if (isEmpty(this._editor)) { + return; + } + + // compare values before forcing a content reset to avoid clobbering + // the undo behaviour + if (this.get('value') !== this._editor.value()) { + let cursor = this._editor.codemirror.getDoc().getCursor(); + this._editor.value(this.get('value')); + this._editor.codemirror.getDoc().setCursor(cursor); + } + }, + // instantiate the editor with the contents of value didInsertElement() { this._super(...arguments); @@ -78,23 +95,6 @@ export default TextArea.extend({ this.onEditorInit(this._editor); }, - // update the editor when the value property changes from the outside - didReceiveAttrs() { - this._super(...arguments); - - if (isEmpty(this._editor)) { - return; - } - - // compare values before forcing a content reset to avoid clobbering - // the undo behaviour - if (this.get('value') !== this._editor.value()) { - let cursor = this._editor.codemirror.getDoc().getCursor(); - this._editor.value(this.get('value')); - this._editor.codemirror.getDoc().setCursor(cursor); - } - }, - willDestroyElement() { this.onEditorDestroy(); this._editor.toTextArea(); diff --git a/app/components/gh-tag-settings-form.js b/app/components/gh-tag-settings-form.js index 64d3e60c1..13ed2b1b5 100644 --- a/app/components/gh-tag-settings-form.js +++ b/app/components/gh-tag-settings-form.js @@ -11,21 +11,20 @@ import {inject as service} from '@ember/service'; const {Handlebars} = Ember; export default Component.extend({ + feature: service(), + config: service(), + mediaQueries: service(), tag: null, + isViewingSubview: false, + scratchName: boundOneWay('tag.name'), scratchSlug: boundOneWay('tag.slug'), scratchDescription: boundOneWay('tag.description'), scratchMetaTitle: boundOneWay('tag.metaTitle'), scratchMetaDescription: boundOneWay('tag.metaDescription'), - isViewingSubview: false, - - feature: service(), - config: service(), - mediaQueries: service(), - isMobile: reads('mediaQueries.maxWidth600'), title: computed('tag.isNew', function () { @@ -97,21 +96,6 @@ export default Component.extend({ this._oldTagId = newTagId; }, - reset() { - this.set('isViewingSubview', false); - if (this.$()) { - this.$('.settings-menu-pane').scrollTop(0); - } - }, - - focusIn() { - key.setScope('tag-settings-form'); - }, - - focusOut() { - key.setScope('default'); - }, - actions: { setProperty(property, value) { invokeAction(this, 'setProperty', property, value); @@ -136,6 +120,21 @@ export default Component.extend({ deleteTag() { invokeAction(this, 'showDeleteTagModal'); } + }, + + reset() { + this.set('isViewingSubview', false); + if (this.$()) { + this.$('.settings-menu-pane').scrollTop(0); + } + }, + + focusIn() { + key.setScope('tag-settings-form'); + }, + + focusOut() { + key.setScope('default'); } }); diff --git a/app/components/gh-tags-management-container.js b/app/components/gh-tags-management-container.js index dacd23cd5..c55556c0c 100644 --- a/app/components/gh-tags-management-container.js +++ b/app/components/gh-tags-management-container.js @@ -5,27 +5,17 @@ import {isBlank} from '@ember/utils'; import {inject as service} from '@ember/service'; export default Component.extend({ + mediaQueries: service(), + classNames: ['view-container'], classNameBindings: ['isMobile'], - mediaQueries: service(), - tags: null, selectedTag: null, isMobile: reads('mediaQueries.maxWidth600'), isEmpty: equal('tags.length', 0), - init() { - this._super(...arguments); - this.get('mediaQueries').on('change', this, this._fireMobileChangeActions); - }, - - willDestroyElement() { - this._super(...arguments); - this.get('mediaQueries').off('change', this, this._fireMobileChangeActions); - }, - displaySettingsPane: computed('isEmpty', 'selectedTag', 'isMobile', function () { let isEmpty = this.get('isEmpty'); let selectedTag = this.get('selectedTag'); @@ -45,6 +35,16 @@ export default Component.extend({ return true; }), + init() { + this._super(...arguments); + this.get('mediaQueries').on('change', this, this._fireMobileChangeActions); + }, + + willDestroyElement() { + this._super(...arguments); + this.get('mediaQueries').off('change', this, this._fireMobileChangeActions); + }, + _fireMobileChangeActions(key, value) { if (key === 'maxWidth600') { let leftMobileAction = this.get('leftMobile'); diff --git a/app/components/gh-task-button.js b/app/components/gh-task-button.js index aab7e6c7c..73750afea 100644 --- a/app/components/gh-task-button.js +++ b/app/components/gh-task-button.js @@ -30,7 +30,6 @@ const GhTaskButton = Component.extend({ task: null, disabled: false, buttonText: 'Save', - runningText: reads('buttonText'), idleClass: '', runningClass: '', successText: 'Saved', @@ -39,11 +38,7 @@ const GhTaskButton = Component.extend({ failureClass: 'gh-btn-red', isRunning: reads('task.last.isRunning'), - - init() { - this._super(...arguments); - this._initialPerformCount = this.get('task.performCount'); - }, + runningText: reads('buttonText'), // hasRun is needed so that a newly rendered button does not show the last // state of the associated task @@ -96,6 +91,11 @@ const GhTaskButton = Component.extend({ return !this.get('isRunning') && !this.get('isSuccess') && !this.get('isFailure'); }), + init() { + this._super(...arguments); + this._initialPerformCount = this.get('task.performCount'); + }, + click() { // do nothing if disabled externally if (this.get('disabled')) { diff --git a/app/components/gh-textarea.js b/app/components/gh-textarea.js index 153853c3c..9e0236104 100644 --- a/app/components/gh-textarea.js +++ b/app/components/gh-textarea.js @@ -10,12 +10,12 @@ export default OneWayTextarea.extend(TextInputMixin, { autoExpand: false, - willInsertElement() { + didReceiveAttrs() { this._super(...arguments); - // disable the draggable resize element that browsers add to textareas + // trigger auto-expand any time the value changes if (this.get('autoExpand')) { - this.element.style.resize = 'none'; + run.scheduleOnce('afterRender', this, this._autoExpand); } }, @@ -29,20 +29,20 @@ export default OneWayTextarea.extend(TextInputMixin, { } }, - didReceiveAttrs() { - this._super(...arguments); - - // trigger auto-expand any time the value changes - if (this.get('autoExpand')) { - run.scheduleOnce('afterRender', this, this._autoExpand); - } - }, - willDestroyElement() { this._teardownAutoExpand(); this._super(...arguments); }, + willInsertElement() { + this._super(...arguments); + + // disable the draggable resize element that browsers add to textareas + if (this.get('autoExpand')) { + this.element.style.resize = 'none'; + } + }, + _autoExpand() { let el = this.element; diff --git a/app/components/gh-timezone-select.js b/app/components/gh-timezone-select.js index 27efde344..8af2db9e7 100644 --- a/app/components/gh-timezone-select.js +++ b/app/components/gh-timezone-select.js @@ -6,13 +6,13 @@ import {mapBy} from '@ember/object/computed'; import {inject as service} from '@ember/service'; export default Component.extend({ + clock: service(), + classNames: ['form-group', 'for-select'], activeTimezone: null, availableTimezones: null, - clock: service(), - availableTimezoneNames: mapBy('availableTimezones', 'name'), hasTimezoneOverride: computed('activeTimezone', 'availableTimezoneNames', function () { diff --git a/app/components/gh-tour-item.js b/app/components/gh-tour-item.js index bbc16c4bb..d7d07ddbe 100644 --- a/app/components/gh-tour-item.js +++ b/app/components/gh-tour-item.js @@ -127,20 +127,6 @@ const GhTourItemComponent = Component.extend({ this._super(...arguments); }, - _removeIfViewed(id) { - if (id === this.get('throbberId')) { - this._remove(); - } - }, - - _remove() { - this.set('_throbber', null); - }, - - _close() { - this.set('isOpen', false); - }, - actions: { open() { this.set('isOpen', true); @@ -162,6 +148,20 @@ const GhTourItemComponent = Component.extend({ this.set('_throbber', null); this._close(); } + }, + + _removeIfViewed(id) { + if (id === this.get('throbberId')) { + this._remove(); + } + }, + + _remove() { + this.set('_throbber', null); + }, + + _close() { + this.set('isOpen', false); } }); diff --git a/app/components/gh-uploader.js b/app/components/gh-uploader.js index 4974743ec..d4771135c 100644 --- a/app/components/gh-uploader.js +++ b/app/components/gh-uploader.js @@ -44,10 +44,10 @@ const UploadTracker = EmberObject.extend({ }); export default Component.extend({ - tagName: '', - ajax: service(), + tagName: '', + // Public attributes accept: '', extensions: '', @@ -98,6 +98,21 @@ export default Component.extend({ this._setFiles(files); }, + actions: { + setFiles(files, resetInput) { + this._setFiles(files); + + if (resetInput) { + resetInput(); + } + }, + + cancel() { + this._reset(); + this.onCancel(); + } + }, + _setFiles(files) { this.set('files', files); @@ -192,6 +207,7 @@ export default Component.extend({ this.onComplete(this.get('uploadUrls')); }).drop(), + // eslint-disable-next-line ghost/ember/order-in-components _uploadFile: task(function* (tracker, file, index) { let ajax = this.get('ajax'); let formData = this._getFormData(file); @@ -286,20 +302,5 @@ export default Component.extend({ this.set('uploadPercentage', 0); this.set('uploadUrls', []); this._uploadTrackers = []; - }, - - actions: { - setFiles(files, resetInput) { - this._setFiles(files); - - if (resetInput) { - resetInput(); - } - }, - - cancel() { - this._reset(); - this.onCancel(); - } } }); diff --git a/app/components/gh-url-preview.js b/app/components/gh-url-preview.js index add4ee8a1..1354132ee 100644 --- a/app/components/gh-url-preview.js +++ b/app/components/gh-url-preview.js @@ -7,12 +7,12 @@ Example usage: {{gh-url-preview prefix="tag" slug=theSlugValue tagName="p" classNames="description"}} */ export default Component.extend({ + config: service(), + classNames: 'ghost-url-preview', prefix: null, slug: null, - config: service(), - url: computed('slug', function () { // Get the blog URL and strip the scheme let blogUrl = this.get('config.blogUrl'); diff --git a/app/components/gh-user-active.js b/app/components/gh-user-active.js index 1fbcfdb25..cc241dfb4 100644 --- a/app/components/gh-user-active.js +++ b/app/components/gh-user-active.js @@ -8,12 +8,12 @@ import {inject as service} from '@ember/service'; const {Handlebars} = Ember; export default Component.extend({ + ghostPaths: service(), + tagName: '', user: null, - ghostPaths: service(), - userDefault: computed('ghostPaths', function () { return `${this.get('ghostPaths.assetRoot')}/img/user-image.png`; }), diff --git a/app/components/gh-user-invited.js b/app/components/gh-user-invited.js index f1d6c16ed..4e03c76a6 100644 --- a/app/components/gh-user-invited.js +++ b/app/components/gh-user-invited.js @@ -5,14 +5,14 @@ import {isNotFoundError} from 'ember-ajax/errors'; import {inject as service} from '@ember/service'; export default Component.extend({ + notifications: service(), + store: service(), + tagName: '', invite: null, isSending: false, - notifications: service(), - store: service(), - createdAt: computed('invite.createdAtUTC', function () { let createdAtUTC = this.get('invite.createdAtUTC'); diff --git a/app/components/modal-base.js b/app/components/modal-base.js index 4e710a7f1..85abafa33 100644 --- a/app/components/modal-base.js +++ b/app/components/modal-base.js @@ -9,6 +9,26 @@ export default Component.extend({ _previousKeymasterScope: null, + didInsertElement() { + this._super(...arguments); + this._setupShortcuts(); + }, + + willDestroyElement() { + this._super(...arguments); + this._removeShortcuts(); + }, + + actions: { + confirm() { + throw new Error('You must override the "confirm" action in your modal component'); + }, + + closeModal() { + invokeAction(this, 'closeModal'); + } + }, + _setupShortcuts() { run(function () { document.activeElement.blur(); @@ -31,25 +51,5 @@ export default Component.extend({ key.unbind('escape', 'modal'); key.setScope(this._previousKeymasterScope); - }, - - didInsertElement() { - this._super(...arguments); - this._setupShortcuts(); - }, - - willDestroyElement() { - this._super(...arguments); - this._removeShortcuts(); - }, - - actions: { - confirm() { - throw new Error('You must override the "confirm" action in your modal component'); - }, - - closeModal() { - invokeAction(this, 'closeModal'); - } } }); diff --git a/app/components/modal-delete-all.js b/app/components/modal-delete-all.js index 3fe30b2f0..c0a723865 100644 --- a/app/components/modal-delete-all.js +++ b/app/components/modal-delete-all.js @@ -9,6 +9,12 @@ export default ModalComponent.extend({ store: service(), ajax: service(), + actions: { + confirm() { + this.get('deleteAll').perform(); + } + }, + _deleteAll() { let deleteUrl = this.get('ghostPaths.url').api('db'); return this.get('ajax').del(deleteUrl); @@ -37,11 +43,5 @@ export default ModalComponent.extend({ } finally { this.send('closeModal'); } - }).drop(), - - actions: { - confirm() { - this.get('deleteAll').perform(); - } - } + }).drop() }); diff --git a/app/components/modal-delete-post.js b/app/components/modal-delete-post.js index 9f16f8506..d2d8659a8 100644 --- a/app/components/modal-delete-post.js +++ b/app/components/modal-delete-post.js @@ -4,11 +4,16 @@ import {inject as service} from '@ember/service'; import {task} from 'ember-concurrency'; export default ModalComponent.extend({ + notifications: service(), post: alias('model.post'), onSuccess: alias('model.onSuccess'), - notifications: service(), + actions: { + confirm() { + this.get('deletePost').perform(); + } + }, _deletePost() { let post = this.get('post'); @@ -43,11 +48,5 @@ export default ModalComponent.extend({ } finally { this.send('closeModal'); } - }).drop(), - - actions: { - confirm() { - this.get('deletePost').perform(); - } - } + }).drop() }); diff --git a/app/components/modal-delete-subscriber.js b/app/components/modal-delete-subscriber.js index 42764cf01..5253cfef5 100644 --- a/app/components/modal-delete-subscriber.js +++ b/app/components/modal-delete-subscriber.js @@ -7,13 +7,13 @@ export default ModalComponent.extend({ subscriber: alias('model'), - deleteSubscriber: task(function* () { - yield invokeAction(this, 'confirm'); - }).drop(), - actions: { confirm() { this.get('deleteSubscriber').perform(); } - } + }, + + deleteSubscriber: task(function* () { + yield invokeAction(this, 'confirm'); + }).drop() }); diff --git a/app/components/modal-delete-tag.js b/app/components/modal-delete-tag.js index c17aabe49..eab73d325 100644 --- a/app/components/modal-delete-tag.js +++ b/app/components/modal-delete-tag.js @@ -12,17 +12,17 @@ export default ModalComponent.extend({ return this.get('tag.count.posts') > 1 ? 'posts' : 'post'; }), + actions: { + confirm() { + this.get('deleteTag').perform(); + } + }, + deleteTag: task(function* () { try { yield invokeAction(this, 'confirm'); } finally { this.send('closeModal'); } - }).drop(), - - actions: { - confirm() { - this.get('deleteTag').perform(); - } - } + }).drop() }); diff --git a/app/components/modal-delete-theme.js b/app/components/modal-delete-theme.js index 8929ec6f1..f186c77df 100644 --- a/app/components/modal-delete-theme.js +++ b/app/components/modal-delete-theme.js @@ -8,17 +8,17 @@ export default ModalComponent.extend({ theme: alias('model.theme'), download: alias('model.download'), + actions: { + confirm() { + this.get('deleteTheme').perform(); + } + }, + deleteTheme: task(function* () { try { yield invokeAction(this, 'confirm'); } finally { this.send('closeModal'); } - }).drop(), - - actions: { - confirm() { - this.get('deleteTheme').perform(); - } - } + }).drop() }); diff --git a/app/components/modal-delete-user.js b/app/components/modal-delete-user.js index db115d10e..c69d4ecac 100644 --- a/app/components/modal-delete-user.js +++ b/app/components/modal-delete-user.js @@ -7,17 +7,17 @@ export default ModalComponent.extend({ user: alias('model'), + actions: { + confirm() { + this.get('deleteUser').perform(); + } + }, + deleteUser: task(function* () { try { yield invokeAction(this, 'confirm'); } finally { this.send('closeModal'); } - }).drop(), - - actions: { - confirm() { - this.get('deleteUser').perform(); - } - } + }).drop() }); diff --git a/app/components/modal-invite-new-user.js b/app/components/modal-invite-new-user.js index 26938ff92..8b20837c1 100644 --- a/app/components/modal-invite-new-user.js +++ b/app/components/modal-invite-new-user.js @@ -9,6 +9,9 @@ import {task} from 'ember-concurrency'; const {Promise} = RSVP; export default ModalComponent.extend(ValidationEngine, { + notifications: service(), + store: service(), + classNames: 'modal-content invite-new-user', role: null, @@ -17,9 +20,6 @@ export default ModalComponent.extend(ValidationEngine, { validationType: 'inviteUser', - notifications: service(), - store: service(), - init() { this._super(...arguments); @@ -46,6 +46,16 @@ export default ModalComponent.extend(ValidationEngine, { this.set('hasValidated', emberA()); }, + actions: { + setRole(role) { + this.set('role', role); + }, + + confirm() { + this.get('sendInvitation').perform(); + } + }, + validate() { let email = this.get('email'); @@ -115,15 +125,5 @@ export default ModalComponent.extend(ValidationEngine, { this.send('closeModal'); } } - }).drop(), - - actions: { - setRole(role) { - this.set('role', role); - }, - - confirm() { - this.get('sendInvitation').perform(); - } - } + }).drop() }); diff --git a/app/components/modal-new-subscriber.js b/app/components/modal-new-subscriber.js index d92442026..71e1dc168 100644 --- a/app/components/modal-new-subscriber.js +++ b/app/components/modal-new-subscriber.js @@ -8,6 +8,18 @@ export default ModalComponent.extend({ subscriber: alias('model'), + actions: { + updateEmail(newEmail) { + this.set('subscriber.email', newEmail); + this.set('subscriber.hasValidated', emberA()); + this.get('subscriber.errors').clear(); + }, + + confirm() { + this.get('addSubscriber').perform(); + } + }, + addSubscriber: task(function* () { try { yield this.get('confirm')(); @@ -31,17 +43,5 @@ export default ModalComponent.extend({ throw error; } } - }).drop(), - - actions: { - updateEmail(newEmail) { - this.set('subscriber.email', newEmail); - this.set('subscriber.hasValidated', emberA()); - this.get('subscriber.errors').clear(); - }, - - confirm() { - this.get('addSubscriber').perform(); - } - } + }).drop() }); diff --git a/app/components/modal-re-authenticate.js b/app/components/modal-re-authenticate.js index 95b977f73..74640b12d 100644 --- a/app/components/modal-re-authenticate.js +++ b/app/components/modal-re-authenticate.js @@ -8,18 +8,24 @@ import {inject as service} from '@ember/service'; import {task} from 'ember-concurrency'; export default ModalComponent.extend(ValidationEngine, { - validationType: 'signin', - - authenticationError: null, - config: service(), notifications: service(), session: service(), + validationType: 'signin', + + authenticationError: null, + identification: computed('session.user.email', function () { return this.get('session.user.email'); }), + actions: { + confirm() { + this.get('reauthenticate').perform(); + } + }, + _authenticate() { let session = this.get('session'); let authStrategy = 'authenticator:oauth2'; @@ -68,11 +74,5 @@ export default ModalComponent.extend(ValidationEngine, { reauthenticate: task(function* () { return yield this._passwordConfirm(); - }).drop(), - - actions: { - confirm() { - this.get('reauthenticate').perform(); - } - } + }).drop() }); diff --git a/app/components/modal-suspend-user.js b/app/components/modal-suspend-user.js index 8c403d90e..587fe52cb 100644 --- a/app/components/modal-suspend-user.js +++ b/app/components/modal-suspend-user.js @@ -7,17 +7,17 @@ export default ModalComponent.extend({ user: alias('model'), + actions: { + confirm() { + return this.get('suspendUser').perform(); + } + }, + suspendUser: task(function* () { try { yield invokeAction(this, 'confirm'); } finally { this.send('closeModal'); } - }).drop(), - - actions: { - confirm() { - return this.get('suspendUser').perform(); - } - } + }).drop() }); diff --git a/app/components/modal-theme-warnings.js b/app/components/modal-theme-warnings.js index 6a37b9ff3..0a81100fa 100644 --- a/app/components/modal-theme-warnings.js +++ b/app/components/modal-theme-warnings.js @@ -2,12 +2,12 @@ import ModalComponent from 'ghost-admin/components/modal-base'; import {reads} from '@ember/object/computed'; export default ModalComponent.extend({ + 'data-test-theme-warnings-modal': true, + title: reads('model.title'), message: reads('model.message'), warnings: reads('model.warnings'), errors: reads('model.errors'), fatalErrors: reads('model.fatalErrors'), - canActivate: reads('model.canActivate'), - - 'data-test-theme-warnings-modal': true + canActivate: reads('model.canActivate') }); diff --git a/app/components/modal-transfer-owner.js b/app/components/modal-transfer-owner.js index 27e8eee75..ef03f1c36 100644 --- a/app/components/modal-transfer-owner.js +++ b/app/components/modal-transfer-owner.js @@ -5,17 +5,17 @@ import {task} from 'ember-concurrency'; export default ModalComponent.extend({ user: null, + actions: { + confirm() { + this.get('transferOwnership').perform(); + } + }, + transferOwnership: task(function* () { try { yield invokeAction(this, 'confirm'); } finally { this.send('closeModal'); } - }).drop(), - - actions: { - confirm() { - this.get('transferOwnership').perform(); - } - } + }).drop() }); diff --git a/app/components/modal-unsuspend-user.js b/app/components/modal-unsuspend-user.js index 287a1e3fa..8a62e621f 100644 --- a/app/components/modal-unsuspend-user.js +++ b/app/components/modal-unsuspend-user.js @@ -7,17 +7,17 @@ export default ModalComponent.extend({ user: alias('model'), + actions: { + confirm() { + return this.get('unsuspendUser').perform(); + } + }, + unsuspendUser: task(function* () { try { yield invokeAction(this, 'confirm'); } finally { this.send('closeModal'); } - }).drop(), - - actions: { - confirm() { - return this.get('unsuspendUser').perform(); - } - } + }).drop() }); diff --git a/app/components/modal-upload-image.js b/app/components/modal-upload-image.js index 20cc60972..6ffa737fa 100644 --- a/app/components/modal-upload-image.js +++ b/app/components/modal-upload-image.js @@ -6,15 +6,15 @@ import {inject as service} from '@ember/service'; import {task} from 'ember-concurrency'; export default ModalComponent.extend({ + config: service(), + notifications: service(), + model: null, url: '', newUrl: '', _isUploading: false, - config: service(), - notifications: service(), - image: computed('model.{model,imageProperty}', { get() { let imageProperty = this.get('model.imageProperty'); @@ -36,6 +36,26 @@ export default ModalComponent.extend({ this.set('newUrl', image); }, + actions: { + fileUploaded(url) { + this.set('url', url); + this.set('newUrl', url); + }, + + removeImage() { + this.set('url', ''); + this.set('newUrl', ''); + }, + + confirm() { + this.get('uploadImage').perform(); + }, + + isUploading() { + this.toggleProperty('_isUploading'); + } + }, + // 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 @@ -83,25 +103,5 @@ export default ModalComponent.extend({ this.send('closeModal'); } } - }).drop(), - - actions: { - fileUploaded(url) { - this.set('url', url); - this.set('newUrl', url); - }, - - removeImage() { - this.set('url', ''); - this.set('newUrl', ''); - }, - - confirm() { - this.get('uploadImage').perform(); - }, - - isUploading() { - this.toggleProperty('_isUploading'); - } - } + }).drop() }); diff --git a/app/components/modal-upload-theme.js b/app/components/modal-upload-theme.js index f17a25896..aa5d5d784 100644 --- a/app/components/modal-upload-theme.js +++ b/app/components/modal-upload-theme.js @@ -17,6 +17,8 @@ const DEFAULTS = { }; export default ModalComponent.extend({ + eventBus: service(), + store: service(), accept: null, extensions: null, @@ -26,10 +28,8 @@ export default ModalComponent.extend({ theme: false, displayOverwriteWarning: false, - eventBus: service(), - store: service(), - hideUploader: or('theme', 'displayOverwriteWarning'), + currentThemeNames: mapBy('model.themes', 'name'), uploadUrl: computed(function () { return `${ghostPaths().apiRoot}/themes/upload/`; @@ -42,8 +42,6 @@ export default ModalComponent.extend({ return themePackage ? `${themePackage.name} - ${themePackage.version}` : name; }), - currentThemeNames: mapBy('model.themes', 'name'), - fileThemeName: computed('file', function () { let file = this.get('file'); return file.name.replace(/\.zip$/, ''); diff --git a/app/controllers/error.js b/app/controllers/error.js index 85b113b47..404dfb141 100644 --- a/app/controllers/error.js +++ b/app/controllers/error.js @@ -4,8 +4,8 @@ import {readOnly} from '@ember/object/computed'; export default Controller.extend({ - error: readOnly('model'), stack: false, + error: readOnly('model'), code: computed('error.status', function () { return this.get('error.status') > 200 ? this.get('error.status') : 500; diff --git a/app/controllers/posts.js b/app/controllers/posts.js index 0892e3a6e..d4f8f7e4a 100644 --- a/app/controllers/posts.js +++ b/app/controllers/posts.js @@ -37,9 +37,14 @@ export default Controller.extend({ session: service(), store: service(), - postsInfinityModel: alias('model'), - queryParams: ['type', 'author', 'tag', 'order'], + + init() { + this._super(...arguments); + this.availableTypes = TYPES; + this.availableOrders = ORDERS; + }, + type: null, author: null, tag: null, @@ -51,6 +56,8 @@ export default Controller.extend({ availableTypes: null, availableOrders: null, + postsInfinityModel: alias('model'), + showingAll: computed('type', 'author', 'tag', function () { let {type, author, tag} = this.getProperties(['type', 'author', 'tag']); @@ -107,12 +114,6 @@ export default Controller.extend({ return authors.findBy('slug', author); }), - init() { - this._super(...arguments); - this.availableTypes = TYPES; - this.availableOrders = ORDERS; - }, - actions: { changeType(type) { this.set('type', get(type, 'value')); diff --git a/app/controllers/reset.js b/app/controllers/reset.js index 84afa8862..64ed203ca 100644 --- a/app/controllers/reset.js +++ b/app/controllers/reset.js @@ -6,6 +6,12 @@ import {inject as service} from '@ember/service'; import {task} from 'ember-concurrency'; export default Controller.extend(ValidationEngine, { + ghostPaths: service(), + notifications: service(), + session: service(), + ajax: service(), + config: service(), + newPassword: '', ne2Password: '', token: '', @@ -13,18 +19,18 @@ export default Controller.extend(ValidationEngine, { validationType: 'reset', - ghostPaths: service(), - notifications: service(), - session: service(), - ajax: service(), - config: service(), - email: computed('token', function () { // The token base64 encodes the email (and some other stuff), // each section is divided by a '|'. Email comes second. return atob(this.get('token')).split('|')[1]; }), + actions: { + submit() { + return this.get('resetPassword').perform(); + } + }, + // Used to clear sensitive information clearData() { this.setProperties({ @@ -68,11 +74,5 @@ export default Controller.extend(ValidationEngine, { throw error; } } - }).drop(), - - actions: { - submit() { - return this.get('resetPassword').perform(); - } - } + }).drop() }); diff --git a/app/controllers/settings/apps/amp.js b/app/controllers/settings/apps/amp.js index ee3b67d5d..caa5ee1cb 100644 --- a/app/controllers/settings/apps/amp.js +++ b/app/controllers/settings/apps/amp.js @@ -8,23 +8,9 @@ export default Controller.extend({ notifications: service(), settings: service(), - ampSettings: alias('settings.amp'), - leaveSettingsTransition: null, - save: task(function* () { - let amp = this.get('ampSettings'); - let settings = this.get('settings'); - - settings.set('amp', amp); - - try { - return yield settings.save(); - } catch (error) { - this.get('notifications').showAPIError(error); - throw error; - } - }).drop(), + ampSettings: alias('settings.amp'), actions: { update(value) { @@ -73,5 +59,19 @@ export default Controller.extend({ return transition.retry(); } - } + }, + + save: task(function* () { + let amp = this.get('ampSettings'); + let settings = this.get('settings'); + + settings.set('amp', amp); + + try { + return yield settings.save(); + } catch (error) { + this.get('notifications').showAPIError(error); + throw error; + } + }).drop() }); diff --git a/app/controllers/settings/apps/slack.js b/app/controllers/settings/apps/slack.js index c72137b78..06c3469c2 100644 --- a/app/controllers/settings/apps/slack.js +++ b/app/controllers/settings/apps/slack.js @@ -12,53 +12,16 @@ export default Controller.extend({ notifications: service(), settings: service(), - slackSettings: boundOneWay('settings.slack.firstObject'), - testNotificationDisabled: empty('slackSettings.url'), - - leaveSettingsTransition: null, - slackArray: null, - init() { this._super(...arguments); this.slackArray = []; }, - save: task(function* () { - let slack = this.get('slackSettings'); - let settings = this.get('settings'); - let slackArray = this.get('slackArray'); + leaveSettingsTransition: null, + slackArray: null, - try { - yield slack.validate(); - // clear existing objects in slackArray to make sure we only push the validated one - slackArray.clear().pushObject(slack); - yield settings.set('slack', slackArray); - return yield settings.save(); - } catch (error) { - if (error) { - this.get('notifications').showAPIError(error); - throw error; - } - } - }).drop(), - - sendTestNotification: task(function* () { - let notifications = this.get('notifications'); - let slackApi = this.get('ghostPaths.url').api('slack', 'test'); - - try { - yield this.get('save').perform(); - yield this.get('ajax').post(slackApi); - notifications.showNotification('Check your Slack channel for the test message!', {type: 'info', key: 'slack-test.send.success'}); - return true; - } catch (error) { - notifications.showAPIError(error, {key: 'slack-test:send'}); - - if (!isInvalidError(error)) { - throw error; - } - } - }).drop(), + slackSettings: boundOneWay('settings.slack.firstObject'), + testNotificationDisabled: empty('slackSettings.url'), actions: { save() { @@ -123,5 +86,42 @@ export default Controller.extend({ return transition.retry(); } - } + }, + + save: task(function* () { + let slack = this.get('slackSettings'); + let settings = this.get('settings'); + let slackArray = this.get('slackArray'); + + try { + yield slack.validate(); + // clear existing objects in slackArray to make sure we only push the validated one + slackArray.clear().pushObject(slack); + yield settings.set('slack', slackArray); + return yield settings.save(); + } catch (error) { + if (error) { + this.get('notifications').showAPIError(error); + throw error; + } + } + }).drop(), + + sendTestNotification: task(function* () { + let notifications = this.get('notifications'); + let slackApi = this.get('ghostPaths.url').api('slack', 'test'); + + try { + yield this.get('save').perform(); + yield this.get('ajax').post(slackApi); + notifications.showNotification('Check your Slack channel for the test message!', {type: 'info', key: 'slack-test.send.success'}); + return true; + } catch (error) { + notifications.showAPIError(error, {key: 'slack-test:send'}); + + if (!isInvalidError(error)) { + throw error; + } + } + }).drop() }); diff --git a/app/controllers/settings/apps/unsplash.js b/app/controllers/settings/apps/unsplash.js index 5b57d516f..1e6630bea 100644 --- a/app/controllers/settings/apps/unsplash.js +++ b/app/controllers/settings/apps/unsplash.js @@ -8,28 +8,11 @@ export default Controller.extend({ notifications: service(), settings: service(), - unsplashSettings: alias('settings.unsplash'), dirtyAttributes: null, rollbackValue: null, - leaveSettingsTransition: null, - save: task(function* () { - let unsplash = this.get('unsplashSettings'); - let settings = this.get('settings'); - - try { - settings.set('unsplash', unsplash); - this.set('dirtyAttributes', false); - this.set('rollbackValue', null); - return yield settings.save(); - } catch (error) { - if (error) { - this.get('notifications').showAPIError(error); - throw error; - } - } - }).drop(), + unsplashSettings: alias('settings.unsplash'), actions: { save() { @@ -83,5 +66,22 @@ export default Controller.extend({ return transition.retry(); } - } + }, + + save: task(function* () { + let unsplash = this.get('unsplashSettings'); + let settings = this.get('settings'); + + try { + settings.set('unsplash', unsplash); + this.set('dirtyAttributes', false); + this.set('rollbackValue', null); + return yield settings.save(); + } catch (error) { + if (error) { + this.get('notifications').showAPIError(error); + throw error; + } + } + }).drop() }); diff --git a/app/controllers/settings/code-injection.js b/app/controllers/settings/code-injection.js index a5317311b..4cb785903 100644 --- a/app/controllers/settings/code-injection.js +++ b/app/controllers/settings/code-injection.js @@ -7,17 +7,6 @@ export default Controller.extend({ notifications: service(), settings: service(), - save: task(function* () { - let notifications = this.get('notifications'); - - try { - return yield this.get('settings').save(); - } catch (error) { - notifications.showAPIError(error, {key: 'code-injection.save'}); - throw error; - } - }), - actions: { save() { this.get('save').perform(); @@ -62,5 +51,16 @@ export default Controller.extend({ return transition.retry(); } - } + }, + + save: task(function* () { + let notifications = this.get('notifications'); + + try { + return yield this.get('settings').save(); + } catch (error) { + notifications.showAPIError(error, {key: 'code-injection.save'}); + throw error; + } + }) }); diff --git a/app/controllers/settings/design.js b/app/controllers/settings/design.js index 74f1d108e..f5ab4069c 100644 --- a/app/controllers/settings/design.js +++ b/app/controllers/settings/design.js @@ -17,6 +17,11 @@ export default Controller.extend({ session: service(), settings: service(), + init() { + this._super(...arguments); + this.set('newNavItem', NavigationItem.create({isNew: true})); + }, + newNavItem: null, dirtyAttributes: false, @@ -31,65 +36,6 @@ export default Controller.extend({ return url.slice(-1) !== '/' ? `${url}/` : url; }), - init() { - this._super(...arguments); - this.set('newNavItem', NavigationItem.create({isNew: true})); - }, - - save: task(function* () { - let navItems = this.get('settings.navigation'); - let newNavItem = this.get('newNavItem'); - let notifications = this.get('notifications'); - let validationPromises = []; - - if (!newNavItem.get('isBlank')) { - validationPromises.pushObject(this.send('addNavItem')); - } - - navItems.map((item) => { - validationPromises.pushObject(item.validate()); - }); - - try { - yield RSVP.all(validationPromises); - this.set('dirtyAttributes', false); - return yield this.get('settings').save(); - } catch (error) { - if (error) { - notifications.showAPIError(error); - throw error; - } - } - }), - - addNewNavItem() { - let navItems = this.get('settings.navigation'); - let newNavItem = this.get('newNavItem'); - - newNavItem.set('isNew', false); - navItems.pushObject(newNavItem); - this.set('dirtyAttributes', true); - this.set('newNavItem', NavigationItem.create({isNew: true})); - $('.gh-blognav-line:last input:first').focus(); - }, - - _deleteTheme() { - let theme = this.get('store').peekRecord('theme', this.get('themeToDelete').name); - - if (!theme) { - return; - } - - return theme.destroyRecord().then(() => { - // HACK: this is a private method, we need to unload from the store - // here so that uploading another theme with the same "id" doesn't - // attempt to update the deleted record - theme.unloadRecord(); - }).catch((error) => { - this.get('notifications').showAPIError(error); - }); - }, - actions: { save() { this.get('save').perform(); @@ -260,5 +206,59 @@ export default Controller.extend({ reset() { this.set('newNavItem', NavigationItem.create({isNew: true})); } + }, + + save: task(function* () { + let navItems = this.get('settings.navigation'); + let newNavItem = this.get('newNavItem'); + let notifications = this.get('notifications'); + let validationPromises = []; + + if (!newNavItem.get('isBlank')) { + validationPromises.pushObject(this.send('addNavItem')); + } + + navItems.map((item) => { + validationPromises.pushObject(item.validate()); + }); + + try { + yield RSVP.all(validationPromises); + this.set('dirtyAttributes', false); + return yield this.get('settings').save(); + } catch (error) { + if (error) { + notifications.showAPIError(error); + throw error; + } + } + }), + + addNewNavItem() { + let navItems = this.get('settings.navigation'); + let newNavItem = this.get('newNavItem'); + + newNavItem.set('isNew', false); + navItems.pushObject(newNavItem); + this.set('dirtyAttributes', true); + this.set('newNavItem', NavigationItem.create({isNew: true})); + $('.gh-blognav-line:last input:first').focus(); + }, + + _deleteTheme() { + let theme = this.get('store').peekRecord('theme', this.get('themeToDelete').name); + + if (!theme) { + return; + } + + return theme.destroyRecord().then(() => { + // HACK: this is a private method, we need to unload from the store + // here so that uploading another theme with the same "id" doesn't + // attempt to update the deleted record + theme.unloadRecord(); + }).catch((error) => { + this.get('notifications').showAPIError(error); + }); } }); diff --git a/app/controllers/settings/general.js b/app/controllers/settings/general.js index 135caa08a..1fab6e9ed 100644 --- a/app/controllers/settings/general.js +++ b/app/controllers/settings/general.js @@ -20,6 +20,11 @@ export default Controller.extend({ session: service(), settings: service(), + init() { + this._super(...arguments); + this.iconExtensions = ICON_EXTENSIONS; + }, + availableTimezones: null, iconExtensions: null, iconMimeTypes: 'image/png,image/x-icon', @@ -51,44 +56,6 @@ export default Controller.extend({ return `${blogUrl}/${publicHash}/rss`; }), - init() { - this._super(...arguments); - this.iconExtensions = ICON_EXTENSIONS; - }, - - _deleteTheme() { - let theme = this.get('store').peekRecord('theme', this.get('themeToDelete').name); - - if (!theme) { - return; - } - - return theme.destroyRecord().catch((error) => { - this.get('notifications').showAPIError(error); - }); - }, - - save: task(function* () { - let notifications = this.get('notifications'); - let config = this.get('config'); - - try { - let settings = yield this.get('settings').save(); - config.set('blogTitle', settings.get('title')); - - // this forces the document title to recompute after - // a blog title change - this.send('collectTitleTokens', []); - - return settings; - } catch (error) { - if (error) { - notifications.showAPIError(error, {key: 'settings.save'}); - } - throw error; - } - }), - actions: { save() { this.get('save').perform(); @@ -294,5 +261,38 @@ export default Controller.extend({ return; } } - } + }, + + _deleteTheme() { + let theme = this.get('store').peekRecord('theme', this.get('themeToDelete').name); + + if (!theme) { + return; + } + + return theme.destroyRecord().catch((error) => { + this.get('notifications').showAPIError(error); + }); + }, + + save: task(function* () { + let notifications = this.get('notifications'); + let config = this.get('config'); + + try { + let settings = yield this.get('settings').save(); + config.set('blogTitle', settings.get('title')); + + // this forces the document title to recompute after + // a blog title change + this.send('collectTitleTokens', []); + + return settings; + } catch (error) { + if (error) { + notifications.showAPIError(error, {key: 'settings.save'}); + } + throw error; + } + }) }); diff --git a/app/controllers/settings/labs.js b/app/controllers/settings/labs.js index 9071cbba5..f713a906c 100644 --- a/app/controllers/settings/labs.js +++ b/app/controllers/settings/labs.js @@ -26,16 +26,6 @@ const JSON_EXTENSION = ['json']; const JSON_MIME_TYPE = ['application/json']; export default Controller.extend({ - importErrors: null, - importSuccessful: false, - showDeleteAllModal: false, - submitting: false, - uploadButtonText: 'Import', - - importMimeType: null, - jsonExtension: null, - jsonMimeType: null, - ajax: service(), config: service(), feature: service(), @@ -51,75 +41,15 @@ export default Controller.extend({ this.jsonMimeType = JSON_MIME_TYPE; }, - // TODO: convert to ember-concurrency task - _validate(file) { - // Windows doesn't have mime-types for json files by default, so we - // need to have some additional checking - if (file.type === '') { - // First check file extension so we can early return - let [, extension] = (/(?:\.([^.]+))?$/).exec(file.name); + importErrors: null, + importSuccessful: false, + showDeleteAllModal: false, + submitting: false, + uploadButtonText: 'Import', - if (!extension || extension.toLowerCase() !== 'json') { - return RSVP.reject(new UnsupportedMediaTypeError()); - } - - return new Promise((resolve, reject) => { - // Extension is correct, so check the contents of the file - let reader = new FileReader(); - - reader.onload = function () { - let {result} = reader; - - try { - JSON.parse(result); - - return resolve(); - } catch (e) { - return reject(new UnsupportedMediaTypeError()); - } - }; - - reader.readAsText(file); - }); - } - - let accept = this.get('importMimeType'); - - if (!isBlank(accept) && file && accept.indexOf(file.type) === -1) { - return RSVP.reject(new UnsupportedMediaTypeError()); - } - - return RSVP.resolve(); - }, - - sendTestEmail: task(function* () { - let notifications = this.get('notifications'); - let emailUrl = this.get('ghostPaths.url').api('mail', 'test'); - - try { - yield this.get('ajax').post(emailUrl); - notifications.showAlert('Check your email for the test message.', {type: 'info', key: 'test-email.send.success'}); - return true; - } catch (error) { - notifications.showAPIError(error, {key: 'test-email:send'}); - } - }).drop(), - - redirectUploadResult: task(function* (success) { - this.set('redirectSuccess', success); - this.set('redirectFailure', !success); - - yield timeout(Ember.testing ? 100 : 5000); // eslint-disable-line - - this.set('redirectSuccess', null); - this.set('redirectFailure', null); - return true; - }).drop(), - - reset() { - this.set('importErrors', null); - this.set('importSuccessful', false); - }, + importMimeType: null, + jsonExtension: null, + jsonMimeType: null, actions: { onUpload(file) { @@ -217,5 +147,75 @@ export default Controller.extend({ .find('input[type="file"]') .click(); } + }, + + // TODO: convert to ember-concurrency task + _validate(file) { + // Windows doesn't have mime-types for json files by default, so we + // need to have some additional checking + if (file.type === '') { + // First check file extension so we can early return + let [, extension] = (/(?:\.([^.]+))?$/).exec(file.name); + + if (!extension || extension.toLowerCase() !== 'json') { + return RSVP.reject(new UnsupportedMediaTypeError()); + } + + return new Promise((resolve, reject) => { + // Extension is correct, so check the contents of the file + let reader = new FileReader(); + + reader.onload = function () { + let {result} = reader; + + try { + JSON.parse(result); + + return resolve(); + } catch (e) { + return reject(new UnsupportedMediaTypeError()); + } + }; + + reader.readAsText(file); + }); + } + + let accept = this.get('importMimeType'); + + if (!isBlank(accept) && file && accept.indexOf(file.type) === -1) { + return RSVP.reject(new UnsupportedMediaTypeError()); + } + + return RSVP.resolve(); + }, + + sendTestEmail: task(function* () { + let notifications = this.get('notifications'); + let emailUrl = this.get('ghostPaths.url').api('mail', 'test'); + + try { + yield this.get('ajax').post(emailUrl); + notifications.showAlert('Check your email for the test message.', {type: 'info', key: 'test-email.send.success'}); + return true; + } catch (error) { + notifications.showAPIError(error, {key: 'test-email:send'}); + } + }).drop(), + + redirectUploadResult: task(function* (success) { + this.set('redirectSuccess', success); + this.set('redirectFailure', !success); + + yield timeout(Ember.testing ? 100 : 5000); // eslint-disable-line + + this.set('redirectSuccess', null); + this.set('redirectFailure', null); + return true; + }).drop(), + + reset() { + this.set('importErrors', null); + this.set('importSuccessful', false); } }); diff --git a/app/controllers/settings/tags.js b/app/controllers/settings/tags.js index 321b5c258..2c8486e74 100644 --- a/app/controllers/settings/tags.js +++ b/app/controllers/settings/tags.js @@ -26,6 +26,17 @@ export default Controller.extend({ return 0; }), + actions: { + leftMobile() { + let firstTag = this.get('tags.firstObject'); + // redirect to first tag if possible so that you're not left with + // tag settings blank slate when switching from portrait to landscape + if (firstTag && !this.get('tagController.tag')) { + this.transitionToRoute('settings.tags.tag', firstTag); + } + } + }, + scrollTagIntoView(tag) { run.scheduleOnce('afterRender', this, function () { let id = `#gh-tag-${tag.get('id')}`; @@ -48,17 +59,5 @@ export default Controller.extend({ } } }); - }, - - actions: { - leftMobile() { - let firstTag = this.get('tags.firstObject'); - // redirect to first tag if possible so that you're not left with - // tag settings blank slate when switching from portrait to landscape - if (firstTag && !this.get('tagController.tag')) { - this.transitionToRoute('settings.tags.tag', firstTag); - } - } } - }); diff --git a/app/controllers/settings/tags/tag.js b/app/controllers/settings/tags/tag.js index c97f0105c..1481ac576 100644 --- a/app/controllers/settings/tags/tag.js +++ b/app/controllers/settings/tags/tag.js @@ -3,15 +3,28 @@ import {alias} from '@ember/object/computed'; import {inject as service} from '@ember/service'; export default Controller.extend({ + applicationController: controller('application'), + tagsController: controller('settings.tags'), + notifications: service(), showDeleteTagModal: false, tag: alias('model'), isMobile: alias('tagsController.isMobile'), - applicationController: controller('application'), - tagsController: controller('settings.tags'), - notifications: service(), + actions: { + setProperty(propKey, value) { + this._saveTagProperty(propKey, value); + }, + + toggleDeleteTagModal() { + this.toggleProperty('showDeleteTagModal'); + }, + + deleteTag() { + return this._deleteTag(); + } + }, _saveTagProperty(propKey, newValue) { let tag = this.get('tag'); @@ -60,19 +73,5 @@ export default Controller.extend({ _deleteTagFailure(error) { this.get('notifications').showAPIError(error, {key: 'tag.delete'}); - }, - - actions: { - setProperty(propKey, value) { - this._saveTagProperty(propKey, value); - }, - - toggleDeleteTagModal() { - this.toggleProperty('showDeleteTagModal'); - }, - - deleteTag() { - return this._deleteTag(); - } } }); diff --git a/app/controllers/setup/three.js b/app/controllers/setup/three.js index 3e1bb4baa..63638f639 100644 --- a/app/controllers/setup/three.js +++ b/app/controllers/setup/three.js @@ -14,12 +14,13 @@ import {task, timeout} from 'ember-concurrency'; const {Errors} = DS; export default Controller.extend({ - notifications: service(), two: controller('setup/two'), + notifications: service(), + + users: '', errors: Errors.create(), hasValidated: emberA(), - users: '', ownerEmail: alias('two.email'), usersArray: computed('users', function () { @@ -71,31 +72,6 @@ export default Controller.extend({ } }), - validate() { - let errors = this.get('errors'); - let validationResult = this.get('validationResult'); - let property = 'users'; - - errors.clear(); - - // If property isn't in the `hasValidated` array, add it to mark that this field can show a validation result - this.get('hasValidated').addObject(property); - - if (validationResult === true) { - return true; - } - - validationResult.forEach((error) => { - // Only one error type here so far, but one day the errors might be more detailed - switch (error.error) { - case 'email': - errors.add(property, `${error.user} is not a valid email.`); - } - }); - - return false; - }, - buttonText: computed('errors.users', 'validUsersArray', 'invalidUsersArray', function () { let usersError = this.get('errors.users.firstObject.message'); let validNum = this.get('validUsersArray').length; @@ -133,6 +109,46 @@ export default Controller.extend({ return this.store.findAll('role', {reload: true}).then(roles => roles.findBy('name', 'Author')); }), + actions: { + validate() { + this.validate(); + }, + + invite() { + this.get('invite').perform(); + }, + + skipInvite() { + this.send('loadServerNotifications'); + this.transitionToRoute('posts.index'); + } + }, + + validate() { + let errors = this.get('errors'); + let validationResult = this.get('validationResult'); + let property = 'users'; + + errors.clear(); + + // If property isn't in the `hasValidated` array, add it to mark that this field can show a validation result + this.get('hasValidated').addObject(property); + + if (validationResult === true) { + return true; + } + + validationResult.forEach((error) => { + // Only one error type here so far, but one day the errors might be more detailed + switch (error.error) { + case 'email': + errors.add(property, `${error.user} is not a valid email.`); + } + }); + + return false; + }, + _transitionAfterSubmission() { if (!this._hasTransitioned) { this._hasTransitioned = true; @@ -223,20 +239,5 @@ export default Controller.extend({ invitationsString = successCount > 1 ? 'invitations' : 'invitation'; notifications.showAlert(`${successCount} ${invitationsString} sent!`, {type: 'success', delayed: true, key: 'signup.send-invitations.success'}); } - }, - - actions: { - validate() { - this.validate(); - }, - - invite() { - this.get('invite').perform(); - }, - - skipInvite() { - this.send('loadServerNotifications'); - this.transitionToRoute('posts.index'); - } } }); diff --git a/app/controllers/setup/two.js b/app/controllers/setup/two.js index 862af5e33..16e838c26 100644 --- a/app/controllers/setup/two.js +++ b/app/controllers/setup/two.js @@ -8,8 +8,8 @@ import {inject as service} from '@ember/service'; import {task} from 'ember-concurrency'; export default Controller.extend(ValidationEngine, { - ajax: service(), application: controller(), + ajax: service(), config: service(), ghostPaths: service(), notifications: service(), @@ -27,6 +27,23 @@ export default Controller.extend(ValidationEngine, { name: null, password: null, + actions: { + setup() { + this.get('setup').perform(); + }, + + preValidate(model) { + // Only triggers validation if a value has been entered, preventing empty errors on focusOut + if (this.get(model)) { + return this.validate({property: model}); + } + }, + + setImage(image) { + this.set('profileImage', image); + } + }, + setup: task(function* () { return yield this._passwordSetup(); }), @@ -173,22 +190,5 @@ export default Controller.extend(ValidationEngine, { } else { return fetchSettingsAndConfig.then(() => this.transitionToRoute('setup.three')); } - }, - - actions: { - setup() { - this.get('setup').perform(); - }, - - preValidate(model) { - // Only triggers validation if a value has been entered, preventing empty errors on focusOut - if (this.get(model)) { - return this.validate({property: model}); - } - }, - - setImage(image) { - this.set('profileImage', image); - } } }); diff --git a/app/controllers/signin.js b/app/controllers/signin.js index 42c8e29ff..d8fc2b96c 100644 --- a/app/controllers/signin.js +++ b/app/controllers/signin.js @@ -9,29 +9,35 @@ import {inject as service} from '@ember/service'; import {task} from 'ember-concurrency'; export default Controller.extend(ValidationEngine, { - submitting: false, - loggingIn: false, - authProperties: null, - - ajax: service(), application: controller(), + ajax: service(), config: service(), ghostPaths: service(), notifications: service(), session: service(), settings: service(), - flowErrors: '', - signin: alias('model'), - - // ValidationEngine settings - validationType: 'signin', - init() { this._super(...arguments); this.authProperties = ['identification', 'password']; }, + submitting: false, + loggingIn: false, + authProperties: null, + + flowErrors: '', + // ValidationEngine settings + validationType: 'signin', + + signin: alias('model'), + + actions: { + authenticate() { + this.get('validateAndAuthenticate').perform(); + } + }, + authenticate: task(function* (authStrategy, authentication) { try { let authResult = yield this.get('session') @@ -131,11 +137,5 @@ export default Controller.extend(ValidationEngine, { notifications.showAPIError(error, {defaultErrorText: 'There was a problem with the reset, please try again.', key: 'forgot-password.send'}); } } - }), - - actions: { - authenticate() { - this.get('validateAndAuthenticate').perform(); - } - } + }) }); diff --git a/app/controllers/signup.js b/app/controllers/signup.js index 697338ea7..f090c7298 100644 --- a/app/controllers/signup.js +++ b/app/controllers/signup.js @@ -18,13 +18,23 @@ export default Controller.extend(ValidationEngine, { session: service(), settings: service(), - // ValidationEngine settings - signupDetails: alias('model'), - validationType: 'signup', - flowErrors: '', profileImage: null, + // ValidationEngine settings + validationType: 'signup', + signupDetails: alias('model'), + + actions: { + signup() { + this.get('signup').perform(); + }, + + setImage(image) { + this.set('profileImage', image); + } + }, + authenticate: task(function* (authStrategy, authentication) { try { let authResult = yield this.get('session') @@ -155,15 +165,5 @@ export default Controller.extend(ValidationEngine, { } }); } - }), - - actions: { - signup() { - this.get('signup').perform(); - }, - - setImage(image) { - this.set('profileImage', image); - } - } + }) }); diff --git a/app/controllers/subscribers.js b/app/controllers/subscribers.js index e2b861eaf..d0b97d2a7 100644 --- a/app/controllers/subscribers.js +++ b/app/controllers/subscribers.js @@ -9,6 +9,7 @@ import {computed} from '@ember/object'; import {inject as service} from '@ember/service'; export default Controller.extend(PaginationMixin, { + session: service(), queryParams: ['order', 'direction'], order: 'created_at', @@ -20,8 +21,6 @@ export default Controller.extend(PaginationMixin, { table: null, subscriberToDelete: null, - session: service(), - // paginationSettings is replaced by the pagination mixin so we need a // getter/setter CP here so that we don't lose the dynamic order param paginationSettings: computed('order', 'direction', { @@ -81,17 +80,6 @@ export default Controller.extend(PaginationMixin, { }]; }), - initializeTable() { - this.set('table', new Table(this.get('columns'), this.get('subscribers'))); - }, - - // capture the total from the server any time we fetch a new page - didReceivePaginationMeta(meta) { - if (meta && meta.pagination) { - this.set('total', meta.pagination.total); - } - }, - actions: { loadFirstPage() { let table = this.get('table'); @@ -164,5 +152,16 @@ export default Controller.extend(PaginationMixin, { iframe.attr('src', downloadURL); } + }, + + initializeTable() { + this.set('table', new Table(this.get('columns'), this.get('subscribers'))); + }, + + // capture the total from the server any time we fetch a new page + didReceivePaginationMeta(meta) { + if (meta && meta.pagination) { + this.set('total', meta.pagination.total); + } } }); diff --git a/app/controllers/team/index.js b/app/controllers/team/index.js index 5d7c4fd33..e014521f5 100644 --- a/app/controllers/team/index.js +++ b/app/controllers/team/index.js @@ -4,9 +4,14 @@ import {inject as service} from '@ember/service'; import {sort} from '@ember/object/computed'; export default Controller.extend({ - session: service(), + init() { + this._super(...arguments); + this.inviteOrder = ['email']; + this.userOrder = ['name', 'email']; + }, + showInviteUserModal: false, activeUsers: null, @@ -20,12 +25,6 @@ export default Controller.extend({ sortedActiveUsers: sort('activeUsers', 'userOrder'), sortedSuspendedUsers: sort('suspendedUsers', 'userOrder'), - init() { - this._super(...arguments); - this.inviteOrder = ['email']; - this.userOrder = ['name', 'email']; - }, - actions: { toggleInviteUserModal() { this.toggleProperty('showInviteUserModal'); diff --git a/app/controllers/team/user.js b/app/controllers/team/user.js index 00774ab59..b3e608d7a 100644 --- a/app/controllers/team/user.js +++ b/app/controllers/team/user.js @@ -15,6 +15,14 @@ import {task, taskGroup} from 'ember-concurrency'; const {Handlebars} = Ember; export default Controller.extend({ + ajax: service(), + config: service(), + dropdown: service(), + ghostPaths: service(), + notifications: service(), + session: service(), + slugGenerator: service(), + leaveSettingsTransition: null, dirtyAttributes: false, showDeleteUserModal: false, @@ -25,13 +33,7 @@ export default Controller.extend({ _scratchFacebook: null, _scratchTwitter: null, - ajax: service(), - config: service(), - dropdown: service(), - ghostPaths: service(), - notifications: service(), - session: service(), - slugGenerator: service(), + saveHandlers: taskGroup().enqueue(), user: alias('model'), currentUser: alias('session.user'), @@ -48,10 +50,10 @@ export default Controller.extend({ rolesDropdownIsVisible: and('isNotOwnProfile', 'canAssignRoles', 'isNotOwnersProfile'), userActionsAreVisible: or('deleteUserActionIsVisible', 'canMakeOwner'), + isNotOwnProfile: not('isOwnProfile'), isOwnProfile: computed('user.id', 'currentUser.id', function () { return this.get('user.id') === this.get('currentUser.id'); }), - isNotOwnProfile: not('isOwnProfile'), deleteUserActionIsVisible: computed('currentUser', 'canAssignRoles', 'user', function () { if ((this.get('canAssignRoles') && this.get('isNotOwnProfile') && !this.get('user.isOwner')) @@ -93,113 +95,6 @@ export default Controller.extend({ return this.store.query('role', {permissions: 'assign'}); }), - _deleteUser() { - if (this.get('deleteUserActionIsVisible')) { - let user = this.get('user'); - return user.destroyRecord(); - } - }, - - _deleteUserSuccess() { - this.get('notifications').closeAlerts('user.delete'); - this.store.unloadAll('post'); - this.transitionToRoute('team'); - }, - - _deleteUserFailure() { - this.get('notifications').showAlert('The user could not be deleted. Please try again.', {type: 'error', key: 'user.delete.failed'}); - }, - - saveHandlers: taskGroup().enqueue(), - - updateSlug: task(function* (newSlug) { - let slug = this.get('user.slug'); - - newSlug = newSlug || slug; - newSlug = newSlug.trim(); - - // Ignore unchanged slugs or candidate slugs that are empty - if (!newSlug || slug === newSlug) { - this.set('slugValue', slug); - - return true; - } - - let serverSlug = yield this.get('slugGenerator').generateSlug('user', newSlug); - - // If after getting the sanitized and unique slug back from the API - // we end up with a slug that matches the existing slug, abort the change - if (serverSlug === slug) { - return true; - } - - // Because the server transforms the candidate slug by stripping - // certain characters and appending a number onto the end of slugs - // to enforce uniqueness, there are cases where we can get back a - // candidate slug that is a duplicate of the original except for - // the trailing incrementor (e.g., this-is-a-slug and this-is-a-slug-2) - - // get the last token out of the slug candidate and see if it's a number - let slugTokens = serverSlug.split('-'); - let check = Number(slugTokens.pop()); - - // if the candidate slug is the same as the existing slug except - // for the incrementor then the existing slug should be used - if (isNumber(check) && check > 0) { - if (slug === slugTokens.join('-') && serverSlug !== newSlug) { - this.set('slugValue', slug); - - return true; - } - } - - this.set('slugValue', serverSlug); - this.set('dirtyAttributes', true); - - return true; - }).group('saveHandlers'), - - save: task(function* () { - let user = this.get('user'); - let slugValue = this.get('slugValue'); - let slugChanged; - - if (user.get('slug') !== slugValue) { - slugChanged = true; - user.set('slug', slugValue); - } - - try { - let currentPath, - newPath; - - user = yield user.save({format: false}); - - // If the user's slug has changed, change the URL and replace - // the history so refresh and back button still work - if (slugChanged) { - currentPath = window.location.hash; - - newPath = currentPath.split('/'); - newPath[newPath.length - 1] = user.get('slug'); - newPath = newPath.join('/'); - - windowProxy.replaceState({path: newPath}, '', newPath); - } - - this.set('dirtyAttributes', false); - this.get('notifications').closeAlerts('user.update'); - - return user; - } catch (error) { - // validation engine returns undefined so we have to check - // before treating the failure as an API error - if (error) { - this.get('notifications').showAPIError(error, {key: 'user.update'}); - } - } - }).group('saveHandlers'), - actions: { changeRole(newRole) { this.get('user').set('role', newRole); @@ -460,5 +355,110 @@ export default Controller.extend({ this.get('user.hasValidated').removeObject('ne2Password'); this.get('user.errors').remove('ne2Password'); } - } + }, + + _deleteUser() { + if (this.get('deleteUserActionIsVisible')) { + let user = this.get('user'); + return user.destroyRecord(); + } + }, + + _deleteUserSuccess() { + this.get('notifications').closeAlerts('user.delete'); + this.store.unloadAll('post'); + this.transitionToRoute('team'); + }, + + _deleteUserFailure() { + this.get('notifications').showAlert('The user could not be deleted. Please try again.', {type: 'error', key: 'user.delete.failed'}); + }, + + updateSlug: task(function* (newSlug) { + let slug = this.get('user.slug'); + + newSlug = newSlug || slug; + newSlug = newSlug.trim(); + + // Ignore unchanged slugs or candidate slugs that are empty + if (!newSlug || slug === newSlug) { + this.set('slugValue', slug); + + return true; + } + + let serverSlug = yield this.get('slugGenerator').generateSlug('user', newSlug); + + // If after getting the sanitized and unique slug back from the API + // we end up with a slug that matches the existing slug, abort the change + if (serverSlug === slug) { + return true; + } + + // Because the server transforms the candidate slug by stripping + // certain characters and appending a number onto the end of slugs + // to enforce uniqueness, there are cases where we can get back a + // candidate slug that is a duplicate of the original except for + // the trailing incrementor (e.g., this-is-a-slug and this-is-a-slug-2) + + // get the last token out of the slug candidate and see if it's a number + let slugTokens = serverSlug.split('-'); + let check = Number(slugTokens.pop()); + + // if the candidate slug is the same as the existing slug except + // for the incrementor then the existing slug should be used + if (isNumber(check) && check > 0) { + if (slug === slugTokens.join('-') && serverSlug !== newSlug) { + this.set('slugValue', slug); + + return true; + } + } + + this.set('slugValue', serverSlug); + this.set('dirtyAttributes', true); + + return true; + }).group('saveHandlers'), + + save: task(function* () { + let user = this.get('user'); + let slugValue = this.get('slugValue'); + let slugChanged; + + if (user.get('slug') !== slugValue) { + slugChanged = true; + user.set('slug', slugValue); + } + + try { + let currentPath, + newPath; + + user = yield user.save({format: false}); + + // If the user's slug has changed, change the URL and replace + // the history so refresh and back button still work + if (slugChanged) { + currentPath = window.location.hash; + + newPath = currentPath.split('/'); + newPath[newPath.length - 1] = user.get('slug'); + newPath = newPath.join('/'); + + windowProxy.replaceState({path: newPath}, '', newPath); + } + + this.set('dirtyAttributes', false); + this.get('notifications').closeAlerts('user.update'); + + return user; + } catch (error) { + // validation engine returns undefined so we have to check + // before treating the failure as an API error + if (error) { + this.get('notifications').showAPIError(error, {key: 'user.update'}); + } + } + }).group('saveHandlers') }); diff --git a/app/routes/about.js b/app/routes/about.js index da4a5333e..0fdfce85b 100644 --- a/app/routes/about.js +++ b/app/routes/about.js @@ -3,13 +3,13 @@ import styleBody from 'ghost-admin/mixins/style-body'; import {inject as service} from '@ember/service'; export default AuthenticatedRoute.extend(styleBody, { + ghostPaths: service(), + ajax: service(), + titleToken: 'About', classNames: ['view-about'], - ghostPaths: service(), - ajax: service(), - cachedConfig: false, model() { diff --git a/app/routes/application.js b/app/routes/application.js index 83e8fd5b7..59410e081 100644 --- a/app/routes/application.js +++ b/app/routes/application.js @@ -30,10 +30,6 @@ shortcuts.esc = {action: 'closeMenus', scope: 'default'}; shortcuts[`${ctrlOrCmd}+s`] = {action: 'save', scope: 'all'}; export default Route.extend(ApplicationRouteMixin, ShortcutsRoute, { - shortcuts, - - routeAfterAuthentication: 'posts', - config: service(), feature: service(), notifications: service(), @@ -41,6 +37,10 @@ export default Route.extend(ApplicationRouteMixin, ShortcutsRoute, { tour: service(), ui: service(), + shortcuts, + + routeAfterAuthentication: 'posts', + beforeModel() { return this.get('config').fetch(); }, @@ -86,36 +86,6 @@ export default Route.extend(ApplicationRouteMixin, ShortcutsRoute, { } }, - title(tokens) { - return `${tokens.join(' - ')} - ${this.get('config.blogTitle')}`; - }, - - sessionAuthenticated() { - if (this.get('session.skipAuthSuccessHandler')) { - return; - } - - // standard ESA post-sign-in redirect - this._super(...arguments); - - // trigger post-sign-in background behaviour - this.get('session.user').then((user) => { - this.send('signedIn', user); - }); - }, - - sessionInvalidated() { - let transition = this.get('appLoadTransition'); - - if (transition) { - transition.send('authorizationFailed'); - } else { - run.scheduleOnce('routerTransitions', this, function () { - this.send('authorizationFailed'); - }); - } - }, - actions: { closeMenus() { this.get('ui').closeMenus(); @@ -233,5 +203,35 @@ export default Route.extend(ApplicationRouteMixin, ShortcutsRoute, { // fallback to 500 error page return true; } + }, + + title(tokens) { + return `${tokens.join(' - ')} - ${this.get('config.blogTitle')}`; + }, + + sessionAuthenticated() { + if (this.get('session.skipAuthSuccessHandler')) { + return; + } + + // standard ESA post-sign-in redirect + this._super(...arguments); + + // trigger post-sign-in background behaviour + this.get('session.user').then((user) => { + this.send('signedIn', user); + }); + }, + + sessionInvalidated() { + let transition = this.get('appLoadTransition'); + + if (transition) { + transition.send('authorizationFailed'); + } else { + run.scheduleOnce('routerTransitions', this, function () { + this.send('authorizationFailed'); + }); + } } }); diff --git a/app/routes/posts.js b/app/routes/posts.js index 2d98602d4..83cc33de5 100644 --- a/app/routes/posts.js +++ b/app/routes/posts.js @@ -5,11 +5,6 @@ import {assign} from '@ember/polyfills'; import {isBlank} from '@ember/utils'; export default AuthenticatedRoute.extend(InfinityRoute, { - titleToken: 'Content', - - perPage: 30, - perPageParam: 'limit', - totalPagesParam: 'meta.pagination.pages', queryParams: { type: { @@ -30,6 +25,12 @@ export default AuthenticatedRoute.extend(InfinityRoute, { } }, + titleToken: 'Content', + + perPage: 30, + perPageParam: 'limit', + totalPagesParam: 'meta.pagination.pages', + _type: null, model(params) { @@ -66,6 +67,40 @@ export default AuthenticatedRoute.extend(InfinityRoute, { }); }, + // trigger a background load of all tags and authors for use in the filter dropdowns + setupController(controller) { + this._super(...arguments); + + if (!controller._hasLoadedTags) { + this.get('store').query('tag', {limit: 'all'}).then(() => { + controller._hasLoadedTags = true; + }); + } + + this.get('session.user').then((user) => { + if (!user.get('isAuthor') && !controller._hasLoadedAuthors) { + this.get('store').query('user', {limit: 'all'}).then(() => { + controller._hasLoadedAuthors = true; + }); + } + }); + }, + + actions: { + willTransition() { + if (this.get('controller')) { + this.resetController(); + } + }, + + queryParamsDidChange() { + // scroll back to the top + $('.content-list').scrollTop(0); + + this._super(...arguments); + } + }, + _typeParams(type) { let status = 'all'; let staticPages = 'all'; @@ -102,39 +137,5 @@ export default AuthenticatedRoute.extend(InfinityRoute, { return `${key}:${filter[key]}`; } }).compact().join('+'); - }, - - // trigger a background load of all tags and authors for use in the filter dropdowns - setupController(controller) { - this._super(...arguments); - - if (!controller._hasLoadedTags) { - this.get('store').query('tag', {limit: 'all'}).then(() => { - controller._hasLoadedTags = true; - }); - } - - this.get('session.user').then((user) => { - if (!user.get('isAuthor') && !controller._hasLoadedAuthors) { - this.get('store').query('user', {limit: 'all'}).then(() => { - controller._hasLoadedAuthors = true; - }); - } - }); - }, - - actions: { - willTransition() { - if (this.get('controller')) { - this.resetController(); - } - }, - - queryParamsDidChange() { - // scroll back to the top - $('.content-list').scrollTop(0); - - this._super(...arguments); - } } }); diff --git a/app/routes/reset.js b/app/routes/reset.js index e28ba3b49..d925a3bca 100644 --- a/app/routes/reset.js +++ b/app/routes/reset.js @@ -4,11 +4,11 @@ import styleBody from 'ghost-admin/mixins/style-body'; import {inject as service} from '@ember/service'; export default Route.extend(styleBody, UnauthenticatedRouteMixin, { - classNames: ['ghost-reset'], - notifications: service(), session: service(), + classNames: ['ghost-reset'], + beforeModel() { if (this.get('session.isAuthenticated')) { this.get('notifications').showAlert('You can\'t reset your password while you\'re signed in.', {type: 'warn', delayed: true, key: 'password.reset.signed-in'}); diff --git a/app/routes/settings/apps/slack.js b/app/routes/settings/apps/slack.js index 848ffc5d9..416a89207 100644 --- a/app/routes/settings/apps/slack.js +++ b/app/routes/settings/apps/slack.js @@ -3,12 +3,12 @@ import styleBody from 'ghost-admin/mixins/style-body'; import {inject as service} from '@ember/service'; export default AuthenticatedRoute.extend(styleBody, { + settings: service(), + titleToken: 'Settings - Apps - Slack', classNames: ['settings-view-apps-slack'], - settings: service(), - afterModel() { return this.get('settings').reload(); }, diff --git a/app/routes/settings/code-injection.js b/app/routes/settings/code-injection.js index 733a6dcd8..67db01e68 100644 --- a/app/routes/settings/code-injection.js +++ b/app/routes/settings/code-injection.js @@ -4,11 +4,11 @@ import styleBody from 'ghost-admin/mixins/style-body'; import {inject as service} from '@ember/service'; export default AuthenticatedRoute.extend(styleBody, CurrentUserSettings, { + settings: service(), + titleToken: 'Settings - Code injection', classNames: ['settings-view-code'], - settings: service(), - beforeModel() { this._super(...arguments); return this.get('session.user') diff --git a/app/routes/settings/tags.js b/app/routes/settings/tags.js index 056fdf21d..91dbda2cb 100644 --- a/app/routes/settings/tags.js +++ b/app/routes/settings/tags.js @@ -5,9 +5,6 @@ import CurrentUserSettings from 'ghost-admin/mixins/current-user-settings'; import ShortcutsRoute from 'ghost-admin/mixins/shortcuts-route'; export default AuthenticatedRoute.extend(CurrentUserSettings, ShortcutsRoute, { - titleToken: 'Settings - Tags', - - shortcuts: null, init() { this._super(...arguments); @@ -20,6 +17,10 @@ export default AuthenticatedRoute.extend(CurrentUserSettings, ShortcutsRoute, { }; }, + titleToken: 'Settings - Tags', + + shortcuts: null, + // authors aren't allowed to manage tags beforeModel() { this._super(...arguments); @@ -47,31 +48,6 @@ export default AuthenticatedRoute.extend(CurrentUserSettings, ShortcutsRoute, { this.send('resetShortcutsScope'); }, - stepThroughTags(step) { - let currentTag = this.modelFor('settings.tags.tag'); - let tags = this.get('controller.sortedTags'); - let length = tags.get('length'); - - if (currentTag && length) { - let newPosition = tags.indexOf(currentTag) + step; - - if (newPosition >= length) { - return; - } else if (newPosition < 0) { - return; - } - - this.transitionTo('settings.tags.tag', tags.objectAt(newPosition)); - } - }, - - scrollContent(amount) { - let content = $('.tag-settings-pane'); - let scrolled = content.scrollTop(); - - content.scrollTop(scrolled + 50 * amount); - }, - actions: { moveUp() { if (this.controller.get('tagContentFocused')) { @@ -104,5 +80,30 @@ export default AuthenticatedRoute.extend(CurrentUserSettings, ShortcutsRoute, { resetShortcutsScope() { key.setScope('default'); } + }, + + stepThroughTags(step) { + let currentTag = this.modelFor('settings.tags.tag'); + let tags = this.get('controller.sortedTags'); + let length = tags.get('length'); + + if (currentTag && length) { + let newPosition = tags.indexOf(currentTag) + step; + + if (newPosition >= length) { + return; + } else if (newPosition < 0) { + return; + } + + this.transitionTo('settings.tags.tag', tags.objectAt(newPosition)); + } + }, + + scrollContent(amount) { + let content = $('.tag-settings-pane'); + let scrolled = content.scrollTop(); + + content.scrollTop(scrolled + 50 * amount); } }); diff --git a/app/routes/setup.js b/app/routes/setup.js index 0aad8b0af..66857f0cb 100644 --- a/app/routes/setup.js +++ b/app/routes/setup.js @@ -3,15 +3,15 @@ import styleBody from 'ghost-admin/mixins/style-body'; import {inject as service} from '@ember/service'; export default Route.extend(styleBody, { - titleToken: 'Setup', - - classNames: ['ghost-setup'], - ghostPaths: service(), session: service(), ajax: service(), config: service(), + titleToken: 'Setup', + + classNames: ['ghost-setup'], + // use the beforeModel hook to check to see whether or not setup has been // previously completed. If it has, stop the transition into the setup page. beforeModel() { diff --git a/app/routes/signout.js b/app/routes/signout.js index 6fc57a05e..fa76d3795 100644 --- a/app/routes/signout.js +++ b/app/routes/signout.js @@ -7,12 +7,12 @@ import {inject as service} from '@ember/service'; const {canInvoke} = Ember; export default AuthenticatedRoute.extend(styleBody, { + notifications: service(), + titleToken: 'Sign Out', classNames: ['ghost-signout'], - notifications: service(), - afterModel(model, transition) { this.get('notifications').clearAll(); if (canInvoke(transition, 'send')) { diff --git a/app/routes/signup.js b/app/routes/signup.js index 53a61fcea..0e83d300c 100644 --- a/app/routes/signup.js +++ b/app/routes/signup.js @@ -10,14 +10,14 @@ const {Promise} = RSVP; const {Errors} = DS; export default Route.extend(styleBody, UnauthenticatedRouteMixin, { - classNames: ['ghost-signup'], - ghostPaths: service(), notifications: service(), session: service(), ajax: service(), config: service(), + classNames: ['ghost-signup'], + beforeModel() { if (this.get('session.isAuthenticated')) { this.get('notifications').showAlert('You need to sign out to register as a new user.', {type: 'warn', delayed: true, key: 'signup.create.already-authenticated'}); diff --git a/app/routes/subscribers.js b/app/routes/subscribers.js index aca4ac47d..3f36437a9 100644 --- a/app/routes/subscribers.js +++ b/app/routes/subscribers.js @@ -3,10 +3,10 @@ import RSVP from 'rsvp'; import {inject as service} from '@ember/service'; export default AuthenticatedRoute.extend({ - titleToken: 'Subscribers', - feature: service(), + titleToken: 'Subscribers', + // redirect if subscribers is disabled or user isn't owner/admin beforeModel() { this._super(...arguments); diff --git a/app/routes/subscribers/new.js b/app/routes/subscribers/new.js index 2fa41288a..c3b943cd9 100644 --- a/app/routes/subscribers/new.js +++ b/app/routes/subscribers/new.js @@ -19,11 +19,6 @@ export default Route.extend({ } }, - rollbackModel() { - let subscriber = this.controller.get('subscriber'); - subscriber.rollbackAttributes(); - }, - actions: { save() { let subscriber = this.controller.get('subscriber'); @@ -37,5 +32,10 @@ export default Route.extend({ this.rollbackModel(); this.transitionTo('subscribers'); } + }, + + rollbackModel() { + let subscriber = this.controller.get('subscriber'); + subscriber.rollbackAttributes(); } }); diff --git a/app/routes/team/user.js b/app/routes/team/user.js index 759856c06..c2ddeec98 100644 --- a/app/routes/team/user.js +++ b/app/routes/team/user.js @@ -12,10 +12,6 @@ export default AuthenticatedRoute.extend(styleBody, CurrentUserSettings, { return this.store.queryRecord('user', {slug: params.user_slug, include: 'count.posts'}); }, - serialize(model) { - return {user_slug: model.get('slug')}; - }, - afterModel(user) { this._super(...arguments); @@ -32,6 +28,10 @@ export default AuthenticatedRoute.extend(styleBody, CurrentUserSettings, { }); }, + serialize(model) { + return {user_slug: model.get('slug')}; + }, + actions: { didTransition() { this.modelFor('team.user').get('errors').clear(); diff --git a/lib/gh-koenig/addon/components/cards/card-html.js b/lib/gh-koenig/addon/components/cards/card-html.js index a4f985a5d..8505d136e 100644 --- a/lib/gh-koenig/addon/components/cards/card-html.js +++ b/lib/gh-koenig/addon/components/cards/card-html.js @@ -9,14 +9,6 @@ export default Component.extend({ layout, hasRendered: false, - // TODO: remove observer - // eslint-disable-next-line ghost/ember/no-observers - save: observer('doSave', function () { - let payload = this.get('payload'); - payload.wordcount = counter(payload.html); - this.get('env').save(payload, false); - }), - value: computed('payload', { get() { return this.get('payload').html || ''; @@ -28,6 +20,14 @@ export default Component.extend({ } }), + // TODO: remove observer + // eslint-disable-next-line ghost/ember/no-observers + save: observer('doSave', function () { + let payload = this.get('payload'); + payload.wordcount = counter(payload.html); + this.get('env').save(payload, false); + }), + actions: { selectCard() { invokeAction(this, 'selectCard'); diff --git a/lib/gh-koenig/addon/components/cards/card-image.js b/lib/gh-koenig/addon/components/cards/card-image.js index de290f2e4..1c619b432 100644 --- a/lib/gh-koenig/addon/components/cards/card-image.js +++ b/lib/gh-koenig/addon/components/cards/card-image.js @@ -17,6 +17,9 @@ import {run} from '@ember/runloop'; import {inject as service} from '@ember/service'; export default Component.extend({ + ajax: service(), + notifications: service(), + layout, tagName: 'section', classNames: ['gh-image-uploader'], @@ -36,14 +39,6 @@ export default Component.extend({ url: null, uploadPercentage: 0, - ajax: service(), - notifications: service(), - - init() { - this._super(...arguments); - this.extensions = ['gif', 'jpg', 'jpeg', 'png', 'svg']; - }, - // TODO: this wouldn't be necessary if the server could accept direct // file uploads formData: computed('file', function () { @@ -73,6 +68,11 @@ export default Component.extend({ return htmlSafe(`width: ${width}`); }), + init() { + this._super(...arguments); + this.extensions = ['gif', 'jpg', 'jpeg', 'png', 'svg']; + }, + didReceiveAttrs() { let image = this.get('payload'); if (image.img) { @@ -83,6 +83,40 @@ export default Component.extend({ } }, + actions: { + fileSelected(fileList) { + // can't use array destructuring here as FileList is not a strict + // array and fails in Safari + // eslint-disable-next-line ember-suave/prefer-destructuring + let file = fileList[0]; + + // jscs:enable requireArrayDestructuring + let validationResult = this._validate(file); + + this.set('file', file); + + invokeAction(this, 'fileSelected', file); + + if (validationResult === true) { + run.schedule('actions', this, function () { + this.generateRequest(); + }); + } else { + this._uploadFailed(validationResult); + } + }, + + reset() { + this.set('file', null); + this.set('uploadPercentage', 0); + }, + + saveUrl() { + let url = this.get('url'); + invokeAction(this, 'update', url); + } + }, + dragOver(event) { if (!event.dataTransfer) { return; @@ -227,39 +261,5 @@ export default Component.extend({ } return true; - }, - - actions: { - fileSelected(fileList) { - // can't use array destructuring here as FileList is not a strict - // array and fails in Safari - // eslint-disable-next-line ember-suave/prefer-destructuring - let file = fileList[0]; - - // jscs:enable requireArrayDestructuring - let validationResult = this._validate(file); - - this.set('file', file); - - invokeAction(this, 'fileSelected', file); - - if (validationResult === true) { - run.schedule('actions', this, function () { - this.generateRequest(); - }); - } else { - this._uploadFailed(validationResult); - } - }, - - reset() { - this.set('file', null); - this.set('uploadPercentage', 0); - }, - - saveUrl() { - let url = this.get('url'); - invokeAction(this, 'update', url); - } } }); diff --git a/lib/gh-koenig/addon/components/cards/card-markdown.js b/lib/gh-koenig/addon/components/cards/card-markdown.js index d235aba86..d15e05566 100644 --- a/lib/gh-koenig/addon/components/cards/card-markdown.js +++ b/lib/gh-koenig/addon/components/cards/card-markdown.js @@ -18,10 +18,11 @@ import {inject as service} from '@ember/service'; /* legacyConverter.makeHtml(_.toString(this.get('markdown'))) */ export default Component.extend({ + ajax: service(), + layout, accept: 'image/gif,image/jpg,image/jpeg,image/png,image/svg+xml', extensions: null, - ajax: service(), preview: computed('value', function () { return formatMarkdown([this.get('payload').markdown]); @@ -54,6 +55,77 @@ export default Component.extend({ } }, + actions: { + fileSelected(fileList) { + // can't use array destructuring here as FileList is not a strict + // array and fails in Safari + // eslint-disable-next-line ember-suave/prefer-destructuring + let file = fileList[0]; + + // jscs:enable requireArrayDestructuring + let validationResult = this._validate(file); + + this.set('file', file); + + invokeAction(this, 'fileSelected', file); + + if (validationResult === true) { + run.schedule('actions', this, function () { + this.generateRequest(); + }); + } else { + this._uploadFailed(validationResult); + } + }, + + reset() { + this.set('file', null); + this.set('uploadPercentage', 0); + }, + + saveUrl() { + let url = this.get('url'); + invokeAction(this, 'update', url); + }, + + selectCard() { + invokeAction(this, 'selectCard'); + }, + + didDrop(event) { + event.preventDefault(); + event.stopPropagation(); + // eslint-disable-next-line ember-suave/prefer-destructuring + let el = this.$('textarea')[0]; // array destructuring here causes ember to throw an error about calling an Object as a Function + + let start = el.selectionStart; + + let end = el.selectionEnd; + + let {files} = event.dataTransfer; + let combinedLength = 0; + + // eslint-disable-next-line ember-suave/prefer-destructuring + let file = files[0]; // array destructuring here causes ember to throw an error about calling an Object as a Function + let placeholderText = `\r\n![uploading:${file.name}]()\r\n`; + el.value = el.value.substring(0, start) + placeholderText + el.value.substring(end, el.value.length); + combinedLength += placeholderText.length; + + el.selectionStart = start; + el.selectionEnd = end + combinedLength; + + this.send('fileSelected', event.dataTransfer.files); + }, + + didDragOver() { + this.$('textarea').addClass('dragOver'); + }, + + didDragLeave() { + this.$('textarea').removeClass('dragOver'); + } + }, + _uploadStarted() { invokeAction(this, 'uploadStarted'); }, @@ -180,77 +252,5 @@ export default Component.extend({ }).finally(() => { this._uploadFinished(); }); - }, - - actions: { - - fileSelected(fileList) { - // can't use array destructuring here as FileList is not a strict - // array and fails in Safari - // eslint-disable-next-line ember-suave/prefer-destructuring - let file = fileList[0]; - - // jscs:enable requireArrayDestructuring - let validationResult = this._validate(file); - - this.set('file', file); - - invokeAction(this, 'fileSelected', file); - - if (validationResult === true) { - run.schedule('actions', this, function () { - this.generateRequest(); - }); - } else { - this._uploadFailed(validationResult); - } - }, - - reset() { - this.set('file', null); - this.set('uploadPercentage', 0); - }, - - saveUrl() { - let url = this.get('url'); - invokeAction(this, 'update', url); - }, - - selectCard() { - invokeAction(this, 'selectCard'); - }, - - didDrop(event) { - event.preventDefault(); - event.stopPropagation(); - // eslint-disable-next-line ember-suave/prefer-destructuring - let el = this.$('textarea')[0]; // array destructuring here causes ember to throw an error about calling an Object as a Function - - let start = el.selectionStart; - - let end = el.selectionEnd; - - let {files} = event.dataTransfer; - let combinedLength = 0; - - // eslint-disable-next-line ember-suave/prefer-destructuring - let file = files[0]; // array destructuring here causes ember to throw an error about calling an Object as a Function - let placeholderText = `\r\n![uploading:${file.name}]()\r\n`; - el.value = el.value.substring(0, start) + placeholderText + el.value.substring(end, el.value.length); - combinedLength += placeholderText.length; - - el.selectionStart = start; - el.selectionEnd = end + combinedLength; - - this.send('fileSelected', event.dataTransfer.files); - }, - - didDragOver() { - this.$('textarea').addClass('dragOver'); - }, - - didDragLeave() { - this.$('textarea').removeClass('dragOver'); - } } }); diff --git a/lib/gh-koenig/addon/components/gh-koenig.js b/lib/gh-koenig/addon/components/gh-koenig.js index 1d29f8f89..c0358ff2a 100644 --- a/lib/gh-koenig/addon/components/gh-koenig.js +++ b/lib/gh-koenig/addon/components/gh-koenig.js @@ -248,84 +248,6 @@ export default Component.extend({ this.processWordcount(); }, - // makes sure the cursor is on screen except when selection is happening in - // which case the browser mostly ensures it. there is an issue with keyboard - // selection on some browsers though so the next step may be to record mouse - // and touch events. - cursorMoved() { - let editor = this.get('editor'); - - if (editor.range.isCollapsed) { - let scrollBuffer = 33; // the extra buffer to scroll. - - let position = getPositionOnScreenFromRange(editor, $(this.get('containerSelector'))); - - if (!position) { - return; - } - - let windowHeight = window.innerHeight; - - if (position.bottom > windowHeight) { - this._domContainer.scrollTop += position.bottom - windowHeight + scrollBuffer; - } else if (position.top < 0) { - this._domContainer.scrollTop += position.top - scrollBuffer; - } - - if (editor.range && editor.range.headSection && editor.range.headSection.isCardSection) { - let id = $(editor.range.headSection.renderNode.element).find('.kg-card > div').attr('id'); - // let id = card.find('div').attr('id'); - window.getSelection().removeAllRanges(); - // if the element is first and we create a card with the '/' menu then the cursor moves before - // element is placed in the dom properly. So we figure it out another way. - if (!id) { - id = editor.range.headSection.renderNode.element.children[0].children[0].id; - } - - this.send('selectCardHard', id); - } else { - this.send('deselectCard'); - } - } else { - this.send('deselectCard'); - } - }, - - // NOTE: This wordcount function doesn't count words that have been entered in cards. - // We should either allow cards to report their own wordcount or use the DOM - // (innerText) to calculate the wordcount. - processWordcount() { - let wordcount = 0; - if (this.editor.post.sections.length) { - this.editor.post.sections.forEach((section) => { - if (section.isMarkerable && section.text.length) { - wordcount += counter(section.text); - } else if (section.isCardSection && section.payload.wordcount) { - wordcount += Number(section.payload.wordcount); - } - }); - } - - let action = this.get('wordcountDidChange'); - if (action) { - action(wordcount); - } - }, - - _willCreateEditor() { - let action = this.get('willCreateEditor'); - if (action) { - action(); - } - }, - - _didCreateEditor(editor) { - let action = this.get('didCreateEditor'); - if (action) { - action(editor); - } - }, - willDestroyElement() { this.editor.destroy(); this.send('deselectCard'); @@ -333,39 +255,6 @@ export default Component.extend({ document.onkeydown = null; }, - postDidChange(editor) { - // store a cache of the local doc so that we don't need to reinitialise it. - let serializeVersion = this.get('serializeVersion'); - let updatedMobiledoc = editor.serialize(serializeVersion); - let onChangeAction = this.get('onChange'); - let onFirstChangeAction = this.get('onFirstChange'); - - this._localMobiledoc = updatedMobiledoc; - - if (onChangeAction) { - onChangeAction(updatedMobiledoc); - } - - // we need to trigger a first-change action so that we can trigger a - // save and transition from new-> edit - if (this._localMobiledoc !== BLANK_DOC && !this._hasChanged) { - this._hasChanged = true; - - if (onFirstChangeAction) { - onFirstChangeAction(this._localMobiledoc); - } - } - - this.processWordcount(); - }, - - _setExpandoProperty(editor) { - // Store a reference to the editor for the acceptance test helpers - if (this.element && testing) { - this.element[TESTING_EXPANDO_PROPERTY] = editor; - } - }, - actions: { // thin border, shows that a card is selected but the user cannot delete // the card with keyboard events. @@ -581,6 +470,116 @@ export default Component.extend({ // required for drop events to fire on markdown cards in firefox. event.preventDefault(); } - } + }, + // makes sure the cursor is on screen except when selection is happening in + // which case the browser mostly ensures it. there is an issue with keyboard + // selection on some browsers though so the next step may be to record mouse + // and touch events. + cursorMoved() { + let editor = this.get('editor'); + + if (editor.range.isCollapsed) { + let scrollBuffer = 33; // the extra buffer to scroll. + + let position = getPositionOnScreenFromRange(editor, $(this.get('containerSelector'))); + + if (!position) { + return; + } + + let windowHeight = window.innerHeight; + + if (position.bottom > windowHeight) { + this._domContainer.scrollTop += position.bottom - windowHeight + scrollBuffer; + } else if (position.top < 0) { + this._domContainer.scrollTop += position.top - scrollBuffer; + } + + if (editor.range && editor.range.headSection && editor.range.headSection.isCardSection) { + let id = $(editor.range.headSection.renderNode.element).find('.kg-card > div').attr('id'); + // let id = card.find('div').attr('id'); + window.getSelection().removeAllRanges(); + // if the element is first and we create a card with the '/' menu then the cursor moves before + // element is placed in the dom properly. So we figure it out another way. + if (!id) { + id = editor.range.headSection.renderNode.element.children[0].children[0].id; + } + + this.send('selectCardHard', id); + } else { + this.send('deselectCard'); + } + } else { + this.send('deselectCard'); + } + }, + + // NOTE: This wordcount function doesn't count words that have been entered in cards. + // We should either allow cards to report their own wordcount or use the DOM + // (innerText) to calculate the wordcount. + processWordcount() { + let wordcount = 0; + if (this.editor.post.sections.length) { + this.editor.post.sections.forEach((section) => { + if (section.isMarkerable && section.text.length) { + wordcount += counter(section.text); + } else if (section.isCardSection && section.payload.wordcount) { + wordcount += Number(section.payload.wordcount); + } + }); + } + + let action = this.get('wordcountDidChange'); + if (action) { + action(wordcount); + } + }, + + _willCreateEditor() { + let action = this.get('willCreateEditor'); + if (action) { + action(); + } + }, + + _didCreateEditor(editor) { + let action = this.get('didCreateEditor'); + if (action) { + action(editor); + } + }, + + postDidChange(editor) { + // store a cache of the local doc so that we don't need to reinitialise it. + let serializeVersion = this.get('serializeVersion'); + let updatedMobiledoc = editor.serialize(serializeVersion); + let onChangeAction = this.get('onChange'); + let onFirstChangeAction = this.get('onFirstChange'); + + this._localMobiledoc = updatedMobiledoc; + + if (onChangeAction) { + onChangeAction(updatedMobiledoc); + } + + // we need to trigger a first-change action so that we can trigger a + // save and transition from new-> edit + if (this._localMobiledoc !== BLANK_DOC && !this._hasChanged) { + this._hasChanged = true; + + if (onFirstChangeAction) { + onFirstChangeAction(this._localMobiledoc); + } + } + + this.processWordcount(); + }, + + _setExpandoProperty(editor) { + // Store a reference to the editor for the acceptance test helpers + if (this.element && testing) { + this.element[TESTING_EXPANDO_PROPERTY] = editor; + } + } }); diff --git a/lib/gh-koenig/addon/components/koenig-slash-menu.js b/lib/gh-koenig/addon/components/koenig-slash-menu.js index 234313c62..a4c0f828c 100644 --- a/lib/gh-koenig/addon/components/koenig-slash-menu.js +++ b/lib/gh-koenig/addon/components/koenig-slash-menu.js @@ -82,30 +82,6 @@ export default Component.extend({ }); }, - cursorChange() { - let editor = this.get('editor'); - let range = this.get('range'); - let isOpen = this.get('isOpen'); - - // if the cursor isn't in the editor then close the menu - if (!range || !editor.range.isCollapsed || editor.range.head.section !== range.section || this.editor.range.head.offset < 1 || !this.editor.range.head.section) { - // unless we click on a tool because the tool will close the menu. - if (isOpen && !$(window.getSelection().anchorNode).parents('.gh-cardmenu').length) { - this.send('closeMenu'); - } - return; - } - - if (isOpen) { - let queryString = editor.range.head.section.text.substring(range.startOffset, editor.range.head.offset); - this.set('query', queryString); - // if we've typed 5 characters and have no tools then close. - if (queryString.length > 5 && !this.get('toolLength')) { - this.send('closeMenu'); - } - } - }, - actions: { openMenu() { let holder = $(this.get('containerSelector')); @@ -259,5 +235,29 @@ export default Component.extend({ editor.deleteRange(editor.range); this.send('closeMenu'); } + }, + + cursorChange() { + let editor = this.get('editor'); + let range = this.get('range'); + let isOpen = this.get('isOpen'); + + // if the cursor isn't in the editor then close the menu + if (!range || !editor.range.isCollapsed || editor.range.head.section !== range.section || this.editor.range.head.offset < 1 || !this.editor.range.head.section) { + // unless we click on a tool because the tool will close the menu. + if (isOpen && !$(window.getSelection().anchorNode).parents('.gh-cardmenu').length) { + this.send('closeMenu'); + } + return; + } + + if (isOpen) { + let queryString = editor.range.head.section.text.substring(range.startOffset, editor.range.head.offset); + this.set('query', queryString); + // if we've typed 5 characters and have no tools then close. + if (queryString.length > 5 && !this.get('toolLength')) { + this.send('closeMenu'); + } + } } }); diff --git a/lib/gh-koenig/addon/components/koenig-title-input.js b/lib/gh-koenig/addon/components/koenig-title-input.js index e1cf0e985..448c64d6f 100644 --- a/lib/gh-koenig/addon/components/koenig-title-input.js +++ b/lib/gh-koenig/addon/components/koenig-title-input.js @@ -16,54 +16,6 @@ export default Component.extend({ editorKeyDownListener: null, _hasSetupEventListeners: false, - didInsertElement() { - this._super(...arguments); - - let title = this.$('.kg-title-input'); - - // setup mutation observer - let mutationObserver = new MutationObserver(() => { - // on mutate we update. - if (title[0].textContent !== '') { - title.removeClass('no-content'); - } else { - title.addClass('no-content'); - } - - // there is no consistency in how characters like nbsp and zwd are handled across browsers - // so we replace every whitespace character with a ' ' - // note: this means that we can't have tabs in the title. - let textContent = title[0].textContent.replace(/\s/g, ' '); - let innerHTML = title[0].innerHTML.replace(/( |\s)/g, ' '); - - // sanity check if there is formatting reset it. - if (innerHTML && innerHTML !== textContent) { - // run in next runloop so that we don't get stuck in infinite loops. - run.next(() => { - title[0].innerHTML = textContent; - }); - } - - if (this.get('val') !== textContent) { - let onChangeAction = this.get('onChange'); - let updateAction = this.get('update'); - - this.set('_cachedVal', textContent); - this.set('val', textContent); - - if (onChangeAction) { - onChangeAction(textContent); - } - if (updateAction) { - updateAction(textContent); - } - } - }); - - mutationObserver.observe(title[0], {childList: true, characterData: true, subtree: true}); - this.set('_mutationObserver', mutationObserver); - }, - didReceiveAttrs() { if (this.get('editorHasRendered') && !this._hasSetupEventListeners) { let editor = this.get('editor'); @@ -147,6 +99,54 @@ export default Component.extend({ } }, + didInsertElement() { + this._super(...arguments); + + let title = this.$('.kg-title-input'); + + // setup mutation observer + let mutationObserver = new MutationObserver(() => { + // on mutate we update. + if (title[0].textContent !== '') { + title.removeClass('no-content'); + } else { + title.addClass('no-content'); + } + + // there is no consistency in how characters like nbsp and zwd are handled across browsers + // so we replace every whitespace character with a ' ' + // note: this means that we can't have tabs in the title. + let textContent = title[0].textContent.replace(/\s/g, ' '); + let innerHTML = title[0].innerHTML.replace(/( |\s)/g, ' '); + + // sanity check if there is formatting reset it. + if (innerHTML && innerHTML !== textContent) { + // run in next runloop so that we don't get stuck in infinite loops. + run.next(() => { + title[0].innerHTML = textContent; + }); + } + + if (this.get('val') !== textContent) { + let onChangeAction = this.get('onChange'); + let updateAction = this.get('update'); + + this.set('_cachedVal', textContent); + this.set('val', textContent); + + if (onChangeAction) { + onChangeAction(textContent); + } + if (updateAction) { + updateAction(textContent); + } + } + }); + + mutationObserver.observe(title[0], {childList: true, characterData: true, subtree: true}); + this.set('_mutationObserver', mutationObserver); + }, + didRender() { let title = this.$('.kg-title-input'); if (!this.get('val')) { diff --git a/lib/gh-koenig/addon/components/koenig-toolbar-button.js b/lib/gh-koenig/addon/components/koenig-toolbar-button.js index d7a729920..8687f2799 100644 --- a/lib/gh-koenig/addon/components/koenig-toolbar-button.js +++ b/lib/gh-koenig/addon/components/koenig-toolbar-button.js @@ -33,10 +33,6 @@ export default Component.extend({ return this.get('tool.label'); }), - click() { - this.tool.onClick(this.get('editor')); - }, - willRender() { // TODO: "selected" doesn't appear to do anything for toolbar items - // it's only used within card menus @@ -46,5 +42,9 @@ export default Component.extend({ if (this.tool.visibility) { this.set(this.tool.visibility, true); } + }, + + click() { + this.tool.onClick(this.get('editor')); } }); diff --git a/lib/gh-koenig/addon/components/koenig-toolbar.js b/lib/gh-koenig/addon/components/koenig-toolbar.js index 06761505c..80b4d4e22 100644 --- a/lib/gh-koenig/addon/components/koenig-toolbar.js +++ b/lib/gh-koenig/addon/components/koenig-toolbar.js @@ -104,6 +104,59 @@ export default Component.extend({ this.editor.destroy(); }, + actions: { + linkKeyDown(event) { + // if escape close link + if (event.keyCode === 27) { + this.send('closeLink'); + } + }, + + linkKeyPress(event) { + // if enter run link + if (event.keyCode === 13) { + let url = event.target.value; + if (!cajaSanitizers.url(url)) { + url = `http://${url}`; + } + this.send('closeLink'); + this.set('isVisible', false); + this.editor.run((postEditor) => { + let markup = postEditor.builder.createMarkup('a', {href: url}); + postEditor.addMarkupToRange(this.get('linkRange'), markup); + }); + + this.set('linkRange', null); + event.stopPropagation(); + } + }, + + doLink(range) { + // if a link is already selected then we remove the links from within the range. + let currentLinks = this.get('activeTags').filter(element => element.tagName === 'a'); + if (currentLinks.length) { + this.get('editor').run((postEditor) => { + currentLinks.forEach((link) => { + postEditor.removeMarkupFromRange(range, link); + }); + }); + + return; + } + this.set('isLink', true); + this.set('linkRange', range); + run.schedule('afterRender', this, + () => { + this.$('input').focus(); + } + ); + }, + + closeLink() { + this.set('isLink', false); + } + }, + // update the location of the toolbar and display it if the range is visible. updateToolbarToRange(toolbar, holder, isMouseDown) { // if there is no cursor: @@ -191,58 +244,5 @@ export default Component.extend({ positions.forEach((position) => { this.set(position, position === tickPosition); }); - }, - - actions: { - linkKeyDown(event) { - // if escape close link - if (event.keyCode === 27) { - this.send('closeLink'); - } - }, - - linkKeyPress(event) { - // if enter run link - if (event.keyCode === 13) { - let url = event.target.value; - if (!cajaSanitizers.url(url)) { - url = `http://${url}`; - } - this.send('closeLink'); - this.set('isVisible', false); - this.editor.run((postEditor) => { - let markup = postEditor.builder.createMarkup('a', {href: url}); - postEditor.addMarkupToRange(this.get('linkRange'), markup); - }); - - this.set('linkRange', null); - event.stopPropagation(); - } - }, - - doLink(range) { - // if a link is already selected then we remove the links from within the range. - let currentLinks = this.get('activeTags').filter(element => element.tagName === 'a'); - if (currentLinks.length) { - this.get('editor').run((postEditor) => { - currentLinks.forEach((link) => { - postEditor.removeMarkupFromRange(range, link); - }); - }); - - return; - } - this.set('isLink', true); - this.set('linkRange', range); - run.schedule('afterRender', this, - () => { - this.$('input').focus(); - } - ); - }, - - closeLink() { - this.set('isLink', false); - } } }); diff --git a/tests/unit/components/gh-post-settings-menu-test.js b/tests/unit/components/gh-post-settings-menu-test.js index cfd166290..48c17945f 100644 --- a/tests/unit/components/gh-post-settings-menu-test.js +++ b/tests/unit/components/gh-post-settings-menu-test.js @@ -116,9 +116,9 @@ describe.skip('Unit: Component: post-settings-menu', function () { it('should be the metaTitle if one exists', function () { let component = this.subject({ post: EmberObject.extend({ + titleScratch: 'should not be used', metaTitle: 'a meta-title', - metaTitleScratch: boundOneWay('metaTitle'), - titleScratch: 'should not be used' + metaTitleScratch: boundOneWay('metaTitle') }).create() }); @@ -138,9 +138,9 @@ describe.skip('Unit: Component: post-settings-menu', function () { it('should be the metaTitle if both title and metaTitle exist', function () { let component = this.subject({ post: EmberObject.extend({ + titleScratch: 'a title', metaTitle: 'a meta-title', - metaTitleScratch: boundOneWay('metaTitle'), - titleScratch: 'a title' + metaTitleScratch: boundOneWay('metaTitle') }).create() }); @@ -150,9 +150,9 @@ describe.skip('Unit: Component: post-settings-menu', function () { it('should revert to the title if explicit metaTitle is removed', function () { let component = this.subject({ post: EmberObject.extend({ + titleScratch: 'a title', metaTitle: 'a meta-title', - metaTitleScratch: boundOneWay('metaTitle'), - titleScratch: 'a title' + metaTitleScratch: boundOneWay('metaTitle') }).create() }); @@ -199,9 +199,9 @@ describe.skip('Unit: Component: post-settings-menu', function () { it('should be generated from the rendered mobiledoc if not explicitly set', function () { let component = this.subject({ post: EmberObject.extend({ - author: RSVP.resolve(), metaDescription: null, metaDescriptionScratch: boundOneWay('metaDescription'), + author: RSVP.resolve(), init() { this._super(...arguments);