From 38338a1b84898e6678b11a2513f0a0a2634fc230 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Wed, 27 May 2026 13:40:39 +0200 Subject: [PATCH] Added archived tiers to members tier filter ref https://linear.app/ghost/issue/BER-3588/add-archived-tiers-to-tiers-filter Members can keep archived paid tiers, so the filter needs to preserve those options while separating them from active tiers. --- .../filter-sources/use-tier-value-source.ts | 61 ++++- .../members/components/members-filters.tsx | 8 +- .../members/use-member-filter-fields.test.ts | 17 ++ .../unit/hooks/use-tier-value-source.test.tsx | 236 ++++++++++++++++++ 4 files changed, 311 insertions(+), 11 deletions(-) create mode 100644 apps/posts/test/unit/hooks/use-tier-value-source.test.tsx diff --git a/apps/posts/src/hooks/filter-sources/use-tier-value-source.ts b/apps/posts/src/hooks/filter-sources/use-tier-value-source.ts index 3081ab0c08c..340e75e978d 100644 --- a/apps/posts/src/hooks/filter-sources/use-tier-value-source.ts +++ b/apps/posts/src/hooks/filter-sources/use-tier-value-source.ts @@ -1,15 +1,66 @@ -import {FilterOption, ValueSource} from '@tryghost/shade/patterns'; import {createLocalValueSource} from './create-local-value-source'; +import {useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers'; +import {useEffect, useMemo} from 'react'; +import type {FilterOption, ValueSource} from '@tryghost/shade/patterns'; +import type {Tier} from '@tryghost/admin-x-framework/api/tiers'; + +const TIER_FILTER_PAGE_LIMIT = '100'; +const TIER_FILTER_TYPE = 'type:paid'; +const ARCHIVED_TIER_LABEL_SUFFIX = ' (archived)'; +const EMPTY_TIERS: Tier[] = []; + +type TierValueSource = ValueSource & { + hasMultipleTiers: boolean; +}; + +function toTierFilterOption(tier: Tier): FilterOption { + return { + value: tier.id, + label: tier.active ? tier.name : `${tier.name}${ARCHIVED_TIER_LABEL_SUFFIX}`, + detail: tier.slug + }; +} + +function buildTierFilterOptions(tiers: Tier[] = []): FilterOption[] { + const activeTiers = tiers.filter(tier => tier.active); + const archivedTiers = tiers.filter(tier => !tier.active); + + return [ + ...activeTiers.map(toTierFilterOption), + ...archivedTiers.map(toTierFilterOption) + ]; +} + +export function useTierValueSource(): TierValueSource { + const { + data: tiersData, + fetchNextPage, + isFetchingNextPage, + isLoading + } = useBrowseTiers({searchParams: {filter: TIER_FILTER_TYPE, limit: TIER_FILTER_PAGE_LIMIT}}); + + useEffect(() => { + if (tiersData?.isEnd === false && !isFetchingNextPage) { + void fetchNextPage(); + } + }, [fetchNextPage, isFetchingNextPage, tiersData?.isEnd]); + + const tiers = tiersData?.tiers ?? EMPTY_TIERS; + const isLoadingTierOptions = isLoading || isFetchingNextPage || tiersData?.isEnd === false; + const options = useMemo(() => buildTierFilterOptions(tiers), [tiers]); + const hasMultipleTiers = tiers.length > 1 || tiersData?.isEnd === false; -export function useTierValueSource(options: FilterOption[] = []): ValueSource { const useLocalTierValueSource = createLocalValueSource, string>({ id: 'posts.tiers.local', useItems: () => ({ - data: options, - isLoading: false + data: isLoadingTierOptions ? undefined : options, + isLoading: isLoadingTierOptions }), toOption: option => option }); - return useLocalTierValueSource(); + return { + ...useLocalTierValueSource(), + hasMultipleTiers + }; } diff --git a/apps/posts/src/views/members/components/members-filters.tsx b/apps/posts/src/views/members/components/members-filters.tsx index d9a41e2c233..a68c5320273 100644 --- a/apps/posts/src/views/members/components/members-filters.tsx +++ b/apps/posts/src/views/members/components/members-filters.tsx @@ -13,7 +13,6 @@ import {getSettingValue, useBrowseSettings} from '@tryghost/admin-x-framework/ap import {getSiteTimezone} from '@src/utils/get-site-timezone'; import {useBrowseNewsletters} from '@tryghost/admin-x-framework/api/newsletters'; import {useBrowseOffers} from '@tryghost/admin-x-framework/api/offers'; -import {useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers'; import {useEmailPostValueSource} from '@src/hooks/filter-sources/use-email-post-value-source'; import {useLabelValueSource} from '@src/hooks/filter-sources/use-label-value-source'; import {usePostResourceValueSource} from '@src/hooks/filter-sources/use-post-resource-value-source'; @@ -55,7 +54,6 @@ const MembersFilters: React.FC = ({ activeView, iconOnly = false }) => { - const {data: tiersData} = useBrowseTiers({searchParams: {limit: '100'}}); const {data: offersData} = useBrowseOffers({}); const {data: newslettersData} = useBrowseNewsletters({searchParams: {limit: '100'}}); const {data: settingsData} = useBrowseSettings({}); @@ -68,11 +66,8 @@ const MembersFilters: React.FC = ({ const emailTrackClicks = getSettingValue(settings, 'email_track_clicks') === true; const siteTimezone = getSiteTimezone(settings); - const tiers = tiersData?.tiers || []; const newsletters = newslettersData?.newsletters || []; const offers = useMemo(() => offersData?.offers ?? EMPTY_OFFERS, [offersData?.offers]); - const activePaidTiers = tiers.filter(tier => tier.type === 'paid' && tier.active); - const hasMultipleTiers = activePaidTiers.length > 1; const offersOptions = useMemo(() => { return buildOfferOptions(offers); @@ -98,7 +93,8 @@ const MembersFilters: React.FC = ({ const postValueSource = usePostResourceValueSource(); const emailValueSource = useEmailPostValueSource(); const labelValueSource = useLabelValueSource(); - const tierValueSource = useTierValueSource(activePaidTiers.map(tier => ({value: tier.id, label: tier.name, detail: tier.slug}))); + const tierValueSource = useTierValueSource(); + const hasMultipleTiers = tierValueSource.hasMultipleTiers; const filterFields = useMemberFilterFields({ newsletters, diff --git a/apps/posts/src/views/members/use-member-filter-fields.test.ts b/apps/posts/src/views/members/use-member-filter-fields.test.ts index 93fb8da4b24..1d9de40f7d0 100644 --- a/apps/posts/src/views/members/use-member-filter-fields.test.ts +++ b/apps/posts/src/views/members/use-member-filter-fields.test.ts @@ -171,6 +171,23 @@ describe('useMemberFilterFields', () => { expect(statusField?.options?.map(o => o.value)).toEqual(['paid', 'free', 'comped', 'gift']); }); + it('includes the membership tier filter when multiple paid tiers are available', () => { + const {result} = renderHook(() => useMemberFilterFields({ + paidMembersEnabled: true, + hasMultipleTiers: true, + tierValueSource, + siteTimezone: 'UTC' + })); + + const subscriptionFields = result.current.find(group => group.group === 'Subscription')?.fields ?? []; + const tierField = subscriptionFields.find(field => field.key === 'tier_id'); + + expect(subscriptionFields.map(field => field.key)).toContain('tier_id'); + expect(tierField).toMatchObject({ + valueSource: tierValueSource + }); + }); + it('hydrates grouped retention offers on the offer field', () => { const {result} = renderHook(() => useMemberFilterFields({ paidMembersEnabled: true, diff --git a/apps/posts/test/unit/hooks/use-tier-value-source.test.tsx b/apps/posts/test/unit/hooks/use-tier-value-source.test.tsx new file mode 100644 index 00000000000..24b45d604e8 --- /dev/null +++ b/apps/posts/test/unit/hooks/use-tier-value-source.test.tsx @@ -0,0 +1,236 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {renderHook} from '@testing-library/react'; +import {useTierValueSource} from '@src/hooks/filter-sources/use-tier-value-source'; +import type {Tier} from '@tryghost/admin-x-framework/api/tiers'; + +const {mockUseBrowseTiers} = vi.hoisted(() => ({ + mockUseBrowseTiers: vi.fn() +})); + +vi.mock('@tryghost/admin-x-framework/api/tiers', () => ({ + useBrowseTiers: mockUseBrowseTiers +})); + +function tier(overrides: Partial): Tier { + return { + id: 'tier-id', + name: 'Tier', + description: null, + slug: 'tier', + active: true, + type: 'paid', + welcome_page_url: null, + created_at: '2024-01-01T00:00:00.000Z', + updated_at: '2024-01-01T00:00:00.000Z', + visibility: 'public', + benefits: [], + trial_days: 0, + ...overrides + }; +} + +function mockTiersResponse({ + tiers = [], + isEnd = true, + isFetchingNextPage = false, + isLoading = false, + fetchNextPage = vi.fn() +}: { + tiers?: Tier[]; + isEnd?: boolean; + isFetchingNextPage?: boolean; + isLoading?: boolean; + fetchNextPage?: ReturnType; +} = {}) { + mockUseBrowseTiers.mockReturnValue({ + data: { + tiers, + isEnd + }, + fetchNextPage, + isFetchingNextPage, + isLoading + }); +} + +describe('useTierValueSource', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('exposes active and archived tier options in display order', () => { + mockTiersResponse({ + tiers: [ + tier({id: 'archived', name: 'Archived Gold', slug: 'archived-gold', active: false}), + tier({id: 'active', name: 'Active Gold', slug: 'active-gold', active: true}) + ] + }); + + const {result} = renderHook(() => { + const source = useTierValueSource(); + return source.useOptions({query: '', selectedValues: []}); + }); + + expect(result.current.options).toEqual([ + { + value: 'active', + label: 'Active Gold', + detail: 'active-gold' + }, + { + value: 'archived', + label: 'Archived Gold (archived)', + detail: 'archived-gold' + } + ]); + }); + + it('counts fetched paid tiers when deciding if the tier filter is available', () => { + const cases = [ + { + tiers: [ + tier({id: 'active', active: true}), + tier({id: 'archived', active: false}) + ], + expected: true + }, + { + tiers: [ + tier({id: 'archived-1', active: false}), + tier({id: 'archived-2', active: false}) + ], + expected: true + }, + { + tiers: [ + tier({id: 'archived', active: false}) + ], + expected: false + }, + { + tiers: [], + expected: false + } + ]; + + for (const testCase of cases) { + mockTiersResponse({tiers: testCase.tiers}); + + const {result} = renderHook(() => useTierValueSource()); + + expect(result.current.hasMultipleTiers).toBe(testCase.expected); + } + }); + + it('treats an incomplete tiers response as multiple tiers', () => { + mockTiersResponse({ + tiers: [ + tier({id: 'active', active: true}) + ], + isEnd: false + }); + + const {result} = renderHook(() => useTierValueSource()); + + expect(result.current.hasMultipleTiers).toBe(true); + }); + + it('fetches paid tiers with a numeric page limit and exposes tier options', () => { + mockTiersResponse({ + tiers: [ + tier({id: 'active-tier', name: 'Active Gold', slug: 'active-gold', active: true}), + tier({id: 'archived-tier', name: 'Archived Gold', slug: 'archived-gold', active: false}) + ] + }); + + const {result} = renderHook(() => { + const source = useTierValueSource(); + return { + hasMultipleTiers: source.hasMultipleTiers, + state: source.useOptions({query: '', selectedValues: []}) + }; + }); + + expect(mockUseBrowseTiers).toHaveBeenCalledWith({searchParams: {filter: 'type:paid', limit: '100'}}); + expect(result.current.hasMultipleTiers).toBe(true); + expect(result.current.state.options).toEqual([ + { + value: 'active-tier', + label: 'Active Gold', + detail: 'active-gold' + }, + { + value: 'archived-tier', + label: 'Archived Gold (archived)', + detail: 'archived-gold' + } + ]); + }); + + it('searches local tier options by archived label text', () => { + mockTiersResponse({ + tiers: [ + tier({id: 'active-tier', name: 'Active Gold', slug: 'active-gold', active: true}), + tier({id: 'archived-tier', name: 'Archived Gold', slug: 'archived-gold', active: false}) + ] + }); + + const {result} = renderHook(() => { + const source = useTierValueSource(); + return source.useOptions({query: 'archived', selectedValues: []}); + }); + + expect(result.current.options).toEqual([ + { + value: 'archived-tier', + label: 'Archived Gold (archived)', + detail: 'archived-gold' + } + ]); + }); + + it('keeps options in the initial load state while additional tier pages are loading', () => { + mockTiersResponse({ + tiers: [ + tier({id: 'active-tier', name: 'Active Gold', slug: 'active-gold', active: true}) + ], + isEnd: false, + isFetchingNextPage: true + }); + + const {result} = renderHook(() => { + const source = useTierValueSource(); + return source.useOptions({query: '', selectedValues: []}); + }); + + expect(result.current.options).toEqual([]); + expect(result.current.isInitialLoad).toBe(true); + }); + + it('loads the next page until the tiers response is complete', () => { + const fetchNextPage = vi.fn(); + mockTiersResponse({ + tiers: [tier({id: 'active-tier'})], + isEnd: false, + fetchNextPage + }); + + renderHook(() => useTierValueSource()); + + expect(fetchNextPage).toHaveBeenCalledOnce(); + }); + + it('does not load another page while a tiers page is already loading', () => { + const fetchNextPage = vi.fn(); + mockTiersResponse({ + tiers: [tier({id: 'active-tier'})], + isEnd: false, + isFetchingNextPage: true, + fetchNextPage + }); + + renderHook(() => useTierValueSource()); + + expect(fetchNextPage).not.toHaveBeenCalled(); + }); +});