Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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 @@ -63,6 +63,10 @@ const features: Feature[] = [{
title: 'Smarter Counts',
description: 'Use optimized COUNT queries for API pagination when safe',
flag: 'smarterCounts'
}, {
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 @@ -230,25 +230,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 @@ -257,7 +257,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
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', {
preserveMultipleActiveStripeCustomersFilter: 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
34 changes: 26 additions & 8 deletions apps/posts/src/views/members/hooks/use-members-filter-state.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {Filter} from '@tryghost/shade/patterns';
import {hasTimezoneSensitiveMemberFilter, parseMemberFilter, serializeMemberFilters} from '../member-filter-query';
import {isMultipleActiveStripeCustomersFilter} from '../multiple-active-stripe-customers';
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {useSearchParams} from 'react-router';

Expand All @@ -23,6 +24,11 @@ interface ToSearchParamsOptions {
filters: Filter[];
search: string;
timezone: string;
rawFilter?: string;
}

interface UseMembersFilterStateOptions {
preserveMultipleActiveStripeCustomersFilter?: boolean;
}

export function shouldDelayMembersDateFilterHydration(
Expand All @@ -33,9 +39,9 @@ export function shouldDelayMembersDateFilterHydration(
return Boolean(filterParam) && isSettingsLoading && !hasResolvedTimezone && hasTimezoneSensitiveMemberFilter(filterParam);
}

function toSearchParams({baseSearchParams, filters, search, timezone}: ToSearchParamsOptions): URLSearchParams {
function toSearchParams({baseSearchParams, filters, search, timezone, rawFilter}: ToSearchParamsOptions): URLSearchParams {
const params = new URLSearchParams(baseSearchParams);
const filter = serializeMemberFilters(filters, timezone);
const filter = rawFilter || serializeMemberFilters(filters, timezone);

params.delete('filter');
params.delete('search');
Expand All @@ -51,24 +57,34 @@ function toSearchParams({baseSearchParams, filters, search, timezone}: ToSearchP
return params;
}

export function useMembersFilterState(timezone: string): UseMembersFilterStateReturn {
export function useMembersFilterState(timezone: string, hookOptions: UseMembersFilterStateOptions = {}): UseMembersFilterStateReturn {
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 preserveMultipleActiveStripeCustomersFilter = hookOptions.preserveMultipleActiveStripeCustomersFilter === true;

const parsedFilters = useMemo(() => {
return parseMemberFilter(filterParam, timezone);
}, [filterParam, timezone]);
const [filters, setDraftFilters] = useState<Filter[]>(parsedFilters);
const preservedRawFilter = useMemo(() => {
return preserveMultipleActiveStripeCustomersFilter && filters.length === 0 && isMultipleActiveStripeCustomersFilter(filterParam)
? filterParam
: undefined;
}, [filterParam, filters.length, preserveMultipleActiveStripeCustomersFilter]);

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

const nql = useMemo(() => {
if (preservedRawFilter) {
return preservedRawFilter;
}

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

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

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

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

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

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