Skip to content

Commit ba5bf42

Browse files
committed
✨ Added a members filter for multiple active paid subscriptions
ref https://linear.app/ghost/issue/BER-3720/cleanupimprove-multi-sub-filter-implementation The multiple active subscriptions filter was only reachable through the warning banner's "View members" link and was hidden from the filter bar, because the API only accepted the one hardcoded filter string. Now that the API supports it as a composable filter field, it can behave like any other filter: it appears in the Subscription group after "Member status" as a boolean toggle, shows as a removable filter chip, and can be combined with other filters or inverted to find unaffected members. Like the "Offer" filter, it's only offered when relevant — it uses the same affected-member count as the banner and stays hidden when that count is zero.
1 parent 599bd2f commit ba5bf42

12 files changed

Lines changed: 210 additions & 61 deletions

apps/posts/src/views/filters/filter-types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export interface FilterField {
2424
parseKeys?: readonly string[];
2525
ui: {
2626
label: string;
27-
type: 'text' | 'select' | 'multiselect' | 'date' | 'number' | 'custom';
27+
type: 'text' | 'select' | 'multiselect' | 'date' | 'number' | 'boolean' | 'custom';
2828
[key: string]: unknown;
2929
};
3030
options?: Array<{value: string; label: string}>;

apps/posts/src/views/members/components/members-filters.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {useBrowseNewsletters} from '@tryghost/admin-x-framework/api/newsletters'
1515
import {useBrowseOffers} from '@tryghost/admin-x-framework/api/offers';
1616
import {useEmailPostValueSource} from '@src/hooks/filter-sources/use-email-post-value-source';
1717
import {useLabelValueSource} from '@src/hooks/filter-sources/use-label-value-source';
18+
import {useMultipleActiveSubscriptionsCount} from '../hooks/use-multiple-active-subscriptions-count';
1819
import {usePostResourceValueSource} from '@src/hooks/filter-sources/use-post-resource-value-source';
1920
import {useTierValueSource} from '@src/hooks/filter-sources/use-tier-value-source';
2021
import type {MemberView} from '../hooks/use-member-views';
@@ -94,6 +95,9 @@ const MembersFilters: React.FC<MembersFiltersProps> = ({
9495
const emailValueSource = useEmailPostValueSource();
9596
const labelValueSource = useLabelValueSource();
9697
const {valueSource: tierValueSource, hasMultipleTiers} = useTierValueSource();
98+
const {count: multipleActiveSubscriptionsCount} = useMultipleActiveSubscriptionsCount({
99+
enabled: paidMembersEnabled
100+
});
97101

98102
const filterFields = useMemberFilterFields({
99103
newsletters,
@@ -104,6 +108,7 @@ const MembersFilters: React.FC<MembersFiltersProps> = ({
104108
labelValueSource,
105109
tierValueSource,
106110
offers,
111+
multipleActiveSubscriptionsCount,
107112
postValueSource,
108113
emailValueSource,
109114
membersTrackSources,

apps/posts/src/views/members/hooks/use-members-filter-state.test.tsx

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,14 +78,43 @@ describe('useMembersFilterState', () => {
7878
{
7979
id: 'count.active_stripe_customers:1',
8080
field: 'count.active_stripe_customers',
81-
operator: 'is-greater',
82-
values: [1]
81+
operator: 'is',
82+
values: [true]
8383
}
8484
]);
8585
expect(result.current.query).toBe('filter=count.active_stripe_customers%3A%3E1');
8686
expect(result.current.hasFilterOrSearch).toBe(true);
8787
});
8888

89+
it('parses the inverted multiple active Stripe customers filter into a predicate', async () => {
90+
const {result} = renderHook(() => {
91+
const state = useMembersFilterState('UTC');
92+
const [searchParams] = useSearchParams();
93+
94+
return {
95+
...state,
96+
query: searchParams.toString()
97+
};
98+
}, {
99+
wrapper: createWrapper('/?filter=count.active_stripe_customers%3A%3C2')
100+
});
101+
102+
await waitFor(() => {
103+
expect(result.current.nql).toBe('count.active_stripe_customers:<2');
104+
});
105+
106+
expect(result.current.filters).toEqual([
107+
{
108+
id: 'count.active_stripe_customers:1',
109+
field: 'count.active_stripe_customers',
110+
operator: 'is',
111+
values: [false]
112+
}
113+
]);
114+
expect(result.current.query).toBe('filter=count.active_stripe_customers%3A%3C2');
115+
expect(result.current.hasFilterOrSearch).toBe(true);
116+
});
117+
89118
it('drops unsupported multiple active Stripe customers filter values', async () => {
90119
const {result} = renderHook(() => {
91120
const state = useMembersFilterState('UTC');

apps/posts/src/views/members/hooks/use-multiple-active-subscriptions-banner.ts

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ import {
77
import {buildMembersUrl} from '../member-route';
88
import {canManageMembers, useEditUser} from '@tryghost/admin-x-framework/api/users';
99
import {toast} from 'sonner';
10-
import {useBrowseMembers} from '@tryghost/admin-x-framework/api/members';
1110
import {useCallback, useEffect, useMemo, useState} from 'react';
1211
import {useCurrentUser} from '@tryghost/admin-x-framework/api/current-user';
12+
import {useMultipleActiveSubscriptionsCount} from './use-multiple-active-subscriptions-count';
1313
import {useNavigate} from 'react-router';
1414

1515
interface UseMultipleActiveSubscriptionsBannerOptions {
@@ -38,23 +38,9 @@ export function useMultipleActiveSubscriptionsBanner({
3838
// affected members themselves — any other filter/search hides the banner.
3939
const shouldConsiderBanner = !search && (!nql || isViewingFilter);
4040

41-
// Count-only query: we just need pagination.total, not the members.
42-
const {
43-
data
44-
} = useBrowseMembers({
45-
searchParams: {
46-
filter: MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FILTER,
47-
limit: '1',
48-
fields: 'id',
49-
order: 'id'
50-
},
51-
defaultErrorHandler: false,
52-
enabled: canManageMemberList && shouldConsiderBanner,
53-
refetchOnMount: 'always',
54-
staleTime: 0
41+
const {count, hasResolvedCount} = useMultipleActiveSubscriptionsCount({
42+
enabled: canManageMemberList && shouldConsiderBanner
5543
});
56-
57-
const count = data?.meta?.pagination?.total ?? 0;
5844
const preference = useMemo(() => {
5945
return getMultipleActiveSubscriptionsBannerPreference(currentUser?.accessibility);
6046
}, [currentUser?.accessibility]);
@@ -80,7 +66,7 @@ export function useMultipleActiveSubscriptionsBanner({
8066
|| optimisticDismissedCount !== null
8167
|| isDismissing
8268
|| storedDismissedCount === undefined
83-
|| data === undefined
69+
|| !hasResolvedCount
8470
|| count >= storedDismissedCount
8571
) {
8672
return;
@@ -103,8 +89,8 @@ export function useMultipleActiveSubscriptionsBanner({
10389
}, [
10490
count,
10591
currentUser,
106-
data,
10792
editUser,
93+
hasResolvedCount,
10894
isDismissing,
10995
optimisticDismissedCount,
11096
preference.dismissedAt,
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import {MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FILTER} from '../multiple-active-subscriptions';
2+
import {useBrowseMembers} from '@tryghost/admin-x-framework/api/members';
3+
4+
interface UseMultipleActiveSubscriptionsCountOptions {
5+
enabled: boolean;
6+
}
7+
8+
/**
9+
* Counts the members that have active subscriptions across multiple Stripe
10+
* customers. Count-only query: we just need pagination.total, not the members.
11+
*/
12+
export function useMultipleActiveSubscriptionsCount({enabled}: UseMultipleActiveSubscriptionsCountOptions) {
13+
const {data} = useBrowseMembers({
14+
searchParams: {
15+
filter: MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FILTER,
16+
limit: '1',
17+
fields: 'id',
18+
order: 'id'
19+
},
20+
defaultErrorHandler: false,
21+
enabled,
22+
refetchOnMount: 'always',
23+
staleTime: 0
24+
});
25+
26+
return {
27+
count: data?.meta?.pagination?.total ?? 0,
28+
hasResolvedCount: data !== undefined
29+
};
30+
}

apps/posts/src/views/members/member-fields.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,59 @@ describe('dateCodec', () => {
139139
});
140140
});
141141

142+
describe('multipleActiveSubscriptionsCodec', () => {
143+
const field = memberFields['count.active_stripe_customers'];
144+
const context: CodecContext = {
145+
key: 'count.active_stripe_customers',
146+
pattern: 'count.active_stripe_customers',
147+
params: {},
148+
timezone: 'UTC'
149+
};
150+
151+
it('serializes the boolean predicate to count comparisons', () => {
152+
const predicate: FilterPredicate = {
153+
id: '1',
154+
field: 'count.active_stripe_customers',
155+
operator: 'is',
156+
values: [true]
157+
};
158+
159+
expect(field.codec.serialize(predicate, context)).toEqual([
160+
'count.active_stripe_customers:>1'
161+
]);
162+
expect(field.codec.serialize({...predicate, values: [false]}, context)).toEqual([
163+
'count.active_stripe_customers:<2'
164+
]);
165+
});
166+
167+
it('rejects non-boolean values and other operators', () => {
168+
const predicate: FilterPredicate = {
169+
id: '1',
170+
field: 'count.active_stripe_customers',
171+
operator: 'is',
172+
values: [1]
173+
};
174+
175+
expect(field.codec.serialize(predicate, context)).toBeNull();
176+
expect(field.codec.serialize({...predicate, operator: 'is-greater', values: [true]}, context)).toBeNull();
177+
});
178+
179+
it('parses only the comparisons it serializes', () => {
180+
expect(field.codec.parse({'count.active_stripe_customers': {$gt: 1}}, context)).toEqual({
181+
field: 'count.active_stripe_customers',
182+
operator: 'is',
183+
values: [true]
184+
});
185+
expect(field.codec.parse({'count.active_stripe_customers': {$lt: 2}}, context)).toEqual({
186+
field: 'count.active_stripe_customers',
187+
operator: 'is',
188+
values: [false]
189+
});
190+
expect(field.codec.parse({'count.active_stripe_customers': {$gt: 2}}, context)).toBeNull();
191+
expect(field.codec.parse({'count.active_stripe_customers': 1}, context)).toBeNull();
192+
});
193+
});
194+
142195
describe('newsletterCodec', () => {
143196
it('serializes newsletter subscription state from a pattern field', () => {
144197
const predicate: FilterPredicate = {

apps/posts/src/views/members/member-fields.ts

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {DATE_FILTER_OPERATORS, DEFAULT_DATE_OPERATOR} from '../filters/filter-date';
2-
import {MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FIELD, MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FILTER} from './multiple-active-subscriptions';
2+
import {MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FIELD, MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FILTER, NO_MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FILTER} from './multiple-active-subscriptions';
33
import {dateCodec, numberCodec, scalarCodec, setCodec, textCodec} from '../filters/filter-codecs';
44
import {defineFields} from '../filters/filter-types';
55
import {escapeNqlString} from '../filters/filter-normalization';
@@ -97,23 +97,42 @@ const multipleActiveSubscriptionsCodec: FilterCodec = {
9797
parse(node, ctx) {
9898
const comparator = extractComparator(node as Record<string, unknown>);
9999

100-
// The API only supports the exact `count.active_stripe_customers:>1` form
101-
if (!comparator || comparator.field !== ctx.key || comparator.operator !== '$gt' || comparator.value !== 1) {
100+
if (!comparator || comparator.field !== ctx.key) {
102101
return null;
103102
}
104103

105-
return {
106-
field: ctx.key,
107-
operator: 'is-greater',
108-
values: [1]
109-
};
104+
if (comparator.operator === '$gt' && comparator.value === 1) {
105+
return {
106+
field: ctx.key,
107+
operator: 'is',
108+
values: [true]
109+
};
110+
}
111+
112+
if (comparator.operator === '$lt' && comparator.value === 2) {
113+
return {
114+
field: ctx.key,
115+
operator: 'is',
116+
values: [false]
117+
};
118+
}
119+
120+
return null;
110121
},
111122
serialize(predicate) {
112-
if (predicate.operator !== 'is-greater' || predicate.values[0] !== 1) {
123+
if (predicate.operator !== 'is') {
113124
return null;
114125
}
115126

116-
return [MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FILTER];
127+
if (predicate.values[0] === true) {
128+
return [MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FILTER];
129+
}
130+
131+
if (predicate.values[0] === false) {
132+
return [NO_MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FILTER];
133+
}
134+
135+
return null;
117136
}
118137
};
119138

@@ -424,14 +443,13 @@ const baseMemberFields = defineFields({
424443
},
425444
codec: setCodec({quoteStrings: true, serializeSingletonAsScalar: true})
426445
},
427-
// Intentionally absent from `useMemberFilterFields`, so it never appears
428-
// in the filter UI — it's only reachable via the multiple active
429-
// subscriptions banner's "View members" link.
430446
[MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FIELD]: {
431-
operators: ['is-greater'],
447+
operators: ['is'],
432448
ui: {
433449
label: 'Multiple active subscriptions',
434-
type: 'number'
450+
type: 'boolean',
451+
defaultValue: true,
452+
hideOperatorSelect: true
435453
},
436454
codec: multipleActiveSubscriptionsCodec
437455
}

apps/posts/src/views/members/members.tsx

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import {buildMemberListSearchParams, getMemberActiveColumns} from './member-quer
1515
import {canBulkDeleteMembers, shouldShowMembersLoading} from './members-view-state';
1616
import {checkStripeEnabled, getSettingValue, useBrowseSettings} from '@tryghost/admin-x-framework/api/settings';
1717
import {getSiteTimezone} from '@src/utils/get-site-timezone';
18-
import {isMultipleActiveSubscriptionsPredicate} from './multiple-active-subscriptions';
1918
import {shouldDelayMembersDateFilterHydration, useMembersFilterState} from './hooks/use-members-filter-state';
2019
import {useActiveMemberView, useMemberViews} from './hooks/use-member-views';
2120
import {useBrowseConfig} from '@tryghost/admin-x-framework/api/config';
@@ -52,12 +51,6 @@ const MembersPage: React.FC<MembersPageProps> = ({
5251
const [searchInput, setSearchInput] = useState(search);
5352
const [debouncedSearch] = useDebounce(searchInput, SEARCH_DEBOUNCE_MS);
5453

55-
// The multiple active subscriptions predicate is applied via the banner's
56-
// "View members" link and has no filter UI, so keep it out of the filter bar.
57-
const visibleFilters = useMemo(() => {
58-
return filters.filter(filter => !isMultipleActiveSubscriptionsPredicate(filter));
59-
}, [filters]);
60-
6154
const activeColumns = useMemo(() => {
6255
return getMemberActiveColumns(filters);
6356
}, [filters]);
@@ -93,7 +86,7 @@ const MembersPage: React.FC<MembersPageProps> = ({
9386
});
9487

9588
const totalMembers = data?.meta?.pagination?.total ?? 0;
96-
const hasFilters = visibleFilters.length > 0;
89+
const hasFilters = filters.length > 0;
9790
const shouldShowMobileSearchRow = showMobileSearch;
9891
const shouldShowFiltersRow = hasFilters;
9992
const shouldShowMemberControls = hasFilterOrSearch || totalMembers > 0;
@@ -162,7 +155,7 @@ const MembersPage: React.FC<MembersPageProps> = ({
162155
{!hasFilters && (
163156
<MembersFilters
164157
activeView={activeView}
165-
filters={visibleFilters}
158+
filters={filters}
166159
iconOnly={true}
167160
nql={nql}
168161
savedViews={savedViews}
@@ -202,7 +195,7 @@ const MembersPage: React.FC<MembersPageProps> = ({
202195
{shouldShowFiltersRow && (
203196
<MembersFilters
204197
activeView={activeView}
205-
filters={visibleFilters}
198+
filters={filters}
206199
nql={nql}
207200
savedViews={savedViews}
208201
onFiltersChange={setFilters}

apps/posts/src/views/members/multiple-active-subscriptions.test.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import {
2-
MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FIELD,
32
MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FILTER,
43
buildDismissedMultipleActiveSubscriptionsPreference,
54
getMultipleActiveSubscriptionsBannerPreference,
65
isMultipleActiveSubscriptionsFilter,
7-
isMultipleActiveSubscriptionsPredicate,
86
parseAccessibilityPreferences
97
} from './multiple-active-subscriptions';
108
import {describe, expect, it} from 'vitest';
@@ -16,11 +14,6 @@ describe('multiple active subscriptions helpers', () => {
1614
expect(isMultipleActiveSubscriptionsFilter(undefined)).toBe(false);
1715
});
1816

19-
it('matches predicates by field', () => {
20-
expect(isMultipleActiveSubscriptionsPredicate({field: MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FIELD})).toBe(true);
21-
expect(isMultipleActiveSubscriptionsPredicate({field: 'status'})).toBe(false);
22-
});
23-
2417
it('parses invalid accessibility JSON as empty preferences', () => {
2518
expect(parseAccessibilityPreferences('{invalid json')).toEqual({});
2619
expect(getMultipleActiveSubscriptionsBannerPreference('{invalid json')).toEqual({});

apps/posts/src/views/members/multiple-active-subscriptions.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {User} from '@tryghost/admin-x-framework/api/users';
33

44
export const MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FIELD = 'count.active_stripe_customers';
55
export const MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FILTER = `${MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FIELD}:>1`;
6+
export const NO_MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FILTER = `${MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FIELD}:<2`;
67

78
const MultipleActiveSubscriptionsBannerPreferenceSchema = z.looseObject({
89
dismissedCount: z.number().finite().optional().catch(undefined),
@@ -20,10 +21,6 @@ export function isMultipleActiveSubscriptionsFilter(nql: string | undefined): bo
2021
return nql === MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FILTER;
2122
}
2223

23-
export function isMultipleActiveSubscriptionsPredicate(predicate: {field: string}): boolean {
24-
return predicate.field === MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FIELD;
25-
}
26-
2724
export function parseAccessibilityPreferences(accessibility: string | null | undefined): AccessibilityPreferences {
2825
if (!accessibility) {
2926
return AccessibilityPreferencesSchema.parse({});

0 commit comments

Comments
 (0)