diff --git a/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx b/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx index 14b7f5471b..61391067c3 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx @@ -55,6 +55,10 @@ const features = [{ title: 'AdminX Offers', description: 'Enables the new offers UI in AdminX settings', flag: 'adminXOffers' +},{ + title: 'Filter by email disabled', + description: 'Allows filtering members by email disabled', + flag: 'filterEmailDisabled' }]; const AlphaFeatures: React.FC = () => { diff --git a/ghost/admin/app/components/members/filter.js b/ghost/admin/app/components/members/filter.js index febad7c715..8f858406e3 100644 --- a/ghost/admin/app/components/members/filter.js +++ b/ghost/admin/app/components/members/filter.js @@ -1,7 +1,7 @@ import Component from '@glimmer/component'; import moment from 'moment-timezone'; import nql from '@tryghost/nql-lang'; -import {AUDIENCE_FEEDBACK_FILTER, CREATED_AT_FILTER, EMAIL_CLICKED_FILTER, EMAIL_COUNT_FILTER, EMAIL_FILTER, EMAIL_OPENED_COUNT_FILTER, EMAIL_OPENED_FILTER, EMAIL_OPEN_RATE_FILTER, EMAIL_SENT_FILTER, LABEL_FILTER, LAST_SEEN_FILTER, NAME_FILTER, NEWSLETTERS_FILTER, NEXT_BILLING_DATE_FILTER, OFFERS_FILTER, PLAN_INTERVAL_FILTER, SIGNUP_ATTRIBUTION_FILTER, STATUS_FILTER, SUBSCRIBED_FILTER, SUBSCRIPTION_ATTRIBUTION_FILTER, SUBSCRIPTION_START_DATE_FILTER, SUBSCRIPTION_STATUS_FILTER, TIER_FILTER} from './filters'; +import {AUDIENCE_FEEDBACK_FILTER, CREATED_AT_FILTER, EMAIL_CLICKED_FILTER, EMAIL_COUNT_FILTER, EMAIL_FILTER, EMAIL_OPENED_COUNT_FILTER, EMAIL_OPENED_FILTER, EMAIL_OPEN_RATE_FILTER, EMAIL_SENT_FILTER, LABEL_FILTER, LAST_SEEN_FILTER, NAME_FILTER, NEWSLETTERS_FILTERS, NEXT_BILLING_DATE_FILTER, OFFERS_FILTER, PLAN_INTERVAL_FILTER, SIGNUP_ATTRIBUTION_FILTER, STATUS_FILTER, SUBSCRIBED_FILTER, SUBSCRIPTION_ATTRIBUTION_FILTER, SUBSCRIPTION_START_DATE_FILTER, SUBSCRIPTION_STATUS_FILTER, TIER_FILTER} from './filters'; import {TrackedArray} from 'tracked-built-ins'; import {action} from '@ember/object'; import {inject as service} from '@ember/service'; @@ -25,6 +25,12 @@ const FILTER_GROUPS = [ SIGNUP_ATTRIBUTION_FILTER ] }, + { + name: 'Newsletters', + filters: [ + NEWSLETTERS_FILTERS + ] + }, { name: 'Subscription', filters: [ @@ -52,6 +58,15 @@ const FILTER_GROUPS = [ ]; const FILTER_PROPERTIES = FILTER_GROUPS.flatMap(group => group.filters.map((f) => { + if (typeof f === 'function') { + return (options) => { + return f({ + ...options, + group: group.name + }); + }; + } + f.group = group.name; return f; })); @@ -148,16 +163,21 @@ export default class MembersFilter extends Component { get filterProperties() { let availableFilters = FILTER_PROPERTIES; - // find list of newsletters from store and add them to filter list if there are more than one newsletter - // it also removes the 'subscribed' filter from the list as that would unsubscribe members from all newsletters, instead replace it with a filter for each newsletter - if (this.newsletters?.length > 1) { - // remove the 'subscribed' filter from the list - availableFilters = availableFilters.filter(prop => prop.name !== 'subscribed'); - // find the index of the 'basic' group and insert the 'multiple newsletters' filter after it - const indexes = availableFilters.map((obj, index) => (obj.group === 'Basic' ? index : null)).filter(i => i !== null); - const lastIndex = indexes.pop(); - availableFilters.splice(lastIndex + 1, 0, ...NEWSLETTERS_FILTER(this.newsletters)); - } + // Convert the method filters to properties + availableFilters = availableFilters.flatMap((filter) => { + if (typeof filter === 'function') { + const filters = filter({ + newsletters: this.newsletters ?? [], + feature: this.feature + }); + if (Array.isArray(filters)) { + return filters; + } + return [filters]; + } + return [filter]; + }); + // only add the offers filter if there are any offers if (this.offers.length > 0) { availableFilters = availableFilters.concat(OFFERS_FILTER); diff --git a/ghost/admin/app/components/members/filters/subscribed.js b/ghost/admin/app/components/members/filters/subscribed.js index 883434624f..2ee38fe5d3 100644 --- a/ghost/admin/app/components/members/filters/subscribed.js +++ b/ghost/admin/app/components/members/filters/subscribed.js @@ -1,61 +1,167 @@ import {MATCH_RELATION_OPTIONS} from './relation-options'; -export const SUBSCRIBED_FILTER = { - label: 'Newsletter subscription', - name: 'subscribed', - columnLabel: 'Subscribed', - relationOptions: MATCH_RELATION_OPTIONS, - valueType: 'options', - buildNqlFilter: (flt) => { - const relation = flt.relation; - const value = flt.value; - - return (relation === 'is' && value === 'true') || (relation === 'is-not' && value === 'false') - ? '(subscribed:true+email_disabled:0)' - : '(subscribed:false,email_disabled:1)'; - }, - parseNqlFilter: (flt) => { - const comparator = flt.$and || flt.$or; - - if (!comparator || comparator.length !== 2) { - return; - } - - if (comparator[0].subscribed === undefined || comparator[1].email_disabled === undefined) { - return; - } - - const subscribed = comparator[0].subscribed; - +export const SUBSCRIBED_FILTER = ({newsletters, feature, group}) => { + if (feature.filterEmailDisabled) { return { - value: subscribed ? 'true' : 'false', - relation: 'is' - }; - }, - options: [ - {label: 'Subscribed', name: 'true'}, - {label: 'Unsubscribed', name: 'false'} - ], - getColumnValue: (member, flt) => { - const relation = flt.relation; - const value = flt.value; + label: newsletters.length > 1 ? 'Newsletter subscriptions' : 'Newsletter subscription', + name: 'subscribed', + columnLabel: 'Subscribed', + relationOptions: MATCH_RELATION_OPTIONS, + valueType: 'options', + group: newsletters.length > 1 ? 'Newsletters' : group, + // Only show the filter for multiple newsletters if feature flag is enabled + feature: newsletters.length > 1 ? 'filterEmailDisabled' : undefined, + buildNqlFilter: (flt) => { + const relation = flt.relation; + const value = flt.value; - return { - text: (relation === 'is' && value === 'true') || (relation === 'is-not' && value === 'false') - ? 'Subscribed' - : 'Unsubscribed' + if (value === 'email-disabled') { + if (relation === 'is') { + return '(email_disabled:1)'; + } + return '(email_disabled:0)'; + } + + if (relation === 'is') { + if (value === 'subscribed') { + return '(subscribed:true+email_disabled:0)'; + } + return '(subscribed:false+email_disabled:0)'; + } + + // relation === 'is-not' + if (value === 'subscribed') { + return '(subscribed:false,email_disabled:1)'; + } + return '(subscribed:true,email_disabled:1)'; + }, + parseNqlFilter: (flt) => { + const comparator = flt.$and || flt.$or; // $or for legacy filter backwards compatibility + + if (!comparator || comparator.length !== 2) { + const filter = flt.yg || flt; + if (filter && filter.email_disabled !== undefined) { + if (filter.email_disabled) { + return { + value: 'email-disabled', + relation: 'is' + }; + } + return { + value: 'email-disabled', + relation: 'is-not' + }; + } + return; + } + + if (comparator[0].subscribed === undefined || comparator[1].email_disabled === undefined) { + return; + } + + const usedOr = flt.$or !== undefined; + const subscribed = comparator[0].subscribed; + + if (usedOr) { + // Is not + return { + value: !subscribed ? 'subscribed' : 'unsubscribed', + relation: 'is-not' + }; + } + + return { + value: subscribed ? 'subscribed' : 'unsubscribed', + relation: 'is' + }; + }, + options: [ + {label: newsletters.length > 1 ? 'Subscribed to one or more' : 'Subscribed', name: 'subscribed'}, + {label: newsletters.length > 1 ? 'Unsubscribed from all' : 'Unsubscribed', name: 'unsubscribed'}, + {label: 'Email disabled', name: 'email-disabled'} + ], + getColumnValue: (member) => { + if (member.emailSuppression && member.emailSuppression.suppressed) { + return { + text: 'Email disabled' + }; + } + + return member.newsletters.length > 0 ? { + text: 'Subscribed' + } : { + text: 'Unsubscribed' + }; + } }; } + + if (newsletters.length > 1) { + // Disable + // Only show the filter for multiple newsletters if feature flag is enabled + return []; + } + + return { + label: 'Newsletter subscription', + name: 'subscribed', + columnLabel: 'Subscribed', + relationOptions: MATCH_RELATION_OPTIONS, + valueType: 'options', + group: group, + buildNqlFilter: (flt) => { + const relation = flt.relation; + const value = flt.value; + + return (relation === 'is' && value === 'true') || (relation === 'is-not' && value === 'false') + ? '(subscribed:true+email_disabled:0)' + : '(subscribed:false,email_disabled:1)'; + }, + parseNqlFilter: (flt) => { + const comparator = flt.$and || flt.$or; + + if (!comparator || comparator.length !== 2) { + return; + } + + if (comparator[0].subscribed === undefined || comparator[1].email_disabled === undefined) { + return; + } + + const subscribed = comparator[0].subscribed; + + return { + value: subscribed ? 'true' : 'false', + relation: 'is' + }; + }, + options: [ + {label: 'Subscribed', name: 'true'}, + {label: 'Unsubscribed', name: 'false'} + ], + getColumnValue: (member, flt) => { + const relation = flt.relation; + const value = flt.value; + + return { + text: (relation === 'is' && value === 'true') || (relation === 'is-not' && value === 'false') + ? 'Subscribed' + : 'Unsubscribed' + }; + } + }; }; -export const NEWSLETTERS_FILTER = (newsletterList) => { - let newsletters = []; - newsletterList.forEach((newsletter) => { - const filter = { +export const NEWSLETTERS_FILTERS = ({newsletters, group, feature}) => { + if (newsletters.length <= 1) { + return []; + } + return newsletters.map((newsletter) => { + return { label: newsletter.name, name: `newsletters.slug:${newsletter.slug}`, relationOptions: MATCH_RELATION_OPTIONS, - group: 'Newsletters', + group, valueType: 'options', buildNqlFilter: (flt) => { const relation = flt.relation; @@ -98,9 +204,26 @@ export const NEWSLETTERS_FILTER = (newsletterList) => { options: [ {label: 'Subscribed', name: 'true'}, {label: 'Unsubscribed', name: 'false'} - ] + ], + columnLabel: newsletter.name, + getColumnValue: (member, flt) => { + const relation = flt.relation; + const value = flt.value; + + if (feature.filterEmailDisabled) { + if (member.emailSuppression && member.emailSuppression.suppressed) { + return { + text: 'Email disabled' + }; + } + } + + return { + text: (relation === 'is' && value === 'true') || (relation === 'is-not' && value === 'false') + ? 'Subscribed' + : 'Unsubscribed' + }; + } }; - newsletters.push(filter); }); - return newsletters; }; diff --git a/ghost/admin/app/services/feature.js b/ghost/admin/app/services/feature.js index 64fab9b9ad..3ae605bc96 100644 --- a/ghost/admin/app/services/feature.js +++ b/ghost/admin/app/services/feature.js @@ -78,6 +78,7 @@ export default class FeatureService extends Service { @feature('recommendations') recommendations; @feature('lexicalIndicators') lexicalIndicators; @feature('editorEmojiPicker') editorEmojiPicker; + @feature('filterEmailDisabled') filterEmailDisabled; _user = null; diff --git a/ghost/core/core/shared/labs.js b/ghost/core/core/shared/labs.js index cfb05e85be..af563e439e 100644 --- a/ghost/core/core/shared/labs.js +++ b/ghost/core/core/shared/labs.js @@ -44,7 +44,9 @@ const ALPHA_FEATURES = [ 'importMemberTier', 'lexicalIndicators', 'listUnsubscribeHeader', - 'adminXOffers' + 'editorEmojiPicker', + 'adminXOffers', + 'filterEmailDisabled' ]; module.exports.GA_KEYS = [...GA_FEATURES]; diff --git a/ghost/members-api/lib/services/MemberBREADService.js b/ghost/members-api/lib/services/MemberBREADService.js index c5b61f86dc..05d05d05e6 100644 --- a/ghost/members-api/lib/services/MemberBREADService.js +++ b/ghost/members-api/lib/services/MemberBREADService.js @@ -242,7 +242,7 @@ module.exports = class MemberBREADService { const suppressionData = await this.emailSuppressionList.getSuppressionData(member.email); member.email_suppression = { - suppressed: suppressionData.suppressed, + suppressed: suppressionData.suppressed || !!model.get('email_disabled'), info: suppressionData.info }; @@ -401,11 +401,10 @@ module.exports = class MemberBREADService { const subscriptions = page.data.flatMap(model => model.related('stripeSubscriptions').slice()); const offerMap = await this.fetchSubscriptionOffers(subscriptions); - const members = page.data.map(model => model.toJSON(options)); + const bulkSuppressionData = await this.emailSuppressionList.getBulkSuppressionData(page.data.map(member => member.get('email'))); - const bulkSuppressionData = await this.emailSuppressionList.getBulkSuppressionData(members.map(member => member.email)); - - const data = members.map((member, index) => { + const data = page.data.map((model, index) => { + const member = model.toJSON(options); member.subscriptions = member.subscriptions.filter(sub => !!sub.price); this.attachSubscriptionsToMember(member); this.attachOffersToSubscriptions(member, offerMap); @@ -413,7 +412,7 @@ module.exports = class MemberBREADService { delete member.products; } member.email_suppression = { - suppressed: bulkSuppressionData[index].suppressed, + suppressed: bulkSuppressionData[index].suppressed || !!model.get('email_disabled'), info: bulkSuppressionData[index].info }; return member; diff --git a/ghost/members-api/test/unit/lib/services/member-bread.test.js b/ghost/members-api/test/unit/lib/services/member-bread.test.js index 720071591c..95ad97c26d 100644 --- a/ghost/members-api/test/unit/lib/services/member-bread.test.js +++ b/ghost/members-api/test/unit/lib/services/member-bread.test.js @@ -42,7 +42,10 @@ describe('MemberBreadService', function () { memberModelStub = { id: MEMBER_ID, related: sinon.stub().returns([]), - toJSON: sinon.stub().returns({...memberModelJSON}) + toJSON: sinon.stub().returns({...memberModelJSON}), + get: function (key) { + return this[key]; + } }; memberRepositoryStub = { get: sinon.stub().resolves(null),