Skip to content

Commit 837d98f

Browse files
authored
ref: Update useListItemCheckboxState to allow toggling lists of items at once (#95188)
The two big things are that `toggleSelected` now accepts `(id: string | string[])` instead of only `string`. And there's a provider now, to hoist the state up and share it between multiple children. The provider/state change means that we can avoid prop-drilling everything all over the place now. It's still optional though, without a provider it'll use it's own internal state as before. Related to REPLAY-509
1 parent cf476ea commit 837d98f

File tree

6 files changed

+396
-31
lines changed

6 files changed

+396
-31
lines changed

static/app/components/feedback/list/feedbackList.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {t} from 'sentry/locale';
2020
import {space} from 'sentry/styles/space';
2121
import useFetchInfiniteListData from 'sentry/utils/api/useFetchInfiniteListData';
2222
import type {FeedbackIssueListItem} from 'sentry/utils/feedback/types';
23-
import useListItemCheckboxState from 'sentry/utils/list/useListItemCheckboxState';
23+
import {useListItemCheckboxContext} from 'sentry/utils/list/useListItemCheckboxState';
2424
import useVirtualizedList from 'sentry/views/replays/detail/useVirtualizedList';
2525

2626
// Ensure this object is created once as it is an input to
@@ -57,7 +57,7 @@ export default function FeedbackList() {
5757
enabled: Boolean(listQueryKey),
5858
});
5959

60-
const checkboxState = useListItemCheckboxState({
60+
const checkboxState = useListItemCheckboxContext({
6161
hits,
6262
knownIds: issues.map(issue => issue.id),
6363
queryKey: listQueryKey,

static/app/components/feedback/list/feedbackListBulkSelection.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ import {IconEllipsis} from 'sentry/icons/iconEllipsis';
88
import {t, tct} from 'sentry/locale';
99
import {space} from 'sentry/styles/space';
1010
import {GroupStatus} from 'sentry/types/group';
11-
import type useListItemCheckboxState from 'sentry/utils/list/useListItemCheckboxState';
11+
import type {useListItemCheckboxContext} from 'sentry/utils/list/useListItemCheckboxState';
1212

1313
interface Props
1414
extends Pick<
15-
ReturnType<typeof useListItemCheckboxState>,
15+
ReturnType<typeof useListItemCheckboxContext>,
1616
'countSelected' | 'deselectAll' | 'selectedIds'
1717
> {
1818
mailbox: ReturnType<typeof decodeMailbox>;

static/app/components/feedback/list/feedbackListHeader.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@ import useFeedbackQueryKeys from 'sentry/components/feedback/useFeedbackQueryKey
1111
import {IconRefresh} from 'sentry/icons';
1212
import {t} from 'sentry/locale';
1313
import {space} from 'sentry/styles/space';
14-
import type useListItemCheckboxState from 'sentry/utils/list/useListItemCheckboxState';
14+
import type {useListItemCheckboxContext} from 'sentry/utils/list/useListItemCheckboxState';
1515
import useLocationQuery from 'sentry/utils/url/useLocationQuery';
1616
import useUrlParams from 'sentry/utils/url/useUrlParams';
1717

1818
interface Props
1919
extends Pick<
20-
ReturnType<typeof useListItemCheckboxState>,
20+
ReturnType<typeof useListItemCheckboxContext>,
2121
| 'countSelected'
2222
| 'deselectAll'
2323
| 'isAllSelected'

static/app/components/feedback/list/useBulkEditFeedbacks.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import useMutateFeedback from 'sentry/components/feedback/useMutateFeedback';
1111
import {t, tct} from 'sentry/locale';
1212
import {GroupStatus} from 'sentry/types/group';
1313
import {trackAnalytics} from 'sentry/utils/analytics';
14-
import type useListItemCheckboxState from 'sentry/utils/list/useListItemCheckboxState';
14+
import type {useListItemCheckboxContext} from 'sentry/utils/list/useListItemCheckboxState';
1515
import {decodeList} from 'sentry/utils/queryString';
1616
import useLocationQuery from 'sentry/utils/url/useLocationQuery';
1717
import useOrganization from 'sentry/utils/useOrganization';
@@ -30,7 +30,7 @@ const statusToText: Record<string, string> = {
3030

3131
interface Props
3232
extends Pick<
33-
ReturnType<typeof useListItemCheckboxState>,
33+
ReturnType<typeof useListItemCheckboxContext>,
3434
'deselectAll' | 'selectedIds'
3535
> {}
3636

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
import {act, renderHook} from 'sentry-test/reactTestingLibrary';
2+
3+
import {useListItemCheckboxContext} from 'sentry/utils/list/useListItemCheckboxState';
4+
import type {ApiQueryKey} from 'sentry/utils/queryClient';
5+
6+
const queryKey: ApiQueryKey = ['test'];
7+
8+
describe('useListItemCheckboxContext', () => {
9+
describe('All hits are already known', () => {
10+
it('should return the correct initial state', () => {
11+
const {result} = renderHook(() =>
12+
useListItemCheckboxContext({hits: 3, knownIds: ['1', '2', '3'], queryKey})
13+
);
14+
expect(result.current).toEqual({
15+
countSelected: 0,
16+
deselectAll: expect.any(Function),
17+
hits: 3,
18+
isAllSelected: false,
19+
isAnySelected: false,
20+
isSelected: expect.any(Function),
21+
knownIds: ['1', '2', '3'],
22+
queryKey,
23+
selectAll: expect.any(Function),
24+
selectedIds: [],
25+
toggleSelected: expect.any(Function),
26+
});
27+
});
28+
29+
it('should allow selecting an individual item when all hits are known', () => {
30+
const {result} = renderHook(() =>
31+
useListItemCheckboxContext({hits: 3, knownIds: ['1', '2', '3'], queryKey})
32+
);
33+
34+
// Initially nothing is selected
35+
expect(result.current.isSelected('1')).toBe(false);
36+
expect(result.current.isSelected('2')).toBe(false);
37+
expect(result.current.isSelected('3')).toBe(false);
38+
expect(result.current.isAllSelected).toBe(false);
39+
expect(result.current.isAnySelected).toBe(false);
40+
expect(result.current.countSelected).toBe(0);
41+
42+
// Select item '1'
43+
act(() => {
44+
result.current.toggleSelected('1');
45+
});
46+
47+
// Check that only item '1' is selected
48+
expect(result.current.isSelected('1')).toBe(true);
49+
expect(result.current.isSelected('2')).toBe(false);
50+
expect(result.current.isSelected('3')).toBe(false);
51+
expect(result.current.isAllSelected).toBe('indeterminate');
52+
expect(result.current.isAnySelected).toBe(true);
53+
expect(result.current.countSelected).toBe(1);
54+
expect(result.current.selectedIds).toEqual(['1']);
55+
56+
// Select item '2' as well
57+
act(() => {
58+
result.current.toggleSelected('2');
59+
});
60+
61+
// Check that both items are selected
62+
expect(result.current.isSelected('1')).toBe(true);
63+
expect(result.current.isSelected('2')).toBe(true);
64+
expect(result.current.isSelected('3')).toBe(false);
65+
expect(result.current.isAllSelected).toBe('indeterminate');
66+
expect(result.current.isAnySelected).toBe(true);
67+
expect(result.current.countSelected).toBe(2);
68+
expect(result.current.selectedIds).toEqual(['1', '2']);
69+
70+
// Deselect item '1'
71+
act(() => {
72+
result.current.toggleSelected('1');
73+
});
74+
75+
// Check that only item '2' is selected
76+
expect(result.current.isSelected('1')).toBe(false);
77+
expect(result.current.isSelected('2')).toBe(true);
78+
expect(result.current.isSelected('3')).toBe(false);
79+
expect(result.current.isAllSelected).toBe('indeterminate');
80+
expect(result.current.isAnySelected).toBe(true);
81+
expect(result.current.countSelected).toBe(1);
82+
expect(result.current.selectedIds).toEqual(['2']);
83+
});
84+
85+
it('sets isAllSelected to true when all items are selected', () => {
86+
const {result} = renderHook(() =>
87+
useListItemCheckboxContext({hits: 3, knownIds: ['1', '2', '3'], queryKey})
88+
);
89+
90+
// Initially nothing is selected
91+
expect(result.current.isSelected('1')).toBe(false);
92+
expect(result.current.isSelected('2')).toBe(false);
93+
expect(result.current.isSelected('3')).toBe(false);
94+
expect(result.current.isAllSelected).toBe(false);
95+
96+
act(() => {
97+
result.current.toggleSelected('1');
98+
result.current.toggleSelected('2');
99+
result.current.toggleSelected('3');
100+
});
101+
102+
// Check that all items are selected
103+
expect(result.current.isSelected('1')).toBe(true);
104+
expect(result.current.isSelected('2')).toBe(true);
105+
expect(result.current.isSelected('3')).toBe(true);
106+
expect(result.current.isAllSelected).toBe(true);
107+
});
108+
109+
it('should allow selecting all items with selectAll', () => {
110+
const {result} = renderHook(() =>
111+
useListItemCheckboxContext({hits: 3, knownIds: ['1', '2', '3'], queryKey})
112+
);
113+
114+
// Initially nothing is selected
115+
expect(result.current.isSelected('1')).toBe(false);
116+
expect(result.current.isSelected('2')).toBe(false);
117+
expect(result.current.isSelected('3')).toBe(false);
118+
expect(result.current.isAllSelected).toBe(false);
119+
expect(result.current.isAnySelected).toBe(false);
120+
expect(result.current.countSelected).toBe(0);
121+
122+
// Select all items
123+
act(() => {
124+
result.current.selectAll();
125+
});
126+
127+
// Check that all items are selected (including virtual items not yet loaded)
128+
expect(result.current.isSelected('1')).toBe(true);
129+
expect(result.current.isSelected('2')).toBe(true);
130+
expect(result.current.isSelected('3')).toBe(true);
131+
expect(result.current.isAllSelected).toBe(true);
132+
expect(result.current.isAnySelected).toBe(true);
133+
expect(result.current.countSelected).toBe(3); // Total hits count
134+
expect(result.current.selectedIds).toBe('all'); // Special sentinel value
135+
136+
// Deselect all
137+
act(() => {
138+
result.current.deselectAll();
139+
});
140+
141+
// Check that nothing is selected
142+
expect(result.current.isSelected('1')).toBe(false);
143+
expect(result.current.isSelected('2')).toBe(false);
144+
expect(result.current.isSelected('3')).toBe(false);
145+
expect(result.current.isAllSelected).toBe(false);
146+
expect(result.current.isAnySelected).toBe(false);
147+
expect(result.current.countSelected).toBe(0);
148+
expect(result.current.selectedIds).toEqual([]);
149+
});
150+
});
151+
152+
describe('More hits to load', () => {
153+
it('should return the correct initial state', () => {
154+
const {result} = renderHook(() =>
155+
useListItemCheckboxContext({hits: 10, knownIds: ['1', '2', '3'], queryKey})
156+
);
157+
expect(result.current).toEqual({
158+
countSelected: 0,
159+
deselectAll: expect.any(Function),
160+
hits: 10,
161+
isAllSelected: false,
162+
isAnySelected: false,
163+
isSelected: expect.any(Function),
164+
knownIds: ['1', '2', '3'],
165+
queryKey,
166+
selectAll: expect.any(Function),
167+
selectedIds: [],
168+
toggleSelected: expect.any(Function),
169+
});
170+
});
171+
172+
it('should allow selecting individual items when there are more hits to load', () => {
173+
const {result} = renderHook(() =>
174+
useListItemCheckboxContext({hits: 10, knownIds: ['1', '2', '3'], queryKey})
175+
);
176+
177+
// Initially nothing is selected
178+
expect(result.current.isSelected('1')).toBe(false);
179+
expect(result.current.isSelected('2')).toBe(false);
180+
expect(result.current.isSelected('3')).toBe(false);
181+
expect(result.current.isAllSelected).toBe(false);
182+
expect(result.current.isAnySelected).toBe(false);
183+
expect(result.current.countSelected).toBe(0);
184+
185+
// Select item '1'
186+
act(() => {
187+
result.current.toggleSelected('1');
188+
});
189+
190+
// Check that only item '1' is selected
191+
expect(result.current.isSelected('1')).toBe(true);
192+
expect(result.current.isSelected('2')).toBe(false);
193+
expect(result.current.isSelected('3')).toBe(false);
194+
expect(result.current.isAllSelected).toBe('indeterminate');
195+
expect(result.current.isAnySelected).toBe(true);
196+
expect(result.current.countSelected).toBe(1);
197+
expect(result.current.selectedIds).toEqual(['1']);
198+
199+
// Select item '2' as well
200+
act(() => {
201+
result.current.toggleSelected('2');
202+
});
203+
204+
// Check that both items are selected
205+
expect(result.current.isSelected('1')).toBe(true);
206+
expect(result.current.isSelected('2')).toBe(true);
207+
expect(result.current.isSelected('3')).toBe(false);
208+
expect(result.current.isAllSelected).toBe('indeterminate');
209+
expect(result.current.isAnySelected).toBe(true);
210+
expect(result.current.countSelected).toBe(2);
211+
expect(result.current.selectedIds).toEqual(['1', '2']);
212+
213+
// Select item '3' to select all known items
214+
act(() => {
215+
result.current.toggleSelected('3');
216+
});
217+
218+
// Check that all known items are selected
219+
expect(result.current.isSelected('1')).toBe(true);
220+
expect(result.current.isSelected('2')).toBe(true);
221+
expect(result.current.isSelected('3')).toBe(true);
222+
expect(result.current.isAllSelected).toBe('indeterminate');
223+
expect(result.current.isAnySelected).toBe(true);
224+
expect(result.current.countSelected).toBe(3);
225+
expect(result.current.selectedIds).toEqual(['1', '2', '3']);
226+
227+
// Deselect item '1'
228+
act(() => {
229+
result.current.toggleSelected('1');
230+
});
231+
232+
// Check that only items '2' and '3' are selected
233+
expect(result.current.isSelected('1')).toBe(false);
234+
expect(result.current.isSelected('2')).toBe(true);
235+
expect(result.current.isSelected('3')).toBe(true);
236+
expect(result.current.isAllSelected).toBe('indeterminate');
237+
expect(result.current.isAnySelected).toBe(true);
238+
expect(result.current.countSelected).toBe(2);
239+
expect(result.current.selectedIds).toEqual(['2', '3']);
240+
});
241+
242+
it('should allow selecting all items with selectAll', () => {
243+
const {result} = renderHook(() =>
244+
useListItemCheckboxContext({hits: 10, knownIds: ['1', '2', '3'], queryKey})
245+
);
246+
247+
// Initially nothing is selected
248+
expect(result.current.isSelected('1')).toBe(false);
249+
expect(result.current.isSelected('2')).toBe(false);
250+
expect(result.current.isSelected('3')).toBe(false);
251+
expect(result.current.isAllSelected).toBe(false);
252+
expect(result.current.isAnySelected).toBe(false);
253+
expect(result.current.countSelected).toBe(0);
254+
255+
// Select all items
256+
act(() => {
257+
result.current.selectAll();
258+
});
259+
260+
// Check that all items are selected (including virtual items not yet loaded)
261+
expect(result.current.isSelected('1')).toBe('all-selected');
262+
expect(result.current.isSelected('2')).toBe('all-selected');
263+
expect(result.current.isSelected('3')).toBe('all-selected');
264+
expect(result.current.isAllSelected).toBe(true);
265+
expect(result.current.isAnySelected).toBe(true);
266+
expect(result.current.countSelected).toBe(10); // Total hits count
267+
expect(result.current.selectedIds).toBe('all'); // Special sentinel value
268+
269+
// Deselect all
270+
act(() => {
271+
result.current.deselectAll();
272+
});
273+
274+
// Check that nothing is selected
275+
expect(result.current.isSelected('1')).toBe(false);
276+
expect(result.current.isSelected('2')).toBe(false);
277+
expect(result.current.isSelected('3')).toBe(false);
278+
expect(result.current.isAllSelected).toBe(false);
279+
expect(result.current.isAnySelected).toBe(false);
280+
expect(result.current.countSelected).toBe(0);
281+
expect(result.current.selectedIds).toEqual([]);
282+
});
283+
});
284+
});

0 commit comments

Comments
 (0)