Skip to content

Commit b7c9a3d

Browse files
committed
🐛 Fixed comment deep-link review regressions
ref #27043 Preserves thread-scoped browsing and exits single-comment mode when row filters are applied
1 parent 4b9b6d0 commit b7c9a3d

3 files changed

Lines changed: 91 additions & 23 deletions

File tree

apps/posts/src/hooks/filter-sources/create-remote-value-source.ts

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,26 @@ export type RemoteValueSource<T = string, Item = unknown> = ValueSource<T> & {
5353

5454
export type RemoteValueSourceHook<T = string, Item = unknown> = (options?: ValueSourceHookOptions) => RemoteValueSource<T, Item>;
5555

56+
function buildFallbackOptions<T>(
57+
selectedValues: T[],
58+
mergedOptions: FilterOption<T>[],
59+
getMissingSelectedOption?: (selectedValue: T) => FilterOption<T>
60+
): FilterOption<T>[] {
61+
if (!getMissingSelectedOption) {
62+
return [];
63+
}
64+
65+
return selectedValues.flatMap((selectedValue) => {
66+
const hasMatch = mergedOptions.some(option => option.value === selectedValue);
67+
68+
if (hasMatch) {
69+
return [];
70+
}
71+
72+
return [getMissingSelectedOption(selectedValue)];
73+
});
74+
}
75+
5676
export function createRemoteValueSource<Item, T = string>(
5777
config: RemoteValueSourceConfig<Item, T>
5878
): RemoteValueSourceHook<T, Item> {
@@ -90,21 +110,11 @@ export function createRemoteValueSource<Item, T = string>(
90110
return mergeFilterOptions(hydratedOptions, visibleOptions);
91111
}, [hydratedOptions, visibleOptions]);
92112
const fallbackOptions = useMemo(() => {
93-
const getMissingSelectedOption = config.getMissingSelectedOption;
94-
95-
if (!getMissingSelectedOption) {
96-
return [];
97-
}
98-
99-
return selectedValues.flatMap((selectedValue) => {
100-
const hasMatch = mergedOptions.some(option => option.value === selectedValue);
101-
102-
if (hasMatch) {
103-
return [];
104-
}
105-
106-
return [getMissingSelectedOption(selectedValue)];
107-
});
113+
return buildFallbackOptions(
114+
selectedValues,
115+
mergedOptions,
116+
config.getMissingSelectedOption
117+
);
108118
}, [mergedOptions, selectedValues]);
109119

110120
if (!enabled) {

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

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import React, {useCallback, useMemo} from 'react';
77
import {Button, EmptyIndicator, LoadingIndicator, LucideIcon, createFilter} from '@tryghost/shade';
88
import {escapeNqlString} from '../filters/filter-normalization';
99
import {getSiteTimezone} from '@src/utils/get-site-timezone';
10+
import {serializeCommentFilters} from './comment-filter-query';
1011
import {shouldDelayCommentDateFilterHydration, useFilterState} from './hooks/use-filter-state';
1112
import {useBrowseComments} from '@tryghost/admin-x-framework/api/comments';
1213
import {useBrowseSettings} from '@tryghost/admin-x-framework/api/settings';
@@ -23,14 +24,32 @@ const CommentsPage: React.FC<{timezone: string; singleCommentId?: string}> = ({
2324
timezone,
2425
singleCommentId
2526
}) => {
26-
const [, setSearchParams] = useSearchParams();
27+
const [searchParams, setSearchParams] = useSearchParams();
2728
const {filters, nql, setFilters} = useFilterState(timezone);
29+
const threadParam = searchParams.get('thread') ?? undefined;
2830
const handleAddFilter = useCallback((field: string, value: string, operator: string = 'is') => {
29-
setFilters((prevFilters) => {
30-
const filtered = prevFilters.filter(f => f.field !== field);
31-
return [...filtered, createFilter(field, operator, [value])];
32-
}, {replace: false});
33-
}, [setFilters]);
31+
const nextFilters = [
32+
...filters.filter(filter => filter.field !== field),
33+
createFilter(field, operator, [value])
34+
];
35+
36+
if (!singleCommentId) {
37+
setFilters(nextFilters, {replace: false});
38+
return;
39+
}
40+
41+
const nextSearchParams = new URLSearchParams(searchParams);
42+
const nextNql = serializeCommentFilters(nextFilters, timezone);
43+
44+
nextSearchParams.delete('id');
45+
nextSearchParams.delete('filter');
46+
47+
if (nextNql) {
48+
nextSearchParams.set('filter', nextNql);
49+
}
50+
51+
setSearchParams(nextSearchParams, {replace: false});
52+
}, [filters, searchParams, setFilters, setSearchParams, singleCommentId, timezone]);
3453
const effectiveFilter = useMemo(() => {
3554
if (singleCommentId) {
3655
return `id:${escapeNqlString(singleCommentId)}`;
@@ -52,7 +71,10 @@ const CommentsPage: React.FC<{timezone: string; singleCommentId?: string}> = ({
5271
fetchNextPage,
5372
hasNextPage
5473
} = useBrowseComments({
55-
searchParams: effectiveFilter ? {filter: effectiveFilter} : {},
74+
searchParams: {
75+
...(threadParam && !singleCommentId ? {thread: threadParam} : {}),
76+
...(effectiveFilter ? {filter: effectiveFilter} : {})
77+
},
5678
keepPreviousData: true
5779
});
5880
const shouldShowLoading = isFetching && !isFetchingNextPage && !isRefetching;

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

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,18 @@ vi.mock('../../../../src/views/comments/components/comments-content', () => ({
3838
}));
3939

4040
vi.mock('../../../../src/views/comments/components/comments-list', () => ({
41-
default: () => <div data-testid="comments-list" />
41+
default: ({onAddFilter}: {onAddFilter: (field: string, value: string, operator?: string) => void}) => (
42+
<div>
43+
<div data-testid="comments-list" />
44+
<button
45+
data-testid="add-author-filter"
46+
type="button"
47+
onClick={() => onAddFilter('author', 'member_456')}
48+
>
49+
Add author filter
50+
</button>
51+
</div>
52+
)
4253
}));
4354

4455
vi.mock('../../../../src/views/comments/components/comments-filters', () => ({
@@ -123,11 +134,36 @@ describe('Comments', () => {
123134
it('keeps thread state separate from canonical filter state updates', async () => {
124135
await renderComments('/?thread=is:comment_456');
125136

137+
expect(useBrowseCommentsMock).toHaveBeenCalledWith(expect.objectContaining({
138+
searchParams: {
139+
thread: 'is:comment_456'
140+
}
141+
}));
142+
126143
fireEvent.click(screen.getByTestId('apply-filter'));
127144

145+
expect(useBrowseCommentsMock).toHaveBeenLastCalledWith(expect.objectContaining({
146+
searchParams: {
147+
filter: 'status:published',
148+
thread: 'is:comment_456'
149+
}
150+
}));
128151
expect(screen.getByTestId('location-search')).toHaveTextContent('?thread=is%3Acomment_456&filter=status%3Apublished');
129152
});
130153

154+
it('leaves single-comment mode when applying a row-level filter', async () => {
155+
await renderComments('/?id=is:comment_123');
156+
157+
fireEvent.click(screen.getByTestId('add-author-filter'));
158+
159+
expect(useBrowseCommentsMock).toHaveBeenLastCalledWith(expect.objectContaining({
160+
searchParams: {
161+
filter: 'member_id:member_456'
162+
}
163+
}));
164+
expect(screen.getByTestId('location-search')).toHaveTextContent('?filter=member_id%3Amember_456');
165+
});
166+
131167
it('delays date-filter hydration until the site timezone is resolved', async () => {
132168
const encodedFilter = encodeURIComponent(
133169
'created_at:>=\'2024-03-10T05:00:00.000Z\'+created_at:<=\'2024-03-11T03:59:59.999Z\''

0 commit comments

Comments
 (0)