🎨 Added confirmation dialog any time a post/page will be published

refs https://github.com/TryGhost/Team/issues/1169

Previously we were only showing a confirmation dialog if a publish action would trigger an email which was inconsistent and did not instil confidence when publishing.

- replaced old `modal-confirm-email-send` modal with the newer-style `modals/editor/confirm-publish` component
  - updated to handle standard publish in addition to email publish
  - updated copy
  - added "error" state when attempting to send email-only post to no members
- updated publish menu `save` task to open the confirm modal when going from `draft` to `published` or `scheduled`
  - underlying save with email polling moved to `_saveTask` so it can be re-used across `save` task (when not publishing) and when confirming from the modal
This commit is contained in:
Kevin Ansfield 2021-12-07 00:18:13 +00:00
parent fc00dc1abc
commit 98b5506d64
8 changed files with 332 additions and 289 deletions

View File

@ -66,21 +66,6 @@
</GhBasicDropdown> </GhBasicDropdown>
{{/if}} {{/if}}
{{#if this.showEmailConfirmationModal}}
<GhFullscreenModal
@modal="confirm-email-send"
@model={{hash
sendEmailWhenPublished=this.sendEmailWhenPublished
isScheduled=(eq this.saveType "schedule")
emailOnly=this.emailOnly
retryEmailSend=this.retryEmailSend
}}
@confirm={{this.confirmEmailSend}}
@close={{this.closeEmailConfirmationModal}}
@modifier="action wide"
/>
{{/if}}
{{!-- {{!--
Workaround to have an always-shown element to attach key handlers to. Workaround to have an always-shown element to attach key handlers to.
TODO: Move onto main element once converted to a glimmer component TODO: Move onto main element once converted to a glimmer component

View File

@ -1,6 +1,6 @@
import Component from '@ember/component'; import Component from '@ember/component';
import EmailFailedError from 'ghost-admin/errors/email-failed-error'; import EmailFailedError from 'ghost-admin/errors/email-failed-error';
import {action} from '@ember/object'; import {bind} from '@ember/runloop';
import {computed} from '@ember/object'; import {computed} from '@ember/object';
import {or, reads} from '@ember/object/computed'; import {or, reads} from '@ember/object/computed';
import {schedule} from '@ember/runloop'; import {schedule} from '@ember/runloop';
@ -12,12 +12,13 @@ const CONFIRM_EMAIL_MAX_POLL_LENGTH = 15 * 1000;
export default Component.extend({ export default Component.extend({
clock: service(), clock: service(),
feature: service(),
settings: service(),
config: service(), config: service(),
session: service(), feature: service(),
store: service(),
limit: service(), limit: service(),
modals: service(),
session: service(),
settings: service(),
store: service(),
classNames: 'gh-publishmenu', classNames: 'gh-publishmenu',
displayState: 'draft', displayState: 'draft',
@ -297,7 +298,7 @@ export default Component.extend({
// wait for actions to be triggered by the focusout/blur before saving // wait for actions to be triggered by the focusout/blur before saving
schedule('actions', this, function () { schedule('actions', this, function () {
this.send('setSaveType', 'published'); this.send('setSaveType', 'publish');
this.save.perform(); this.save.perform();
}); });
} }
@ -336,98 +337,15 @@ export default Component.extend({
} }
}), }),
// action is required because <GhFullscreenModal> only uses actions
confirmEmailSend: action(function () {
return this._confirmEmailSend.perform();
}),
_confirmEmailSend: task(function* () {
this.sendEmailConfirmed = true;
let post = yield this.save.perform();
// simulate a validation error if saving failed so that the confirm
// modal can react accordingly
if (!post || post.errors.length) {
throw null;
}
let pollTimeout = 0;
if (post.email && post.email.status !== 'submitted') {
while (pollTimeout < CONFIRM_EMAIL_MAX_POLL_LENGTH) {
yield timeout(CONFIRM_EMAIL_POLL_LENGTH);
post = yield post.reload();
if (post.email.status === 'submitted') {
break;
}
if (post.email.status === 'failed') {
throw new EmailFailedError(post.email.error);
}
}
}
return post;
}),
retryEmailSend: action(function () {
return this._retryEmailSend.perform();
}),
_retryEmailSend: task(function* () {
if (!this.post.email) {
return;
}
let email = yield this.post.email.retry();
let pollTimeout = 0;
if (email && email.status !== 'submitted') {
while (pollTimeout < CONFIRM_EMAIL_POLL_LENGTH) {
yield timeout(CONFIRM_EMAIL_POLL_LENGTH);
email = yield email.reload();
if (email.status === 'submitted') {
break;
}
if (email.status === 'failed') {
throw new EmailFailedError(email.error);
}
}
}
return email;
}),
openEmailConfirmationModal: action(function (dropdown) {
if (dropdown) {
this._skipDropdownCloseCleanup = true;
dropdown.actions.close();
}
this.set('showEmailConfirmationModal', true);
}),
closeEmailConfirmationModal: action(function () {
this.set('showEmailConfirmationModal', false);
this._cleanup();
}),
reloadSettingsTask: task(function* () { reloadSettingsTask: task(function* () {
yield this.settings.reload(); yield this.settings.reload();
}), }),
save: task(function* ({dropdown} = {}) { save: task(function* (options = {}) {
let { const {post, saveType} = this;
post,
emailOnly,
sendEmailWhenPublished,
sendEmailConfirmed,
saveType,
typedDateError,
distributionAction
} = this;
// don't allow save if an invalid schedule date is present // don't allow save if an invalid schedule date is present
if (typedDateError) { if (this.typedDateError) {
return false; return false;
} }
@ -443,18 +361,66 @@ export default Component.extend({
return false; return false;
} }
if ( // always opens publish confirmation if post will be published/scheduled
post.status === 'draft' && if (post.status === 'draft' && (saveType === 'publish' || saveType === 'schedule')) {
!post.email && // email sent previously if (options.dropdown) {
sendEmailWhenPublished && sendEmailWhenPublished !== 'none' && this._skipDropdownCloseCleanup = true;
distributionAction !== 'publish' && options.dropdown.actions.close();
!sendEmailConfirmed // set once confirmed so normal save happens }
) {
this.openEmailConfirmationModal(dropdown); return yield this.modals.open('modals/editor/confirm-publish', {
post: this.post,
emailOnly: this.emailOnly,
sendEmailWhenPublished: this.sendEmailWhenPublished,
isScheduled: saveType === 'schedule',
confirm: this.saveWithConfirmedPublish.perform,
retryEmailSend: this.retryEmailSendTask.perform
}, {
beforeClose: bind(this, this._cleanup)
});
}
return yield this._saveTask.perform(options);
}),
saveWithConfirmedPublish: task(function* () {
return yield this._saveTask.perform();
}),
retryEmailSendTask: task(function* () {
if (!this.post.email) {
return; return;
} }
this.sendEmailConfirmed = false; let email = yield this.post.email.retry();
let pollTimeout = 0;
if (email && email.status !== 'submitted') {
while (pollTimeout < CONFIRM_EMAIL_MAX_POLL_LENGTH) {
yield timeout(CONFIRM_EMAIL_POLL_LENGTH);
pollTimeout += CONFIRM_EMAIL_POLL_LENGTH;
email = yield email.reload();
if (email.status === 'submitted') {
break;
}
if (email.status === 'failed') {
throw new EmailFailedError(email.error);
}
}
}
return email;
}),
_saveTask: task(function* () {
let {
post,
emailOnly,
sendEmailWhenPublished,
saveType
} = this;
// runningText needs to be declared before the other states change during the // runningText needs to be declared before the other states change during the
// save action. // save action.
@ -467,6 +433,28 @@ export default Component.extend({
post = yield this.saveTask.perform({sendEmailWhenPublished, emailOnly}); post = yield this.saveTask.perform({sendEmailWhenPublished, emailOnly});
this._cachePublishedAtBlogTZ(); this._cachePublishedAtBlogTZ();
if (sendEmailWhenPublished && sendEmailWhenPublished !== 'none') {
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;
post = yield post.reload();
if (post.email.status === 'submitted') {
break;
}
if (post.email.status === 'failed') {
throw new EmailFailedError(post.email.error);
}
}
}
}
this._cleanup();
return post; return post;
} catch (error) { } catch (error) {
// re-throw if we don't have a validation error // re-throw if we don't have a validation error
@ -482,7 +470,6 @@ export default Component.extend({
_cleanup() { _cleanup() {
this.set('distributionAction', 'publish_send'); this.set('distributionAction', 'publish_send');
this.set('showConfirmEmailModal', false);
// when closing the menu we reset the publishedAtBlogTZ date so that the // when closing the menu we reset the publishedAtBlogTZ date so that the
// unsaved changes made to the scheduled date aren't reflected in the PSM // unsaved changes made to the scheduled date aren't reflected in the PSM

View File

@ -1,90 +0,0 @@
{{#unless this.errorMessage}}
<header class="modal-header" data-test-modal="delete-user">
<h1>Ready to go? Heres what happens next</h1>
</header>
<button class="close" title="Close" {{on "click" this.closeModal}}>{{svg-jar "close"}}<span class="hidden">Close</span></button>
<div class="modal-body" {{did-insert this.countRecipients}}>
{{#if this.countRecipientsTask.isRunning}}
<div class="flex flex-column items-center">
<div class="gh-loading-spinner"></div>
</div>
{{else}}
<p>
Your post will be delivered to
<strong>{{this.memberCount}}</strong>
{{#if this.model.emailOnly}}
but it will <strong>not</strong>
{{else}}
and will
{{/if}}
be published on your site{{#if this.model.isScheduled}} at the scheduled time{{/if}}. Sound good?
</p>
{{/if}}
</div>
<div class="modal-footer">
<button {{on "click" this.closeModal}} class="gh-btn" data-test-button="cancel-publish-and-email">
<span>Cancel</span>
</button>
{{#if this.model.isScheduled}}
<GhTaskButton
@disabled={{this.countRecipientsTask.isRunning}}
@buttonText="Schedule"
@runningText="Scheduling..."
@task={{this.confirmAndCheckErrorTask}}
@class="gh-btn gh-btn-black gh-btn-icon"
data-test-button="confirm-publish-and-email"
/>
{{else}}
<GhTaskButton
@disabled={{this.countRecipientsTask.isRunning}}
@buttonText={{if this.model.emailOnly "Send" "Publish and send"}}
@runningText={{if this.model.emailOnly "Sending..." "Publishing..."}}
@task={{this.confirmAndCheckErrorTask}}
@class="gh-btn gh-btn-black gh-btn-icon"
data-test-button="confirm-publish-and-email"
/>
{{/if}}
</div>
{{else}}
<header class="modal-header" data-test-modal="delete-user">
<h1>Failed to send email</h1>
</header>
<button class="close" title="Close" {{on "click" this.closeModal}}>{{svg-jar "close"}}<span class="hidden">Close</span></button>
<div class="modal-body">
<p>Your post has been published but the email failed to send. Please verify your email settings if the error persists.</p>
<p class="mb0">
<button type="button" class="gh-btn gh-btn-text regular" {{action (toggle "errorDetailsOpen" this)}} data-test-toggle-error>
{{#if this.errorDetailsOpen}}
{{svg-jar "arrow-down" class="nudge-top--2 w2 h2 fill-darkgrey mr1"}}
{{else}}
{{svg-jar "arrow-right" class="nudge-top--1 w2 h2 fill-darkgrey mr1"}}
{{/if}}
<span>Error details</span>
</button>
</p>
{{#liquid-if this.errorDetailsOpen}}
<p class="error gh-box gh-box-error mt3 mb3">
{{svg-jar "warning"}}
{{this.errorMessage}}
</p>
{{/liquid-if}}
</div>
<div class="modal-footer">
<button {{on "click" this.closeModal}} class="gh-btn" data-test-button="cancel-publish-and-email">
<span>Close</span>
</button>
<GhTaskButton
@buttonText="Retry email"
@runningText="Sending..."
@task={{this.retryEmailTask}}
@class="gh-btn gh-btn-red gh-btn-icon"
data-test-button="retry-email"
/>
</div>
{{/unless}}

View File

@ -1,80 +0,0 @@
import ModalComponent from 'ghost-admin/components/modal-base';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
export default ModalComponent.extend({
membersCountCache: service(),
session: service(),
store: service(),
errorMessage: null,
memberCount: null,
// Allowed actions
confirm: () => {},
actions: {
confirm() {
if (this.errorMessage) {
return this.retryEmailTask.perform();
} else {
if (!this.countRecipientsTask.isRunning) {
return this.confirmAndCheckErrorTask.perform();
}
}
}
},
countRecipients: action(function () {
this.countRecipientsTask.perform();
}),
countRecipientsTask: task(function* () {
const {sendEmailWhenPublished} = this.model;
const filter = `subscribed:true+(${sendEmailWhenPublished})`;
const result = sendEmailWhenPublished ? yield this.membersCountCache.countString(filter) : 'no members';
this.set('memberCount', result);
}),
confirmAndCheckErrorTask: task(function* () {
try {
yield this.confirm();
this.closeModal();
return true;
} catch (e) {
// switch to "failed" state if email fails
if (e && e.name === 'EmailFailedError') {
this.set('errorMessage', e.message);
return;
}
// close modal and continue with normal error handling if it was
// a non-email-related error
this.closeModal();
if (e) {
throw e;
}
}
}),
retryEmailTask: task(function* () {
try {
yield this.model.retryEmailSend();
this.closeModal();
return true;
} catch (e) {
// update "failed" state if email fails again
if (e && e.name === 'EmailFailedError') {
this.set('errorMessage', e.message);
return;
}
// TODO: test a non-email failure - maybe this needs to go through
// the notifications service
if (e) {
throw e;
}
}
})
});

View File

@ -0,0 +1,116 @@
<div class="modal-content" data-test-modal="editor/confirm-publish">
{{#unless this.errorMessage}}
<header class="modal-header" data-test-state="confirm-publish">
<h1>Ready to go? Heres what happens next</h1>
</header>
<button class="close" title="Close" {{on "click" @close}}>{{svg-jar "close"}}<span class="hidden">Close</span></button>
<div class="modal-body" {{did-insert this.countRecipientsTask.perform}}>
{{#if (eq @data.post.displayName 'page')}}
<p>
Your page will be published {{if @data.isScheduled "at the scheduled time" "immediately"}}. Sound good?
</p>
{{else if this.isPublishOnly}}
<p>
Your post will be published {{if @data.isScheduled "at the scheduled time" "immediately"}}
and won't be sent as an email. Sound good?
</p>
{{else}}
{{#if this.countRecipientsTask.isRunning}}
<div class="flex flex-column items-center">
<div class="gh-loading-spinner"></div>
</div>
{{else}}
{{#if this.isEmailOnlyWithNoMembers}}
<p>
You're trying to {{if @data.isScheduled "schedule" "send"}} a post
as an email newsletter with <strong>0 members</strong> selected.
Choose a segment of your audience and try again!
</p>
{{else}}
<p>
Your post will be delivered to <strong>{{this.memberCountString}}</strong>
{{#if @data.emailOnly}}
but it will <strong>not</strong>
{{else}}
and will
{{/if}}
be published on your site{{#if @data.isScheduled}} at the scheduled time{{/if}}. Sound good?
</p>
{{/if}}
{{/if}}
{{/if}}
</div>
<div class="modal-footer">
{{#if this.isEmailOnlyWithNoMembers}}
<button type="button" class="gh-btn" {{on "click" @close}} data-test-button="cancel-email-with-no-members">
<span>Close</span>
</button>
{{else}}
<button {{on "click" @close}} class="gh-btn" data-test-button="cancel-publish-and-email">
<span>Cancel</span>
</button>
{{#if @data.isScheduled}}
<GhTaskButton
@disabled={{this.countRecipientsTask.isRunning}}
@buttonText="Schedule"
@runningText="Scheduling..."
@task={{this.confirmAndCheckErrorTask}}
@class="gh-btn gh-btn-black gh-btn-icon"
data-test-button="confirm-schedule"
/>
{{else}}
<GhTaskButton
@disabled={{this.countRecipientsTask.isRunning}}
@buttonText={{this.publishAndSendButtonText}}
@runningText={{if @data.emailOnly "Sending..." "Publishing..."}}
@task={{this.confirmAndCheckErrorTask}}
@class="gh-btn gh-btn-black gh-btn-icon"
data-test-button="confirm-publish"
/>
{{/if}}
{{/if}}
</div>
{{else}}
<header class="modal-header" data-test-state="failed-send">
<h1>Failed to send email</h1>
</header>
<button class="close" title="Close" {{on "click" @close}}>{{svg-jar "close"}}<span class="hidden">Close</span></button>
<div class="modal-body">
<p>Your post has been published but the email failed to send. Please verify your email settings if the error persists.</p>
<p class="mb0">
<button type="button" class="gh-btn gh-btn-text regular" {{on "click" this.toggleErrorDetails}} data-test-toggle-error>
{{#if this.errorDetailsOpen}}
{{svg-jar "arrow-down" class="nudge-top--2 w2 h2 fill-darkgrey mr1"}}
{{else}}
{{svg-jar "arrow-right" class="nudge-top--1 w2 h2 fill-darkgrey mr1"}}
{{/if}}
<span>Error details</span>
</button>
</p>
{{#liquid-if this.errorDetailsOpen}}
<p class="error gh-box gh-box-error mt3 mb3">
{{svg-jar "warning"}}
{{this.errorMessage}}
</p>
{{/liquid-if}}
</div>
<div class="modal-footer">
<button {{on "click" @close}} class="gh-btn" data-test-button="cancel-publish-and-email">
<span>Close</span>
</button>
<GhTaskButton
@buttonText="Retry email"
@runningText="Sending..."
@task={{this.retryEmailTask}}
@class="gh-btn gh-btn-red gh-btn-icon"
data-test-button="retry-email"
/>
</div>
{{/unless}}
</div>

View File

@ -0,0 +1,117 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency-decorators';
import {tracked} from '@glimmer/tracking';
export default class ModalsEditorConfirmPublishComponent extends Component {
@service membersCountCache;
@service session;
@service store;
@tracked errorMessage = null;
@tracked errorDetailsOpen = false;
@tracked memberCount = null;
@tracked memberCountString = null;
get isPublishOnly() {
return this.args.data.sendEmailWhenPublished === 'none'
|| this.args.data.post.displayName === 'page'
|| this.args.data.post.email;
}
get isEmailOnly() {
return this.args.data.emailOnly;
}
get isEmailOnlyWithNoMembers() {
return this.isEmailOnly && this.memberCount === 0;
}
get publishAndSendButtonText() {
if (this.isEmailOnly) {
return 'Send';
}
if (this.isPublishOnly || this.memberCount === 0) {
return 'Publish';
}
return 'Publish and Send';
}
@action
confirm() {
if (this.errorMessage) {
return this.retryEmailTask.perform();
} else {
if (!this.countRecipientsTask.isRunning) {
return this.confirmAndCheckErrorTask.perform();
}
}
}
@action
toggleErrorDetails() {
this.errorDetailsOpen = !this.errorDetailsOpen;
}
@task
*countRecipientsTask() {
const {sendEmailWhenPublished} = this.args.data;
if (sendEmailWhenPublished === 'none') {
this.memberCount = 0;
this.memberCountString = 'no members';
}
const filter = `subscribed:true+(${sendEmailWhenPublished})`;
this.memberCount = sendEmailWhenPublished ? yield this.membersCountCache.count(filter) : 0;
this.memberCountString = sendEmailWhenPublished ? yield this.membersCountCache.countString(filter) : 'no members';
}
@task
*confirmAndCheckErrorTask() {
try {
yield this.args.data.confirm();
this.args.close();
return true;
} catch (e) {
// switch to "failed" state if email fails
if (e && e.name === 'EmailFailedError') {
this.errorMessage = e.message;
return false;
}
// close modal and continue with normal error handling if it was
// a non-email-related error
this.args.close();
if (e) {
throw e;
}
}
}
@task
*retryEmailTask() {
try {
yield this.args.data.retryEmailSend();
this.args.close();
return true;
} catch (e) {
// update "failed" state if email fails again
if (e && e.name === 'EmailFailedError') {
this.errorMessage = e.message;
return;
}
// TODO: test a non-email failure - maybe this needs to go through
// the notifications service
if (e) {
throw e;
}
}
}
}

View File

@ -208,8 +208,10 @@ describe('Acceptance: Editor', function () {
'draft post publish button text' 'draft post publish button text'
).to.equal('Publish'); ).to.equal('Publish');
// Publish the post // Publish the post and re-open publish menu
await click('[data-test-publishmenu-save]'); await click('[data-test-publishmenu-save]');
await click('[data-test-button="confirm-publish"]');
await click('[data-test-publishmenu-trigger]');
expect( expect(
find('[data-test-publishmenu-save]').textContent.trim(), find('[data-test-publishmenu-save]').textContent.trim(),
@ -341,6 +343,8 @@ describe('Acceptance: Editor', function () {
await datepickerSelect('[data-test-publishmenu-draft] [data-test-date-time-picker-datepicker]', new Date(newFutureTime.format().replace(/\+.*$/, ''))); await datepickerSelect('[data-test-publishmenu-draft] [data-test-date-time-picker-datepicker]', new Date(newFutureTime.format().replace(/\+.*$/, '')));
await click('[data-test-publishmenu-save]'); await click('[data-test-publishmenu-save]');
await click('[data-test-button="confirm-schedule"]');
await click('[data-test-publishmenu-trigger]');
expect( expect(
find('[data-test-publishmenu-save]').textContent.trim(), find('[data-test-publishmenu-save]').textContent.trim(),
@ -451,6 +455,7 @@ describe('Acceptance: Editor', function () {
await blur('[data-test-publishmenu-draft] [data-test-date-time-picker-time-input]'); await blur('[data-test-publishmenu-draft] [data-test-date-time-picker-time-input]');
await click('[data-test-publishmenu-save]'); await click('[data-test-publishmenu-save]');
await click('[data-test-button="confirm-schedule"]');
expect( expect(
findAll('.gh-alert').length, findAll('.gh-alert').length,
@ -475,6 +480,7 @@ describe('Acceptance: Editor', function () {
await fillIn('[data-test-editor-title-input]', Array(260).join('a')); await fillIn('[data-test-editor-title-input]', Array(260).join('a'));
await click('[data-test-publishmenu-trigger]'); await click('[data-test-publishmenu-trigger]');
await click('[data-test-publishmenu-save]'); await click('[data-test-publishmenu-save]');
await click('[data-test-button="confirm-publish"]');
expect( expect(
findAll('.gh-alert').length, findAll('.gh-alert').length,

View File

@ -38,7 +38,8 @@ describe('Acceptance: Error Handling', function () {
await visit('/posts'); await visit('/posts');
await click('.posts-list li:nth-of-type(2) a'); // select second post await click('.posts-list li:nth-of-type(2) a'); // select second post
await click('[data-test-publishmenu-trigger]'); await click('[data-test-publishmenu-trigger]');
await click('[data-test-publishmenu-save]'); // "Save post" await click('[data-test-publishmenu-save]');
await click('[data-test-button="confirm-publish"]'); // "Save post"
// has the refresh to update alert // has the refresh to update alert
expect(findAll('.gh-alert').length).to.equal(1); expect(findAll('.gh-alert').length).to.equal(1);
@ -100,6 +101,7 @@ describe('Acceptance: Error Handling', function () {
await visit('/editor/post/1'); await visit('/editor/post/1');
await click('[data-test-publishmenu-trigger]'); await click('[data-test-publishmenu-trigger]');
await click('[data-test-publishmenu-save]'); await click('[data-test-publishmenu-save]');
await click('[data-test-button="confirm-publish"]');
expect(findAll('.gh-alert').length).to.equal(1); expect(findAll('.gh-alert').length).to.equal(1);
expect(find('.gh-alert').textContent).to.not.match(/html>/); expect(find('.gh-alert').textContent).to.not.match(/html>/);