From adeef741fb235b74f2c0e011f94075a80e2151e4 Mon Sep 17 00:00:00 2001
From: Kevin Ansfield
Date: Wed, 4 May 2022 10:30:37 +0100
Subject: [PATCH] Added first iteration of saving to new publish flow
refs https://github.com/TryGhost/Team/issues/1542
- extracted before/after save routines in the editor controller into separate actions
- allows saving to occur in the publish flow without it needing any editor-specific knowledge
- allows for easier cleanup of email related logic from the editor save tasks later on
- added `saveTask` to `PublishOptions`
- applies the selected options to the post model where they correspond to model attributes and keeps the previous values in memory so the changes can be undone on failure - this keeps the local model state in sync because if a publish fails we want the editor to continue showing the draft state, non-scheduled publish time, and not have an unexpected email-only state
- saves the post model directly passing `adapterOptions` so the save request query params match the chosen publish options
- added a `saveTask` to the `` component
- passed through to the `publish-flow` modal and is triggered by the confirm button on the confirmation screen
- runs the before/afterSave arguments passed in from the editor
- runs the `saveTask` on `PublishOptions` which handles everything needed to change status and send emails
- polls the post after saving to wait for the attached email to switch to submitted/failed which lets us show a failure message and retry button as required (message + retry not yet implemented)
- adds "complete" state to publish flow once save has finished
- confirms what just happened based on saved post data rather than chosen publish options
- has a link to the view the post
---
ghost/admin/app/adapters/post.js | 6 +-
.../editor-labs/modals/publish-flow.hbs | 7 +-
.../editor-labs/modals/publish-flow.js | 10 ++
.../modals/publish-flow/complete.hbs | 50 +++++++
.../modals/publish-flow/confirm.hbs | 9 +-
.../editor-labs/publish-management.js | 141 ++++++++++++++++--
ghost/admin/app/controllers/editor.js | 116 ++++++++------
ghost/admin/app/templates/editor.hbs | 6 +-
8 files changed, 280 insertions(+), 65 deletions(-)
create mode 100644 ghost/admin/app/components/editor-labs/modals/publish-flow/complete.hbs
diff --git a/ghost/admin/app/adapters/post.js b/ghost/admin/app/adapters/post.js
index 0826e8f9ed..7e42f56b57 100644
--- a/ghost/admin/app/adapters/post.js
+++ b/ghost/admin/app/adapters/post.js
@@ -6,9 +6,11 @@ export default class Post extends ApplicationAdapter {
const url = this.buildURL(modelName, id, snapshot, requestType, query);
const parsedUrl = new URL(url);
- if (snapshot?.adapterOptions?.sendEmailWhenPublished) {
- let emailRecipientFilter = snapshot.adapterOptions.sendEmailWhenPublished;
+ // TODO: cleanup sendEmailWhenPublished when removing publishingFlow flag
+ let emailRecipientFilter = snapshot?.adapterOptions?.emailRecipientFilter
+ || snapshot?.adapterOptions?.sendEmailWhenPublished;
+ if (emailRecipientFilter) {
if (emailRecipientFilter === 'status:free,status:-free') {
emailRecipientFilter = 'all';
}
diff --git a/ghost/admin/app/components/editor-labs/modals/publish-flow.hbs b/ghost/admin/app/components/editor-labs/modals/publish-flow.hbs
index 4dd20f4151..d58ed54caa 100644
--- a/ghost/admin/app/components/editor-labs/modals/publish-flow.hbs
+++ b/ghost/admin/app/components/editor-labs/modals/publish-flow.hbs
@@ -9,10 +9,15 @@
{{#if this.isConfirming}}
+ {{else if this.isComplete}}
+
{{else}}
+ Your {{post.displayName}} is
+ {{#if post.isScheduled}}
+ scheduled
+ {{else if post.emailOnly}}
+ emailed
+ {{else}}
+ live
+ {{~/if~}}
+ !
+
+
+
+ {{#if post.isScheduled}}
+ {{#let (moment-site-tz post.publishedAtUTC) as |scheduledAt|}}
+ On
+
+ {{moment-format scheduledAt "D MMM YYYY"}}
+ at
+ {{moment-format scheduledAt "HH:mm"}}
+
+ your
+ {{/let}}
+ {{else}}
+ Your
+ {{/if}}
+
+ {{post.displayName}} was
+
+ {{#if post.email}}
+ delivered to
+
+ {{pluralize post.email.emailCount "member"}}
+
+ {{#if post.emailOnly}}
+ and was not
+ {{else}}
+ and was
+ {{/if}}
+ {{/if}}
+
+ published on your site.
+
+{{/let}}
\ No newline at end of file
diff --git a/ghost/admin/app/components/editor-labs/modals/publish-flow/confirm.hbs b/ghost/admin/app/components/editor-labs/modals/publish-flow/confirm.hbs
index d33869cb54..58749a9d28 100644
--- a/ghost/admin/app/components/editor-labs/modals/publish-flow/confirm.hbs
+++ b/ghost/admin/app/components/editor-labs/modals/publish-flow/confirm.hbs
@@ -12,6 +12,7 @@
at
{{moment-format scheduledAt "HH:mm"}}
+ your
{{/let}}
{{else}}
Your
@@ -41,6 +42,12 @@
-
+
\ No newline at end of file
diff --git a/ghost/admin/app/components/editor-labs/publish-management.js b/ghost/admin/app/components/editor-labs/publish-management.js
index 12f7fdf00a..e72e45689e 100644
--- a/ghost/admin/app/components/editor-labs/publish-management.js
+++ b/ghost/admin/app/components/editor-labs/publish-management.js
@@ -1,13 +1,17 @@
import Component from '@glimmer/component';
+import EmailFailedError from 'ghost-admin/errors/email-failed-error';
import PublishFlowModal from './modals/publish-flow';
import PublishOptionsResource from 'ghost-admin/helpers/publish-options';
import moment from 'moment';
import {action, get} from '@ember/object';
import {inject as service} from '@ember/service';
-import {task} from 'ember-concurrency';
+import {task, timeout} from 'ember-concurrency';
import {tracked} from '@glimmer/tracking';
import {use} from 'ember-could-get-used-to-this';
+const CONFIRM_EMAIL_POLL_LENGTH = 1000;
+const CONFIRM_EMAIL_MAX_POLL_LENGTH = 15 * 1000;
+
export class PublishOptions {
// passed in services
config = null;
@@ -18,6 +22,8 @@ export class PublishOptions {
post = null;
user = null;
+ @tracked totalMemberCount = 0;
+
get isLoading() {
return this.setupTask.isRunning;
}
@@ -30,6 +36,10 @@ export class PublishOptions {
return this.publishType !== 'send';
}
+ get willOnlyEmail() {
+ return this.publishType === 'send';
+ }
+
// publish date ------------------------------------------------------------
@tracked isScheduled = false;
@@ -75,10 +85,10 @@ export class PublishOptions {
get publishTypeOptions() {
return [{
- value: 'publish+send',
- label: 'Publish and email',
- display: 'Publish and email',
- confirmButton: 'Publish and send',
+ value: 'publish+send', // internal
+ label: 'Publish and email', // shown in expanded options
+ display: 'Publish and email', // shown in option title
+ confirmButton: 'Publish and send', // shown in confirm step
disabled: this.emailDisabled
}, {
value: 'publish',
@@ -108,13 +118,14 @@ export class PublishOptions {
// publish type dropdown is shown but email options are disabled
get emailDisabled() {
- const mailgunConfigured = get(this.settings, 'mailgunIsConfigured')
- || get(this.config, 'mailgunIsConfigured');
+ const mailgunIsNotConfigured = !get(this.settings, 'mailgunIsConfigured')
+ && !get(this.config, 'mailgunIsConfigured');
+
+ const hasNoMembers = this.totalMemberCount === 0;
- // TODO: check members count
// TODO: check email limit
- return !mailgunConfigured;
+ return mailgunIsNotConfigured || hasNoMembers;
}
@action
@@ -157,7 +168,7 @@ export class PublishOptions {
this.store = store;
this.user = user;
- // these need to be set here rather than class-level properties because
+ // this needs to be set here rather than a class-level property because
// unlike Ember-based classes the services are not injected so can't be
// used until after they are assigned above
this.allNewsletters = this.store.peekAll('newsletter');
@@ -181,7 +192,9 @@ export class PublishOptions {
@task
*fetchRequiredDataTask() {
// total # of members - used to enable/disable email
- const countTotalMembers = this.store.query('member', {limit: 1}).then(res => res.meta.pagination.total);
+ const countTotalMembers = this.store.query('member', {limit: 1}).then((res) => {
+ this.totalMemberCount = res.meta.pagination.total;
+ });
// email limits
// TODO: query limit service
@@ -193,10 +206,64 @@ export class PublishOptions {
}
// saving ------------------------------------------------------------------
+
+ revertableModelProperties = ['status', 'publishedAtUTC', 'emailOnly'];
+
+ @task({drop: true})
+ *saveTask() {
+ this._applyModelChanges();
+
+ const adapterOptions = {};
+
+ if (this.willEmail) {
+ adapterOptions.newsletterId = this.newsletter.id;
+ // TODO: replace with real filter
+ adapterOptions.emailRecipientFilter = 'status:free,status:-free';
+ }
+
+ try {
+ return yield this.post.save({adapterOptions});
+ } catch (e) {
+ this._revertModelChanges();
+ throw e;
+ }
+ }
+
+ // Publishing/scheduling is a side-effect of changing model properties.
+ // We don't want to get into a situation where we've applied these changes
+ // but they haven't been saved because that would result in confusing UI.
+ //
+ // Here we apply those changes from the selected publish options but keep
+ // track of the previous values in case saving fails. We can't use ED's
+ // rollbackAttributes() because it would also rollback any other unsaved edits
+ _applyModelChanges() {
+ // store backup of original values in case we need to revert
+ this._originalModelValues = {};
+ this.revertableModelProperties.forEach((property) => {
+ this._originalModelValues[property] = this.post[property];
+ });
+
+ this.post.status = this.isScheduled ? 'scheduled' : 'published';
+
+ if (this.post.isScheduled) {
+ this.post.publishedAtUTC = this.scheduledAtUTC;
+ }
+
+ this.post.emailOnly = this.publishType === 'email';
+ }
+
+ _revertModelChanges() {
+ this.revertableModelProperties.forEach((property) => {
+ this.post[property] = this._originalModelValues[property];
+ });
+ }
}
+/* Component -----------------------------------------------------------------*/
+
// This component exists for the duration of the editor screen being open.
-// It's used to store the selected publish options and control the publishing flow.
+// It's used to store the selected publish options and control the
+// publishing flow modal.
export default class PublishManagement extends Component {
@service modals;
@@ -218,8 +285,56 @@ export default class PublishManagement extends Component {
this.publishOptions.resetPastScheduledAt();
this.publishFlowModal = this.modals.open(PublishFlowModal, {
- publishOptions: this.publishOptions
+ publishOptions: this.publishOptions,
+ saveTask: this.saveTask
});
}
}
+
+ @task
+ *saveTask() {
+ // clean up blank editor cards
+ // apply cloned mobiledoc
+ // apply scratch values
+ // generate slug if needed (should never happen - publish flow can't be opened on new posts)
+ yield this.args.beforeSave();
+
+ // apply publish options (with undo on failure)
+ // save with the required query params for emailing
+ const result = yield this.publishOptions.saveTask.perform();
+
+ // perform any post-save cleanup for the editor
+ yield this.args.afterSave(result);
+
+ // if emailed, wait until it has been submitted so we can show a failure message if needed
+ if (this.publishOptions.post.email) {
+ yield this.confirmEmailTask.perform();
+ }
+
+ return result;
+ }
+
+ @task
+ *confirmEmailTask() {
+ const post = this.publishOptions.post;
+
+ let pollTimeout = 0;
+ if (post.email && post.email.status !== 'submitted') {
+ while (pollTimeout < CONFIRM_EMAIL_MAX_POLL_LENGTH) {
+ yield timeout(CONFIRM_EMAIL_POLL_LENGTH);
+ pollTimeout += CONFIRM_EMAIL_POLL_LENGTH;
+
+ yield post.reload();
+
+ if (post.email.status === 'submitted') {
+ break;
+ }
+ if (post.email.status === 'failed') {
+ throw new EmailFailedError(post.email.error);
+ }
+ }
+ }
+
+ return true;
+ }
}
diff --git a/ghost/admin/app/controllers/editor.js b/ghost/admin/app/controllers/editor.js
index aa22e7059e..e10c5dff1b 100644
--- a/ghost/admin/app/controllers/editor.js
+++ b/ghost/admin/app/controllers/editor.js
@@ -265,7 +265,7 @@ export default class EditorController extends Controller {
keyboardEvent?.preventDefault();
if (this.post.isDraft) {
- this.send('openPostPreviewModal');
+ this.openPostPreviewModal();
} else {
window.open(this.post.previewUrl, '_blank', 'noopener');
}
@@ -444,7 +444,8 @@ export default class EditorController extends Controller {
/* Public tasks ----------------------------------------------------------*/
// separate task for autosave so that it doesn't override a manual save
- @dropTask *autosaveTask() {
+ @dropTask
+ *autosaveTask() {
if (!this.get('saveTask.isRunning')) {
return yield this.saveTask.perform({
silent: true,
@@ -461,7 +462,7 @@ export default class EditorController extends Controller {
let isNew = this.get('post.isNew');
let status;
- this.send('cancelAutosave');
+ this.cancelAutosave();
if (options.backgroundSave && !this.hasDirtyAttributes) {
return;
@@ -496,43 +497,11 @@ export default class EditorController extends Controller {
}
}
- // ensure we remove any blank cards when performing a full save
- if (!options.backgroundSave) {
- if (this._koenig) {
- this._koenig.cleanup();
- this.set('hasDirtyAttributes', true);
- }
- }
-
- // Set the properties that are indirected
- // set mobiledoc equal to what's in the editor but create a copy so that
- // nested objects/arrays don't keep references which can mean that both
- // scratch and mobiledoc get updated simultaneously
- this.set('post.mobiledoc', JSON.parse(JSON.stringify(this.post.scratch || null)));
+ // set manually here instead of in beforeSaveTask because the
+ // new publishing flow sets the post status manually on publish
this.set('post.status', status);
- // Set a default title
- if (!this.get('post.titleScratch').trim()) {
- this.set('post.titleScratch', DEFAULT_TITLE);
- }
-
- this.set('post.title', this.get('post.titleScratch'));
- this.set('post.customExcerpt', this.get('post.customExcerptScratch'));
- this.set('post.footerInjection', this.get('post.footerExcerptScratch'));
- this.set('post.headerInjection', this.get('post.headerExcerptScratch'));
- this.set('post.metaTitle', this.get('post.metaTitleScratch'));
- this.set('post.metaDescription', this.get('post.metaDescriptionScratch'));
- this.set('post.ogTitle', this.get('post.ogTitleScratch'));
- this.set('post.ogDescription', this.get('post.ogDescriptionScratch'));
- this.set('post.twitterTitle', this.get('post.twitterTitleScratch'));
- this.set('post.twitterDescription', this.get('post.twitterDescriptionScratch'));
- this.set('post.emailSubject', this.get('post.emailSubjectScratch'));
-
- if (!this.get('post.slug')) {
- this.saveTitleTask.cancelAll();
-
- yield this.generateSlugTask.perform();
- }
+ yield this.beforeSaveTask.perform();
try {
let post = yield this._savePostTask.perform(options);
@@ -577,7 +546,7 @@ export default class EditorController extends Controller {
// re-throw if we have a general server error
if (error && !isInvalidError(error)) {
- this.send('error', error);
+ this.error(error);
return;
}
@@ -592,6 +561,50 @@ export default class EditorController extends Controller {
}
}
+ @task
+ *beforeSaveTask(options = {}) {
+ // ensure we remove any blank cards when performing a full save
+ if (!options.backgroundSave) {
+ if (this._koenig) {
+ this._koenig.cleanup();
+ this.set('hasDirtyAttributes', true);
+ }
+ }
+
+ // TODO: There's no need for (at least) most of these scratch values.
+ // Refactor so we're setting model attributes directly
+
+ // Set the properties that are indirected
+
+ // Set mobiledoc equal to what's in the editor but create a copy so that
+ // nested objects/arrays don't keep references which can mean that both
+ // scratch and mobiledoc get updated simultaneously
+ this.set('post.mobiledoc', JSON.parse(JSON.stringify(this.post.scratch || null)));
+
+ // Set a default title
+ if (!this.get('post.titleScratch').trim()) {
+ this.set('post.titleScratch', DEFAULT_TITLE);
+ }
+
+ this.set('post.title', this.get('post.titleScratch'));
+ this.set('post.customExcerpt', this.get('post.customExcerptScratch'));
+ this.set('post.footerInjection', this.get('post.footerExcerptScratch'));
+ this.set('post.headerInjection', this.get('post.headerExcerptScratch'));
+ this.set('post.metaTitle', this.get('post.metaTitleScratch'));
+ this.set('post.metaDescription', this.get('post.metaDescriptionScratch'));
+ this.set('post.ogTitle', this.get('post.ogTitleScratch'));
+ this.set('post.ogDescription', this.get('post.ogDescriptionScratch'));
+ this.set('post.twitterTitle', this.get('post.twitterTitleScratch'));
+ this.set('post.twitterDescription', this.get('post.twitterDescriptionScratch'));
+ this.set('post.emailSubject', this.get('post.emailSubjectScratch'));
+
+ if (!this.get('post.slug')) {
+ this.saveTitleTask.cancelAll();
+
+ yield this.generateSlugTask.perform();
+ }
+ }
+
/*
* triggered by a user manually changing slug
*/
@@ -671,7 +684,8 @@ export default class EditorController extends Controller {
}
// convenience method for saving the post and performing post-save cleanup
- @task *_savePostTask(options = {}) {
+ @task
+ *_savePostTask(options = {}) {
let {post} = this;
const previousEmailOnlyValue = this.post.emailOnly;
@@ -695,6 +709,13 @@ export default class EditorController extends Controller {
throw error;
}
+ this.afterSave(post);
+
+ return post;
+ }
+
+ @action
+ afterSave(post) {
this.notifications.closeAlerts('post.save');
// remove any unsaved tags
@@ -718,11 +739,10 @@ export default class EditorController extends Controller {
if (titlesMatch && bodiesMatch) {
this.set('hasDirtyAttributes', false);
}
-
- return post;
}
- @task *saveTitleTask() {
+ @task
+ *saveTitleTask() {
let post = this.post;
let currentTitle = post.get('title');
let newTitle = post.get('titleScratch').trim();
@@ -747,7 +767,8 @@ export default class EditorController extends Controller {
this.ui.updateDocumentTitle();
}
- @enqueueTask *generateSlugTask() {
+ @enqueueTask
+ *generateSlugTask() {
let title = this.get('post.titleScratch');
// Only set an "untitled" slug once per post
@@ -772,7 +793,8 @@ export default class EditorController extends Controller {
}
// load supplementel data such as the members count in the background
- @restartableTask *backgroundLoaderTask() {
+ @restartableTask
+ *backgroundLoaderTask() {
yield this.store.query('snippet', {limit: 'all'});
}
@@ -861,7 +883,7 @@ export default class EditorController extends Controller {
// if an autosave is scheduled, cancel it, save then transition
if (this._autosaveRunning) {
- this.send('cancelAutosave');
+ this.cancelAutosave();
this.autosaveTask.cancelAll();
await this.autosaveTask.perform();
@@ -898,7 +920,7 @@ export default class EditorController extends Controller {
// make sure the save tasks aren't still running in the background
// after leaving the edit route
- this.send('cancelAutosave');
+ this.cancelAutosave();
if (post) {
// clear post of any unsaved, client-generated tags
diff --git a/ghost/admin/app/templates/editor.hbs b/ghost/admin/app/templates/editor.hbs
index 34c27de3c7..a4ff2711c4 100644
--- a/ghost/admin/app/templates/editor.hbs
+++ b/ghost/admin/app/templates/editor.hbs
@@ -46,7 +46,11 @@
data-test-contributor-save />
{{else}}
{{#if (feature "publishingFlow")}}
-
+
{{else}}