diff --git a/app/components/gh-markdown-editor.js b/app/components/gh-markdown-editor.js index 7876a99d8..9cf8f4b82 100644 --- a/app/components/gh-markdown-editor.js +++ b/app/components/gh-markdown-editor.js @@ -204,6 +204,13 @@ export default Component.extend(ShortcutsMixin, { let markdown = mobiledoc.cards[0][1].markdown; this.set('markdown', markdown); + // focus the editor when the markdown value changes, this is necessary + // because both the autofocus and markdown values can change without a + // re-render, eg. navigating from edit->new + if (this._editor && markdown !== this._editor.value() && this.get('autofocus')) { + this.send('focusEditor'); + } + // use internal values to avoid updating bound values if (!isEmpty(this.get('isFullScreen'))) { this.set('_isFullScreen', this.get('isFullScreen')); diff --git a/app/components/gh-scheduled-post-countdown.js b/app/components/gh-scheduled-post-countdown.js new file mode 100644 index 000000000..bd0950ce1 --- /dev/null +++ b/app/components/gh-scheduled-post-countdown.js @@ -0,0 +1,28 @@ +import Component from '@ember/component'; +import moment from 'moment'; +import {computed} from '@ember/object'; +import {inject as service} from '@ember/service'; + +export default Component.extend({ + clock: service(), + + post: null, + + // countdown timer to show the time left until publish time for a scheduled post + // starts 15 minutes before scheduled time + countdown: computed('post.{publishedAtUTC,isScheduled}', 'clock.second', function () { + let isScheduled = this.get('post.isScheduled'); + let publishTime = this.get('post.publishedAtUTC') || moment.utc(); + let timeUntilPublished = publishTime.diff(moment.utc(), 'minutes', true); + let isPublishedSoon = timeUntilPublished > 0 && timeUntilPublished < 15; + + // force a recompute + this.get('clock.second'); + + if (isScheduled && isPublishedSoon) { + return moment(publishTime).fromNow(); + } else { + return false; + } + }) +}); diff --git a/app/components/gh-simplemde.js b/app/components/gh-simplemde.js index cb6f66421..3ecc7afb5 100644 --- a/app/components/gh-simplemde.js +++ b/app/components/gh-simplemde.js @@ -76,8 +76,12 @@ export default TextArea.extend({ this._editor = new SimpleMDE(editorOptions); this._editor.value(this.get('value') || ''); - this._editor.codemirror.on('change', () => { - this.onChange(this._editor.value()); + this._editor.codemirror.on('change', (instance, changeObj) => { + // avoid a "modified x twice in a single render" error that occurs + // when the underlying value is completely swapped out + if (changeObj.origin !== 'setValue') { + this.onChange(this._editor.value()); + } }); this._editor.codemirror.on('focus', () => { diff --git a/app/mixins/editor-base-controller.js b/app/controllers/editor.js similarity index 57% rename from app/mixins/editor-base-controller.js rename to app/controllers/editor.js index 23acf8bc2..d6fbac1e7 100644 --- a/app/mixins/editor-base-controller.js +++ b/app/controllers/editor.js @@ -1,11 +1,9 @@ +import Controller from '@ember/controller'; import Ember from 'ember'; -import Mixin from '@ember/object/mixin'; import PostModel from 'ghost-admin/models/post'; import boundOneWay from 'ghost-admin/utils/bound-one-way'; -import ghostPaths from 'ghost-admin/utils/ghost-paths'; import isNumber from 'ghost-admin/utils/isNumber'; -import moment from 'moment'; -import {alias} from '@ember/object/computed'; +import {alias, mapBy, reads} from '@ember/object/computed'; import {computed} from '@ember/object'; import {inject as controller} from '@ember/controller'; import {htmlSafe} from '@ember/string'; @@ -13,14 +11,9 @@ import {isBlank} from '@ember/utils'; import {isArray as isEmberArray} from '@ember/array'; import {isInvalidError} from 'ember-ajax/errors'; import {isVersionMismatchError} from 'ghost-admin/services/ajax'; -import {mapBy, reads} from '@ember/object/computed'; import {inject as service} from '@ember/service'; import {task, taskGroup, timeout} from 'ember-concurrency'; -// this array will hold properties we need to watch -// to know if the post has been changed (`controller.hasDirtyAttributes`) -const watchedProps = ['post.scratch', 'post.titleScratch', 'post.hasDirtyAttributes', 'post.tags.[]']; - const DEFAULT_TITLE = '(Untitled)'; // time in ms to save after last content edit @@ -28,75 +21,229 @@ const AUTOSAVE_TIMEOUT = 3000; // time in ms to force a save if the user is continuously typing const TIMEDSAVE_TIMEOUT = 60000; +// this array will hold properties we need to watch for this.hasDirtyAttributes +let watchedProps = ['post.scratch', 'post.titleScratch', 'post.hasDirtyAttributes', 'post.tags.[]', 'post.isError']; + +// add all post model attrs to the watchedProps array, easier to do it this way +// than remember to update every time we add a new attr PostModel.eachAttribute(function (name) { watchedProps.push(`post.${name}`); }); -export default Mixin.create({ +// TODO: This has to be moved to the I18n localization file. +// This structure is supposed to be close to the i18n-localization which will be used soon. +const messageMap = { + errors: { + post: { + published: { + published: 'Update failed', + draft: 'Saving failed', + scheduled: 'Scheduling failed' + }, + draft: { + published: 'Publish failed', + draft: 'Saving failed', + scheduled: 'Scheduling failed' + }, + scheduled: { + scheduled: 'Updated failed', + draft: 'Unscheduling failed', + published: 'Publish failed' + } - post: alias('model'), + } + }, - showLeaveEditorModal: false, - showReAuthenticateModal: false, - showDeletePostModal: false, - shouldFocusEditor: true, + success: { + post: { + published: { + published: 'Updated.', + draft: 'Saved.', + scheduled: 'Scheduled.' + }, + draft: { + published: 'Published!', + draft: 'Saved.', + scheduled: 'Scheduled.' + }, + scheduled: { + scheduled: 'Updated.', + draft: 'Unscheduled.', + published: 'Published!' + } + } + } +}; +export default Controller.extend({ application: controller(), notifications: service(), - clock: service(), + router: service(), slugGenerator: service(), ui: service(), - wordcount: 0, - cards: [], // for apps - atoms: [], // for apps - toolbar: [], // for apps - apiRoot: ghostPaths().apiRoot, - assetPath: ghostPaths().assetRoot, - editor: null, - editorMenuIsOpen: false, + /* public properties -----------------------------------------------------*/ + leaveEditorTransition: null, + shouldFocusEditor: false, + showDeletePostModal: false, + showLeaveEditorModal: false, + showReAuthenticateModal: false, + + /* private properties ----------------------------------------------------*/ + + // set by setPost and _postSaved, used in hasDirtyAttributes + _previousTagNames: null, + + /* computed properties ---------------------------------------------------*/ + + post: alias('model'), + + // used within {{gh-editor}} as a trigger for responsive css changes navIsClosed: reads('application.autoNav'), - init() { - this._super(...arguments); - window.onbeforeunload = () => { - if (this.get('hasDirtyAttributes')) { - return this.unloadDirtyMessage(); - } - }; - }, + // store the desired post status locally without updating the model, + // the model will only be updated when a save occurs + willPublish: boundOneWay('post.isPublished'), + willSchedule: boundOneWay('post.isScheduled'), - _canAutosave: computed('post.isDraft', function () { - return !Ember.testing && this.get('post.isDraft'); // eslint-disable-line + // updateSlug and save should always be enqueued so that we don't run into + // problems with concurrency, for example when Cmd-S is pressed whilst the + // cursor is in the slug field - that would previously trigger a simultaneous + // slug update and save resulting in ember data errors and inconsistent save + // results + saveTasks: taskGroup().enqueue(), + + _tagNames: mapBy('post.tags', 'name'), + + // computed.apply is a bit of an ugly hack, but necessary to watch all the + // post's attributes and more without having to be explicit and remember + // to update the watched props list every time we add a new post attr + hasDirtyAttributes: computed.apply(Ember, watchedProps.concat({ + get() { + return this._hasDirtyAttributes(); + }, + set(key, value) { + return value; + } + })), + + _autosaveRunning: computed('_autosave.isRunning', '_timedSave.isRunning', function () { + let autosave = this.get('_autosave.isRunning'); + let timedsave = this.get('_timedSave.isRunning'); + + return autosave || timedsave; }), - // save 3 seconds after the last edit - _autosave: task(function* () { - if (!this.get('_canAutosave')) { - return; - } + _canAutosave: computed('post.isDraft', function () { + return !Ember.testing && this.get('post.isDraft'); + }), - // force an instant save on first body edit for new posts - if (this.get('post.isNew')) { - return this.get('autosave').perform(); - } + /* actions ---------------------------------------------------------------*/ - yield timeout(AUTOSAVE_TIMEOUT); - this.get('autosave').perform(); - }).restartable(), + actions: { + updateScratch(mobiledoc) { + this.set('post.scratch', mobiledoc); - // save at 60 seconds even if the user doesn't stop typing - _timedSave: task(function* () { - if (!this.get('_canAutosave')) { - return; - } + // save 3 seconds after last edit + this.get('_autosave').perform(); + // force save at 60 seconds + this.get('_timedSave').perform(); + }, - while (!Ember.testing && true) { // eslint-disable-line - yield timeout(TIMEDSAVE_TIMEOUT); - this.get('autosave').perform(); + updateTitleScratch(title) { + this.set('post.titleScratch', title); + }, + + // updates local willPublish/Schedule values, does not get applied to + // the post's `status` value until a save is triggered + setSaveType(newType) { + if (newType === 'publish') { + this.set('willPublish', true); + this.set('willSchedule', false); + } else if (newType === 'draft') { + this.set('willPublish', false); + this.set('willSchedule', false); + } else if (newType === 'schedule') { + this.set('willSchedule', true); + this.set('willPublish', false); + } + }, + + save(options) { + return this.get('save').perform(options); + }, + + // used to prevent unexpected background saves. Triggered when opening + // publish menu, starting a manual save, and when leaving the editor + cancelAutosave() { + this.get('_autosave').cancelAll(); + this.get('_timedSave').cancelAll(); + }, + + toggleLeaveEditorModal(transition) { + let leaveTransition = this.get('leaveEditorTransition'); + + // "cancel" was clicked in the "are you sure?" modal so we just + // reset the saved transition and remove the modal + if (!transition && this.get('showLeaveEditorModal')) { + this.set('leaveEditorTransition', null); + this.set('showLeaveEditorModal', false); + return; + } + + if (!leaveTransition || transition.targetName === leaveTransition.targetName) { + this.set('leaveEditorTransition', transition); + + // if a save is running, wait for it to finish then transition + if (this.get('saveTasks.isRunning')) { + return this.get('saveTasks.last').then(() => { + transition.retry(); + }); + } + + // if an autosave is scheduled, cancel it, save then transition + if (this.get('_autosaveRunning')) { + this.send('cancelAutosave'); + this.get('autosave').cancelAll(); + + return this.get('autosave').perform().then(() => { + transition.retry(); + }); + } + + // we genuinely have unsaved data, show the modal + this.set('showLeaveEditorModal', true); + } + }, + + // called by the "are you sure?" modal + leaveEditor() { + let transition = this.get('leaveEditorTransition'); + + if (!transition) { + this.get('notifications').showAlert('Sorry, there was an error in the application. Please let the Ghost team know what happened.', {type: 'error'}); + return; + } + + // perform cleanup and reset manually, ensures the transition will succeed + this.reset(); + + return transition.retry(); + }, + + toggleDeletePostModal() { + if (!this.get('post.isNew')) { + this.toggleProperty('showDeletePostModal'); + } + }, + + toggleReAuthenticateModal() { + this.toggleProperty('showReAuthenticateModal'); } - }).drop(), + }, + + /* Public tasks ----------------------------------------------------------*/ // separate task for autosave so that it doesn't override a manual save autosave: task(function* () { @@ -108,20 +255,6 @@ export default Mixin.create({ } }).drop(), - _autosaveRunning: computed('_autosave.isRunning', '_timedSave.isRunning', function () { - let autosave = this.get('_autosave.isRunning'); - let timedsave = this.get('_timedSave.isRunning'); - - return autosave || timedsave; - }), - - // updateSlug and save should always be enqueued so that we don't run into - // problems with concurrency, for example when Cmd-S is pressed whilst the - // cursor is in the slug field - that would previously trigger a simultaneous - // slug update and save resulting in ember data errors and inconsistent save - // results - saveTasks: taskGroup().enqueue(), - // save tasks cancels autosave before running, although this cancels the // _xSave tasks that will also cancel the autosave task save: task(function* (options = {}) { @@ -142,9 +275,9 @@ export default Mixin.create({ if (this.get('post.pastScheduledTime')) { status = (!this.get('willSchedule') && !this.get('willPublish')) ? 'draft' : 'published'; } else { - if (this.get('willPublish') && !this.get('post.isScheduled') && !this.get('statusFreeze')) { + if (this.get('willPublish') && !this.get('post.isScheduled')) { status = 'published'; - } else if (this.get('willSchedule') && !this.get('post.isPublished') && !this.get('statusFreeze')) { + } else if (this.get('willSchedule') && !this.get('post.isPublished')) { status = 'scheduled'; } else { status = 'draft'; @@ -153,7 +286,7 @@ export default Mixin.create({ } // Set the properties that are indirected - // set mobiledoc equal to what's in the editor, minus the image markers. + // set mobiledoc equal to what's in the editor this.set('post.mobiledoc', this.get('post.scratch')); this.set('post.status', status); @@ -183,7 +316,7 @@ export default Mixin.create({ let post = yield this.get('post').save(options); if (!options.silent) { - this.showSaveNotification(prevStatus, post.get('status'), isNew ? true : false); + this._showSaveNotification(prevStatus, post.get('status'), isNew ? true : false); } this.get('post').set('statusScratch', null); @@ -208,7 +341,7 @@ export default Mixin.create({ if (!options.silent) { let errorOrMessages = error || this.get('post.errors.messages'); - this.showErrorAlert(prevStatus, this.get('post.status'), errorOrMessages); + this._showErrorAlert(prevStatus, this.get('post.status'), errorOrMessages); // simulate a validation error for upstream tasks throw undefined; } @@ -281,252 +414,13 @@ export default Mixin.create({ } catch (error) { if (error) { let status = this.get('post.status'); - this.showErrorAlert(status, status, error); + this._showErrorAlert(status, status, error); } throw error; } }).group('saveTasks'), - /** - * By default, a post will not change its publish state. - * Only with a user-set value (via setSaveType action) - * can the post's status change. - */ - willPublish: boundOneWay('post.isPublished'), - willSchedule: boundOneWay('post.isScheduled'), - - // set by the editor route and `hasDirtyAttributes`. useful when checking - // whether the number of tags has changed for `hasDirtyAttributes`. - previousTagNames: null, - - tagNames: mapBy('post.tags', 'name'), - - postOrPage: computed('post.page', function () { - return this.get('post.page') ? 'Page' : 'Post'; - }), - - // countdown timer to show the time left until publish time for a scheduled post - // starts 15 minutes before scheduled time - scheduleCountdown: computed('post.{publishedAtUTC,isScheduled}', 'clock.second', function () { - let isScheduled = this.get('post.isScheduled'); - let publishTime = this.get('post.publishedAtUTC') || moment.utc(); - let timeUntilPublished = publishTime.diff(moment.utc(), 'minutes', true); - let isPublishedSoon = timeUntilPublished > 0 && timeUntilPublished < 15; - - // force a recompute - this.get('clock.second'); - - if (isScheduled && isPublishedSoon) { - return moment(publishTime).fromNow(); - } else { - return false; - } - }), - - // compares previousTagNames to tagNames - tagNamesEqual() { - let tagNames = this.get('tagNames') || []; - let previousTagNames = this.get('previousTagNames') || []; - let hashCurrent, - hashPrevious; - - // beware! even if they have the same length, - // that doesn't mean they're the same. - if (tagNames.length !== previousTagNames.length) { - return false; - } - - // instead of comparing with slow, nested for loops, - // perform join on each array and compare the strings - hashCurrent = tagNames.join(''); - hashPrevious = previousTagNames.join(''); - - return hashCurrent === hashPrevious; - }, - - // a hook created in editor-base-route's setupController - postSaved() { - let post = this.get('post'); - - // safer to updateTags on save in one place - // rather than in all other places save is called - post.updateTags(); - - // set previousTagNames to current tagNames for hasDirtyAttributes check - this.set('previousTagNames', this.get('tagNames')); - - // `updateTags` triggers `hasDirtyAttributes => true`. - // for a saved post it would otherwise be false. - - // if the two "scratch" properties (title and content) match the post, then - // it's ok to set hasDirtyAttributes to false - if (post.get('titleScratch') === post.get('title') - && JSON.stringify(post.get('scratch')) === JSON.stringify(post.get('mobiledoc'))) { - this.set('hasDirtyAttributes', false); - } - }, - - // an ugly hack, but necessary to watch all the post's properties - // and more, without having to be explicit and do it manually - hasDirtyAttributes: computed.apply(Ember, watchedProps.concat({ - get() { - let post = this.get('post'); - - if (!post) { - return false; - } - - let mobiledoc = JSON.stringify(post.get('mobiledoc')); - let scratch = JSON.stringify(post.get('scratch')); - let title = post.get('title'); - let titleScratch = post.get('titleScratch'); - let changedAttributes; - - if (!this.tagNamesEqual()) { - return true; - } - - if (titleScratch !== title) { - return true; - } - - // since `scratch` is not post property, we need to check - // it explicitly against the post's mobiledoc attribute - if (mobiledoc !== scratch) { - return true; - } - - // if the Adapter failed to save the post isError will be true - // and we should consider the post still dirty. - if (post.get('isError')) { - return true; - } - - // posts created on the client always return `hasDirtyAttributes: true`, - // so we need to see which properties have actually changed. - if (post.get('isNew')) { - changedAttributes = Object.keys(post.changedAttributes()); - - if (changedAttributes.length) { - return true; - } - - return false; - } - - // even though we use the `scratch` prop to show edits, - // which does *not* change the post's `hasDirtyAttributes` property, - // `hasDirtyAttributes` will tell us if the other props have changed, - // as long as the post is not new (post.isNew === false). - return post.get('hasDirtyAttributes'); - }, - set(key, value) { - return value; - } - })), - - // used on window.onbeforeunload - unloadDirtyMessage() { - return '==============================\n\n' - + 'Hey there! It looks like you\'re in the middle of writing' - + ' something and you haven\'t saved all of your content.' - + '\n\nSave before you go!\n\n' - + '=============================='; - }, - - // TODO: This has to be moved to the I18n localization file. - // This structure is supposed to be close to the i18n-localization which will be used soon. - messageMap: { - errors: { - post: { - published: { - published: 'Update failed', - draft: 'Saving failed', - scheduled: 'Scheduling failed' - }, - draft: { - published: 'Publish failed', - draft: 'Saving failed', - scheduled: 'Scheduling failed' - }, - scheduled: { - scheduled: 'Updated failed', - draft: 'Unscheduling failed', - published: 'Publish failed' - } - - } - }, - - success: { - post: { - published: { - published: 'Updated.', - draft: 'Saved.', - scheduled: 'Scheduled.' - }, - draft: { - published: 'Published!', - draft: 'Saved.', - scheduled: 'Scheduled.' - }, - scheduled: { - scheduled: 'Updated.', - draft: 'Unscheduled.', - published: 'Published!' - } - } - } - }, - - // TODO: Update for new notification click-action API - showSaveNotification(prevStatus, status, delay) { - let message = this.messageMap.success.post[prevStatus][status]; - let notifications = this.get('notifications'); - let type, path; - - if (status === 'published') { - type = this.get('postOrPage'); - path = this.get('post.absoluteUrl'); - } else { - type = 'Preview'; - path = this.get('post.previewUrl'); - } - - message += ` View ${type}`; - - notifications.showNotification(message.htmlSafe(), {delayed: delay}); - }, - - showErrorAlert(prevStatus, status, error, delay) { - let message = this.messageMap.errors.post[prevStatus][status]; - let notifications = this.get('notifications'); - let errorMessage; - - function isString(str) { - /* global toString */ - return toString.call(str) === '[object String]'; - } - - if (error && isString(error)) { - errorMessage = error; - } else if (error && isEmberArray(error)) { - // This is here because validation errors are returned as an array - // TODO: remove this once validations are fixed - errorMessage = error[0]; - } else if (error && error.payload && error.payload.errors && error.payload.errors[0].message) { - errorMessage = error.payload.errors[0].message; - } else { - errorMessage = 'Unknown Error'; - } - - message += `: ${errorMessage}`; - message = htmlSafe(message); - - notifications.showAlert(message, {type: 'error', delayed: delay, key: 'post.save'}); - }, - saveTitle: task(function* () { let post = this.get('post'); let currentTitle = post.get('title'); @@ -574,117 +468,281 @@ export default Mixin.create({ } }).enqueue(), - actions: { - updateScratch(value) { - this.set('post.scratch', value); + /* Public methods --------------------------------------------------------*/ - // save 3 seconds after last edit - this.get('_autosave').perform(); - // force save at 60 seconds - this.get('_timedSave').perform(); - }, + // called by the new/edit routes to change the post model + setPost(post) { + // don't do anything if the post is the same + if (post === this.get('post')) { + return; + } - cancelAutosave() { - this.get('_autosave').cancelAll(); - this.get('_timedSave').cancelAll(); - }, + // reset everything ready for a new post + this.reset(); - save(options) { - return this.get('save').perform(options); - }, + this.set('post', post); - setSaveType(newType) { - if (newType === 'publish') { - this.set('willPublish', true); - this.set('willSchedule', false); - } else if (newType === 'draft') { - this.set('willPublish', false); - this.set('willSchedule', false); - } else if (newType === 'schedule') { - this.set('willSchedule', true); - this.set('willPublish', false); + // only autofocus the editor if we have a new post + this.set('shouldFocusEditor', post.get('isNew')); + + // need to set scratch values because they won't be present on first + // edit of the post + // TODO: can these be `boundOneWay` on the model as per the other attrs? + post.set('titleScratch', post.get('title')); + post.set('scratch', post.get('mobiledoc')); + + this._previousTagNames = this.get('_tagNames'); + this._attachModelHooks(); + + // triggered any time the admin tab is closed, we need to use a native + // dialog here instead of our custom modal + window.onbeforeunload = () => { + if (this.get('hasDirtyAttributes')) { + return '==============================\n\n' + + 'Hey there! It looks like you\'re in the middle of writing' + + ' something and you haven\'t saved all of your content.' + + '\n\nSave before you go!\n\n' + + '=============================='; } - }, + }; + }, - toggleLeaveEditorModal(transition) { - let leaveTransition = this.get('leaveEditorTransition'); + // called by editor route's willTransition hook, fires for editor.new->edit, + // editor.edit->edit, or editor->any. Triggers `toggleLeaveEditorModal` action + // which will either finish autosave then retry transition or abort and show + // the "are you sure?" modal + willTransition(transition) { + let post = this.get('post'); - if (!transition && this.get('showLeaveEditorModal')) { - this.set('leaveEditorTransition', null); - this.set('showLeaveEditorModal', false); - return; - } + // exit early and allow transition if we have no post, occurs if reset + // has already been called as in the `leaveEditor` action + if (!post) { + return; + } - if (!leaveTransition || transition.targetName === leaveTransition.targetName) { - this.set('leaveEditorTransition', transition); + let hasDirtyAttributes = this.get('hasDirtyAttributes'); + let state = post.getProperties('isDeleted', 'isSaving', 'hasDirtyAttributes', 'isNew'); - // if a save is running, wait for it to finish then transition - if (this.get('saveTasks.isRunning')) { - return this.get('saveTasks.last').then(() => { - transition.retry(); - }); - } + let fromNewToEdit = this.get('router.currentRouteName') === 'editor.new' + && transition.targetName === 'editor.edit' + && transition.intent.contexts + && transition.intent.contexts[0] + && transition.intent.contexts[0].id === post.get('id'); - // if an autosave is scheduled, cancel it, save then transition - if (this.get('_autosaveRunning')) { - this.send('cancelAutosave'); - this.get('autosave').cancelAll(); + let deletedWithoutChanges = state.isDeleted + && (state.isSaving || !state.hasDirtyAttributes); - return this.get('autosave').perform().then(() => { - transition.retry(); - }); - } + // controller is dirty and we aren't in a new->edit or delete->index + // transition so show our "are you sure you want to leave?" modal + if (!fromNewToEdit && !deletedWithoutChanges && hasDirtyAttributes) { + transition.abort(); + this.send('toggleLeaveEditorModal', transition); + return; + } - // we genuinely have unsaved data, show the modal - this.set('showLeaveEditorModal', true); - } - }, + // the transition is now certain to complete so cleanup and reset if + // we're exiting the editor. new->edit keeps everything around and + // edit->edit will call reset in the setPost method if necessary + if (!fromNewToEdit && transition.targetName !== 'editor.edit') { + this.reset(); + } + }, - leaveEditor() { - let transition = this.get('leaveEditorTransition'); - let post = this.get('post'); + // called when the editor route is left or the post model is swapped + reset() { + let post = this.get('post'); - if (!transition) { - this.get('notifications').showAlert('Sorry, there was an error in the application. Please let the Ghost team know what happened.', {type: 'error'}); - return; - } + // make sure the save tasks aren't still running in the background + // after leaving the edit route + this.send('cancelAutosave'); - // definitely want to clear the data store and post of any unsaved, client-generated tags + if (post) { + // clear post of any unsaved, client-generated tags post.updateTags(); + // remove new+unsaved records from the store and rollback any unsaved changes if (post.get('isNew')) { - // the user doesn't want to save the new, unsaved post, so delete it. post.deleteRecord(); } else { - // roll back changes on post props post.rollbackAttributes(); } - // setting hasDirtyAttributes to false here allows willTransition on the editor route to succeed - this.set('hasDirtyAttributes', false); - - // since the transition is now certain to complete, we can unset window.onbeforeunload here - window.onbeforeunload = null; - - return transition.retry(); - }, - - updateTitle(newTitle) { - this.set('post.titleScratch', newTitle); - }, - - toggleDeletePostModal() { - if (!this.get('post.isNew')) { - this.toggleProperty('showDeletePostModal'); - } - }, - - toggleReAuthenticateModal() { - this.toggleProperty('showReAuthenticateModal'); - }, - - setWordcount(wordcount) { - this.set('wordcount', wordcount); + // remove the create/update event handlers that were added to the post + this._detachModelHooks(); } + + this._previousTagNames = []; + + this.set('post', null); + this.set('hasDirtyAttributes', false); + this.set('shouldFocusEditor', false); + this.set('leaveEditorTransition', null); + + // remove the onbeforeunload handler as it's only relevant whilst on + // the editor route + window.onbeforeunload = null; + }, + + /* Private tasks ---------------------------------------------------------*/ + + // save 3 seconds after the last edit + _autosave: task(function* () { + if (!this.get('_canAutosave')) { + return; + } + + // force an instant save on first body edit for new posts + if (this.get('post.isNew')) { + return this.get('autosave').perform(); + } + + yield timeout(AUTOSAVE_TIMEOUT); + this.get('autosave').perform(); + }).restartable(), + + // save at 60 seconds even if the user doesn't stop typing + _timedSave: task(function* () { + if (!this.get('_canAutosave')) { + return; + } + + while (!Ember.testing && true) { + yield timeout(TIMEDSAVE_TIMEOUT); + this.get('autosave').perform(); + } + }).drop(), + + /* Private methods -------------------------------------------------------*/ + + _hasDirtyAttributes() { + let post = this.get('post'); + + if (!post) { + return false; + } + + // if the Adapter failed to save the post isError will be true + // and we should consider the post still dirty. + if (post.get('isError')) { + return true; + } + + // post.tags is an array so hasDirtyAttributes doesn't pick up + // changes unless the array ref is changed + let currentTags = this.getWithDefault('_tagNames', []).join(''); + let previousTags = this.getWithDefault('_previousTagNames', []).join(''); + if (currentTags !== previousTags) { + return true; + } + + // titleScratch isn't an attr so needs a manual dirty check + if (this.get('titleScratch') !== this.get('title')) { + return true; + } + + // scratch isn't an attr so needs a manual dirty check + let mobiledoc = JSON.stringify(post.get('mobiledoc')); + let scratch = JSON.stringify(post.get('scratch')); + if (scratch !== mobiledoc) { + return true; + } + + // new+unsaved posts always return `hasDirtyAttributes: true` + // so we need a manual check to see if any + if (post.get('isNew')) { + let changedAttributes = Object.keys(post.changedAttributes()); + return changedAttributes.length ? true : false; + } + + // we've covered all the non-tracked cases we care about so fall + // back on Ember Data's default dirty attribute checks + return post.get('hasDirtyAttributes'); + }, + + // post.save() is called in multiple places, rather than remembering to + // add a .then in every instance we use model hooks to update our local + // values used for `hasDirtyAttributes` + _attachModelHooks() { + let post = this.get('post'); + if (post) { + post.on('didCreate', this, this._postSaved); + post.on('didUpdate', this, this._postSaved); + } + }, + + _detachModelHooks() { + let post = this.get('post'); + if (post) { + post.off('didCreate', this, this._postSaved); + post.off('didUpdate', this, this._postSaved); + } + }, + + _postSaved() { + let post = this.get('post'); + + // remove any unsaved tags + // NOTE: `updateTags` changes `hasDirtyAttributes => true`. + // For a saved post it would otherwise be false. + post.updateTags(); + + this._previousTagNames = this.get('_tagNames'); + + // if the two "scratch" properties (title and content) match the post, + // then it's ok to set hasDirtyAttributes to false + // TODO: why is this necessary? + let titlesMatch = post.get('titleScratch') === post.get('title'); + let bodiesMatch = JSON.stringify(post.get('scratch')) === JSON.stringify(post.get('mobiledoc')); + + if (titlesMatch && bodiesMatch) { + this.set('hasDirtyAttributes', false); + } + }, + + _showSaveNotification(prevStatus, status, delay) { + let message = messageMap.success.post[prevStatus][status]; + let notifications = this.get('notifications'); + let type, path; + + if (status === 'published') { + type = this.get('post.page') ? 'Page' : 'Post'; + path = this.get('post.absoluteUrl'); + } else { + type = 'Preview'; + path = this.get('post.previewUrl'); + } + + message += ` View ${type}`; + + notifications.showNotification(message.htmlSafe(), {delayed: delay}); + }, + + _showErrorAlert(prevStatus, status, error, delay) { + let message = messageMap.errors.post[prevStatus][status]; + let notifications = this.get('notifications'); + let errorMessage; + + function isString(str) { + /* global toString */ + return toString.call(str) === '[object String]'; + } + + if (error && isString(error)) { + errorMessage = error; + } else if (error && isEmberArray(error)) { + // This is here because validation errors are returned as an array + // TODO: remove this once validations are fixed + errorMessage = error[0]; + } else if (error && error.payload && error.payload.errors && error.payload.errors[0].message) { + errorMessage = error.payload.errors[0].message; + } else { + errorMessage = 'Unknown Error'; + } + + message += `: ${errorMessage}`; + message = htmlSafe(message); + + notifications.showAlert(message, {type: 'error', delayed: delay, key: 'post.save'}); } + }); diff --git a/app/controllers/editor/edit.js b/app/controllers/editor/edit.js deleted file mode 100644 index 1fee4eb89..000000000 --- a/app/controllers/editor/edit.js +++ /dev/null @@ -1,5 +0,0 @@ -/* eslint-disable ghost/ember/alias-model-in-controller */ -import Controller from '@ember/controller'; -import EditorControllerMixin from 'ghost-admin/mixins/editor-base-controller'; - -export default Controller.extend(EditorControllerMixin); diff --git a/app/controllers/editor/new.js b/app/controllers/editor/new.js deleted file mode 100644 index 1fee4eb89..000000000 --- a/app/controllers/editor/new.js +++ /dev/null @@ -1,5 +0,0 @@ -/* eslint-disable ghost/ember/alias-model-in-controller */ -import Controller from '@ember/controller'; -import EditorControllerMixin from 'ghost-admin/mixins/editor-base-controller'; - -export default Controller.extend(EditorControllerMixin); diff --git a/app/mixins/editor-base-route.js b/app/mixins/editor-base-route.js deleted file mode 100644 index 703a57f6a..000000000 --- a/app/mixins/editor-base-route.js +++ /dev/null @@ -1,141 +0,0 @@ -import $ from 'jquery'; -import Mixin from '@ember/object/mixin'; -import ShortcutsRoute from 'ghost-admin/mixins/shortcuts-route'; -import ctrlOrCmd from 'ghost-admin/utils/ctrl-or-cmd'; -import styleBody from 'ghost-admin/mixins/style-body'; -import {run} from '@ember/runloop'; - -let generalShortcuts = {}; - -generalShortcuts[`${ctrlOrCmd}+shift+p`] = 'publish'; - -export default Mixin.create(styleBody, ShortcutsRoute, { - classNames: ['editor'], - - shortcuts: generalShortcuts, - - actions: { - save() { - let selectedElement = $(document.activeElement); - - if (selectedElement.is('input[type="text"]')) { - selectedElement.trigger('focusout'); - } - - run.scheduleOnce('actions', this, function () { - this.get('controller').send('save'); - }); - }, - - publish() { - let controller = this.get('controller'); - - controller.send('setSaveType', 'publish'); - controller.send('save'); - }, - - willTransition(transition) { - let controller = this.get('controller'); - let scratch = controller.get('post.scratch'); - let controllerIsDirty = controller.get('hasDirtyAttributes'); - let post = controller.get('post'); - let state = post.getProperties('isDeleted', 'isSaving', 'hasDirtyAttributes', 'isNew'); - - if (this.get('upgradeStatus.isRequired')) { - return this._super(...arguments); - } - - let fromNewToEdit = this.get('routeName') === 'editor.new' - && transition.targetName === 'editor.edit' - && transition.intent.contexts - && transition.intent.contexts[0] - && transition.intent.contexts[0].id === post.get('id'); - - let deletedWithoutChanges = state.isDeleted - && (state.isSaving || !state.hasDirtyAttributes); - - if (!fromNewToEdit && !deletedWithoutChanges && controllerIsDirty) { - transition.abort(); - controller.send('toggleLeaveEditorModal', transition); - return; - } - - // The controller may hold post state that will be lost in the - // new->edit transition, so we need to apply it now. - if (fromNewToEdit && controllerIsDirty) { - if (scratch !== post.get('mobiledoc')) { - post.set('mobiledoc', scratch); - } - } - - // make sure the save tasks aren't still running in the background - // after leaving the edit route - // TODO: the edit screen should really be a component so that we get - // automatic state cleanup and task cancellation - controller.send('cancelAutosave'); - - if (state.isNew) { - post.deleteRecord(); - } - - // since the transition is now certain to complete.. - window.onbeforeunload = null; - - // remove post-related listeners created in editor-base-route - this.detachModelHooks(controller, post); - } - }, - - attachModelHooks(controller, post) { - // this will allow us to track when the post is saved and update the controller - // so that we can be sure controller.hasDirtyAttributes is correct, without having to update the - // controller on each instance of `post.save()`. - // - // another reason we can't do this on `post.save().then()` is because the post-settings-menu - // also saves the post, and passing messages is difficult because we have two - // types of editor controllers, and the PSM also exists on the posts.post route. - // - // The reason we can't just keep this functionality in the editor controller is - // because we need to remove these handlers on `willTransition` in the editor route. - post.on('didCreate', controller, controller.get('postSaved')); - post.on('didUpdate', controller, controller.get('postSaved')); - }, - - detachModelHooks(controller, post) { - post.off('didCreate', controller, controller.get('postSaved')); - post.off('didUpdate', controller, controller.get('postSaved')); - }, - - setupController(controller, post) { - let tags = post.get('tags'); - - post.set('scratch', post.get('mobiledoc')); - post.set('titleScratch', post.get('title')); - - // reset the leave editor transition so new->edit will still work - controller.set('leaveEditorTransition', null); - - this._super(...arguments); - - if (tags) { - // used to check if anything has changed in the editor - controller.set('previousTagNames', tags.mapBy('name')); - } else { - controller.set('previousTagNames', []); - } - - // trigger an immediate autosave timeout if post has changed between - // new->edit (typical as first save will only contain the first char) - // so that leaving the route waits for save instead of showing the - // "Are you sure you want to leave?" modal unexpectedly - if (!post.get('isNew') && post.get('hasDirtyAttributes')) { - controller.get('_autosave').perform(); - } - - // reset save-on-first-change (gh-koenig specific) - // controller._hasChanged = false; - - // attach post-related listeners created in editor-base-route - this.attachModelHooks(controller, post); - } -}); diff --git a/app/routes/editor.js b/app/routes/editor.js new file mode 100644 index 000000000..1d6da2fdb --- /dev/null +++ b/app/routes/editor.js @@ -0,0 +1,59 @@ +import $ from 'jquery'; +import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; +import ShortcutsRoute from 'ghost-admin/mixins/shortcuts-route'; +import ctrlOrCmd from 'ghost-admin/utils/ctrl-or-cmd'; +import {run} from '@ember/runloop'; + +let generalShortcuts = {}; +generalShortcuts[`${ctrlOrCmd}+shift+p`] = 'publish'; + +export default AuthenticatedRoute.extend(ShortcutsRoute, { + classNames: ['editor'], + shortcuts: generalShortcuts, + titleToken: 'Editor', + + actions: { + save() { + this._blurAndScheduleAction(function () { + this.get('controller').send('save'); + }); + }, + + publish() { + this._blurAndScheduleAction(function () { + this.get('controller').send('setSaveType', 'publish'); + this.get('controller').send('save'); + }); + }, + + authorizationFailed() { + this.get('controller').send('toggleReAuthenticateModal'); + }, + + redirectToContentScreen() { + this.transitionTo('posts'); + }, + + willTransition(transition) { + // exit early if an upgrade is required because our extended route + // class will abort the transition and show an error + if (this.get('upgradeStatus.isRequired')) { + return this._super(...arguments); + } + + this.get('controller').willTransition(transition); + } + }, + + _blurAndScheduleAction(func) { + let selectedElement = $(document.activeElement); + + // TODO: we should trigger a blur for textareas as well as text inputs + if (selectedElement.is('input[type="text"]')) { + selectedElement.trigger('focusout'); + } + + // wait for actions triggered by the focusout to finish before saving + run.scheduleOnce('actions', this, func); + } +}); diff --git a/app/routes/editor/edit.js b/app/routes/editor/edit.js index dc4b88b48..3d2848bb5 100644 --- a/app/routes/editor/edit.js +++ b/app/routes/editor/edit.js @@ -1,37 +1,21 @@ -/* eslint-disable camelcase */ import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; -import base from 'ghost-admin/mixins/editor-base-route'; - -export default AuthenticatedRoute.extend(base, { - titleToken: 'Editor', - - beforeModel(transition) { - this.set('_transitionedFromNew', transition.data.fromNew); - - this._super(...arguments); - }, +export default AuthenticatedRoute.extend({ model(params) { - /* eslint-disable camelcase */ let query = { id: params.post_id, status: 'all', staticPages: 'all', formats: 'mobiledoc,plaintext' }; - /* eslint-enable camelcase */ - return this.store.query('post', query).then((records) => { - let post = records.get('firstObject'); - - if (post) { - return post; - } - - return this.replaceWith('posts.index'); - }); + return this.store.query('post', query) + .then(records => records.get('firstObject')); }, + // the API will return a post even if the logged in user doesn't have + // permission to edit it (all posts are public) so we need to do our + // own permissions check and redirect if necessary afterModel(post) { this._super(...arguments); @@ -42,18 +26,10 @@ export default AuthenticatedRoute.extend(base, { }); }, - setupController(controller) { - this._super(...arguments); - controller.set('shouldFocusEditor', this.get('_transitionedFromNew')); - }, - - actions: { - authorizationFailed() { - this.get('controller').send('toggleReAuthenticateModal'); - }, - - redirectToContentScreen() { - this.transitionTo('posts'); - } + // there's no specific controller for this route, instead all editor + // handling is done on the editor route/controler + setupController(controller, post) { + let editor = this.controllerFor('editor'); + editor.setPost(post); } }); diff --git a/app/routes/editor/index.js b/app/routes/editor/index.js deleted file mode 100644 index cd29a2c8d..000000000 --- a/app/routes/editor/index.js +++ /dev/null @@ -1,8 +0,0 @@ -import Route from '@ember/routing/route'; - -export default Route.extend({ - beforeModel() { - this._super(...arguments); - this.transitionTo('editor.new'); - } -}); diff --git a/app/routes/editor/new.js b/app/routes/editor/new.js index e4f13f6c3..520be90d8 100644 --- a/app/routes/editor/new.js +++ b/app/routes/editor/new.js @@ -1,31 +1,16 @@ import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; -import base from 'ghost-admin/mixins/editor-base-route'; - -export default AuthenticatedRoute.extend(base, { - titleToken: 'Editor', +export default AuthenticatedRoute.extend({ model() { return this.get('session.user').then(user => ( - this.store.createRecord('post', { - author: user - }) + this.store.createRecord('post', {author: user}) )); }, - renderTemplate(controller, model) { - this.render('editor/edit', { - controller, - model - }); - }, - - actions: { - willTransition(transition) { - // decorate the transition object so the editor.edit route - // knows this was the previous active route - transition.data.fromNew = true; - - this._super(...arguments); - } + // there's no specific controller for this route, instead all editor + // handling is done on the editor route/controler + setupController(controller, newPost) { + let editor = this.controllerFor('editor'); + editor.setPost(newPost); } }); diff --git a/app/templates/components/gh-scheduled-post-countdown.hbs b/app/templates/components/gh-scheduled-post-countdown.hbs new file mode 100644 index 000000000..10e0edb07 --- /dev/null +++ b/app/templates/components/gh-scheduled-post-countdown.hbs @@ -0,0 +1,3 @@ +{{#if countdown}} + {{yield post countdown}} +{{/if}} diff --git a/app/templates/editor.hbs b/app/templates/editor.hbs new file mode 100644 index 000000000..3e8bcd7a4 --- /dev/null +++ b/app/templates/editor.hbs @@ -0,0 +1,162 @@ +{{#if post}} + {{#gh-editor + tagName="section" + class="gh-editor gh-view" + navIsClosed=navIsClosed + as |editor| + }} +
+
+ {{gh-editor-post-status + post=post + isSaving=(or autosave.isRunning saveTasks.isRunning) + }} +
+ {{#gh-scheduled-post-countdown post=post as |post countdown|}} + + {{/gh-scheduled-post-countdown}} +
+ {{#unless post.isNew}} + {{gh-publishmenu + post=post + saveTask=save + setSaveType=(action "setSaveType") + onOpen=(action "cancelAutosave")}} + {{/unless}} + + +
+
+ + {{!-- + NOTE: title is part of the markdown editor container so that it has + access to the markdown editor's "focus" action + --}} + {{#gh-markdown-editor + tabindex="2" + placeholder="Begin writing your story..." + autofocus=shouldFocusEditor + uploadedImageUrls=editor.uploadedImageUrls + mobiledoc=(readonly post.scratch) + isFullScreen=editor.isFullScreen + onChange=(action "updateScratch") + onFullScreenToggle=(action editor.toggleFullScreen) + onPreviewToggle=(action editor.togglePreview) + onSplitScreenToggle=(action editor.toggleSplitScreen) + onImageFilesSelected=(action editor.uploadImages) + imageMimeTypes=editor.imageMimeTypes + as |markdown| + }} +
+ {{gh-textarea post.titleScratch + class="gh-editor-title" + placeholder="Post Title" + tabindex="1" + autoExpand=".gh-markdown-editor-pane" + update=(action "updateTitleScratch") + focusOut=(action (perform saveTitle)) + keyEvents=(hash + 9=(action markdown.focus 'bottom') + 13=(action markdown.focus 'top') + ) + data-test-editor-title-input=true + }} + {{markdown.editor}} +
+ + {{#if markdown.isSplitScreen}} +
+

{{post.titleScratch}}

+
+
+ {{/if}} + + {{gh-tour-item "using-the-editor" + target=".gh-editor-footer" + throbberAttachment="top left" + throbberOffset="0 20%" + popoverTriangleClass="bottom" + }} + {{/gh-markdown-editor}} + + {{!-- TODO: put tool/status bar in here so that scroll area can be fixed --}} + + + {{!-- files are dragged over editor pane --}} + {{#if editor.isDraggedOver}} +
+
+

Drop image(s) here to upload

+
+
+ {{/if}} + + {{!-- files have been dropped ready to be uploaded --}} + {{#if editor.droppedFiles}} + {{#gh-uploader + files=editor.droppedFiles + accept=editor.imageMimeTypes + extensions=editor.imageExtensions + onComplete=(action editor.uploadComplete) + onCancel=(action editor.uploadCancelled) + as |upload| + }} +
+
+ {{#if upload.errors}} +

Upload failed

+ + {{#each upload.errors as |error|}} +
{{error.fileName}} - {{error.message}}
+ {{/each}} + + + {{else}} + +

Uploading {{pluralize upload.files.length "image"}}...

+ {{upload.progressBar}} + {{/if}} +
+
+ {{/gh-uploader}} + {{/if}} + {{/gh-editor}} + + {{#if showDeletePostModal}} + {{gh-fullscreen-modal "delete-post" + model=(hash post=post onSuccess=(route-action 'redirectToContentScreen')) + close=(action "toggleDeletePostModal") + modifier="action wide"}} + {{/if}} + + {{#if showLeaveEditorModal}} + {{gh-fullscreen-modal "leave-editor" + confirm=(action "leaveEditor") + close=(action "toggleLeaveEditorModal") + modifier="action wide"}} + {{/if}} + + {{#if showReAuthenticateModal}} + {{gh-fullscreen-modal "re-authenticate" + close=(action "toggleReAuthenticateModal") + modifier="action wide"}} + {{/if}} + + {{#liquid-wormhole}} + {{gh-post-settings-menu + post=post + showSettingsMenu=ui.showSettingsMenu + deletePost=(action "toggleDeletePostModal") + updateSlug=updateSlug + savePost=savePost + }} + {{/liquid-wormhole}} +{{/if}} + +{{outlet}} diff --git a/app/templates/editor/edit.hbs b/app/templates/editor/edit.hbs deleted file mode 100644 index 6b6a910fb..000000000 --- a/app/templates/editor/edit.hbs +++ /dev/null @@ -1,158 +0,0 @@ -{{#gh-editor - tagName="section" - class="gh-editor gh-view" - navIsClosed=navIsClosed - as |editor| -}} -
-
- {{gh-editor-post-status - post=post - isSaving=(or autosave.isRunning saveTasks.isRunning) - }} -
- {{#if scheduleCountdown}} - - {{/if}} -
- {{#unless post.isNew}} - {{gh-publishmenu - post=post - saveTask=save - setSaveType=(action "setSaveType") - onOpen=(action "cancelAutosave")}} - {{/unless}} - - -
-
- - {{!-- - NOTE: title is part of the markdown editor container so that it has - access to the markdown editor's "focus" action - --}} - {{#gh-markdown-editor - tabindex="2" - placeholder="Begin writing your story..." - autofocus=shouldFocusEditor - uploadedImageUrls=editor.uploadedImageUrls - mobiledoc=(readonly post.scratch) - isFullScreen=editor.isFullScreen - onChange=(action "updateScratch") - onFullScreenToggle=(action editor.toggleFullScreen) - onPreviewToggle=(action editor.togglePreview) - onSplitScreenToggle=(action editor.toggleSplitScreen) - onImageFilesSelected=(action editor.uploadImages) - imageMimeTypes=editor.imageMimeTypes - as |markdown| - }} -
- {{gh-textarea post.titleScratch - class="gh-editor-title" - placeholder="Post Title" - tabindex="1" - autoExpand=".gh-markdown-editor-pane" - update=(action "updateTitle") - focusOut=(action (perform saveTitle)) - keyEvents=(hash - 9=(action markdown.focus 'bottom') - 13=(action markdown.focus 'top') - ) - data-test-editor-title-input=true - }} - {{markdown.editor}} -
- - {{#if markdown.isSplitScreen}} -
-

{{post.titleScratch}}

-
-
- {{/if}} - - {{gh-tour-item "using-the-editor" - target=".gh-editor-footer" - throbberAttachment="top left" - throbberOffset="0 20%" - popoverTriangleClass="bottom" - }} - {{/gh-markdown-editor}} - - {{!-- TODO: put tool/status bar in here so that scroll area can be fixed --}} - - - {{!-- files are dragged over editor pane --}} - {{#if editor.isDraggedOver}} -
-
-

Drop image(s) here to upload

-
-
- {{/if}} - - {{!-- files have been dropped ready to be uploaded --}} - {{#if editor.droppedFiles}} - {{#gh-uploader - files=editor.droppedFiles - accept=editor.imageMimeTypes - extensions=editor.imageExtensions - onComplete=(action editor.uploadComplete) - onCancel=(action editor.uploadCancelled) - as |upload| - }} -
-
- {{#if upload.errors}} -

Upload failed

- - {{#each upload.errors as |error|}} -
{{error.fileName}} - {{error.message}}
- {{/each}} - - - {{else}} - -

Uploading {{pluralize upload.files.length "image"}}...

- {{upload.progressBar}} - {{/if}} -
-
- {{/gh-uploader}} - {{/if}} -{{/gh-editor}} - -{{#if showDeletePostModal}} - {{gh-fullscreen-modal "delete-post" - model=(hash post=post onSuccess=(route-action 'redirectToContentScreen')) - close=(action "toggleDeletePostModal") - modifier="action wide"}} -{{/if}} - -{{#if showLeaveEditorModal}} - {{gh-fullscreen-modal "leave-editor" - confirm=(action "leaveEditor") - close=(action "toggleLeaveEditorModal") - modifier="action wide"}} -{{/if}} - -{{#if showReAuthenticateModal}} - {{gh-fullscreen-modal "re-authenticate" - close=(action "toggleReAuthenticateModal") - modifier="action wide"}} -{{/if}} - -{{#liquid-wormhole}} - {{gh-post-settings-menu - post=post - showSettingsMenu=ui.showSettingsMenu - deletePost=(action "toggleDeletePostModal") - updateSlug=updateSlug - savePost=savePost - }} -{{/liquid-wormhole}} diff --git a/tests/unit/controllers/editor-test.js b/tests/unit/controllers/editor-test.js new file mode 100644 index 000000000..7abeb2985 --- /dev/null +++ b/tests/unit/controllers/editor-test.js @@ -0,0 +1,188 @@ +import EmberObject from '@ember/object'; +import RSVP from 'rsvp'; +import wait from 'ember-test-helpers/wait'; +import {describe, it} from 'mocha'; +import {expect} from 'chai'; +import {run} from '@ember/runloop'; +import {setupTest} from 'ember-mocha'; +import {task} from 'ember-concurrency'; + +describe('Unit: Controller: editor', function () { + setupTest('controller:editor', { + needs: [ + 'controller:application', + 'service:notifications', + // 'service:router', + 'service:slugGenerator', + 'service:ui' + ] + }); + + describe('generateSlug', function () { + it('should generate a slug and set it on the post', function (done) { + run(() => { + let controller = this.subject(); + + controller.set('slugGenerator', EmberObject.create({ + generateSlug(slugType, str) { + return RSVP.resolve(`${str}-slug`); + } + })); + controller.set('post', EmberObject.create({slug: ''})); + + controller.set('post.titleScratch', 'title'); + + expect(controller.get('post.slug')).to.equal(''); + + run(() => { + controller.get('generateSlug').perform(); + }); + + wait().then(() => { + expect(controller.get('post.slug')).to.equal('title-slug'); + done(); + }); + }); + }); + + it('should not set the destination if the title is "(Untitled)" and the post already has a slug', function (done) { + let controller = this.subject(); + + run(() => { + controller.set('slugGenerator', EmberObject.create({ + generateSlug(slugType, str) { + return RSVP.resolve(`${str}-slug`); + } + })); + controller.set('post', EmberObject.create({slug: 'whatever'})); + }); + + expect(controller.get('post.slug')).to.equal('whatever'); + + controller.set('post.titleScratch', '(Untitled)'); + + run(() => { + controller.get('generateSlug').perform(); + }); + + wait().then(() => { + expect(controller.get('post.slug')).to.equal('whatever'); + done(); + }); + }); + }); + + describe('saveTitle', function () { + it('should invoke generateSlug if the post is new and a title has not been set', function (done) { + let controller = this.subject(); + + run(() => { + controller.set('generateSlug', task(function * () { + this.set('post.slug', 'test-slug'); + yield RSVP.resolve(); + })); + controller.set('post', EmberObject.create({isNew: true})); + }); + + expect(controller.get('post.isNew')).to.be.true; + expect(controller.get('post.titleScratch')).to.not.be.ok; + + controller.set('post.titleScratch', 'test'); + + run(() => { + controller.get('saveTitle').perform(); + }); + + wait().then(() => { + expect(controller.get('post.titleScratch')).to.equal('test'); + expect(controller.get('post.slug')).to.equal('test-slug'); + done(); + }); + }); + + it('should invoke generateSlug if the post is not new and it\'s title is "(Untitled)"', function (done) { + let controller = this.subject(); + + run(() => { + controller.set('generateSlug', task(function * () { + this.set('post.slug', 'test-slug'); + yield RSVP.resolve(); + })); + controller.set('post', EmberObject.create({isNew: false, title: '(Untitled)'})); + }); + + expect(controller.get('post.isNew')).to.be.false; + expect(controller.get('post.titleScratch')).to.not.be.ok; + + controller.set('post.titleScratch', 'New Title'); + + run(() => { + controller.get('saveTitle').perform(); + }); + + wait().then(() => { + expect(controller.get('post.titleScratch')).to.equal('New Title'); + expect(controller.get('post.slug')).to.equal('test-slug'); + done(); + }); + }); + + it('should not invoke generateSlug if the post is new but has a title', function (done) { + let controller = this.subject(); + + run(() => { + controller.set('generateSlug', task(function * () { + expect(false, 'generateSlug should not be called').to.equal(true); + yield RSVP.resolve(); + })); + controller.set('post', EmberObject.create({ + isNew: true, + title: 'a title' + })); + }); + + expect(controller.get('post.isNew')).to.be.true; + expect(controller.get('post.title')).to.equal('a title'); + expect(controller.get('post.titleScratch')).to.not.be.ok; + + controller.set('post.titleScratch', 'test'); + + run(() => { + controller.get('saveTitle').perform(); + }); + + wait().then(() => { + expect(controller.get('post.titleScratch')).to.equal('test'); + expect(controller.get('post.slug')).to.not.be.ok; + done(); + }); + }); + + it('should not invoke generateSlug if the post is not new and the title is not "(Untitled)"', function (done) { + let controller = this.subject(); + + run(() => { + controller.set('generateSlug', task(function * () { + expect(false, 'generateSlug should not be called').to.equal(true); + yield RSVP.resolve(); + })); + controller.set('post', EmberObject.create({isNew: false})); + }); + + expect(controller.get('post.isNew')).to.be.false; + expect(controller.get('post.title')).to.not.be.ok; + + controller.set('post.titleScratch', 'title'); + + run(() => { + controller.get('saveTitle').perform(); + }); + + wait().then(() => { + expect(controller.get('post.titleScratch')).to.equal('title'); + expect(controller.get('post.slug')).to.not.be.ok; + done(); + }); + }); + }); +}); diff --git a/tests/unit/mixins/editor-base-controller-test.js b/tests/unit/mixins/editor-base-controller-test.js deleted file mode 100644 index ae4f1f715..000000000 --- a/tests/unit/mixins/editor-base-controller-test.js +++ /dev/null @@ -1,197 +0,0 @@ -import EditorBaseControllerMixin from 'ghost-admin/mixins/editor-base-controller'; -import EmberObject from '@ember/object'; -import RSVP from 'rsvp'; -import wait from 'ember-test-helpers/wait'; -import { - describe, - it -} from 'mocha'; -import {expect} from 'chai'; -import {run} from '@ember/runloop'; -import {task} from 'ember-concurrency'; - -describe('Unit: Mixin: editor-base-controller', function () { - describe('generateSlug', function () { - it('should generate a slug and set it on the post', function (done) { - let object; - - run(() => { - object = EmberObject.extend(EditorBaseControllerMixin, { - slugGenerator: EmberObject.create({ - generateSlug(slugType, str) { - return RSVP.resolve(`${str}-slug`); - } - }), - post: EmberObject.create({slug: ''}) - }).create(); - - object.set('post.titleScratch', 'title'); - - expect(object.get('post.slug')).to.equal(''); - - run(() => { - object.get('generateSlug').perform(); - }); - - wait().then(() => { - expect(object.get('post.slug')).to.equal('title-slug'); - done(); - }); - }); - }); - - it('should not set the destination if the title is "(Untitled)" and the post already has a slug', function (done) { - let object; - - run(() => { - object = EmberObject.extend(EditorBaseControllerMixin, { - slugGenerator: EmberObject.create({ - generateSlug(slugType, str) { - return RSVP.resolve(`${str}-slug`); - } - }), - post: EmberObject.create({ - slug: 'whatever' - }) - }).create(); - }); - - expect(object.get('post.slug')).to.equal('whatever'); - - object.set('post.titleScratch', '(Untitled)'); - - run(() => { - object.get('generateSlug').perform(); - }); - - wait().then(() => { - expect(object.get('post.slug')).to.equal('whatever'); - done(); - }); - }); - }); - - describe('saveTitle', function () { - it('should invoke generateSlug if the post is new and a title has not been set', function (done) { - let object; - - run(() => { - object = EmberObject.extend(EditorBaseControllerMixin, { - post: EmberObject.create({isNew: true}), - generateSlug: task(function* () { - this.set('post.slug', 'test-slug'); - yield RSVP.resolve(); - }) - }).create(); - }); - - expect(object.get('post.isNew')).to.be.true; - expect(object.get('post.titleScratch')).to.not.be.ok; - - object.set('post.titleScratch', 'test'); - - run(() => { - object.get('saveTitle').perform(); - }); - - wait().then(() => { - expect(object.get('post.titleScratch')).to.equal('test'); - expect(object.get('post.slug')).to.equal('test-slug'); - done(); - }); - }); - - it('should invoke generateSlug if the post is not new and it\'s title is "(Untitled)"', function (done) { - let object; - - run(() => { - object = EmberObject.extend(EditorBaseControllerMixin, { - post: EmberObject.create({isNew: false, title: '(Untitled)'}), - generateSlug: task(function* () { - this.set('post.slug', 'test-slug'); - yield RSVP.resolve(); - }) - }).create(); - }); - - expect(object.get('post.isNew')).to.be.false; - expect(object.get('post.titleScratch')).to.not.be.ok; - - object.set('post.titleScratch', 'New Title'); - - run(() => { - object.get('saveTitle').perform(); - }); - - wait().then(() => { - expect(object.get('post.titleScratch')).to.equal('New Title'); - expect(object.get('post.slug')).to.equal('test-slug'); - done(); - }); - }); - - it('should not invoke generateSlug if the post is new but has a title', function (done) { - let object; - - run(() => { - object = EmberObject.extend(EditorBaseControllerMixin, { - post: EmberObject.create({ - isNew: true, - title: 'a title' - }), - generateSlug: task(function* () { - expect(false, 'generateSlug should not be called').to.equal(true); - - yield RSVP.resolve(); - }) - }).create(); - }); - - expect(object.get('post.isNew')).to.be.true; - expect(object.get('post.title')).to.equal('a title'); - expect(object.get('post.titleScratch')).to.not.be.ok; - - object.set('post.titleScratch', 'test'); - - run(() => { - object.get('saveTitle').perform(); - }); - - wait().then(() => { - expect(object.get('post.titleScratch')).to.equal('test'); - expect(object.get('post.slug')).to.not.be.ok; - done(); - }); - }); - - it('should not invoke generateSlug if the post is not new and the title is not "(Untitled)"', function (done) { - let object; - - run(() => { - object = EmberObject.extend(EditorBaseControllerMixin, { - post: EmberObject.create({isNew: false}), - generateSlug: task(function* () { - expect(false, 'generateSlug should not be called').to.equal(true); - - yield RSVP.resolve(); - }) - }).create(); - }); - - expect(object.get('post.isNew')).to.be.false; - expect(object.get('post.title')).to.not.be.ok; - - object.set('post.titleScratch', 'title'); - - run(() => { - object.get('saveTitle').perform(); - }); - - wait().then(() => { - expect(object.get('post.titleScratch')).to.equal('title'); - expect(object.get('post.slug')).to.not.be.ok; - done(); - }); - }); - }); -});