From ebd9a7cdac74587e7a709540e66f81ef8eb0020a Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Fri, 10 Apr 2026 16:04:57 -0700 Subject: [PATCH] feat(supergroups): Add bulk actions to supergroup drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the checkbox from the supergroup row in the main issue list and simplify the count to a static "X issues". Move selection and actions into the drawer instead — wraps the issue list in IssueSelectionProvider, enables per-row checkboxes on StreamGroup, and adds an actions header bar with the full ActionSet (resolve, archive, merge, priority, etc). The header replaces GroupListHeader with a combined checkbox + column labels bar that swaps to action buttons when issues are selected, same pattern as the main issue list. Filter match indicator sits above the checkbox in each row, unread dot is hidden in this context. Co-Authored-By: Claude Opus 4.6 --- .../stream/supergroups/supergroupCheckbox.tsx | 61 ---- .../stream/supergroups/supergroupRow.tsx | 54 +-- static/app/views/issueList/groupListBody.tsx | 1 - .../supergroups/supergroupDrawer.tsx | 330 +++++++++++++++--- 4 files changed, 297 insertions(+), 149 deletions(-) delete mode 100644 static/app/components/stream/supergroups/supergroupCheckbox.tsx diff --git a/static/app/components/stream/supergroups/supergroupCheckbox.tsx b/static/app/components/stream/supergroups/supergroupCheckbox.tsx deleted file mode 100644 index d0aba581ff380a..00000000000000 --- a/static/app/components/stream/supergroups/supergroupCheckbox.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import {useCallback} from 'react'; -import styled from '@emotion/styled'; - -import {Checkbox} from '@sentry/scraps/checkbox'; - -import {t} from 'sentry/locale'; -import {useOptionalIssueSelectionActions} from 'sentry/views/issueList/issueSelectionContext'; - -interface SupergroupCheckboxProps { - matchedGroupIds: string[]; - selectedCount: number; -} - -export function SupergroupCheckbox({ - matchedGroupIds, - selectedCount, -}: SupergroupCheckboxProps) { - const actions = useOptionalIssueSelectionActions(); - - const checkedState = - selectedCount === 0 - ? false - : selectedCount === matchedGroupIds.length - ? true - : ('indeterminate' as const); - - const handleChange = useCallback( - (evt: React.MouseEvent) => { - evt.stopPropagation(); - const nextValue = checkedState !== true; - actions?.setSelectionForIds(matchedGroupIds, nextValue); - }, - [actions, matchedGroupIds, checkedState] - ); - - if (!actions) { - return null; - } - - return ( - - {}} - onClick={handleChange} - /> - - ); -} - -export const CheckboxLabel = styled('label')` - margin: 0; - display: flex; - align-items: center; - justify-content: center; -`; - -const CheckboxWithBackground = styled(Checkbox)` - background-color: ${p => p.theme.tokens.background.primary}; -`; diff --git a/static/app/components/stream/supergroups/supergroupRow.tsx b/static/app/components/stream/supergroups/supergroupRow.tsx index 399e4522a307dc..9ded1f077c0fbe 100644 --- a/static/app/components/stream/supergroups/supergroupRow.tsx +++ b/static/app/components/stream/supergroups/supergroupRow.tsx @@ -1,4 +1,4 @@ -import {useMemo, useState} from 'react'; +import {useState} from 'react'; import styled from '@emotion/styled'; import InteractionStateLayer from '@sentry/scraps/interactionStateLayer'; @@ -11,21 +11,15 @@ import {Count} from 'sentry/components/count'; import {useDrawer} from 'sentry/components/globalDrawer'; import {PanelItem} from 'sentry/components/panels/panelItem'; import {Placeholder} from 'sentry/components/placeholder'; -import { - CheckboxLabel, - SupergroupCheckbox, -} from 'sentry/components/stream/supergroups/supergroupCheckbox'; import {TimeSince} from 'sentry/components/timeSince'; import {IconStack} from 'sentry/icons'; import {t} from 'sentry/locale'; import {COLUMN_BREAKPOINTS} from 'sentry/views/issueList/actions/utils'; -import {useOptionalIssueSelectionSummary} from 'sentry/views/issueList/issueSelectionContext'; import type {AggregatedSupergroupStats} from 'sentry/views/issueList/supergroups/aggregateSupergroupStats'; import {SupergroupDetailDrawer} from 'sentry/views/issueList/supergroups/supergroupDrawer'; import type {SupergroupDetail} from 'sentry/views/issueList/supergroups/types'; interface SupergroupRowProps { - matchedGroupIds: string[]; supergroup: SupergroupDetail; aggregatedStats?: AggregatedSupergroupStats | null; memberList?: IndexedMembersByProject; @@ -33,11 +27,9 @@ interface SupergroupRowProps { export function SupergroupRow({ supergroup, - matchedGroupIds, aggregatedStats, memberList, }: SupergroupRowProps) { - const matchedCount = matchedGroupIds.length; const {openDrawer, isDrawerOpen} = useDrawer(); const [isActive, setIsActive] = useState(false); const handleClick = () => { @@ -58,20 +50,6 @@ export function SupergroupRow({ ); }; - const summary = useOptionalIssueSelectionSummary(); - const selectedCount = useMemo(() => { - if (!summary) { - return 0; - } - let count = 0; - for (const id of matchedGroupIds) { - if (summary.records.get(id)) { - count++; - } - } - return count; - }, [summary, matchedGroupIds]); - const highlighted = isActive && isDrawerOpen; return ( @@ -79,10 +57,6 @@ export function SupergroupRow({ - @@ -92,21 +66,15 @@ export function SupergroupRow({ {supergroup.error_type} - - {supergroup.code_area} - - {supergroup.code_area && matchedCount > 0 ? : null} - {matchedCount > 0 ? ( - 0 ? 'primary' : 'muted'} - bold={selectedCount > 0} - > - {selectedCount > 0 - ? `${selectedCount} / ${supergroup.group_ids.length} ${t('issues selected')}` - : `${matchedCount} / ${supergroup.group_ids.length} ${t('issues matched')}`} + {supergroup.code_area ? ( + + {supergroup.code_area} ) : null} + {supergroup.code_area ? : null} + + {`${supergroup.group_ids.length} ${t('issues')}`} + @@ -192,12 +160,6 @@ const Wrapper = styled(PanelItem)<{highlighted: boolean}>` min-height: 82px; background: ${p => p.highlighted ? p.theme.tokens.background.secondary : 'transparent'}; - - &:not(:hover):not(:has(input:checked)):not(:has(input:indeterminate)) { - ${CheckboxLabel} { - ${p => p.theme.visuallyHidden}; - } - } `; const Summary = styled('div')` diff --git a/static/app/views/issueList/groupListBody.tsx b/static/app/views/issueList/groupListBody.tsx index cb7d48cadff080..a4bb65dbc2bf4b 100644 --- a/static/app/views/issueList/groupListBody.tsx +++ b/static/app/views/issueList/groupListBody.tsx @@ -237,7 +237,6 @@ function GroupList({ diff --git a/static/app/views/issueList/supergroups/supergroupDrawer.tsx b/static/app/views/issueList/supergroups/supergroupDrawer.tsx index 1312ba80762c78..8e5abada896b48 100644 --- a/static/app/views/issueList/supergroups/supergroupDrawer.tsx +++ b/static/app/views/issueList/supergroups/supergroupDrawer.tsx @@ -1,12 +1,19 @@ -import {Fragment, useState} from 'react'; +import {Fragment, useCallback, useMemo, useState} from 'react'; import styled from '@emotion/styled'; import {Badge} from '@sentry/scraps/badge'; import {Button} from '@sentry/scraps/button'; +import {Checkbox} from '@sentry/scraps/checkbox'; import {inlineCodeStyles} from '@sentry/scraps/code'; import {Container, Flex, Stack} from '@sentry/scraps/layout'; import {Heading, Text} from '@sentry/scraps/text'; +import {bulkDelete, bulkUpdate, mergeGroups} from 'sentry/actionCreators/group'; +import { + addErrorMessage, + addLoadingMessage, + clearIndicators, +} from 'sentry/actionCreators/indicator'; import type {IndexedMembersByProject} from 'sentry/actionCreators/members'; import { CrumbContainer, @@ -15,8 +22,9 @@ import { } from 'sentry/components/events/eventDrawer'; import {DrawerBody, DrawerHeader} from 'sentry/components/globalDrawer/components'; import type {GroupListColumn} from 'sentry/components/issues/groupList'; -import {GroupListHeader} from 'sentry/components/issues/groupListHeader'; +import {IssueStreamHeaderLabel} from 'sentry/components/IssueStreamHeaderLabel'; import {ALL_ACCESS_PROJECTS} from 'sentry/components/pageFilters/constants'; +import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters'; import {Panel} from 'sentry/components/panels/panel'; import {PanelBody} from 'sentry/components/panels/panelBody'; import { @@ -26,14 +34,25 @@ import { } from 'sentry/components/stream/group'; import {IconChevron, IconFilter, IconFocus} from 'sentry/icons'; import {t} from 'sentry/locale'; +import {GroupStore} from 'sentry/stores/groupStore'; import type {Group} from 'sentry/types/group'; import {apiOptions} from 'sentry/utils/api/apiOptions'; +import {uniq} from 'sentry/utils/array/uniq'; import {MarkedText} from 'sentry/utils/marked/markedText'; -import {useQuery} from 'sentry/utils/queryClient'; +import {useQuery, useQueryClient} from 'sentry/utils/queryClient'; +import {useApi} from 'sentry/utils/useApi'; import {useLocation} from 'sentry/utils/useLocation'; import {useOrganization} from 'sentry/utils/useOrganization'; +import {ActionSet} from 'sentry/views/issueList/actions/actionSet'; +import {COLUMN_BREAKPOINTS, ConfirmAction} from 'sentry/views/issueList/actions/utils'; +import { + IssueSelectionProvider, + useIssueSelectionActions, + useIssueSelectionSummary, +} from 'sentry/views/issueList/issueSelectionContext'; import {SupergroupFeedback} from 'sentry/views/issueList/supergroups/supergroupFeedback'; import type {SupergroupDetail} from 'sentry/views/issueList/supergroups/types'; +import type {IssueUpdateData} from 'sentry/views/issueList/types'; const DRAWER_COLUMNS: GroupListColumn[] = [ 'graph', @@ -198,7 +217,10 @@ function SupergroupIssueList({ if (isPending) { return ( - + + {t('Issue')} + + {pageGroupIds.map(id => ( @@ -224,6 +246,8 @@ function SupergroupIssueList({ return 0; }); + const visibleGroupIds = sortedGroups.map(g => g.id); + return ( {matchedIds.size > 0 && ( @@ -234,34 +258,36 @@ function SupergroupIssueList({ )} - - - - {sortedGroups.map(group => { - const members = memberList?.[group.project?.slug] - ? memberList[group.project.slug] - : undefined; - return ( - - {matchedIds.has(group.id) && ( - - - - )} - - - ); - })} - - + + + + + {sortedGroups.map(group => { + const members = memberList?.[group.project?.slug] + ? memberList[group.project.slug] + : undefined; + return ( + + {matchedIds.has(group.id) && ( + + + + )} + + + ); + })} + + + {totalPages > 1 && ( @@ -289,6 +315,229 @@ function SupergroupIssueList({ ); } +function DrawerActionsBar({groupIds}: {groupIds: string[]}) { + const api = useApi(); + const organization = useOrganization(); + const queryClient = useQueryClient(); + const {selection} = usePageFilters(); + const {toggleSelectAllVisible, deselectAll} = useIssueSelectionActions(); + const {pageSelected, anySelected, multiSelected, selectedIdsSet} = + useIssueSelectionSummary(); + + const selectedProjectSlug = useMemo(() => { + const projects = [...selectedIdsSet] + .map(id => GroupStore.get(id)) + .filter((group): group is Group => !!group?.project) + .map(group => group.project.slug); + const uniqProjects = uniq(projects); + return uniqProjects.length === 1 ? uniqProjects[0] : undefined; + }, [selectedIdsSet]); + + const handleUpdate = useCallback( + (data: IssueUpdateData) => { + const itemIds = [...selectedIdsSet]; + if (itemIds.length) { + addLoadingMessage(t('Saving changes\u2026')); + } + bulkUpdate( + api, + { + orgId: organization.slug, + itemIds, + data, + project: selection.projects, + environment: selection.environments, + ...selection.datetime, + }, + { + success: () => { + clearIndicators(); + for (const itemId of itemIds) { + queryClient.invalidateQueries({ + queryKey: [`/organizations/${organization.slug}/issues/${itemId}/`], + exact: false, + }); + } + }, + error: () => { + clearIndicators(); + addErrorMessage(t('Unable to update issues')); + }, + } + ); + deselectAll(); + }, + [api, organization.slug, selectedIdsSet, selection, queryClient, deselectAll] + ); + + const handleDelete = useCallback(() => { + const itemIds = [...selectedIdsSet]; + bulkDelete( + api, + { + orgId: organization.slug, + itemIds, + project: selection.projects, + environment: selection.environments, + ...selection.datetime, + }, + {} + ); + deselectAll(); + }, [api, organization.slug, selectedIdsSet, selection, deselectAll]); + + const handleMerge = useCallback(() => { + const itemIds = [...selectedIdsSet]; + mergeGroups( + api, + { + orgId: organization.slug, + itemIds, + project: selection.projects, + environment: selection.environments, + ...selection.datetime, + }, + {} + ); + deselectAll(); + }, [api, organization.slug, selectedIdsSet, selection, deselectAll]); + + const onShouldConfirm = useCallback( + (action: ConfirmAction) => { + switch (action) { + case ConfirmAction.RESOLVE: + case ConfirmAction.UNRESOLVE: + case ConfirmAction.ARCHIVE: + case ConfirmAction.SET_PRIORITY: + case ConfirmAction.UNBOOKMARK: + return pageSelected && selectedIdsSet.size > 1; + case ConfirmAction.BOOKMARK: + return selectedIdsSet.size > 1; + case ConfirmAction.MERGE: + case ConfirmAction.DELETE: + default: + return true; + } + }, + [pageSelected, selectedIdsSet.size] + ); + + return ( + + + {anySelected ? ( + + + + ) : ( + + {t('Issue')} + + + )} + + ); +} + +function DrawerColumnHeaders() { + return ( + + {DRAWER_COLUMNS.includes('lastSeen') && ( + + {t('Last Seen')} + + )} + {DRAWER_COLUMNS.includes('firstSeen') && ( + + {t('Age')} + + )} + {DRAWER_COLUMNS.includes('graph') && ( + + {t('Graph')} + + )} + {DRAWER_COLUMNS.includes('event') && ( + + {t('Events')} + + )} + {DRAWER_COLUMNS.includes('users') && ( + + {t('Users')} + + )} + {DRAWER_COLUMNS.includes('assignee') && ( + + {t('Assignee')} + + )} + + ); +} + +const ActionsBarContainer = styled('div')` + display: flex; + gap: ${p => p.theme.space.md}; + height: 36px; + padding: 0; + padding-left: ${p => p.theme.space.xl}; + align-items: center; + background: ${p => p.theme.tokens.background.secondary}; + border-radius: ${p => p.theme.radius.md} ${p => p.theme.radius.md} 0 0; + border-bottom: 1px solid ${p => p.theme.tokens.border.primary}; +`; + +const HeaderButtonsWrapper = styled('div')` + flex: 1; + display: flex; + gap: ${p => p.theme.space.xs}; + white-space: nowrap; +`; + +const IssueLabel = styled(IssueStreamHeaderLabel)` + flex: 1; +`; + +const ColumnLabel = styled(IssueStreamHeaderLabel)` + width: 60px; +`; + +const GraphColumnLabel = styled(IssueStreamHeaderLabel)` + width: 175px; +`; + +const AssigneeColumnLabel = styled(IssueStreamHeaderLabel)` + width: 66px; +`; + +const LoadingHeader = styled('div')` + display: flex; + min-height: 36px; + padding: ${p => p.theme.space.xs} 0; + padding-left: ${p => p.theme.space.xl}; + align-items: center; + background: ${p => p.theme.tokens.background.secondary}; + border-radius: ${p => p.theme.radius.md} ${p => p.theme.radius.md} 0 0; + border-bottom: 1px solid ${p => p.theme.tokens.border.primary}; +`; + const DrawerContentBody = styled(DrawerBody)` padding: 0; `; @@ -300,20 +549,19 @@ const PanelContainer = styled(Panel)` const IssueRow = styled('div')` position: relative; - > * { - /* Leave room for checkbox + filter icon */ - padding-left: 12px; + /* Hide the unread indicator — the filter icon replaces it in this context */ + [data-test-id='unread-issue-indicator'] { + display: none; } `; -const MatchedIcon = styled('div')` +const MatchedIndicator = styled('div')` position: absolute; - top: 23px; - /* Positioned after where the checkbox will go */ - left: -2px; - transform: translateY(-50%); - z-index: 1; + top: 14px; + left: 18px; + z-index: 2; color: ${p => p.theme.tokens.graphics.accent.vibrant}; + pointer-events: none; `; const StyledMarkedText = styled(MarkedText)`