Added default newsletter recipients setting (#1946)

refs https://github.com/TryGhost/Team/issues/496
reqs https://github.com/TryGhost/Ghost/pull/12925

The publish menu was meant to default to matching post visibility but that wasn't working consistently and didn't make sense for sites which don't email every post to their members.

A "Default newsletter recipients" option has been added to the "Email newsletter" settings screen and the publish menu updated to reflect the option. The free/paid toggles in the publish menu have also been swapped out for a multi-select style component that will cater to more complex member segmentation.
This commit is contained in:
Kevin Ansfield 2021-05-07 10:02:19 +01:00 committed by GitHub
parent 09b50995ec
commit da49dc4922
22 changed files with 450 additions and 251 deletions

View File

@ -16,6 +16,11 @@
<div class="gh-main-section">
<h4 class="gh-main-section-header small bn">Email</h4>
<section class="gh-expandable">
<Settings::DefaultEmailRecipients
@expanded={{this.emailRecipientsExpanded}}
@toggleExpansion={{this.toggleEmailRecipientsExpansion}}
/>
<div class="gh-expandable-block">
<div class="gh-expandable-header">
<div>

View File

@ -0,0 +1,7 @@
<span
class="segment-totals"
{{did-insert (perform this.fetchTotalsTask)}}
{{did-update (perform this.fetchSegmentTotalTask) @segment}}
>
{{format-number this.segmentTotal}} / {{format-number this.total}} members
</span>

View File

@ -0,0 +1,31 @@
import Component from '@glimmer/component';
import {inject as service} from '@ember/service';
import {task, taskGroup} from 'ember-concurrency-decorators';
import {tracked} from '@glimmer/tracking';
export default class GhMembersSegmentCountComponent extends Component {
@service store;
@tracked total = 0;
@tracked segmentTotal = 0;
@taskGroup fetchTasks;
@task({group: 'fetchTasks'})
*fetchTotalsTask() {
this.fetchSegmentTotalTask.perform();
const members = yield this.store.query('member', {limit: 1});
this.total = members.meta.pagination.total;
}
@task({group: 'fetchTasks'})
*fetchSegmentTotalTask() {
if (!this.args.segment) {
return this.segmentTotal = 0;
}
const members = yield this.store.query('member', {limit: 1, filter: this.args.segment});
this.segmentTotal = members.meta.pagination.total;
}
}

View File

@ -0,0 +1,15 @@
<GhTokenInput
@options={{this.options}}
@selected={{this.selectedOptions}}
@disabled={{or @disabled this.fetchOptionsTask.isRunning}}
@optionsComponent="power-select/options"
@allowCreation={{false}}
@renderInPlace={{this.renderInPlace}}
@onChange={{this.setSegment}}
@disabled={{@disabled}}
as |option|
>
{{option.name}}
</GhTokenInput>
<GhMembersSegmentCount @segment={{@segment}} />

View File

@ -0,0 +1,62 @@
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 GhMembersSegmentSelect extends Component {
@service store;
@tracked options = [];
get renderInPlace() {
return this.args.renderInPlace === undefined ? false : this.args.renderInPlace;
}
constructor() {
super(...arguments);
this.fetchOptionsTask.perform();
}
get flatOptions() {
const options = [];
function getOptions(option) {
if (option.options) {
return option.options.forEach(getOptions);
}
options.push(option);
}
this.options.forEach(getOptions);
return options;
}
get selectedOptions() {
const segments = (this.args.segment || '').split(',');
return this.flatOptions.filter(option => segments.includes(option.segment));
}
@action
setSegment(options) {
const segment = options.mapBy('segment').join(',') || null;
this.args.onChange?.(segment);
}
@task
*fetchOptionsTask() {
const options = yield [{
name: 'Free members',
segment: 'status:free',
class: 'segment-status'
}, {
name: 'Paid members',
segment: 'status:-free', // paid & comped
class: 'segment-status'
}];
this.options = options;
}
}

View File

@ -29,52 +29,21 @@
</div>
</div>
{{#if (and this.canSendEmail showSendEmail)}}
{{#if this.canSendEmail}}
<div class="gh-publishmenu-section">
<div class="gh-publishmenu-email">
{{#if this.backgroundLoader.isRunning}}
<div class="gh-loading-spinner" style="zoom: 50%"></div>
{{else if this.isSendingEmailLimited}}
{{#if this.isSendingEmailLimited}}
<p class="gh-box gh-box-alert">{{html-safe this.sendingEmailLimitError}}</p>
{{else}}
<div class="gh-publishmenu-email-label {{if this.disableEmailOption "pe-none"}}">
<label class="gh-publishmenu-radio-label mb3 {{if this.disableEmailOption "midgrey"}}">Send by email to</label>
<div class="form-group">
<div class="gh-publishmenu-send-to-option">
<p>Free members <span class="gh-publishmenu-emailcount">{{this.freeMemberCountLabel}}</span></p>
<div class="for-switch small" {{action "toggleSendEmailWhenPublished" "free" bubbles="false"}}>
<label class="switch" for="send-email-to-free">
<input
id="send-email-to-free"
type="checkbox"
checked={{this.sendEmailToFreeMembersWhenPublished}}
disabled={{this.disableEmailOption}}
class="gh-input post-settings-featured"
onclick={{action "toggleSendEmailWhenPublished" value="free"}}
data-test-checkbox="free-members"
>
<span class="input-toggle-component"></span>
</label>
</div>
</div>
<div class="gh-publishmenu-send-to-option">
<p>Paid members <span class="gh-publishmenu-emailcount">{{this.paidMemberCountLabel}}</span></p>
<div class="for-switch small" {{action "toggleSendEmailWhenPublished" "paid" bubbles="false"}}>
<label class="switch" for="send-email-to-paid">
<input
id="send-email-to-paid"
type="checkbox"
checked={{this.sendEmailToPaidMembersWhenPublished}}
disabled={{this.disableEmailOption}}
class="gh-input post-settings-featured"
onclick={{action "toggleSendEmailWhenPublished" value="paid"}}
data-test-checkbox="paid-members"
>
<span class="input-toggle-component"></span>
</label>
</div>
</div>
<GhMembersSegmentSelect
@segment={{this.recipientsSegment}}
@onChange={{action "setSendEmailWhenPublished"}}
@renderInPlace={{true}}
/>
</div>
</div>
{{/if}}

View File

@ -1,11 +1,23 @@
import Component from '@ember/component';
import moment from 'moment';
import {computed} from '@ember/object';
import {formatNumber} from 'ghost-admin/helpers/format-number';
import {isEmpty} from '@ember/utils';
import {or} from '@ember/object/computed';
import {inject as service} from '@ember/service';
const MEMBERS_SEGMENT_MAP = [{
name: 'all',
segment: 'status:free,status:-free'
}, {
name: 'free',
segment: 'status:free'
}, {
name: 'paid',
segment: 'status:-free'
}, {
name: 'none',
segment: null
}];
export default Component.extend({
feature: service(),
settings: service(),
@ -19,50 +31,11 @@ export default Component.extend({
_publishedAtBlogTZ: null,
'data-test-publishmenu-draft': true,
showSendEmail: or('session.user.isOwner', 'session.user.isAdmin', 'session.user.isEditor'),
disableEmailOption: computed('memberCount', function () {
return (this.get('session.user.isOwnerOrAdmin') && this.memberCount === 0);
}),
disableFreeMemberCheckbox: computed('freeMemberCount', function () {
return (this.get('session.user.isOwnerOrAdmin') && this.freeMemberCount === 0);
}),
disablePaidMemberCheckbox: computed('paidMemberCount', function () {
return (this.get('session.user.isOwnerOrAdmin') && this.paidMemberCount === 0);
}),
freeMemberCountLabel: computed('freeMemberCount', function () {
if (this.get('freeMemberCount') !== undefined) {
return `(${formatNumber(this.get('freeMemberCount'))})`;
}
return '';
}),
paidMemberCountLabel: computed('freeMemberCount', function () {
if (this.get('freeMemberCount') !== undefined) {
return `(${formatNumber(this.get('paidMemberCount'))})`;
}
return '';
}),
canSendEmail: computed('post.{isPost,email}', 'settings.{mailgunApiKey,mailgunDomain,mailgunBaseUrl}', 'config.mailgunIsConfigured', function () {
let mailgunIsConfigured = this.get('settings.mailgunApiKey') && this.get('settings.mailgunDomain') && this.get('settings.mailgunBaseUrl') || this.get('config.mailgunIsConfigured');
let isPost = this.post.isPost;
let hasSentEmail = !!this.post.email;
return mailgunIsConfigured && isPost && !hasSentEmail;
}),
sendEmailToFreeMembersWhenPublished: computed('sendEmailWhenPublished', function () {
return ['free', 'all'].includes(this.sendEmailWhenPublished);
}),
sendEmailToPaidMembersWhenPublished: computed('sendEmailWhenPublished', function () {
return ['paid', 'all'].includes(this.sendEmailWhenPublished);
}),
didInsertElement() {
this.post.set('publishedAtBlogTZ', this.get('post.publishedAtUTC'));
this.send('setSaveType', 'publish');
@ -115,23 +88,9 @@ export default Component.extend({
return post.validate();
},
toggleSendEmailWhenPublished(type) {
let isFree = this.get('sendEmailToFreeMembersWhenPublished');
let isPaid = this.get('sendEmailToPaidMembersWhenPublished');
if (type === 'free') {
isFree = !isFree;
} else if (type === 'paid') {
isPaid = !isPaid;
}
if (isFree && isPaid) {
this.setSendEmailWhenPublished('all');
} else if (isFree && !isPaid) {
this.setSendEmailWhenPublished('free');
} else if (!isFree && isPaid) {
this.setSendEmailWhenPublished('paid');
} else if (!isFree && !isPaid) {
this.setSendEmailWhenPublished('none');
}
setSendEmailWhenPublished(segment) {
const segmentName = MEMBERS_SEGMENT_MAP.findBy('segment', segment).name;
this.setSendEmailWhenPublished(segmentName);
}
},

View File

@ -28,55 +28,25 @@
</div>
</div>
</section>
{{#if (and canSendEmail showSendEmail)}}
{{#unless this.post.email}}
<section class="gh-publishmenu-section">
<div class="gh-publishmenu-email">
{{#if this.backgroundLoader.isRunning}}
<div class="gh-loading-spinner" style="zoom: 50%"></div>
{{else if this.isSendingEmailLimited}}
<p>{{html-safe this.sendingEmailLimitError}}</p>
{{else}}
<div class="gh-publishmenu-email-label">
<label class="gh-publishmenu-radio-label mb3 pe-none">Send by email to</label>
<div class="form-group">
<div class="gh-publishmenu-send-to-option gh-publishmenu-checkbox-disabled">
<p>Free members</p>
<div class="for-switch small">
<label class="switch" for="send-email-to-free">
<input
id="send-email-to-free"
type="checkbox"
checked={{this.sendEmailToFreeMembersWhenPublished}}
disabled={{this.disableEmailOption}}
class="gh-input post-settings-featured"
data-test-checkbox="free-members"
>
<span class="input-toggle-component"></span>
</label>
</div>
</div>
<div class="gh-publishmenu-send-to-option gh-publishmenu-checkbox-disabled">
<p>Paid members</p>
<div class="for-switch small">
<label class="switch" for="send-email-to-paid">
<input
id="send-email-to-paid"
type="checkbox"
checked={{this.sendEmailToPaidMembersWhenPublished}}
disabled={{this.disableEmailOption}}
class="gh-input post-settings-featured"
data-test-checkbox="paid-members"
>
<span class="input-toggle-component"></span>
</label>
</div>
</div>
</div>
{{#if this.canSendEmail}}
<section class="gh-publishmenu-section">
<div class="gh-publishmenu-email">
{{#if this.isSendingEmailLimited}}
<p>{{html-safe this.sendingEmailLimitError}}</p>
{{else}}
<div class="gh-publishmenu-email-label {{if this.disableEmailOption "pe-none"}}">
<label class="gh-publishmenu-radio-label mb3 {{if this.disableEmailOption "midgrey"}}">Send by email to</label>
<div class="form-group">
<GhMembersSegmentSelect
@segment={{this.recipientsSegment}}
@disabled={{true}}
@renderInPlace={{true}}
/>
</div>
{{/if}}
</div>
</section>
{{/unless}}
</div>
{{/if}}
</div>
</section>
{{/if}}
</div>

View File

@ -1,8 +1,6 @@
import Component from '@ember/component';
import moment from 'moment';
import {computed} from '@ember/object';
import {equal, or} from '@ember/object/computed';
import {formatNumber} from 'ghost-admin/helpers/format-number';
import {inject as service} from '@ember/service';
export default Component.extend({
@ -21,47 +19,6 @@ export default Component.extend({
'data-test-publishmenu-scheduled': true,
disableEmailOption: equal('memberCount', 0),
showSendEmail: or('session.user.isOwner', 'session.user.isAdmin', 'session.user.isEditor'),
disableFreeMemberCheckbox: computed('freeMemberCount', function () {
return (this.get('session.user.isOwnerOrAdmin') && this.freeMemberCount === 0);
}),
disablePaidMemberCheckbox: computed('paidMemberCount', function () {
return (this.get('session.user.isOwnerOrAdmin') && this.paidMemberCount === 0);
}),
freeMemberCountLabel: computed('freeMemberCount', function () {
if (this.get('freeMemberCount') !== undefined) {
return `(${formatNumber(this.get('freeMemberCount'))})`;
}
return '';
}),
paidMemberCountLabel: computed('freeMemberCount', function () {
if (this.get('freeMemberCount') !== undefined) {
return `(${formatNumber(this.get('paidMemberCount'))})`;
}
return '';
}),
canSendEmail: computed('post.{isPost,email}', 'settings.{mailgunApiKey,mailgunDomain,mailgunBaseUrl}', 'config.mailgunIsConfigured', function () {
let mailgunIsConfigured = this.get('settings.mailgunApiKey') && this.get('settings.mailgunDomain') && this.get('settings.mailgunBaseUrl') || this.get('config.mailgunIsConfigured');
let isPost = this.post.isPost;
let hasSentEmail = !!this.post.email;
return mailgunIsConfigured && isPost && !hasSentEmail;
}),
sendEmailToFreeMembersWhenPublished: computed('post.emailRecipientFilter', function () {
return ['free', 'all'].includes(this.post.emailRecipientFilter);
}),
sendEmailToPaidMembersWhenPublished: computed('post.emailRecipientFilter', function () {
return ['paid', 'all'].includes(this.post.emailRecipientFilter);
}),
timeToPublished: computed('post.publishedAtUTC', 'clock.second', function () {
let publishedAtUTC = this.get('post.publishedAtUTC');

View File

@ -8,17 +8,15 @@
<GhPublishmenuPublished
@post={{this.post}}
@saveType={{this.saveType}}
@setSaveType={{action "setSaveType"}}
@backgroundTask={{this.backgroundTask}} />
@setSaveType={{action "setSaveType"}} />
{{else if (eq this.displayState "scheduled")}}
<GhPublishmenuScheduled
@post={{this.post}}
@saveType={{this.saveType}}
@isClosing={{this.isClosing}}
@memberCount={{this.memberCount}}
@paidMemberCount={{this.paidMemberCount}}
@freeMemberCount={{this.freeMemberCount}}
@canSendEmail={{this.canSendEmail}}
@recipientsSegment={{this.recipientsSegment}}
@setSaveType={{action "setSaveType"}}
@setTypedDateError={{action (mut this.typedDateError)}}
@isSendingEmailLimited={{this.isSendingEmailLimited}}
@ -30,12 +28,9 @@
@saveType={{this.saveType}}
@setSaveType={{action "setSaveType"}}
@setTypedDateError={{action (mut this.typedDateError)}}
@canSendEmail={{this.canSendEmail}}
@recipientsSegment={{this.recipientsSegment}}
@setSendEmailWhenPublished={{action "setSendEmailWhenPublished"}}
@backgroundTask={{this.backgroundTask}}
@memberCount={{this.memberCount}}
@paidMemberCount={{this.paidMemberCount}}
@freeMemberCount={{this.freeMemberCount}}
@sendEmailWhenPublished={{this.sendEmailWhenPublished}}
@isSendingEmailLimited={{this.isSendingEmailLimited}}
@sendingEmailLimitError={{this.sendingEmailLimitError}} />
{{/if}}

View File

@ -9,6 +9,20 @@ import {task, timeout} from 'ember-concurrency';
const CONFIRM_EMAIL_POLL_LENGTH = 1000;
const CONFIRM_EMAIL_MAX_POLL_LENGTH = 15 * 1000;
const MEMBERS_SEGMENT_MAP = [{
name: 'all',
segment: 'status:free,status:-free'
}, {
name: 'free',
segment: 'status:free'
}, {
name: 'paid',
segment: 'status:-free'
}, {
name: 'none',
segment: null
}];
export default Component.extend({
clock: service(),
feature: service(),
@ -18,7 +32,6 @@ export default Component.extend({
store: service(),
limit: service(),
backgroundTask: null,
classNames: 'gh-publishmenu',
displayState: 'draft',
post: null,
@ -41,12 +54,22 @@ export default Component.extend({
hasEmailPermission: or('session.user.isOwner', 'session.user.isAdmin', 'session.user.isEditor'),
canSendEmail: computed('post.{isPost,email}', 'settings.{mailgunApiKey,mailgunDomain,mailgunBaseUrl}', 'config.mailgunIsConfigured', function () {
let mailgunIsConfigured = this.get('settings.mailgunApiKey') && this.get('settings.mailgunDomain') && this.get('settings.mailgunBaseUrl') || this.get('config.mailgunIsConfigured');
canSendEmail: computed('hasEmailPermission', 'post.{isPost,email}', 'settings.{editorDefaultEmailRecipients,membersSignupAccess,mailgunIsConfigured}', 'config.mailgunIsConfigured', function () {
let isDisabled = this.settings.get('editorDefaultEmailRecipients') === 'disabled' || this.settings.get('membersSignupAccess') === 'none';
let mailgunIsConfigured = this.settings.get('mailgunIsConfigured') || this.config.get('mailgunIsConfigured');
let isPost = this.post.isPost;
let hasSentEmail = !!this.post.email;
return mailgunIsConfigured && isPost && !hasSentEmail;
return this.hasEmailPermission &&
!isDisabled &&
mailgunIsConfigured &&
isPost &&
!hasSentEmail;
}),
recipientsSegment: computed('sendEmailWhenPublished', function () {
const segmentName = this.sendEmailWhenPublished;
return MEMBERS_SEGMENT_MAP.findBy('name', segmentName).segment;
}),
postState: computed('post.{isPublished,isScheduled}', 'forcePublishedMenu', function () {
@ -131,6 +154,26 @@ export default Component.extend({
return buttonText;
}),
defaultEmailRecipients: computed('settings.{editorDefaultEmailRecipients,editorDefaultEmailRecipientsFilter}', 'post.visibility', function () {
const defaultEmailRecipients = this.settings.get('editorDefaultEmailRecipients');
if (defaultEmailRecipients === 'disabled' || defaultEmailRecipients === 'none') {
return 'none';
}
if (defaultEmailRecipients === 'visibility') {
if (this.post.visibility === 'public' || this.post.visibility === 'members') {
return 'all';
}
if (this.post.visibility === 'paid') {
return 'paid';
}
}
return MEMBERS_SEGMENT_MAP.findBy('segment', this.settings.get('editorDefaultEmailRecipientsFilter')).name;
}),
didReceiveAttrs() {
this._super(...arguments);
@ -151,17 +194,8 @@ export default Component.extend({
}
this._postStatus = this.postStatus;
if (this.postStatus === 'draft' && this.canSendEmail && this.hasEmailPermission) {
// Set default newsletter recipients
if (this.post.visibility === 'public' || this.post.visibility === 'members') {
this.set('sendEmailWhenPublished', 'all');
} else {
this.set('sendEmailWhenPublished', 'paid');
}
}
this.setDefaultSendEmailWhenPublished();
this.checkIsSendingEmailLimited();
this.countPaidMembers();
},
actions: {
@ -187,6 +221,9 @@ export default Component.extend({
this._cachePublishedAtBlogTZ();
this.set('isClosing', false);
this.get('post.errors').clear();
this.setDefaultSendEmailWhenPublished();
if (this.onOpen) {
this.onOpen();
}
@ -215,12 +252,14 @@ export default Component.extend({
}
},
countPaidMembers: action(function () {
// TODO: remove editor conditional once editors can query member counts
if (!this.session.get('user.isEditor') && this.canSendEmail) {
this.countPaidMembersTask.perform();
setDefaultSendEmailWhenPublished() {
if (this.postStatus === 'draft' && this.canSendEmail) {
// Set default newsletter recipients
this.set('sendEmailWhenPublished', this.defaultEmailRecipients);
} else {
this.set('sendEmailWhenPublished', this.post.emailRecipientFilter);
}
}),
},
checkIsSendingEmailLimited: action(function () {
if (this.limit.limiter && this.limit.limiter.isLimited('emails')) {
@ -231,34 +270,6 @@ export default Component.extend({
}
}),
countPaidMembersTask: task(function* () {
const result = yield this.store.query('member', {filter: 'subscribed:true+status:-free', limit: 1, page: 1});
const paidMemberCount = result.meta.pagination.total;
const freeMemberCount = this.memberCount - paidMemberCount;
this.set('paidMemberCount', paidMemberCount);
this.set('freeMemberCount', freeMemberCount);
if (this.postStatus === 'draft' && this.canSendEmail && this.hasEmailPermission) {
// Set default newsletter recipients
if (this.isSendingEmailLimited) {
this.set('sendEmailWhenPublished', 'none');
} else if (this.post.visibility === 'public' || this.post.visibility === 'members') {
if (paidMemberCount > 0 && freeMemberCount > 0) {
this.set('sendEmailWhenPublished', 'all');
} else if (!paidMemberCount && freeMemberCount > 0) {
this.set('sendEmailWhenPublished', 'free');
} else if (!freeMemberCount && paidMemberCount > 0) {
this.set('sendEmailWhenPublished', 'paid');
} else if (!freeMemberCount && !paidMemberCount) {
this.set('sendEmailWhenPublished', 'none');
}
} else {
const type = paidMemberCount > 0 ? 'paid' : 'none';
this.set('sendEmailWhenPublished', type);
}
}
}),
checkIsSendingEmailLimitedTask: task(function* () {
try {
yield this.limit.limiter.errorIfWouldGoOverLimit('emails');

View File

@ -10,7 +10,7 @@
{{#each @select.selected as |opt idx|}}
{{#component (or @extra.tokenComponent "draggable-object")
tagName="li"
class="ember-power-select-multiple-option"
class=(concat "ember-power-select-multiple-option" (if opt.class (concat " token-" opt.class)))
select=@select
content=(readonly opt)
idx=idx

View File

@ -0,0 +1,71 @@
<div class="gh-expandable-block">
<h4 class="gh-expandable-header">
<div>
<h4 class="gh-expandable-title">Default newsletter recipients</h4>
<p class="gh-expandable-description">Who do you usually want to send emails to?</p>
</div>
<button type="button" class="gh-btn" {{on "click" @toggleExpansion}} data-test-toggle="post-access"><span>{{if @expanded "Close" "Expand"}}</span></button>
</h4>
<div class="gh-expandable-content">
{{#liquid-if @expanded}}
<div class="flex flex-column w-50">
<div class="{{if this.isDisabled "disabled-overlay"}}">
<div
class="gh-radio {{if this.isDisabledSelected "active"}}"
{{on "click" (fn this.setDefaultEmailRecipients "disabled")}}
>
<div class="gh-radio-button"></div>
<div class="gh-radio-content">
<div class="gh-radio-label">Disabled</div>
</div>
</div>
<div
class="gh-radio {{if this.isVisibilitySelected "active"}}"
{{on "click" (fn this.setDefaultEmailRecipients "visibility")}}
>
<div class="gh-radio-button"></div>
<div class="gh-radio-content">
<div class="gh-radio-label">Match post access level</div>
</div>
</div>
<div
class="gh-radio {{if this.isNobodySelected "active"}}"
{{on "click" (fn this.setDefaultEmailRecipients "none")}}
>
<div class="gh-radio-button"></div>
<div class="gh-radio-content">
<div class="gh-radio-label">Nobody</div>
</div>
</div>
<div
class="gh-radio {{if this.isAllSelected "active"}}"
{{on "click" (fn this.setDefaultEmailRecipients "all")}}
>
<div class="gh-radio-button"></div>
<div class="gh-radio-content">
<div class="gh-radio-label">All members</div>
</div>
</div>
<div
class="gh-radio {{if this.isFreeSelected "active"}}"
{{on "click" (fn this.setDefaultEmailRecipients "free")}}
>
<div class="gh-radio-button"></div>
<div class="gh-radio-content">
<div class="gh-radio-label">Free members</div>
</div>
</div>
<div
class="gh-radio {{if this.isPaidSelected "active"}}"
{{on "click" (fn this.setDefaultEmailRecipients "paid")}}
>
<div class="gh-radio-button"></div>
<div class="gh-radio-content">
<div class="gh-radio-label">Paid members</div>
</div>
</div>
</div>
</div>
{{/liquid-if}}
</div>
</div>

View File

@ -0,0 +1,88 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
import {tracked} from '@glimmer/tracking';
export default class SettingsDefaultEmailRecipientsComponent extends Component {
@service settings;
@tracked segmentSelected = false;
get isDisabled() {
return this.settings.get('membersSignupAccess') === 'none';
}
get isDisabledSelected() {
return this.isDisabled ||
this.settings.get('editorDefaultEmailRecipients') === 'disabled';
}
get isVisibilitySelected() {
return !this.isDisabled &&
this.settings.get('editorDefaultEmailRecipients') === 'visibility';
}
get isNobodySelected() {
return !this.isDisabled &&
!this.segmentSelected &&
this.settings.get('editorDefaultEmailRecipients') === 'filter' &&
this.settings.get('editorDefaultEmailRecipientsFilter') === null;
}
get isAllSelected() {
return !this.isDisabled &&
!this.segmentSelected &&
this.settings.get('editorDefaultEmailRecipients') === 'filter' &&
this.settings.get('editorDefaultEmailRecipientsFilter') === 'status:free,status:-free';
}
get isFreeSelected() {
return !this.isDisabled &&
!this.segmentSelected &&
this.settings.get('editorDefaultEmailRecipients') === 'filter' &&
this.settings.get('editorDefaultEmailRecipientsFilter') === 'status:free';
}
get isPaidSelected() {
return !this.isDisabled &&
!this.segmentSelected &&
this.settings.get('editorDefaultEmailRecipients') === 'filter' &&
this.settings.get('editorDefaultEmailRecipientsFilter') === 'status:-free';
}
get isSegmentSelected() {
const isCustomSegment = this.settings.get('editorDefaultEmailRecipients') === 'filter' &&
!this.isNobodySelected &&
!this.isAllSelected &&
!this.isFreeSelected &&
!this.isPaidSelected;
return !this.isDisabled && (this.segmentSelected || isCustomSegment);
}
@action
setDefaultEmailRecipients(value) {
if (['disabled', 'visibility'].includes(value)) {
this.settings.set('editorDefaultEmailRecipients', value);
return;
}
if (value === 'none') {
this.settings.set('editorDefaultEmailRecipientsFilter', null);
}
if (value === 'all') {
this.settings.set('editorDefaultEmailRecipientsFilter', 'status:free,status:-free');
}
if (value === 'free') {
this.settings.set('editorDefaultEmailRecipientsFilter', 'status:free');
}
if (value === 'paid') {
this.settings.set('editorDefaultEmailRecipientsFilter', 'status:-free');
}
this.settings.set('editorDefaultEmailRecipients', 'filter');
}
}

View File

@ -7,6 +7,8 @@ import {tracked} from '@glimmer/tracking';
export default class MembersAccessController extends Controller {
@service settings;
queryParams = ['signupAccessOpen', 'postAccessOpen']
@tracked showLeaveSettingsModal = false;
@tracked signupAccessOpen = false;
@tracked postAccessOpen = false;

View File

@ -9,11 +9,14 @@ export default class MembersEmailController extends Controller {
@service session;
@service settings;
queryParams = ['emailRecipientsOpen']
// from/supportAddress are set here so that they can be reset to saved values on save
// to avoid it looking like they've been saved when they have a separate update process
@tracked fromAddress = '';
@tracked supportAddress = '';
@tracked emailRecipientsOpen = false;
@tracked showLeaveSettingsModal = false;
@action
@ -21,6 +24,11 @@ export default class MembersEmailController extends Controller {
this[property] = email;
}
@action
toggleEmailRecipientsOpen() {
this.emailRecipientsOpen = !this.emailRecipientsOpen;
}
leaveRoute(transition) {
if (this.settings.get('hasDirtyAttributes')) {
transition.abort();

View File

@ -1,6 +1,7 @@
/* eslint-disable camelcase */
import Model, {attr} from '@ember-data/model';
import ValidationEngine from 'ghost-admin/mixins/validation-engine';
import {and} from '@ember/object/computed';
export default Model.extend(ValidationEngine, {
validationType: 'setting',
@ -51,8 +52,8 @@ export default Model.extend(ValidationEngine, {
/**
* Members settings
*/
defaultContentVisibility: attr('string'),
membersSignupAccess: attr('string'),
defaultContentVisibility: attr('string'),
membersFromAddress: attr('string'),
membersSupportAddress: attr('string'),
membersReplyAddress: attr('string'),
@ -79,5 +80,12 @@ export default Model.extend(ValidationEngine, {
* OAuth settings
*/
oauthClientId: attr('string'),
oauthClientSecret: attr('string')
oauthClientSecret: attr('string'),
/**
* Editor settings
*/
editorDefaultEmailRecipients: attr('string'),
editorDefaultEmailRecipientsFilter: attr('members-segment-string'),
mailgunIsConfigured: and('mailgunApiKey', 'mailgunDomain', 'mailgunBaseUrl')
});

View File

@ -27,7 +27,7 @@ export default Service.extend(_ProxyMixin, ValidationEngine, {
_loadSettings() {
if (!this._loadingPromise) {
this._loadingPromise = this.store
.queryRecord('setting', {group: 'site,theme,private,members,portal,newsletter,email,amp,labs,slack,unsplash,views,firstpromoter,oauth'})
.queryRecord('setting', {group: 'site,theme,private,members,portal,newsletter,email,amp,labs,slack,unsplash,views,firstpromoter,oauth,editor'})
.then((settings) => {
this._loadingPromise = null;
return settings;

View File

@ -256,6 +256,18 @@
fill: var(--black);
}
/* Segment input */
.token-segment-status {
background: color-mod(var(--blue) alpha(0.1));
color: var(--blue);
}
.token-segment-status svg path {
stroke: var(--blue);
fill: var(--blue);
}
/* Inside settings / Mailgun region */
/* TODO: make these general styles? */

View File

@ -50,7 +50,6 @@
@saveTask={{this.save}}
@setSaveType={{action "setSaveType"}}
@onOpen={{action "cancelAutosave"}}
@backgroundTask={{this.backgroundLoader}}
@memberCount={{this.memberCount}} />
</div>
{{/if}}

View File

@ -21,10 +21,11 @@
{{#if this.session.user.isOwner}}
<div class="gh-setting-liquid-section">
<GhMembersEmailSetting
@settings={{this.settings}}
@fromAddress={{this.fromAddress}}
@supportAddress={{this.supportAddress}}
@setEmailAddress={{this.setEmailAddress}}
@emailRecipientsExpanded={{this.emailRecipientsOpen}}
@toggleEmailRecipientsExpansion={{this.toggleEmailRecipientsOpen}}
/>
</div>
{{/if}}

View File

@ -0,0 +1,29 @@
import Transform from '@ember-data/serializer/transform';
// the members segment supports `'none'` and `'all'` as special-case options
// but that doesn't map well for options in our token select inputs so we
// expand/convert them here to make usage elsewhere easier
export default class MembersSegmentStringTransform extends Transform {
deserialize(serialized) {
if (serialized === 'all') {
return 'status:free,status:-free';
}
if (serialized === 'none') {
return null;
}
return serialized;
}
serialize(deserialized) {
if (deserialized === 'status:free,status:-free') {
return 'all';
}
if (!deserialized) {
return 'none';
}
return deserialized;
}
}