Skip to content

Commit d0d69d2

Browse files
scttcperclaude
andauthored
feat(supergroups): Add checkbox to supergroup rows for bulk selection (#112301)
Add checkbox to supergroup rows for bulk issue selection, plus small QoL fixes. - Checkbox below the stack icon selects/deselects all member groups at once (indeterminate when partial). Hidden until hover or checked. - New `setSelectionForIds` action on the issue selection context for multi-ID dispatch. - Label switches from "issues matched" to bold "issues selected" when any are checked. - `retry: false` on supergroups query, it 404s when they're not found etc. <img width="550" height="94" alt="image" src="https://github.com/user-attachments/assets/27df4440-76e5-4376-bc3d-24000045f207" /> fixes https://linear.app/getsentry/issue/ID-1442/figuring-out-what-happens-when-selecting-a-supergroup --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a77fb6b commit d0d69d2

File tree

7 files changed

+151
-8
lines changed

7 files changed

+151
-8
lines changed

.github/CODEOWNERS

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -692,7 +692,7 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get
692692
/static/app/components/events/highlights/ @getsentry/issue-workflow
693693
/static/app/components/issues/ @getsentry/issue-workflow
694694
/static/app/components/stackTrace/ @getsentry/issue-workflow
695-
/static/app/components/stream/supergroupRow.tsx @getsentry/issue-detection-frontend
695+
/static/app/components/stream/supergroups/ @getsentry/issue-detection-frontend
696696
/static/app/views/issueList/ @getsentry/issue-workflow
697697
/static/app/views/issueList/issueListSeerComboBox.tsx @getsentry/issue-workflow @getsentry/machine-learning-ai
698698
/static/app/views/issueList/pages/supergroups.tsx @getsentry/issue-detection-frontend
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import {useCallback} from 'react';
2+
import styled from '@emotion/styled';
3+
4+
import {Checkbox} from '@sentry/scraps/checkbox';
5+
6+
import {t} from 'sentry/locale';
7+
import {useOptionalIssueSelectionActions} from 'sentry/views/issueList/issueSelectionContext';
8+
9+
interface SupergroupCheckboxProps {
10+
matchedGroupIds: string[];
11+
selectedCount: number;
12+
}
13+
14+
export function SupergroupCheckbox({
15+
matchedGroupIds,
16+
selectedCount,
17+
}: SupergroupCheckboxProps) {
18+
const actions = useOptionalIssueSelectionActions();
19+
20+
const checkedState =
21+
selectedCount === 0
22+
? false
23+
: selectedCount === matchedGroupIds.length
24+
? true
25+
: ('indeterminate' as const);
26+
27+
const handleChange = useCallback(
28+
(evt: React.MouseEvent) => {
29+
evt.stopPropagation();
30+
const nextValue = checkedState !== true;
31+
actions?.setSelectionForIds(matchedGroupIds, nextValue);
32+
},
33+
[actions, matchedGroupIds, checkedState]
34+
);
35+
36+
if (!actions) {
37+
return null;
38+
}
39+
40+
return (
41+
<CheckboxLabel>
42+
<CheckboxWithBackground
43+
aria-label={t('Select supergroup issues')}
44+
checked={checkedState}
45+
onChange={() => {}}
46+
onClick={handleChange}
47+
/>
48+
</CheckboxLabel>
49+
);
50+
}
51+
52+
export const CheckboxLabel = styled('label')`
53+
margin: 0;
54+
display: flex;
55+
align-items: center;
56+
justify-content: center;
57+
`;
58+
59+
const CheckboxWithBackground = styled(Checkbox)`
60+
background-color: ${p => p.theme.tokens.background.primary};
61+
`;

static/app/components/stream/supergroupRow.tsx renamed to static/app/components/stream/supergroups/supergroupRow.tsx

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {useState} from 'react';
1+
import {useMemo, useState} from 'react';
22
import styled from '@emotion/styled';
33

44
import InteractionStateLayer from '@sentry/scraps/interactionStateLayer';
@@ -11,10 +11,15 @@ import {Count} from 'sentry/components/count';
1111
import {useDrawer} from 'sentry/components/globalDrawer';
1212
import {PanelItem} from 'sentry/components/panels/panelItem';
1313
import {Placeholder} from 'sentry/components/placeholder';
14+
import {
15+
CheckboxLabel,
16+
SupergroupCheckbox,
17+
} from 'sentry/components/stream/supergroups/supergroupCheckbox';
1418
import {TimeSince} from 'sentry/components/timeSince';
1519
import {IconStack} from 'sentry/icons';
1620
import {t} from 'sentry/locale';
1721
import {COLUMN_BREAKPOINTS} from 'sentry/views/issueList/actions/utils';
22+
import {useOptionalIssueSelectionSummary} from 'sentry/views/issueList/issueSelectionContext';
1823
import type {AggregatedSupergroupStats} from 'sentry/views/issueList/supergroups/aggregateSupergroupStats';
1924
import {SupergroupDetailDrawer} from 'sentry/views/issueList/supergroups/supergroupDrawer';
2025
import type {SupergroupDetail} from 'sentry/views/issueList/supergroups/types';
@@ -53,13 +58,31 @@ export function SupergroupRow({
5358
);
5459
};
5560

61+
const summary = useOptionalIssueSelectionSummary();
62+
const selectedCount = useMemo(() => {
63+
if (!summary) {
64+
return 0;
65+
}
66+
let count = 0;
67+
for (const id of matchedGroupIds) {
68+
if (summary.records.get(id)) {
69+
count++;
70+
}
71+
}
72+
return count;
73+
}, [summary, matchedGroupIds]);
74+
5675
const highlighted = isActive && isDrawerOpen;
5776

5877
return (
5978
<Wrapper onClick={handleClick} highlighted={highlighted}>
6079
<InteractionStateLayer />
6180
<IconArea>
6281
<AccentIcon size="md" />
82+
<SupergroupCheckbox
83+
matchedGroupIds={matchedGroupIds}
84+
selectedCount={selectedCount}
85+
/>
6386
</IconArea>
6487
<Summary>
6588
{supergroup.error_type ? (
@@ -78,8 +101,14 @@ export function SupergroupRow({
78101
) : null}
79102
{supergroup.code_area && matchedCount > 0 ? <Dot /> : null}
80103
{matchedCount > 0 ? (
81-
<Text size="sm" variant="muted">
82-
{matchedCount} / {supergroup.group_ids.length} {t('issues matched')}
104+
<Text
105+
size="sm"
106+
variant={selectedCount > 0 ? 'primary' : 'muted'}
107+
bold={selectedCount > 0}
108+
>
109+
{selectedCount > 0
110+
? `${selectedCount} / ${supergroup.group_ids.length} ${t('issues selected')}`
111+
: `${matchedCount} / ${supergroup.group_ids.length} ${t('issues matched')}`}
83112
</Text>
84113
) : null}
85114
</MetaRow>
@@ -167,6 +196,12 @@ const Wrapper = styled(PanelItem)<{highlighted: boolean}>`
167196
min-height: 82px;
168197
background: ${p =>
169198
p.highlighted ? p.theme.tokens.background.secondary : 'transparent'};
199+
200+
&:not(:hover):not(:has(input:checked)):not(:has(input:indeterminate)) {
201+
${CheckboxLabel} {
202+
${p => p.theme.visuallyHidden};
203+
}
204+
}
170205
`;
171206

172207
const Summary = styled('div')`
@@ -185,10 +220,11 @@ const IconArea = styled('div')`
185220
align-self: flex-start;
186221
width: 32px;
187222
display: flex;
188-
align-items: center;
189-
justify-content: flex-end;
223+
flex-direction: column;
224+
align-items: flex-end;
190225
flex-shrink: 0;
191226
padding-top: ${p => p.theme.space.sm};
227+
gap: ${p => p.theme.space.xs};
192228
`;
193229

194230
const AccentIcon = styled(IconStack)`

static/app/views/issueList/groupListBody.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type {GroupListColumn} from 'sentry/components/issues/groupList';
66
import {LoadingError} from 'sentry/components/loadingError';
77
import {PanelBody} from 'sentry/components/panels/panelBody';
88
import {LoadingStreamGroup, StreamGroup} from 'sentry/components/stream/group';
9-
import {SupergroupRow} from 'sentry/components/stream/supergroupRow';
9+
import {SupergroupRow} from 'sentry/components/stream/supergroups/supergroupRow';
1010
import {GroupStore} from 'sentry/stores/groupStore';
1111
import type {Group} from 'sentry/types/group';
1212
import {useApi} from 'sentry/utils/useApi';

static/app/views/issueList/issueSelectionContext.spec.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,30 @@ describe('IssueSelectionContext', () => {
109109
expect(result.current.lastSelected).toBe('14');
110110
});
111111

112+
it('sets selection for a subset of ids', () => {
113+
const {result} = renderSelectionHook({
114+
visibleGroupIds: ['1', '2', '3', '4'],
115+
});
116+
117+
act(() => result.current.setSelectionForIds(['2', '3'], true));
118+
expect(result.current.records.get('1')).toBe(false);
119+
expect(result.current.records.get('2')).toBe(true);
120+
expect(result.current.records.get('3')).toBe(true);
121+
expect(result.current.records.get('4')).toBe(false);
122+
123+
act(() => result.current.setSelectionForIds(['2', '3'], false));
124+
expect(result.current.records.get('2')).toBe(false);
125+
expect(result.current.records.get('3')).toBe(false);
126+
});
127+
128+
it('ignores unknown ids in setSelectionForIds', () => {
129+
const {result} = renderSelectionHook({visibleGroupIds: ['1', '2']});
130+
131+
act(() => result.current.setSelectionForIds(['1', 'unknown'], true));
132+
expect(result.current.records.get('1')).toBe(true);
133+
expect(result.current.records.has('unknown')).toBe(false);
134+
});
135+
112136
it('resets all-in-query selection when selection changes', () => {
113137
const {result} = renderSelectionHook({visibleGroupIds: ['1', '2']});
114138

static/app/views/issueList/issueSelectionContext.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ type IssueSelectionAction =
1414
| {groupId: string; type: 'SHIFT_TOGGLE_SELECT'; visibleGroupIds: string[]}
1515
| {type: 'TOGGLE_SELECT_ALL_VISIBLE'}
1616
| {type: 'DESELECT_ALL'}
17-
| {type: 'SET_ALL_IN_QUERY_SELECTED'; value: boolean};
17+
| {type: 'SET_ALL_IN_QUERY_SELECTED'; value: boolean}
18+
| {groupIds: string[]; type: 'SET_SELECTION_FOR_IDS'; value: boolean};
1819

1920
interface IssueSelectionSummary extends IssueSelectionState {
2021
anySelected: boolean;
@@ -27,6 +28,7 @@ interface IssueSelectionActions {
2728
deselectAll: () => void;
2829
reconcileVisibleGroupIds: (groupIds: string[]) => void;
2930
setAllInQuerySelected: (value: boolean) => void;
31+
setSelectionForIds: (groupIds: string[], value: boolean) => void;
3032
shiftToggleSelect: (groupId: string) => void;
3133
toggleSelect: (groupId: string) => void;
3234
toggleSelectAllVisible: () => void;
@@ -193,6 +195,19 @@ function issueSelectionReducer(
193195
records: nextRecords,
194196
};
195197
}
198+
case 'SET_SELECTION_FOR_IDS': {
199+
const nextRecords = new Map(state.records);
200+
for (const id of action.groupIds) {
201+
if (nextRecords.has(id)) {
202+
nextRecords.set(id, action.value);
203+
}
204+
}
205+
return {
206+
...state,
207+
allInQuerySelected: false,
208+
records: nextRecords,
209+
};
210+
}
196211
case 'SET_ALL_IN_QUERY_SELECTED':
197212
return {...state, allInQuerySelected: action.value};
198213
default:
@@ -243,6 +258,10 @@ export function IssueSelectionProvider({
243258
dispatch({type: 'DESELECT_ALL'});
244259
}, []);
245260

261+
const setSelectionForIds = useCallback((groupIds: string[], value: boolean) => {
262+
dispatch({type: 'SET_SELECTION_FOR_IDS', groupIds, value});
263+
}, []);
264+
246265
const setAllInQuerySelected = useCallback((value: boolean) => {
247266
dispatch({type: 'SET_ALL_IN_QUERY_SELECTED', value});
248267
}, []);
@@ -271,6 +290,7 @@ export function IssueSelectionProvider({
271290
shiftToggleSelect,
272291
toggleSelectAllVisible,
273292
deselectAll,
293+
setSelectionForIds,
274294
setAllInQuerySelected,
275295
reconcileVisibleGroupIds,
276296
}),
@@ -279,6 +299,7 @@ export function IssueSelectionProvider({
279299
shiftToggleSelect,
280300
toggleSelectAllVisible,
281301
deselectAll,
302+
setSelectionForIds,
282303
setAllInQuerySelected,
283304
reconcileVisibleGroupIds,
284305
]

static/app/views/issueList/supergroups/useSuperGroups.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export function useSuperGroups(groupIds: string[]): {
5252
{
5353
staleTime: 30_000,
5454
enabled,
55+
retry: false,
5556
placeholderData: previousData => {
5657
if (!previousData) {
5758
return undefined;

0 commit comments

Comments
 (0)