Skip to content
Open
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
61 changes: 56 additions & 5 deletions apps/posts/src/hooks/filter-sources/use-tier-value-source.ts
Original file line number Diff line number Diff line change
@@ -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<string> & {
hasMultipleTiers: boolean;
};

function toTierFilterOption(tier: Tier): FilterOption<string> {
return {
value: tier.id,
label: tier.active ? tier.name : `${tier.name}${ARCHIVED_TIER_LABEL_SUFFIX}`,
detail: tier.slug
};
}
Comment on lines +16 to +22
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for translation usage in apps/posts
rg -nP "\bt\(\s*['\"]" apps/posts/src | head -50
fd -t f -e ts -e tsx . apps/posts/src | xargs rg -nl "useTranslation|`@doist/react-interpolate`|import.*\bt\b.*i18n" 2>/dev/null | head -20

Repository: TryGhost/Ghost

Length of output: 40


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Locate the suffix constant definition/usages
rg -n "ARCHIVED_TIER_LABEL_SUFFIX" -S apps/posts/src

# 2) Inspect the actual implementation around the reported lines
sed -n '1,120p' apps/posts/src/hooks/filter-sources/use-tier-value-source.ts

# 3) Broader check: find i18n usage patterns in apps/posts/src (not just `t(`)
rg -n "useTranslation|`@doist/react-interpolate`|react-interpolate|i18n|next-i18next|formatMessage|intl|translate" apps/posts/src || true

# 4) Check for any `t('...')` usage and capture a few examples
rg -nP "\bt\(\s*['\"]" apps/posts/src | head -50 || true

# 5) If no `t(` usage, check for direct user-facing English strings near tiers/filters
rg -n "(archived|Archived|\(archived\)|filter.*tier|tier.*(archived|archived))" apps/posts/src || true

Repository: TryGhost/Ghost

Length of output: 8507


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Translation helper presence checks in apps/posts
rg -n --hidden -S "useTranslation|react-i18next|i18next|formatMessage|useIntl|next-intl|`@doist/react-interpolate`|react-interpolate|declare.*t\(|\bt\(\s*[`'\"]|\bIntl\." apps/posts/src || true

# Specifically check for any t() usage patterns (including template literals)
rg -nP "\bt\(\s*[`'\"].{0,200}\)" apps/posts/src || true

# Check for translation dictionaries / message IDs (common in this codebase style)
rg -n "messages|i18n\.[a-zA-Z_]+|translations|localized|locale" apps/posts/src || true

Repository: TryGhost/Ghost

Length of output: 4268


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== t('...') usage in apps/posts/src =="
rg -n "\bt\(\s*['\"]" apps/posts/src || true

echo "== translation helper usage (various) in apps/posts/src =="
rg -n "useTranslation\(|react-i18next|i18next|formatMessage|useIntl\(|next-intl|`@doist/react-interpolate`|react-interpolate" apps/posts/src || true

echo "== literal archived suffix usage in apps/posts/src =="
rg -n "(\(archived\)|archived\)|' \(archived\)'|\" \(archived\)\")" apps/posts/src || true

echo "== constant usage =="
rg -n "ARCHIVED_TIER_LABEL_SUFFIX" apps/posts/src || true

Repository: TryGhost/Ghost

Length of output: 607


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "posts\.tiers\.local" apps/posts/src || true
rg -n "posts\.tiers" apps/posts/src || true
rg -n "FilterOption<" apps/posts/src || true
rg -n "\.detail\b|\.label\b" apps/posts/src/views/filters apps/posts/src/views/members apps/posts/src/components apps/posts/src/hooks/filter-sources || true

Repository: TryGhost/Ghost

Length of output: 4121


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Look for other i18n patterns used in posts UI (not only t('...'))
rg -n "i18n=\{|\bi18n=\{|\bi18n:\s*\{|<.*\bi18n=" apps/posts/src || true
rg -n "from '.*i18n|from \".*i18n|from '.*translate|from \".*translate" apps/posts/src || true
rg -n "translate\(|formatMessage\(|intl\." apps/posts/src || true

Repository: TryGhost/Ghost

Length of output: 784


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '130,190p' apps/posts/src/components/label-picker/label-filter-renderer.tsx
sed -n '220,270p' apps/posts/src/views/members/use-member-filter-fields.ts

Repository: TryGhost/Ghost

Length of output: 3173


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find where tierValueSource is consumed in the member filter fields hook
rg -n "tierValueSource" apps/posts/src/views/members/use-member-filter-fields.ts

# Inspect the surrounding logic (small targeted ranges)
sed -n '1,120p' apps/posts/src/views/members/use-member-filter-fields.ts
sed -n '120,240p' apps/posts/src/views/members/use-member-filter-fields.ts
sed -n '240,360p' apps/posts/src/views/members/use-member-filter-fields.ts
sed -n '360,520p' apps/posts/src/views/members/use-member-filter-fields.ts

Repository: TryGhost/Ghost

Length of output: 16029


Localize the archived tier label suffix instead of hardcoding English

ARCHIVED_TIER_LABEL_SUFFIX is hardcoded to ' (archived)' and concatenated into FilterOption.label:

function toTierFilterOption(tier: Tier): FilterOption<string> {
    return {
        value: tier.id,
        label: tier.active ? tier.name : `${tier.name}${ARCHIVED_TIER_LABEL_SUFFIX}`,
        detail: tier.slug
    };
}

Because this string is constructed outside the app’s i18n layer, the “(archived)” part won’t be translated for localized users. Build the archived label via the app’s translation mechanism instead, using a single translatable string with interpolation (e.g. {name} (archived)).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/posts/src/hooks/filter-sources/use-tier-value-source.ts` around lines 16
- 22, toTierFilterOption currently appends the hardcoded
ARCHIVED_TIER_LABEL_SUFFIX to build FilterOption.label; change this to use the
app's i18n translator so the archived suffix is localized (e.g., use the
translation key with interpolation like "{name} (archived)"). Update the
toTierFilterOption implementation to call the translation function (or accept a
t/translate argument) and pass tier.name into the interpolated string instead of
concatenating ARCHIVED_TIER_LABEL_SUFFIX, and remove reliance on the hardcoded
ARCHIVED_TIER_LABEL_SUFFIX constant so labels are produced via i18n.


function buildTierFilterOptions(tiers: Tier[] = []): FilterOption<string>[] {
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<string>[] = []): ValueSource<string> {
const useLocalTierValueSource = createLocalValueSource<FilterOption<string>, string>({
id: 'posts.tiers.local',
useItems: () => ({
data: options,
isLoading: false
data: isLoadingTierOptions ? undefined : options,
isLoading: isLoadingTierOptions
}),
toOption: option => option
});

return useLocalTierValueSource();
return {
...useLocalTierValueSource(),
hasMultipleTiers
};
}
8 changes: 2 additions & 6 deletions apps/posts/src/views/members/components/members-filters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {getSiteTimezone} from '@src/utils/get-site-timezone';
import {useBrowseConfig} from '@tryghost/admin-x-framework/api/config';
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';
Expand Down Expand Up @@ -56,7 +55,6 @@ const MembersFilters: React.FC<MembersFiltersProps> = ({
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({});
Expand All @@ -71,11 +69,8 @@ const MembersFilters: React.FC<MembersFiltersProps> = ({
const siteTimezone = getSiteTimezone(settings);
const giftSubscriptionsEnabled = configData?.config?.labs?.giftSubscriptions === true;

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);
Expand All @@ -101,7 +96,8 @@ const MembersFilters: React.FC<MembersFiltersProps> = ({
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,
Expand Down
17 changes: 17 additions & 0 deletions apps/posts/src/views/members/use-member-filter-fields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,23 @@ describe('useMemberFilterFields', () => {
expect(statusField?.options?.map(o => o.value)).toEqual(['paid', 'free', 'comped']);
});

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('includes the gift status option when giftSubscriptionsEnabled is true', () => {
const {result} = renderHook(() => useMemberFilterFields({
paidMembersEnabled: true,
Expand Down
236 changes: 236 additions & 0 deletions apps/posts/test/unit/hooks/use-tier-value-source.test.tsx
Original file line number Diff line number Diff line change
@@ -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>): 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<typeof vi.fn>;
} = {}) {
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();
});
});
Loading