Refactor new->edit transition to avoid editor re-renders (#949)

closes TryGhost/Ghost#8287
closes TryGhost/Ghost#8750
- moved all editor model functionality into the new `editor` route+controller
- moved editor template from `editor/edit.hbs` to `editor.hbs`
- refactored `editor` controller and the `editor.new`/`editor.edit` routes to work with a persistent editor rather than a teardown/re-render when transitioning from new->edit
- fixed issue in `{{gh-markdown-editor}}` for `autofocus` behaviour when the component isn't re-rendered
- fixed issue in `{{gh-simplemde}}` that was causing multiple updates to the `value` property when a new value is passed in to the component
- added `{{gh-scheduled-post-countdown}}` component to lower the noise in the editor controller
- removed editor route/controller mixins
- removed the `editor.edit` and `editor.new` controllers
This commit is contained in:
Kevin Ansfield 2018-01-17 13:27:37 +00:00 committed by GitHub
parent 5a54a60003
commit 51e890b991
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 931 additions and 975 deletions

View File

@ -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'));

View File

@ -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;
}
})
});

View File

@ -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', () => {

View File

@ -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 += `&nbsp;<a href="${path}" target="_blank">View ${type}</a>`;
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 += `&nbsp;<a href="${path}" target="_blank">View ${type}</a>`;
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'});
}
});

View File

@ -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);

View File

@ -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);

View File

@ -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);
}
});

59
app/routes/editor.js Normal file
View File

@ -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);
}
});

View File

@ -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);
}
});

View File

@ -1,8 +0,0 @@
import Route from '@ember/routing/route';
export default Route.extend({
beforeModel() {
this._super(...arguments);
this.transitionTo('editor.new');
}
});

View File

@ -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);
}
});

View File

@ -0,0 +1,3 @@
{{#if countdown}}
{{yield post countdown}}
{{/if}}

162
app/templates/editor.hbs Normal file
View File

@ -0,0 +1,162 @@
{{#if post}}
{{#gh-editor
tagName="section"
class="gh-editor gh-view"
navIsClosed=navIsClosed
as |editor|
}}
<header class="gh-editor-header {{editor.headerClass}}">
<div class="gh-editor-status">
{{gh-editor-post-status
post=post
isSaving=(or autosave.isRunning saveTasks.isRunning)
}}
</div>
{{#gh-scheduled-post-countdown post=post as |post countdown|}}
<time datetime="{{post.publishedAtUTC}}" class="gh-notification gh-notification-schedule" data-test-schedule-countdown>
Post will be published {{countdown}}.
</time>
{{/gh-scheduled-post-countdown}}
<section class="view-actions">
{{#unless post.isNew}}
{{gh-publishmenu
post=post
saveTask=save
setSaveType=(action "setSaveType")
onOpen=(action "cancelAutosave")}}
{{/unless}}
<button type="button" class="post-settings" title="Settings" {{action "openSettingsMenu" target=ui}} data-test-psm-trigger>
{{inline-svg "settings"}}
</button>
</section>
</header>
{{!--
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|
}}
<div class="gh-markdown-editor-pane">
{{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}}
</div>
{{#if markdown.isSplitScreen}}
<div class="gh-markdown-editor-preview">
<h1 class="gh-markdown-editor-preview-title">{{post.titleScratch}}</h1>
<div class="gh-markdown-editor-preview-content"></div>
</div>
{{/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 --}}
<footer class="gh-editor-footer"></footer>
{{!-- files are dragged over editor pane --}}
{{#if editor.isDraggedOver}}
<div class="drop-target gh-editor-drop-target">
<div class="drop-target-message">
<h3>Drop image(s) here to upload</h3>
</div>
</div>
{{/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|
}}
<div class="gh-editor-image-upload {{if upload.errors "-error"}}">
<div class="gh-editor-image-upload-content">
{{#if upload.errors}}
<h3>Upload failed</h3>
{{#each upload.errors as |error|}}
<div class="failed">{{error.fileName}} - {{error.message}}</div>
{{/each}}
<button class="gh-btn gh-btn-grey gh-btn-icon" {{action upload.cancel}}>
<span>{{inline-svg "close"}} Close</span>
</button>
{{else}}
<h3>Uploading {{pluralize upload.files.length "image"}}...</h3>
{{upload.progressBar}}
{{/if}}
</div>
</div>
{{/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}}

View File

@ -1,158 +0,0 @@
{{#gh-editor
tagName="section"
class="gh-editor gh-view"
navIsClosed=navIsClosed
as |editor|
}}
<header class="gh-editor-header {{editor.headerClass}}">
<div class="gh-editor-status">
{{gh-editor-post-status
post=post
isSaving=(or autosave.isRunning saveTasks.isRunning)
}}
</div>
{{#if scheduleCountdown}}
<time datetime="{{post.publishedAtUTC}}" class="gh-notification gh-notification-schedule" data-test-schedule-countdown>
Post will be published {{scheduleCountdown}}.
</time>
{{/if}}
<section class="view-actions">
{{#unless post.isNew}}
{{gh-publishmenu
post=post
saveTask=save
setSaveType=(action "setSaveType")
onOpen=(action "cancelAutosave")}}
{{/unless}}
<button type="button" class="post-settings" title="Settings" {{action "openSettingsMenu" target=ui}} data-test-psm-trigger>
{{inline-svg "settings"}}
</button>
</section>
</header>
{{!--
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|
}}
<div class="gh-markdown-editor-pane">
{{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}}
</div>
{{#if markdown.isSplitScreen}}
<div class="gh-markdown-editor-preview">
<h1 class="gh-markdown-editor-preview-title">{{post.titleScratch}}</h1>
<div class="gh-markdown-editor-preview-content"></div>
</div>
{{/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 --}}
<footer class="gh-editor-footer"></footer>
{{!-- files are dragged over editor pane --}}
{{#if editor.isDraggedOver}}
<div class="drop-target gh-editor-drop-target">
<div class="drop-target-message">
<h3>Drop image(s) here to upload</h3>
</div>
</div>
{{/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|
}}
<div class="gh-editor-image-upload {{if upload.errors "-error"}}">
<div class="gh-editor-image-upload-content">
{{#if upload.errors}}
<h3>Upload failed</h3>
{{#each upload.errors as |error|}}
<div class="failed">{{error.fileName}} - {{error.message}}</div>
{{/each}}
<button class="gh-btn gh-btn-grey gh-btn-icon" {{action upload.cancel}}>
<span>{{inline-svg "close"}} Close</span>
</button>
{{else}}
<h3>Uploading {{pluralize upload.files.length "image"}}...</h3>
{{upload.progressBar}}
{{/if}}
</div>
</div>
{{/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}}

View File

@ -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();
});
});
});
});

View File

@ -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();
});
});
});
});