Skip to content

Commit f00488a

Browse files
committed
Fixed comment filter migration regressions
ref https://linear.app/ghost/issue/BER-3505/migrate-comment-moderation-filters-to-new-filter-primitives Aligned comments filtering with members by preserving draft filter state, using site timezone date defaults, keeping thread as URL state, and isolating temporary legacy URL fallback code.
1 parent a90af6a commit f00488a

8 files changed

Lines changed: 221 additions & 19 deletions

File tree

apps/posts/src/views/comments/comments.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ const CommentsPage: React.FC<{timezone: string; singleCommentId?: string}> = ({
2828
}) => {
2929
const [searchParams, setSearchParams] = useSearchParams();
3030
const {filters, nql, setFilters} = useFilterState(timezone);
31-
const threadParam = searchParams.get('thread') ?? undefined;
3231
const handleAddFilter = useCallback((field: string, value: string, operator: string = 'is') => {
3332
const nextFilters = [
3433
...filters.filter(filter => filter.field !== field),
@@ -74,7 +73,6 @@ const CommentsPage: React.FC<{timezone: string; singleCommentId?: string}> = ({
7473
hasNextPage
7574
} = useBrowseComments({
7675
searchParams: {
77-
...(threadParam && !singleCommentId ? {thread: threadParam} : {}),
7876
...(effectiveFilter ? {filter: effectiveFilter} : {})
7977
},
8078
keepPreviousData: true
@@ -88,6 +86,7 @@ const CommentsPage: React.FC<{timezone: string; singleCommentId?: string}> = ({
8886
{!singleCommentId && (
8987
<CommentsFilters
9088
filters={filters}
89+
siteTimezone={timezone}
9190
onFiltersChange={setFilters}
9291
/>
9392
)}

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,21 @@ import {usePostResourceValueSource} from '@src/hooks/filter-sources/use-post-res
77

88
interface CommentsFiltersProps {
99
filters: Filter[];
10+
siteTimezone: string;
1011
onFiltersChange: (filters: Filter[]) => void;
1112
}
1213

1314
const CommentsFilters: React.FC<CommentsFiltersProps> = ({
1415
filters,
16+
siteTimezone,
1517
onFiltersChange
1618
}) => {
1719
const postValueSource = usePostResourceValueSource();
1820
const memberValueSource = useMemberValueSource();
1921
const filterFields = useCommentFilterFields({
2022
memberValueSource,
21-
postValueSource
23+
postValueSource,
24+
siteTimezone
2225
});
2326

2427
const hasFilters = filters.length > 0;

apps/posts/src/views/comments/hooks/use-filter-state.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {Filter} from '@tryghost/shade/patterns';
22
import {hasTimezoneSensitiveCommentFilter, parseCommentFilter, serializeCommentFilters} from '../comment-filter-query';
3-
import {useCallback, useMemo} from 'react';
3+
import {parseLegacyCommentFilters, removeLegacyCommentFilterParams} from '../legacy-comment-filter-query';
4+
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
45
import {useSearchParams} from '@tryghost/admin-x-framework';
56

67
type SetFiltersAction = Filter[] | ((prevFilters: Filter[]) => Filter[]);
@@ -22,6 +23,7 @@ function toSearchParams(baseSearchParams: URLSearchParams, filters: Filter[], ti
2223
const filter = serializeCommentFilters(filters, timezone);
2324

2425
params.delete('filter');
26+
removeLegacyCommentFilterParams(params);
2527

2628
if (filter) {
2729
params.set('filter', filter);
@@ -40,21 +42,51 @@ export function shouldDelayCommentDateFilterHydration(
4042

4143
export function useFilterState(timezone: string): UseFilterStateReturn {
4244
const [searchParams, setSearchParams] = useSearchParams();
45+
const lastWrittenQueryRef = useRef<string | null>(null);
4346
const filterParam = useMemo(() => searchParams.get('filter') ?? undefined, [searchParams]);
47+
const currentQuery = useMemo(() => searchParams.toString(), [searchParams]);
4448

45-
const filters = useMemo(() => {
46-
return parseCommentFilter(filterParam, timezone);
47-
}, [filterParam, timezone]);
49+
const parsedFilters = useMemo(() => {
50+
if (filterParam !== undefined) {
51+
return parseCommentFilter(filterParam, timezone);
52+
}
53+
54+
return parseLegacyCommentFilters(searchParams);
55+
}, [filterParam, searchParams, timezone]);
56+
const [filters, setDraftFilters] = useState<Filter[]>(parsedFilters);
4857

4958
const nql = useMemo(() => {
5059
return serializeCommentFilters(filters, timezone);
5160
}, [filters, timezone]);
5261

62+
useEffect(() => {
63+
if (currentQuery !== lastWrittenQueryRef.current) {
64+
setDraftFilters(parsedFilters);
65+
lastWrittenQueryRef.current = currentQuery;
66+
}
67+
}, [currentQuery, parsedFilters]);
68+
69+
useEffect(() => {
70+
if (lastWrittenQueryRef.current !== null && currentQuery !== lastWrittenQueryRef.current) {
71+
return;
72+
}
73+
74+
const nextParams = toSearchParams(searchParams, filters, timezone);
75+
const nextQuery = nextParams.toString();
76+
77+
if (nextQuery !== currentQuery) {
78+
lastWrittenQueryRef.current = nextQuery;
79+
setSearchParams(nextParams, {replace: true});
80+
}
81+
}, [currentQuery, filters, searchParams, setSearchParams, timezone]);
82+
5383
const setFilters = useCallback((action: SetFiltersAction, options: SetFiltersOptions = {}) => {
5484
const newFilters = typeof action === 'function' ? action(filters) : action;
5585
const newParams = toSearchParams(searchParams, newFilters, timezone);
5686
const replace = options.replace ?? true;
5787

88+
setDraftFilters(newFilters);
89+
lastWrittenQueryRef.current = newParams.toString();
5890
setSearchParams(newParams, {replace});
5991
}, [filters, searchParams, setSearchParams, timezone]);
6092

@@ -63,6 +95,9 @@ export function useFilterState(timezone: string): UseFilterStateReturn {
6395

6496
newParams.delete('filter');
6597

98+
removeLegacyCommentFilterParams(newParams);
99+
setDraftFilters([]);
100+
lastWrittenQueryRef.current = newParams.toString();
66101
setSearchParams(newParams, {replace});
67102
}, [searchParams, setSearchParams]);
68103

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import {Filter} from '@tryghost/shade/patterns';
2+
3+
// TODO: Remove this file after the comment filters migration has safely rolled out.
4+
const LEGACY_COMMENT_FILTER_FIELDS = ['status', 'created_at', 'body', 'post', 'author', 'reported'] as const;
5+
const LEGACY_OPERATOR_MAP: Record<string, string> = {
6+
is_not: 'is-not',
7+
not_contains: 'does-not-contain'
8+
};
9+
10+
function parseLegacyFilterValue(queryValue: string): {operator: string; value: string} | null {
11+
const colonIndex = queryValue.indexOf(':');
12+
13+
if (colonIndex <= 0) {
14+
return null;
15+
}
16+
17+
const operator = queryValue.substring(0, colonIndex);
18+
const value = queryValue.substring(colonIndex + 1);
19+
20+
if (!value) {
21+
return null;
22+
}
23+
24+
return {
25+
operator: LEGACY_OPERATOR_MAP[operator] ?? operator,
26+
value
27+
};
28+
}
29+
30+
export function parseLegacyCommentFilters(searchParams: URLSearchParams): Filter[] {
31+
const filters: Filter[] = [];
32+
33+
for (const [field, queryValue] of searchParams.entries()) {
34+
if (!LEGACY_COMMENT_FILTER_FIELDS.includes(field as typeof LEGACY_COMMENT_FILTER_FIELDS[number])) {
35+
continue;
36+
}
37+
38+
const parsed = parseLegacyFilterValue(queryValue);
39+
40+
if (!parsed) {
41+
continue;
42+
}
43+
44+
filters.push({
45+
id: `${field}:${filters.length + 1}`,
46+
field,
47+
operator: parsed.operator,
48+
values: [parsed.value]
49+
});
50+
}
51+
52+
return filters;
53+
}
54+
55+
export function removeLegacyCommentFilterParams(searchParams: URLSearchParams): void {
56+
LEGACY_COMMENT_FILTER_FIELDS.forEach(field => searchParams.delete(field));
57+
}

apps/posts/src/views/comments/use-comment-filter-fields.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React, {useMemo} from 'react';
2+
import moment from 'moment-timezone';
23
import {FilterFieldConfig, ValueSource} from '@tryghost/shade/patterns';
34
import {LucideIcon} from '@tryghost/shade/utils';
45
import {commentFields} from './comment-fields';
@@ -7,6 +8,7 @@ import {createOperatorOptions} from '../filters/filter-operator-options';
78
interface UseCommentFilterFieldsOptions {
89
postValueSource: ValueSource<string>;
910
memberValueSource: ValueSource<string>;
11+
siteTimezone?: string;
1012
}
1113

1214
const COMMENT_FIELD_ORDER = ['author', 'post', 'body', 'status', 'reported', 'created_at'] as const;
@@ -32,9 +34,12 @@ function getFieldIcon(key: string) {
3234

3335
export function useCommentFilterFields({
3436
postValueSource,
35-
memberValueSource
37+
memberValueSource,
38+
siteTimezone = 'UTC'
3639
}: UseCommentFilterFieldsOptions): FilterFieldConfig[] {
3740
return useMemo(() => {
41+
const today = moment.tz(siteTimezone).format('YYYY-MM-DD');
42+
3843
return COMMENT_FIELD_ORDER.map((key) => {
3944
const field = commentFields[key];
4045

@@ -44,9 +49,10 @@ export function useCommentFilterFields({
4449
icon: getFieldIcon(key),
4550
operators: createOperatorOptions(field.operators),
4651
...('options' in field && field.options ? {options: field.options} : {}),
52+
...(key === 'created_at' ? {defaultValue: today} : {}),
4753
...(key === 'author' ? {valueSource: memberValueSource} : {}),
4854
...(key === 'post' ? {valueSource: postValueSource} : {})
4955
};
5056
});
51-
}, [memberValueSource, postValueSource]);
57+
}, [memberValueSource, postValueSource, siteTimezone]);
5258
}

apps/posts/test/unit/views/comments/comments.test.tsx

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -119,36 +119,33 @@ describe('Comments', () => {
119119
keepPreviousData: true,
120120
searchParams: {filter: 'id:\'comment_123\''}
121121
}));
122-
expect(screen.queryByTestId('apply-filter')).not.toBeInTheDocument();
123-
expect(screen.getByRole('button', {name: 'Show all comments'})).toBeInTheDocument();
122+
expect(screen.queryByTestId('apply-filter')).toBeNull();
123+
expect(screen.getByRole('button', {name: 'Show all comments'})).not.toBeNull();
124124
});
125125

126126
it('clears the full query string when leaving single-comment mode', async () => {
127127
await renderComments('/?id=is:comment_123&thread=is:comment_456&filter=status:published');
128128

129129
fireEvent.click(screen.getByRole('button', {name: 'Show all comments'}));
130130

131-
expect(screen.getByTestId('location-search')).toHaveTextContent('');
131+
expect(screen.getByTestId('location-search').textContent).toBe('');
132132
});
133133

134134
it('keeps thread state separate from canonical filter state updates', async () => {
135135
await renderComments('/?thread=is:comment_456');
136136

137137
expect(useBrowseCommentsMock).toHaveBeenCalledWith(expect.objectContaining({
138-
searchParams: {
139-
thread: 'is:comment_456'
140-
}
138+
searchParams: {}
141139
}));
142140

143141
fireEvent.click(screen.getByTestId('apply-filter'));
144142

145143
expect(useBrowseCommentsMock).toHaveBeenLastCalledWith(expect.objectContaining({
146144
searchParams: {
147-
filter: 'status:published',
148-
thread: 'is:comment_456'
145+
filter: 'status:published'
149146
}
150147
}));
151-
expect(screen.getByTestId('location-search')).toHaveTextContent('?thread=is%3Acomment_456&filter=status%3Apublished');
148+
expect(screen.getByTestId('location-search').textContent).toBe('?thread=is%3Acomment_456&filter=status%3Apublished');
152149
});
153150

154151
it('leaves single-comment mode when applying a row-level filter', async () => {
@@ -161,7 +158,7 @@ describe('Comments', () => {
161158
filter: 'member_id:member_456'
162159
}
163160
}));
164-
expect(screen.getByTestId('location-search')).toHaveTextContent('?filter=member_id%3Amember_456');
161+
expect(screen.getByTestId('location-search').textContent).toBe('?filter=member_id%3Amember_456');
165162
});
166163

167164
it('delays date-filter hydration until the site timezone is resolved', async () => {
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import {afterEach, describe, expect, it, vi} from 'vitest';
2+
import {renderHook} from '@testing-library/react';
3+
import {useCommentFilterFields} from '@src/views/comments/use-comment-filter-fields';
4+
import type {ValueSource} from '@tryghost/shade/patterns';
5+
6+
const emptyValueSource: ValueSource<string> = {
7+
id: 'empty',
8+
useOptions: () => ({
9+
options: [],
10+
isInitialLoad: false,
11+
isSearching: false,
12+
isLoadingMore: false,
13+
hasMore: false,
14+
loadMore: vi.fn()
15+
})
16+
};
17+
18+
describe('useCommentFilterFields', () => {
19+
afterEach(() => {
20+
vi.useRealTimers();
21+
});
22+
23+
it('sets date filter defaults in the site timezone', () => {
24+
vi.useFakeTimers();
25+
vi.setSystemTime(new Date('2024-03-10T06:00:00.000Z'));
26+
27+
const {result} = renderHook(() => useCommentFilterFields({
28+
memberValueSource: emptyValueSource,
29+
postValueSource: emptyValueSource,
30+
siteTimezone: 'America/Los_Angeles'
31+
}));
32+
33+
expect(result.current.find(field => field.key === 'created_at')).toMatchObject({
34+
defaultValue: '2024-03-09'
35+
});
36+
});
37+
});

apps/posts/test/unit/views/comments/use-filter-state.test.tsx

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,40 @@ describe('use-filter-state', () => {
4040
expect(result.current.nql).toBe('status:published');
4141
});
4242

43+
it('migrates legacy per-field filter params to canonical filters', () => {
44+
const {result} = renderHook(() => {
45+
const state = useFilterState('UTC');
46+
const [searchParams] = useSearchParams();
47+
48+
return {
49+
...state,
50+
query: searchParams.toString()
51+
};
52+
}, {wrapper: createWrapper('/?status=is:hidden&body=not_contains:spam&author=is_not:member_123')});
53+
54+
expect(result.current.filters).toEqual([
55+
{
56+
id: 'status:1',
57+
field: 'status',
58+
operator: 'is',
59+
values: ['hidden']
60+
},
61+
{
62+
id: 'body:2',
63+
field: 'body',
64+
operator: 'does-not-contain',
65+
values: ['spam']
66+
},
67+
{
68+
id: 'author:3',
69+
field: 'author',
70+
operator: 'is-not',
71+
values: ['member_123']
72+
}
73+
]);
74+
expect(result.current.query).toBe('filter=html%3A-%7E%27spam%27%2Bmember_id%3A-member_123%2Bstatus%3Ahidden');
75+
});
76+
4377
it('ignores legacy id params because single-comment mode is handled outside filter state', () => {
4478
const {result} = renderHook(() => useFilterState('UTC'), {
4579
wrapper: createWrapper('/?id=is:comment_123')
@@ -74,6 +108,40 @@ describe('use-filter-state', () => {
74108
expect(result.current.query).toBe('thread=is%3Acomment_123&filter=count.reports%3A%3E0');
75109
});
76110

111+
it('keeps draft filters that are not serializable yet', () => {
112+
const {result} = renderHook(() => {
113+
const state = useFilterState('UTC');
114+
const [searchParams] = useSearchParams();
115+
116+
return {
117+
...state,
118+
query: searchParams.toString()
119+
};
120+
}, {wrapper: createWrapper('/?thread=is:comment_123')});
121+
122+
act(() => {
123+
result.current.setFilters([
124+
{
125+
id: '1',
126+
field: 'body',
127+
operator: 'contains',
128+
values: ['']
129+
}
130+
], {replace: false});
131+
});
132+
133+
expect(result.current.filters).toEqual([
134+
{
135+
id: '1',
136+
field: 'body',
137+
operator: 'contains',
138+
values: ['']
139+
}
140+
]);
141+
expect(result.current.nql).toBeUndefined();
142+
expect(result.current.query).toBe('thread=is%3Acomment_123');
143+
});
144+
77145
it('removes only the canonical filter param when clearing filters', () => {
78146
const {result} = renderHook(() => {
79147
const state = useFilterState('UTC');

0 commit comments

Comments
 (0)