Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ const features: Feature[] = [{
title: 'Get helper deduplication',
description: 'Deduplicate identical {{#get}} helper queries within a single request to avoid redundant database calls',
flag: 'getHelperDeduplication'
}, {
title: 'Multiple subscriptions filter',
description: 'Show a members notification and filter for multiple active Stripe subscriptions',
flag: 'multipleSubsFilter'
}];

const AlphaFeatures: React.FC = () => {
Expand Down
12 changes: 6 additions & 6 deletions apps/posts/src/views/members/components/members-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, {useCallback, useState} from 'react';
import {AddLabelModal, DeleteModal, ImportMembersModal, RemoveLabelModal, UnsubscribeModal} from './bulk-action-modals';
import {Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger} from '@tryghost/shade/components';
import {type ImportResponse} from './bulk-action-modals/import-members/state';
import {LucideIcon} from '@tryghost/shade/utils';
import {LucideIcon, formatNumber} from '@tryghost/shade/utils';
import {blobDownloadFromEndpoint} from '@tryghost/admin-x-framework/helpers';
import {buildMemberOperationParams} from '../member-query-params';
import {buildMembersUrl} from '../member-route';
Expand Down Expand Up @@ -229,25 +229,25 @@ const MembersActions: React.FC<MembersActionsProps> = ({
<DropdownMenuItem onClick={handleExport}>
<LucideIcon.Download className="mr-2 size-4" />
{hasFilterOrSearch
? `Export ${memberCount.toLocaleString()} members`
? `Export ${formatNumber(memberCount)} members`
: 'Export all members'}
</DropdownMenuItem>

<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setShowAddLabelModal(true)}>
<LucideIcon.Tags className="mr-2 size-4" />
Add label to {memberCount.toLocaleString()} {memberCount === 1 ? 'member' : 'members'}
Add label to {formatNumber(memberCount)} {memberCount === 1 ? 'member' : 'members'}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setShowRemoveLabelModal(true)}>
<LucideIcon.Tag className="mr-2 size-4" />
Remove label from {memberCount.toLocaleString()} {memberCount === 1 ? 'member' : 'members'}
Remove label from {formatNumber(memberCount)} {memberCount === 1 ? 'member' : 'members'}
</DropdownMenuItem>
<DropdownMenuItem
disabled={isLoadingNewsletters}
onClick={() => setShowUnsubscribeModal(true)}
>
<LucideIcon.MailX className="mr-2 size-4" />
Unsubscribe {memberCount.toLocaleString()} {memberCount === 1 ? 'member' : 'members'}
Unsubscribe {formatNumber(memberCount)} {memberCount === 1 ? 'member' : 'members'}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
Expand All @@ -256,7 +256,7 @@ const MembersActions: React.FC<MembersActionsProps> = ({
onClick={() => setShowDeleteModal(true)}
>
<LucideIcon.Trash2 className="mr-2 size-4" />
Delete {memberCount.toLocaleString()} {memberCount === 1 ? 'member' : 'members'}
Delete {formatNumber(memberCount)} {memberCount === 1 ? 'member' : 'members'}
</DropdownMenuItem>
</>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {Banner, Button} from '@tryghost/shade/components';
import {formatNumber} from '@tryghost/shade/utils';
import {useMultipleActiveSubscriptionsBanner} from '../hooks/use-multiple-active-subscriptions-banner';

interface MultipleActiveSubscriptionsBannerProps {
nql?: string;
search: string;
}

const MultipleActiveSubscriptionsBanner = ({
nql,
search
}: MultipleActiveSubscriptionsBannerProps) => {
const banner = useMultipleActiveSubscriptionsBanner({
nql,
search
});

if (!banner.shouldShow) {
return null;
}

return (
<Banner
role="status"
variant="warning"
{...(banner.canDismiss ? {
dismissible: true as const,
onDismiss: banner.handleDismiss
} : {
dismissible: false as const
})}
>
<div className="flex flex-col gap-3 pr-8 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm font-medium">
We found {formatNumber(banner.count)} {banner.count === 1 ? 'member' : 'members'} with more than one active paid subscription.{' '}
<a className="font-semibold underline" href="https://ghost.org/help/duplicate-subscription-warning/" rel="noopener noreferrer" target="_blank">Learn more</a>
</div>
{banner.canDismiss && (
<Button size="sm" variant="outline" onClick={banner.handleViewMembers}>
View members
</Button>
)}
</div>
</Banner>
);
};

export default MultipleActiveSubscriptionsBanner;
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,52 @@ describe('useMembersFilterState', () => {
expect(result.current.hasFilterOrSearch).toBe(false);
});

it('preserves the multiple active Stripe customers raw filter', async () => {
const {result} = renderHook(() => {
const state = useMembersFilterState('UTC', {
preserveMultipleActiveSubscriptionsFilter: true
});
const [searchParams] = useSearchParams();

return {
...state,
query: searchParams.toString()
};
}, {
wrapper: createWrapper('/?filter=count.active_stripe_customers%3A%3E1')
});

await waitFor(() => {
expect(result.current.nql).toBe('count.active_stripe_customers:>1');
});

expect(result.current.filters).toEqual([]);
expect(result.current.query).toBe('filter=count.active_stripe_customers%3A%3E1');
expect(result.current.hasFilterOrSearch).toBe(true);
});

it('drops the multiple active Stripe customers raw filter when preservation is disabled', async () => {
const {result} = renderHook(() => {
const state = useMembersFilterState('UTC');
const [searchParams] = useSearchParams();

return {
...state,
query: searchParams.toString()
};
}, {
wrapper: createWrapper('/?filter=count.active_stripe_customers%3A%3E1')
});

await waitFor(() => {
expect(result.current.query).toBe('');
});

expect(result.current.filters).toEqual([]);
expect(result.current.nql).toBeUndefined();
expect(result.current.hasFilterOrSearch).toBe(false);
});

it('retains supported filters and rewrites mixed URLs canonically', async () => {
const {result} = renderHook(() => {
const state = useMembersFilterState('UTC');
Expand Down
44 changes: 32 additions & 12 deletions apps/posts/src/views/members/hooks/use-members-filter-state.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {Filter} from '@tryghost/shade/patterns';
import {getMemberFields} from '../member-fields';
import {hasTimezoneSensitiveMemberFilter, isPredicateEnabled, parseMemberFilter, serializeMemberFilters} from '../member-filter-query';
import {isMultipleActiveSubscriptionsFilter} from '../multiple-active-subscriptions';
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {useSearchParams} from 'react-router';
import type {MemberFields} from '../member-fields';
Expand All @@ -26,6 +27,11 @@ interface ToSearchParamsOptions {
search: string;
timezone: string;
fields: MemberFields;
rawFilter?: string;
}

interface UseMembersFilterStateOptions {
preserveMultipleActiveSubscriptionsFilter?: boolean;
}

/**
Expand All @@ -43,10 +49,13 @@ export function shouldDelayMembersDateFilterHydration(
return Boolean(filterParam) && isLoadingDependencies && !hasResolvedDependencies && hasTimezoneSensitiveMemberFilter(filterParam);
}

function toSearchParams({baseSearchParams, filters, search, timezone, fields}: ToSearchParamsOptions): URLSearchParams {
function getEnabledFilters(filters: Filter[], fields: MemberFields): Filter[] {
return filters.filter(predicate => isPredicateEnabled(predicate, fields));
}

function toSearchParams({baseSearchParams, filters, search, timezone, fields, rawFilter}: ToSearchParamsOptions): URLSearchParams {
const params = new URLSearchParams(baseSearchParams);
const enabled = filters.filter(predicate => isPredicateEnabled(predicate, fields));
const filter = serializeMemberFilters(enabled, timezone);
const filter = rawFilter || serializeMemberFilters(getEnabledFilters(filters, fields), timezone);

params.delete('filter');
params.delete('search');
Expand All @@ -62,26 +71,35 @@ function toSearchParams({baseSearchParams, filters, search, timezone, fields}: T
return params;
}

export function useMembersFilterState(timezone: string): UseMembersFilterStateReturn {
export function useMembersFilterState(timezone: string, hookOptions: UseMembersFilterStateOptions = {}): UseMembersFilterStateReturn {
const fields = useMemo(() => getMemberFields(), []);
const [searchParams, setSearchParams] = useSearchParams();
const lastWrittenQueryRef = useRef<string | null>(null);
const filterParam = useMemo(() => searchParams.get('filter') ?? undefined, [searchParams]);
const currentQuery = useMemo(() => searchParams.toString(), [searchParams]);
const preserveMultipleActiveSubscriptionsFilter = hookOptions.preserveMultipleActiveSubscriptionsFilter === true;

const parsedFilters = useMemo(() => {
return parseMemberFilter(filterParam, timezone).filter(predicate => isPredicateEnabled(predicate, fields));
return getEnabledFilters(parseMemberFilter(filterParam, timezone), fields);
}, [filterParam, timezone, fields]);
const [filters, setDraftFilters] = useState<Filter[]>(parsedFilters);
const preservedRawFilter = useMemo(() => {
return preserveMultipleActiveSubscriptionsFilter && filters.length === 0 && isMultipleActiveSubscriptionsFilter(filterParam)
? filterParam
: undefined;
}, [filterParam, filters.length, preserveMultipleActiveSubscriptionsFilter]);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks a bit fishy to me. I think we should have a general way to preserve additional filter parameters without coupling this to the multiple active subscriptions banner. Ideally, we would just have that banner link to the member list view with the appropriate filter query state, and the rest would be driven by the URL. No preserveMultipleActiveSubscriptionsFilter or preservedRawFilter shenanigans.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we would just have that banner link to the member list view with the appropriate filter query state

Do you mean without having the filter in the URL? One of the main use-cases for the URL approach is to allow it to be bookmarked and linked to from our help docs.


const search = useMemo(() => {
return searchParams.get('search') ?? '';
}, [searchParams]);

const nql = useMemo(() => {
const enabled = filters.filter(predicate => isPredicateEnabled(predicate, fields));
return serializeMemberFilters(enabled, timezone);
}, [filters, timezone, fields]);
if (preservedRawFilter) {
return preservedRawFilter;
}

return serializeMemberFilters(getEnabledFilters(filters, fields), timezone);
}, [filters, preservedRawFilter, timezone, fields]);

useEffect(() => {
if (currentQuery !== lastWrittenQueryRef.current) {
Expand All @@ -100,15 +118,16 @@ export function useMembersFilterState(timezone: string): UseMembersFilterStateRe
filters,
search,
timezone,
fields
fields,
rawFilter: preservedRawFilter
});
const nextQuery = nextParams.toString();

if (nextQuery !== currentQuery) {
lastWrittenQueryRef.current = nextQuery;
setSearchParams(nextParams, {replace: true});
}
}, [currentQuery, filters, search, searchParams, setSearchParams, timezone, fields]);
}, [currentQuery, filters, preservedRawFilter, search, searchParams, setSearchParams, timezone, fields]);

const setFilters = useCallback((nextFilters: Filter[], setOptions: SetFiltersOptions = {}) => {
const replace = setOptions.replace ?? true;
Expand All @@ -132,12 +151,13 @@ export function useMembersFilterState(timezone: string): UseMembersFilterStateRe
filters,
search: nextSearch,
timezone,
fields
fields,
rawFilter: preservedRawFilter
});

lastWrittenQueryRef.current = nextParams.toString();
setSearchParams(nextParams, {replace});
}, [filters, searchParams, setSearchParams, timezone, fields]);
}, [filters, preservedRawFilter, searchParams, setSearchParams, timezone, fields]);

const clearFilters = useCallback(({replace = true}: SetFiltersOptions = {}) => {
const nextParams = toSearchParams({
Expand Down
Loading
Loading