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:
parent
5a54a60003
commit
51e890b991
|
@ -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'));
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
})
|
||||
});
|
|
@ -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', () => {
|
||||
|
|
|
@ -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 += ` <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 += ` <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'});
|
||||
}
|
||||
|
||||
});
|
|
@ -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);
|
|
@ -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);
|
|
@ -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);
|
||||
}
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
import Route from '@ember/routing/route';
|
||||
|
||||
export default Route.extend({
|
||||
beforeModel() {
|
||||
this._super(...arguments);
|
||||
this.transitionTo('editor.new');
|
||||
}
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
{{#if countdown}}
|
||||
{{yield post countdown}}
|
||||
{{/if}}
|
|
@ -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}}
|
|
@ -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}}
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue