Ghost/ghost/admin/app/mixins/editor-base-controller.js
Kevin Ansfield 3656297843 🐛 fix duplicate posts bug, more intelligent autosave when transitioning (#800)
no issue
- fixes bug where multiple posts were created starting with one char and growing until the new->edit transition completed, e.g. posts with content such as `a`, `ab`, `abcd` were created in quick succession
  - moves old `_savePromise` body into the `save` task
  - call the `save` task instead of the old `_savePromise` so that concurrency is handled properly
- fixes odd behaviour with the "Are you sure you want to leave?" modal appearing too often - it's now aware of on-going or scheduled saves and will wait for those to complete before transitioning
  - move all transition abort/save/retry handling into `toggleLeaveEditorModal` method
  - check for a running save, wait for it to finish then retry the transition
  - check for a scheduled autosave, cancel it if present and perform an immediate autosave then retry the transition
  - don't attempt new->edit transition on successful save of new post if we're already waiting for a different transition
  - once the new->edit transition has completed, if the post body content has changed schedule an autosave manually so that the user doesn't need to type something again to save what they assume is already saved
  - remove debounced slug generation/save on type of title field in favour of generation and save on focus out which plays a lot nicer with the new transition autosave behaviour
2017-07-22 10:25:00 -04:00

676 lines
23 KiB
JavaScript

import Ember from 'ember';
import Mixin from 'ember-metal/mixin';
import PostModel from 'ghost-admin/models/post';
import boundOneWay from 'ghost-admin/utils/bound-one-way';
import computed, {mapBy, reads} from 'ember-computed';
import ghostPaths from 'ghost-admin/utils/ghost-paths';
import injectController from 'ember-controller/inject';
import injectService from 'ember-service/inject';
import isNumber from 'ghost-admin/utils/isNumber';
import moment from 'moment';
import {htmlSafe} from 'ember-string';
import {isBlank} from 'ember-utils';
import {isEmberArray} from 'ember-array/utils';
import {isInvalidError} from 'ember-ajax/errors';
import {isVersionMismatchError} from 'ghost-admin/services/ajax';
import {task, taskGroup, timeout} from 'ember-concurrency';
// ember-cli-shims doesn't export Ember.testing
const {testing} = Ember;
// this array will hold properties we need to watch
// to know if the model has been changed (`controller.hasDirtyAttributes`)
const watchedProps = ['model.scratch', 'model.titleScratch', 'model.hasDirtyAttributes', 'model.tags.[]'];
const DEFAULT_TITLE = '(Untitled)';
// time in ms to save after last content edit
const AUTOSAVE_TIMEOUT = 3000;
// time in ms to force a save if the user is continuously typing
const TIMEDSAVE_TIMEOUT = 60000;
PostModel.eachAttribute(function (name) {
watchedProps.push(`model.${name}`);
});
export default Mixin.create({
showLeaveEditorModal: false,
showReAuthenticateModal: false,
showDeletePostModal: false,
shouldFocusEditor: true,
application: injectController(),
notifications: injectService(),
clock: injectService(),
slugGenerator: injectService(),
wordcount: 0,
cards: [], // for apps
atoms: [], // for apps
toolbar: [], // for apps
apiRoot: ghostPaths().apiRoot,
assetPath: ghostPaths().assetRoot,
editor: null,
editorMenuIsOpen: false,
navIsClosed: reads('application.autoNav'),
init() {
this._super(...arguments);
window.onbeforeunload = () => {
return this.get('hasDirtyAttributes') ? this.unloadDirtyMessage() : null;
};
},
_canAutosave: computed('model.isDraft', function () {
return !testing && this.get('model.isDraft');
}),
// save 3 seconds after the last edit
_autosave: task(function* () {
// force an instant save on first body edit for new posts
if (this.get('_canAutosave') && this.get('model.isNew')) {
return this.get('autosave').perform();
}
yield timeout(AUTOSAVE_TIMEOUT);
if (this.get('_canAutosave')) {
this.get('autosave').perform();
}
}).restartable(),
// save at 60 seconds even if the user doesn't stop typing
_timedSave: task(function* () {
// eslint-disable-next-line no-constant-condition
while (!testing && true) {
yield timeout(TIMEDSAVE_TIMEOUT);
if (this.get('_canAutosave')) {
this.get('autosave').perform();
}
}
}).drop(),
// separate task for autosave so that it doesn't override a manual save
autosave: task(function* () {
if (!this.get('save.isRunning')) {
return yield this.get('save').perform({
silent: true,
backgroundSave: true
});
}
}).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 = {}) {
let prevStatus = this.get('model.status');
let isNew = this.get('model.isNew');
let status;
this.send('cancelAutosave');
if (options.backgroundSave && !this.get('hasDirtyAttributes')) {
return;
}
if (options.backgroundSave) {
// do not allow a post's status to be set to published by a background save
status = 'draft';
} else {
if (this.get('post.pastScheduledTime')) {
status = (!this.get('willSchedule') && !this.get('willPublish')) ? 'draft' : 'published';
} else {
if (this.get('willPublish') && !this.get('model.isScheduled') && !this.get('statusFreeze')) {
status = 'published';
} else if (this.get('willSchedule') && !this.get('model.isPublished') && !this.get('statusFreeze')) {
status = 'scheduled';
} else {
status = 'draft';
}
}
}
// Set the properties that are indirected
// set mobiledoc equal to what's in the editor, minus the image markers.
this.set('model.mobiledoc', this.get('model.scratch'));
this.set('model.status', status);
// Set a default title
if (!this.get('model.titleScratch').trim()) {
this.set('model.titleScratch', DEFAULT_TITLE);
}
this.set('model.title', this.get('model.titleScratch'));
this.set('model.metaTitle', this.get('model.metaTitleScratch'));
this.set('model.metaDescription', this.get('model.metaDescriptionScratch'));
if (!this.get('model.slug')) {
this.get('saveTitle').cancelAll();
yield this.get('generateSlug').perform();
}
try {
let model = yield this.get('model').save(options);
if (!options.silent) {
this.showSaveNotification(prevStatus, model.get('status'), isNew ? true : false);
}
this.get('model').set('statusScratch', null);
// redirect to edit route if saving a new record
if (isNew && model.get('id')) {
if (!this.get('leaveEditorTransition')) {
this.replaceRoute('editor.edit', model);
}
return true;
}
return model;
} catch (error) {
// re-throw if we have a general server error
if (error && !isInvalidError(error)) {
this.send('error', error);
return;
}
this.set('model.status', prevStatus);
if (!options.silent) {
let errorOrMessages = error || this.get('model.errors.messages');
this.showErrorAlert(prevStatus, this.get('model.status'), errorOrMessages);
// simulate a validation error for upstream tasks
throw undefined;
}
return this.get('model');
}
}).group('saveTasks'),
/*
* triggered by a user manually changing slug
*/
updateSlug: task(function* (_newSlug) {
let slug = this.get('model.slug');
let newSlug, serverSlug;
newSlug = _newSlug || slug;
newSlug = newSlug && newSlug.trim();
// Ignore unchanged slugs or candidate slugs that are empty
if (!newSlug || slug === newSlug) {
// reset the input to its previous state
this.set('slugValue', slug);
return;
}
serverSlug = yield this.get('slugGenerator').generateSlug('post', newSlug);
// If after getting the sanitized and unique slug back from the API
// we end up with a slug that matches the existing slug, abort the change
if (serverSlug === slug) {
return;
}
// Because the server transforms the candidate slug by stripping
// certain characters and appending a number onto the end of slugs
// to enforce uniqueness, there are cases where we can get back a
// candidate slug that is a duplicate of the original except for
// the trailing incrementor (e.g., this-is-a-slug and this-is-a-slug-2)
// get the last token out of the slug candidate and see if it's a number
let slugTokens = serverSlug.split('-');
let check = Number(slugTokens.pop());
// if the candidate slug is the same as the existing slug except
// for the incrementor then the existing slug should be used
if (isNumber(check) && check > 0) {
if (slug === slugTokens.join('-') && serverSlug !== newSlug) {
this.set('slugValue', slug);
return;
}
}
this.set('model.slug', serverSlug);
// If this is a new post. Don't save the model. Defer the save
// to the user pressing the save button
if (this.get('model.isNew')) {
return;
}
return yield this.get('model').save();
}).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('model.isPublished'),
willSchedule: boundOneWay('model.isScheduled'),
// set by the editor route and `hasDirtyAttributes`. useful when checking
// whether the number of tags has changed for `hasDirtyAttributes`.
previousTagNames: null,
tagNames: mapBy('model.tags', 'name'),
postOrPage: computed('model.page', function () {
return this.get('model.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('model.{publishedAtUTC,isScheduled}', 'clock.second', function () {
let isScheduled = this.get('model.isScheduled');
let publishTime = this.get('model.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
modelSaved() {
let model = this.get('model');
// safer to updateTags on save in one place
// rather than in all other places save is called
model.updateTags();
// set previousTagNames to current tagNames for hasDirtyAttributes check
this.set('previousTagNames', this.get('tagNames'));
// `updateTags` triggers `hasDirtyAttributes => true`.
// for a saved model it would otherwise be false.
// if the two "scratch" properties (title and content) match the model, then
// it's ok to set hasDirtyAttributes to false
if (model.get('titleScratch') === model.get('title')
&& JSON.stringify(model.get('scratch')) === JSON.stringify(model.get('mobiledoc'))) {
this.set('hasDirtyAttributes', false);
}
},
// an ugly hack, but necessary to watch all the model's properties
// and more, without having to be explicit and do it manually
hasDirtyAttributes: computed.apply(Ember, watchedProps.concat({
get() {
let model = this.get('model');
if (!model) {
return false;
}
let mobiledoc = JSON.stringify(model.get('mobiledoc'));
let scratch = JSON.stringify(model.get('scratch'));
let title = model.get('title');
let titleScratch = model.get('titleScratch');
let changedAttributes;
if (!this.tagNamesEqual()) {
return true;
}
if (titleScratch !== title) {
return true;
}
// since `scratch` is not model property, we need to check
// it explicitly against the model's mobiledoc attribute
if (mobiledoc !== scratch) {
return true;
}
// if the Adapter failed to save the model isError will be true
// and we should consider the model still dirty.
if (model.get('isError')) {
return true;
}
// models created on the client always return `hasDirtyAttributes: true`,
// so we need to see which properties have actually changed.
if (model.get('isNew')) {
changedAttributes = Object.keys(model.changedAttributes());
if (changedAttributes.length) {
return true;
}
return false;
}
// even though we use the `scratch` prop to show edits,
// which does *not* change the model's `hasDirtyAttributes` property,
// `hasDirtyAttributes` will tell us if the other props have changed,
// as long as the model is not new (model.isNew === false).
return model.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('model.absoluteUrl');
} else {
type = 'Preview';
path = this.get('model.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.errors && error.errors[0].message) {
errorMessage = error.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 model = this.get('model');
let currentTitle = model.get('title');
let newTitle = model.get('titleScratch').trim();
if (currentTitle && newTitle && newTitle === currentTitle) {
return;
}
// this is necessary to force a save when the title is blank
this.set('hasDirtyAttributes', true);
// generate a slug if a post is new and doesn't have a title yet or
// if the title is still '(Untitled)'
if ((model.get('isNew') && !currentTitle) || currentTitle === DEFAULT_TITLE) {
yield this.get('generateSlug').perform();
}
if (this.get('model.isDraft')) {
yield this.get('autosave').perform();
}
}),
generateSlug: task(function* () {
let title = this.get('model.titleScratch');
// Only set an "untitled" slug once per post
if (title === DEFAULT_TITLE && this.get('model.slug')) {
return;
}
try {
let slug = yield this.get('slugGenerator').generateSlug('post', title);
if (!isBlank(slug)) {
this.set('model.slug', slug);
}
} catch (error) {
// Nothing to do (would be nice to log this somewhere though),
// but a rejected promise needs to be handled here so that a resolved
// promise is returned.
if (isVersionMismatchError(error)) {
this.get('notifications').showAPIError(error);
}
}
}).enqueue(),
actions: {
updateScratch(value) {
this.set('model.scratch', value);
// save 3 seconds after last edit
this.get('_autosave').perform();
// force save at 60 seconds
this.get('_timedSave').perform();
},
cancelAutosave() {
this.get('_autosave').cancelAll();
this.get('_timedSave').cancelAll();
},
save(options) {
return this.get('save').perform(options);
},
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);
}
},
closeNavMenu() {
this.get('application').send('closeAutoNav');
},
closeMenus() {
this.get('application').send('closeMenus');
},
toggleLeaveEditorModal(transition) {
let leaveTransition = this.get('leaveEditorTransition');
if (!transition && this.get('showLeaveEditorModal')) {
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);
}
},
leaveEditor() {
let transition = this.get('leaveEditorTransition');
let model = this.get('model');
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;
}
// definitely want to clear the data store and post of any unsaved, client-generated tags
model.updateTags();
if (model.get('isNew')) {
// the user doesn't want to save the new, unsaved post, so delete it.
model.deleteRecord();
} else {
// roll back changes on model props
model.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('model.titleScratch', newTitle);
},
toggleDeletePostModal() {
if (!this.get('model.isNew')) {
this.toggleProperty('showDeletePostModal');
}
},
toggleReAuthenticateModal() {
this.toggleProperty('showReAuthenticateModal');
},
setWordcount(wordcount) {
this.set('wordcount', wordcount);
},
toggleAutoNav() {
this.get('application').send('toggleAutoNav');
}
}
});