From 411353c539076b50e759e24b6a49a6b6b2139367 Mon Sep 17 00:00:00 2001 From: JonasBa Date: Thu, 9 Apr 2026 11:11:22 +0200 Subject: [PATCH] feat(cmdk): Add issue feed actions Extend the JSX-powered command palette to support richer action labels, scoped child limits, improved navigation behavior, and issue feed actions on the issues list page. This keeps the issue feed actions inside the CMDK model so users can bulk act, sort, and drill into visible issues from the palette. Co-Authored-By: OpenAI Codex --- .../commandPalette/__stories__/components.tsx | 15 +- .../app/components/commandPalette/ui/cmdk.tsx | 31 +- .../commandPalette/ui/collection.tsx | 1 + .../commandPalette/ui/commandPalette.spec.tsx | 8 +- .../commandPalette/ui/commandPalette.tsx | 132 +++++- .../ui/commandPaletteGlobalActions.tsx | 119 +++--- .../ui/commandPaletteStateContext.tsx | 13 +- .../components/commandPalette/ui/modal.tsx | 24 +- .../useCommandPaletteActions.mdx | 7 +- static/app/views/issueList/actions/index.tsx | 398 +++++++++++++++--- 10 files changed, 583 insertions(+), 165 deletions(-) diff --git a/static/app/components/commandPalette/__stories__/components.tsx b/static/app/components/commandPalette/__stories__/components.tsx index e7d81df6b38a..93fc5a148508 100644 --- a/static/app/components/commandPalette/__stories__/components.tsx +++ b/static/app/components/commandPalette/__stories__/components.tsx @@ -1,4 +1,5 @@ import {useCallback} from 'react'; +import type {LocationDescriptorObject} from 'history'; import {addSuccessMessage} from 'sentry/actionCreators/indicator'; import {CommandPaletteProvider} from 'sentry/components/commandPalette/ui/cmdk'; @@ -6,15 +7,27 @@ import {CMDKAction} from 'sentry/components/commandPalette/ui/cmdk'; import type {CMDKActionData} from 'sentry/components/commandPalette/ui/cmdk'; import type {CollectionTreeNode} from 'sentry/components/commandPalette/ui/collection'; import {CommandPalette} from 'sentry/components/commandPalette/ui/commandPalette'; +import type {CMDKModifierKeys} from 'sentry/components/commandPalette/ui/commandPalette'; import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import {useNavigate} from 'sentry/utils/useNavigate'; +function locationToString(to: string | LocationDescriptorObject) { + if (typeof to === 'string') { + return to; + } + return [to.pathname, to.search, to.hash].filter(Boolean).join(''); +} + export function CommandPaletteDemo() { const navigate = useNavigate(); const handleAction = useCallback( - (action: CollectionTreeNode) => { + (action: CollectionTreeNode, modifierKeys: CMDKModifierKeys) => { if ('to' in action) { + if (modifierKeys.shift) { + window.open(locationToString(normalizeUrl(action.to)), '_blank'); + return; + } navigate(normalizeUrl(action.to)); } else if ('onAction' in action) { action.onAction(); diff --git a/static/app/components/commandPalette/ui/cmdk.tsx b/static/app/components/commandPalette/ui/cmdk.tsx index 5847fb97e042..5572c271762d 100644 --- a/static/app/components/commandPalette/ui/cmdk.tsx +++ b/static/app/components/commandPalette/ui/cmdk.tsx @@ -13,15 +13,28 @@ import { useCommandPaletteState, } from './commandPaletteStateContext'; -interface DisplayProps { - label: string; - details?: string; +type DisplayProps = { icon?: React.ReactNode; -} +} & ( + | { + label: string; + details?: string; + searchableDetails?: never; + searchableLabel?: never; + } + | { + label: Exclude; + searchableLabel: string; + details?: React.ReactNode; + searchableDetails?: string; + } +); interface CMDKActionDataBase { display: DisplayProps; keywords?: string[]; + /** Maximum number of children to show in browse and search mode. */ + limit?: number; ref?: React.RefObject; } @@ -66,6 +79,8 @@ interface CMDKActionProps { display: DisplayProps; children?: React.ReactNode | ((data: CommandPaletteAsyncResult[]) => React.ReactNode); keywords?: string[]; + /** Maximum number of children to show in browse and search mode. */ + limit?: number; onAction?: () => void; resource?: (query: string) => CMDKQueryOptions; to?: LocationDescriptor; @@ -80,6 +95,7 @@ interface CMDKActionProps { export function CMDKAction({ display, keywords, + limit, children, to, onAction, @@ -90,11 +106,12 @@ export function CMDKAction({ const nodeData: CMDKActionData = to === undefined ? onAction === undefined - ? {display, keywords, ref, resource} - : {display, keywords, ref, onAction} - : {display, keywords, ref, to}; + ? {display, keywords, limit, ref, resource} + : {display, keywords, limit, ref, onAction} + : {display, keywords, limit, ref, to}; const key = CMDKCollection.useRegisterNode(nodeData); + const {query} = useCommandPaletteState(); const resourceOptions = resource diff --git a/static/app/components/commandPalette/ui/collection.tsx b/static/app/components/commandPalette/ui/collection.tsx index a16b3d0fd1b9..2f42fffc8b29 100644 --- a/static/app/components/commandPalette/ui/collection.tsx +++ b/static/app/components/commandPalette/ui/collection.tsx @@ -164,6 +164,7 @@ export function makeCollection(): CollectionInstance { return () => { store.unregister(key); }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [key, parentKey, store]); return key; diff --git a/static/app/components/commandPalette/ui/commandPalette.spec.tsx b/static/app/components/commandPalette/ui/commandPalette.spec.tsx index c0147e89d611..4c108ab38154 100644 --- a/static/app/components/commandPalette/ui/commandPalette.spec.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.spec.tsx @@ -30,6 +30,7 @@ import {CMDKAction} from 'sentry/components/commandPalette/ui/cmdk'; import type {CMDKActionData} from 'sentry/components/commandPalette/ui/cmdk'; import type {CollectionTreeNode} from 'sentry/components/commandPalette/ui/collection'; import {CommandPalette} from 'sentry/components/commandPalette/ui/commandPalette'; +import type {CMDKModifierKeys} from 'sentry/components/commandPalette/ui/commandPalette'; import {CommandPaletteSlot} from 'sentry/components/commandPalette/ui/commandPaletteSlot'; import {useNavigate} from 'sentry/utils/useNavigate'; @@ -84,7 +85,7 @@ function GlobalActionsComponent({ const navigate = useNavigate(); const handleAction = useCallback( - (action: CollectionTreeNode) => { + (action: CollectionTreeNode, _modifierKeys: CMDKModifierKeys) => { if ('to' in action) { navigate(action.to); } else if ('onAction' in action) { @@ -372,7 +373,10 @@ describe('CommandPalette', () => { // Mirror the updated modal.tsx handleSelect: invoke callback, skip close when // action has children so the palette can push into the secondary actions. - const handleAction = (action: CollectionTreeNode) => { + const handleAction = ( + action: CollectionTreeNode, + _modifierKeys: CMDKModifierKeys + ) => { if ('onAction' in action) { action.onAction(); if (action.children.length > 0) { diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index 5900bc7cb7b7..f338c18eb207 100644 --- a/static/app/components/commandPalette/ui/commandPalette.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.tsx @@ -1,4 +1,4 @@ -import {Fragment, useCallback, useLayoutEffect, useMemo, useRef} from 'react'; +import {Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef} from 'react'; import {preload} from 'react-dom'; import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; @@ -30,7 +30,7 @@ import { import {useCommandPaletteAnalytics} from 'sentry/components/commandPalette/useCommandPaletteAnalytics'; import {FeedbackButton} from 'sentry/components/feedbackButton/feedbackButton'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; -import {IconArrow, IconClose, IconSearch} from 'sentry/icons'; +import {IconArrow, IconClose, IconLink, IconOpen, IconSearch} from 'sentry/icons'; import {IconDefaultsProvider} from 'sentry/icons/useIconDefaults'; import {t} from 'sentry/locale'; import {fzf} from 'sentry/utils/search/fzf'; @@ -64,8 +64,15 @@ type CMDKFlatItem = CollectionTreeNode & { listItemType: 'action' | 'section'; }; +export interface CMDKModifierKeys { + shift: boolean; +} + interface CommandPaletteProps { - onAction: (action: CollectionTreeNode) => void; + onAction: ( + action: CollectionTreeNode, + modifierKeys: CMDKModifierKeys + ) => void; children?: React.ReactNode; } @@ -90,7 +97,7 @@ export function CommandPalette(props: CommandPaletteProps) { const actions = useMemo(() => { if (!state.query) { - return flattenActions(currentNodes, null); + return flattenActions(currentNodes, null, !state.action); } const scores = new Map< @@ -99,7 +106,7 @@ export function CommandPalette(props: CommandPaletteProps) { >(); scoreTree(currentNodes, scores, state.query.toLowerCase()); return flattenActions(currentNodes, scores); - }, [currentNodes, state.query]); + }, [currentNodes, state.query, state.action]); const analytics = useCommandPaletteAnalytics(actions.length); @@ -116,12 +123,14 @@ export function CommandPalette(props: CommandPaletteProps) { children: actions.map(action => { const menuItem = makeMenuItemFromAction(action); + const textValue = getTextValue(action.display); + if (action.listItemType === 'section') { return ( {...menuItem} key={action.key} - textValue={action.display.label} + textValue={textValue} {...{ leadingItems: null, label: ( @@ -152,7 +161,7 @@ export function CommandPalette(props: CommandPaletteProps) { {...menuItem} key={action.key} - textValue={action.display.label} + textValue={textValue} > {menuItem.label} @@ -211,25 +220,72 @@ export function CommandPalette(props: CommandPaletteProps) { const resultIndex = actions.indexOf(action); if (action.children.length > 0) { - analytics.recordGroupAction(action, resultIndex); + analytics.recordGroupAction( + { + display: {label: getTextValue(action.display)}, + ...('to' in action ? {to: action.to} : {}), + }, + resultIndex + ); if ('onAction' in action) { // Invoke the callback but keep the modal open so users can select // secondary actions from the children that follow. - props.onAction(action); + props.onAction(action, {shift: false}); } - dispatch({type: 'push action', key: action.key, label: action.display.label}); + dispatch({ + type: 'push action', + key: action.key, + label: getTextValue(action.display), + }); return; } - analytics.recordAction(action, resultIndex, ''); - dispatch({type: 'trigger action'}); - props.onAction(action); + const modifierKeys: CMDKModifierKeys = {shift: shiftHeldRef.current}; + + analytics.recordAction( + { + display: {label: getTextValue(action.display)}, + ...('to' in action ? {to: action.to} : {}), + }, + resultIndex, + '' + ); + // When Shift is held and the action is a navigation link, keep the palette + // open (no trigger action dispatch) so the user can continue selecting. + if (!modifierKeys.shift || !('to' in action)) { + dispatch({type: 'trigger action'}); + } + props.onAction(action, modifierKeys); }, [actions, analytics, dispatch, props] ); const resultsListRef = useRef(null); + useEffect(() => { + if (resultsListRef.current) { + resultsListRef.current.scrollTop = 0; + } + }, [state.action, state.query]); + + // Track whether Shift is held so that actions with `to` can be opened in a + // new tab. Using a ref (instead of state) avoids re-renders on every keypress. + const shiftHeldRef = useRef(false); + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Shift') shiftHeldRef.current = true; + }; + const onKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Shift') shiftHeldRef.current = false; + }; + document.addEventListener('keydown', onKeyDown); + document.addEventListener('keyup', onKeyUp); + return () => { + document.removeEventListener('keydown', onKeyDown); + document.removeEventListener('keyup', onKeyUp); + }; + }, []); + const debouncedQuery = useDebouncedValue(state.query, 300); const isLoading = state.query.length > 0 && debouncedQuery !== state.query; @@ -294,9 +350,6 @@ export function CommandPalette(props: CommandPaletteProps) { onChange: (e: React.ChangeEvent) => { dispatch({type: 'set query', query: e.target.value}); treeState.selectionManager.setFocusedKey(null); - if (resultsListRef.current) { - resultsListRef.current.scrollTop = 0; - } }, onKeyDown: (e: React.KeyboardEvent) => { if (e.key === 'Backspace' && state.query.length === 0) { @@ -417,8 +470,13 @@ function scoreNode( query: string, node: CollectionTreeNode ): {matched: boolean; score: number} { - const label = node.display.label; - const details = node.display.details ?? ''; + const display = node.display; + const label = + typeof display.label === 'string' ? display.label : display.searchableLabel; + const details = + typeof display.details === 'string' + ? display.details + : (display.searchableDetails ?? ''); const keywords = node.keywords ?? []; // Score each field independently and take the best result. This lets @@ -464,7 +522,11 @@ function flattenActions( scores: Map< string, {node: CollectionTreeNode; score: {matched: boolean; score: number}} - > | null + > | null, + // Only expand groups inline at the true root level. When the user has + // navigated into a group, nested groups should appear as navigable actions + // rather than being pre-expanded as section headers with their children. + expandGroups = true ): CMDKFlatItem[] { // Browse mode: show each top-level node and its direct children. if (!scores) { @@ -476,11 +538,21 @@ function flattenActions( if (!isGroup && !('to' in node) && !('onAction' in node)) { continue; } - results.push({...node, listItemType: isGroup ? 'section' : 'action'}); - if (isGroup) { - for (const child of node.children) { + if (isGroup && expandGroups) { + // Expand the group inline: render it as a section header followed by + // its direct children. This only happens at the true root level so + // that nested groups (e.g. "Set Priority" inside "Select All") are + // shown as navigable actions rather than pre-expanded sections. + results.push({...node, listItemType: 'section'}); + for (const child of node.limit === undefined + ? node.children + : node.children.slice(0, node.limit)) { results.push({...child, listItemType: 'action'}); } + } else { + // Leaf action or a group that should not be expanded inline — treat + // as a single navigable item regardless. + results.push({...node, listItemType: 'action'}); } } return results; @@ -526,6 +598,7 @@ function flattenActions( (scores.get(b.key)?.score.score ?? 0) - (scores.get(a.key)?.score.score ?? 0) ) + .slice(0, item.limit) .map(c => ({...c, listItemType: 'action' as const})), ]; } @@ -540,6 +613,12 @@ function flattenActions( }); } +function getTextValue(display: CMDKActionData['display']): string { + return typeof display.label === 'string' + ? display.label + : (display.searchableLabel ?? ''); +} + function makeMenuItemFromAction(action: CMDKFlatItem): CommandPaletteActionMenuItem { return { key: action.key, @@ -558,6 +637,15 @@ function makeMenuItemFromAction(action: CMDKFlatItem): CommandPaletteActionMenuI {action.display.icon} ), + trailingItems: + 'to' in action ? ( + typeof action.to === 'string' && + (action.to.startsWith('http://') || action.to.startsWith('https://')) ? ( + + ) : ( + + ) + ) : undefined, children: [], hideCheck: true, }; diff --git a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx index 31c7dab78a98..c8244f218eb5 100644 --- a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx +++ b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx @@ -4,7 +4,6 @@ import DOMPurify from 'dompurify'; import {ProjectAvatar} from '@sentry/scraps/avatar'; import {addLoadingMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; -import {openInviteMembersModal} from 'sentry/actionCreators/modal'; import {openSudo} from 'sentry/actionCreators/sudoModal'; import type { CMDKQueryOptions, @@ -30,7 +29,6 @@ import { IconSearch, IconSettings, IconStar, - IconUser, } from 'sentry/icons'; import {t} from 'sentry/locale'; import {apiOptions} from 'sentry/utils/api/apiOptions'; @@ -108,13 +106,15 @@ export function GlobalCommandPaletteActions() { to={`${prefix}/issues/feedback/`} /> - {starredViews.map(starredView => ( - }} - to={`${prefix}/issues/views/${starredView.id}/`} - /> - ))} + }}> + {starredViews.map(starredView => ( + + ))} + }}> @@ -208,7 +208,10 @@ export function GlobalCommandPaletteActions() { )} - }}> + }} + limit={10} + > {projects.map(project => ( + {user.isStaff && ( + + }} + keywords={[t('superuser')]} + to="/_admin/" + /> + , + }} + keywords={[t('superuser')]} + to={`/_admin/customers/${organization.slug}/`} + /> + {!isActiveSuperuser() && ( + }} + keywords={[t('superuser')]} + onAction={() => openSudo({isSuperuser: true, needsReload: true})} + /> + )} + {isActiveSuperuser() && ( + }} + keywords={[t('superuser')]} + onAction={() => exitSuperuser()} + /> + )} + + )} + }} @@ -238,17 +273,13 @@ export function GlobalCommandPaletteActions() { keywords={[t('add project')]} to={`${prefix}/projects/new/`} /> - }} - keywords={[t('team invite')]} - onAction={openInviteMembersModal} - /> }} keywords={[t('client keys'), t('dsn keys')]} + limit={10} > {projects.map(project => ( }} - onAction={() => window.open('https://docs.sentry.io', '_blank', 'noreferrer')} + to="https://docs.sentry.io" /> }} - onAction={() => - window.open('https://discord.gg/sentry', '_blank', 'noreferrer') - } + to="https://discord.gg/sentry" /> }} - onAction={() => - window.open('https://github.com/getsentry/sentry', '_blank', 'noreferrer') - } + to="https://github.com/getsentry/sentry" /> }} - onAction={() => - window.open('https://sentry.io/changelog/', '_blank', 'noreferrer') - } + display={{label: t('View Changelog')}} + to="https://sentry.io/changelog/" /> - - }}> + + { @@ -395,44 +420,6 @@ export function GlobalCommandPaletteActions() { /> - - {user.isStaff && ( - - }} - keywords={[t('superuser')]} - onAction={() => window.open('/_admin/', '_blank', 'noreferrer')} - /> - , - }} - keywords={[t('superuser')]} - onAction={() => - window.open( - `/_admin/customers/${organization.slug}/`, - '_blank', - 'noreferrer' - ) - } - /> - {!isActiveSuperuser() && ( - }} - keywords={[t('superuser')]} - onAction={() => openSudo({isSuperuser: true, needsReload: true})} - /> - )} - {isActiveSuperuser() && ( - }} - keywords={[t('superuser')]} - onAction={() => exitSuperuser()} - /> - )} - - )} ); } diff --git a/static/app/components/commandPalette/ui/commandPaletteStateContext.tsx b/static/app/components/commandPalette/ui/commandPaletteStateContext.tsx index b50c37a1de0a..9be341f229ca 100644 --- a/static/app/components/commandPalette/ui/commandPaletteStateContext.tsx +++ b/static/app/components/commandPalette/ui/commandPaletteStateContext.tsx @@ -46,11 +46,20 @@ function commandPaletteReducer( ): CommandPaletteState { const type = action.type; switch (type) { - case 'toggle modal': + case 'toggle modal': { + const nextOpen = !state.open; return { ...state, - open: !state.open, + open: nextOpen, + // Reset navigation and query when opening. The slot system portals + // CMDKAction children into the outlet's DOM element, so switching + // between portal and in-place rendering causes React to remount those + // components with fresh useId() keys. Any nav-stack key stored from a + // prior session is therefore stale — resetting here ensures the palette + // always opens at the root. + ...(nextOpen ? {action: null, query: ''} : {}), }; + } case 'reset': return { ...state, diff --git a/static/app/components/commandPalette/ui/modal.tsx b/static/app/components/commandPalette/ui/modal.tsx index 11303e4982a3..c0ce6ea690b0 100644 --- a/static/app/components/commandPalette/ui/modal.tsx +++ b/static/app/components/commandPalette/ui/modal.tsx @@ -1,22 +1,42 @@ import {useCallback} from 'react'; import {css} from '@emotion/react'; +import type {LocationDescriptorObject} from 'history'; import type {ModalRenderProps} from 'sentry/actionCreators/modal'; import type {CMDKActionData} from 'sentry/components/commandPalette/ui/cmdk'; import type {CollectionTreeNode} from 'sentry/components/commandPalette/ui/collection'; import {CommandPalette} from 'sentry/components/commandPalette/ui/commandPalette'; +import type {CMDKModifierKeys} from 'sentry/components/commandPalette/ui/commandPalette'; import {GlobalCommandPaletteActions} from 'sentry/components/commandPalette/ui/commandPaletteGlobalActions'; import type {Theme} from 'sentry/utils/theme'; import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import {useNavigate} from 'sentry/utils/useNavigate'; +function locationToString(to: string | LocationDescriptorObject): string { + if (typeof to === 'string') { + return to; + } + return [to.pathname, to.search, to.hash].filter(Boolean).join(''); +} + export default function CommandPaletteModal({Body, closeModal}: ModalRenderProps) { const navigate = useNavigate(); const handleSelect = useCallback( - (action: CollectionTreeNode) => { + (action: CollectionTreeNode, modifierKeys: CMDKModifierKeys) => { if ('to' in action) { - navigate(normalizeUrl(action.to)); + const href = locationToString(action.to); + const isExternal = href.startsWith('http://') || href.startsWith('https://'); + if (isExternal) { + window.open(href, '_blank', 'noreferrer'); + } else if (modifierKeys.shift) { + // Open in a new tab and leave the palette open so the user can + // continue selecting more items. + window.open(locationToString(normalizeUrl(action.to)), '_blank'); + return; + } else { + navigate(normalizeUrl(action.to)); + } } else if ('onAction' in action) { action.onAction(); // When the action has children, the palette will push into them so the diff --git a/static/app/components/commandPalette/useCommandPaletteActions.mdx b/static/app/components/commandPalette/useCommandPaletteActions.mdx index 5f5b11de3840..96199445aea4 100644 --- a/static/app/components/commandPalette/useCommandPaletteActions.mdx +++ b/static/app/components/commandPalette/useCommandPaletteActions.mdx @@ -45,6 +45,7 @@ import { import type {CMDKActionData} from 'sentry/components/commandPalette/ui/cmdk'; import type {CollectionTreeNode} from 'sentry/components/commandPalette/ui/collection'; import {CommandPalette} from 'sentry/components/commandPalette/ui/commandPalette'; +import type {CMDKModifierKeys} from 'sentry/components/commandPalette/ui/commandPalette'; import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import {useNavigate} from 'sentry/utils/useNavigate'; @@ -52,8 +53,12 @@ function YourComponent() { const navigate = useNavigate(); const handleAction = useCallback( - (action: CollectionTreeNode) => { + (action: CollectionTreeNode, modifierKeys: CMDKModifierKeys) => { if ('to' in action) { + if (modifierKeys.shift) { + window.open(String(normalizeUrl(action.to)), '_blank'); + return; + } navigate(normalizeUrl(String(action.to))); } else if ('onAction' in action) { action.onAction(); diff --git a/static/app/views/issueList/actions/index.tsx b/static/app/views/issueList/actions/index.tsx index 3affc33c3d4c..b40a489b21e5 100644 --- a/static/app/views/issueList/actions/index.tsx +++ b/static/app/views/issueList/actions/index.tsx @@ -4,8 +4,10 @@ import styled from '@emotion/styled'; import {AnimatePresence, motion, type MotionNodeAnimationOptions} from 'framer-motion'; import {Alert} from '@sentry/scraps/alert'; +import {ProjectAvatar} from '@sentry/scraps/avatar'; import {Checkbox} from '@sentry/scraps/checkbox'; -import {Flex} from '@sentry/scraps/layout'; +import {Container, Flex} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; import {bulkDelete, bulkUpdate, mergeGroups} from 'sentry/actionCreators/group'; import { @@ -13,19 +15,37 @@ import { addLoadingMessage, clearIndicators, } from 'sentry/actionCreators/indicator'; +import {IconCellSignal} from 'sentry/components/badge/iconCellSignal'; +import {CMDKAction} from 'sentry/components/commandPalette/ui/cmdk'; +import {CommandPaletteSlot} from 'sentry/components/commandPalette/ui/commandPaletteSlot'; +import {ErrorLevel} from 'sentry/components/events/errorLevel'; import {IssueStreamHeaderLabel} from 'sentry/components/IssueStreamHeaderLabel'; import {Sticky} from 'sentry/components/sticky'; +import {TimeSince} from 'sentry/components/timeSince'; +import { + IconCheckmark, + IconIssues, + IconMerge, + IconMute, + IconSliders, + IconSort, + IconStack, +} from 'sentry/icons'; import {t, tct, tn} from 'sentry/locale'; import {GroupStore} from 'sentry/stores/groupStore'; import {ProjectsStore} from 'sentry/stores/projectsStore'; import type {PageFilters} from 'sentry/types/core'; import type {Group} from 'sentry/types/group'; +import {GroupStatus, GroupSubstatus, PriorityLevel} from 'sentry/types/group'; import {defined} from 'sentry/utils'; import {trackAnalytics} from 'sentry/utils/analytics'; import {uniq} from 'sentry/utils/array/uniq'; import {useQueryClient} from 'sentry/utils/queryClient'; +import {decodeScalar} from 'sentry/utils/queryString'; import {useApi} from 'sentry/utils/useApi'; +import {useLocation} from 'sentry/utils/useLocation'; import {useMedia} from 'sentry/utils/useMedia'; +import {useNavigate} from 'sentry/utils/useNavigate'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useSyncedLocalStorageState} from 'sentry/utils/useSyncedLocalStorageState'; import { @@ -33,7 +53,13 @@ import { useIssueSelectionSummary, } from 'sentry/views/issueList/issueSelectionContext'; import type {IssueUpdateData} from 'sentry/views/issueList/types'; -import {SAVED_SEARCHES_SIDEBAR_OPEN_LOCALSTORAGE_KEY} from 'sentry/views/issueList/utils'; +import { + DEFAULT_ISSUE_STREAM_SORT, + FOR_REVIEW_QUERIES, + getSortLabel, + IssueSortOptions, + SAVED_SEARCHES_SIDEBAR_OPEN_LOCALSTORAGE_KEY, +} from 'sentry/views/issueList/utils'; import {ActionSet} from './actionSet'; import {Headers} from './headers'; @@ -167,6 +193,12 @@ export function IssueListActions({ const api = useApi(); const queryClient = useQueryClient(); const organization = useOrganization(); + const location = useLocation(); + const navigate = useNavigate(); + const sort = decodeScalar( + location.query.sort, + DEFAULT_ISSUE_STREAM_SORT + ) as IssueSortOptions; const {setAllInQuerySelected, deselectAll, toggleSelectAllVisible} = useIssueSelectionActions(); const {pageSelected, multiSelected, anySelected, allInQuerySelected, selectedIdsSet} = @@ -272,6 +304,60 @@ export function IssueListActions({ return selection.projects; } + function handleUpdateForItems(itemIds: string[], data: IssueUpdateData) { + if ('status' in data && data.status === 'ignored') { + const statusDetails = + 'ignoreCount' in data.statusDetails + ? 'ignoreCount' + : 'ignoreDuration' in data.statusDetails + ? 'ignoreDuration' + : 'ignoreUserCount' in data.statusDetails + ? 'ignoreUserCount' + : undefined; + trackAnalytics('issues_stream.archived', { + action_status_details: statusDetails, + action_substatus: data.substatus, + organization, + }); + } + if ('priority' in data) { + trackAnalytics('issues_stream.updated_priority', { + organization, + priority: data.priority, + }); + } + addLoadingMessage(t('Saving changes\u2026')); + bulkUpdate( + api, + { + orgId: organization.slug, + itemIds, + data, + query, + environment: selection.environments, + failSilently: true, + project: getSelectedProjectIds(itemIds), + ...selection.datetime, + }, + { + success: () => { + clearIndicators(); + onActionTaken?.(itemIds, data); + for (const itemId of itemIds) { + queryClient.invalidateQueries({ + queryKey: [`/organizations/${organization.slug}/issues/${itemId}/`], + exact: false, + }); + } + }, + error: () => { + clearIndicators(); + addErrorMessage(t('Unable to update issues')); + }, + } + ); + } + function handleUpdate(data: IssueUpdateData) { if ('status' in data && data.status === 'ignored') { const statusDetails = @@ -355,69 +441,257 @@ export function IssueListActions({ } return ( - - - {!allResultsVisible && pageSelected && ( - - - {allInQuerySelected ? ( - queryCount >= BULK_LIMIT ? ( - tct( - 'Selected up to the first [count] issues that match this search query.', - { - count: BULK_LIMIT_STR, + + + }} limit={6}> + }} + onAction={() => { + if (!allInQuerySelected) { + toggleSelectAllVisible(); + setAllInQuerySelected(true); + } + }} + > + }} + onAction={() => + handleUpdate({status: GroupStatus.RESOLVED, statusDetails: {}}) + } + /> + }} + onAction={() => + handleUpdate({ + status: GroupStatus.IGNORED, + statusDetails: {}, + substatus: GroupSubstatus.ARCHIVED_UNTIL_ESCALATING, + }) + } + /> + }}> + }} + onAction={() => handleUpdate({priority: PriorityLevel.HIGH})} + /> + }} + onAction={() => handleUpdate({priority: PriorityLevel.MEDIUM})} + /> + }} + onAction={() => handleUpdate({priority: PriorityLevel.LOW})} + /> + + {groupIds.length > 1 && ( + }} + onAction={handleMerge} + /> + )} + + }}> + {[ + ...(FOR_REVIEW_QUERIES.includes(query || '') + ? [IssueSortOptions.INBOX] + : []), + IssueSortOptions.DATE, + IssueSortOptions.NEW, + IssueSortOptions.TRENDS, + IssueSortOptions.FREQ, + IssueSortOptions.USER, + ].map(sortOption => ( + : undefined, + }} + onAction={() => { + trackAnalytics('issues_stream.sort_changed', { + organization, + sort: sortOption, + }); + navigate({...location, query: {...location.query, sort: sortOption}}); + }} + /> + ))} + + + }} limit={6}> + {groupIds.map(id => { + const group = GroupStore.get(id); + if (!group) return null; + + const errorType = group.metadata.type; + const errorValue = group.metadata.value; + const labelText = errorType + ? `${errorType}: ${errorValue ?? ''}` + : group.title; + const detailsText = [ + group.project.slug, + group.assignedTo ? `assigned to ${group.assignedTo.name}` : 'unassigned', + group.substatus, + ] + .filter(Boolean) + .join(' '); + + return ( + + + + + {errorType ? ( + + + {errorType} + + {errorValue ? `: ${errorValue}` : null} + + ) : ( + group.title + )} + + + + ), + searchableLabel: labelText, + details: ( + + + + {group.project.slug},{' '} + {group.assignedTo + ? tct('assigned to: [name]', {name: group.assignedTo.name}) + : t('Unassigned')} + {', '} + + {(group.substatus === GroupSubstatus.ESCALATING || + group.substatus === GroupSubstatus.ONGOING || + group.substatus === GroupSubstatus.REGRESSED) && + `, ${group.substatus}`} + + + ), + searchableDetails: detailsText, + }} + > + }} + onAction={() => + handleUpdateForItems([id], { + status: GroupStatus.RESOLVED, + statusDetails: {}, + }) } + /> + }} + onAction={() => + handleUpdateForItems([id], { + status: GroupStatus.IGNORED, + statusDetails: {}, + substatus: GroupSubstatus.ARCHIVED_UNTIL_ESCALATING, + }) + } + /> + }}> + }} + onAction={() => + handleUpdateForItems([id], {priority: PriorityLevel.HIGH}) + } + /> + }} + onAction={() => + handleUpdateForItems([id], {priority: PriorityLevel.MEDIUM}) + } + /> + }} + onAction={() => + handleUpdateForItems([id], {priority: PriorityLevel.LOW}) + } + /> + + + ); + })} + + + + + {!allResultsVisible && pageSelected && ( + + + {allInQuerySelected ? ( + queryCount >= BULK_LIMIT ? ( + tct( + 'Selected up to the first [count] issues that match this search query.', + { + count: BULK_LIMIT_STR, + } + ) + ) : ( + tct('Selected all [count] issues that match this search query.', { + count: queryCount, + }) ) ) : ( - tct('Selected all [count] issues that match this search query.', { - count: queryCount, - }) - ) - ) : ( - - {tn( - '%s issue on this page selected.', - '%s issues on this page selected.', - numIssues - )} - - setAllInQuerySelected(true)}> - {queryCount >= BULK_LIMIT - ? tct( - 'Select the first [count] issues that match this search query.', - { - count: BULK_LIMIT_STR, - } - ) - : tct('Select all [count] issues that match this search query.', { - count: queryCount, - })} - - - )} - - - )} - + + {tn( + '%s issue on this page selected.', + '%s issues on this page selected.', + numIssues + )} + + setAllInQuerySelected(true)}> + {queryCount >= BULK_LIMIT + ? tct( + 'Select the first [count] issues that match this search query.', + { + count: BULK_LIMIT_STR, + } + ) + : tct('Select all [count] issues that match this search query.', { + count: queryCount, + })} + + + )} + + + )} + + ); }