diff --git a/static/app/components/commandPalette/__stories__/components.tsx b/static/app/components/commandPalette/__stories__/components.tsx index e7d81df6b38a..8cfbb59b8b2b 100644 --- a/static/app/components/commandPalette/__stories__/components.tsx +++ b/static/app/components/commandPalette/__stories__/components.tsx @@ -6,6 +6,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 {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import {useNavigate} from 'sentry/utils/useNavigate'; @@ -13,8 +14,12 @@ export function CommandPaletteDemo() { 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(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..57f86ef1aa2f 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} @@ -215,21 +224,55 @@ export function CommandPalette(props: CommandPaletteProps) { 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; } + const modifierKeys: CMDKModifierKeys = {shift: shiftHeldRef.current}; + analytics.recordAction(action, resultIndex, ''); - dispatch({type: 'trigger action'}); - props.onAction(action); + // 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 +337,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 +457,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 +509,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 +525,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 +585,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 +600,10 @@ 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 +622,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/components/core/slot/slot.spec.tsx b/static/app/components/core/slot/slot.spec.tsx index d23b461d357c..16b2a26123fb 100644 --- a/static/app/components/core/slot/slot.spec.tsx +++ b/static/app/components/core/slot/slot.spec.tsx @@ -11,7 +11,7 @@ describe('slot', () => { expect(SlotModule.Fallback).toBeDefined(); }); - it('renders children in place when no Outlet is registered', () => { + it('renders nothing when no Outlet is registered', () => { const SlotModule = slot(['header'] as const); render( @@ -22,7 +22,7 @@ describe('slot', () => { ); - expect(screen.getByText('inline content')).toBeInTheDocument(); + expect(screen.queryByText('inline content')).not.toBeInTheDocument(); }); it('portals children to the Outlet element', () => { @@ -44,7 +44,7 @@ describe('slot', () => { ); }); - it('multiple slot consumers render their children independently', () => { + it('multiple slot consumers render nothing independently when no Outlet is registered', () => { const SlotModule = slot(['a', 'b'] as const); render( @@ -58,8 +58,8 @@ describe('slot', () => { ); - expect(screen.getByText('slot a content')).toBeInTheDocument(); - expect(screen.getByText('slot b content')).toBeInTheDocument(); + expect(screen.queryByText('slot a content')).not.toBeInTheDocument(); + expect(screen.queryByText('slot b content')).not.toBeInTheDocument(); }); it('consumer throws when rendered outside provider', () => { diff --git a/static/app/components/core/slot/slot.tsx b/static/app/components/core/slot/slot.tsx index a405d3476a0a..62467617aa47 100644 --- a/static/app/components/core/slot/slot.tsx +++ b/static/app/components/core/slot/slot.tsx @@ -140,8 +140,6 @@ function makeSlotConsumer( return () => dispatch({type: 'decrement counter', name}); }, [dispatch, name]); - const element = state[name]?.element; - // Provide outletNameContext from the consumer so that portaled children // (which don't descend through the outlet in the component tree) can still // read which slot they belong to via useSlotOutletRef. @@ -151,10 +149,12 @@ function makeSlotConsumer( ); + const element = state[name]?.element; + if (!element) { - // Render in place as a fallback when no target element is registered yet - return wrappedChildren; + return null; } + return createPortal(wrappedChildren, element); } 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, + })} + + + )} + + + )} + + ); } diff --git a/static/app/views/issueList/groupListBody.tsx b/static/app/views/issueList/groupListBody.tsx index fad13644e0ff..cb7d48cadff0 100644 --- a/static/app/views/issueList/groupListBody.tsx +++ b/static/app/views/issueList/groupListBody.tsx @@ -6,7 +6,7 @@ import type {GroupListColumn} from 'sentry/components/issues/groupList'; import {LoadingError} from 'sentry/components/loadingError'; import {PanelBody} from 'sentry/components/panels/panelBody'; import {LoadingStreamGroup, StreamGroup} from 'sentry/components/stream/group'; -import {SupergroupRow} from 'sentry/components/stream/supergroupRow'; +import {SupergroupRow} from 'sentry/components/stream/supergroups/supergroupRow'; import {GroupStore} from 'sentry/stores/groupStore'; import type {Group} from 'sentry/types/group'; import {useApi} from 'sentry/utils/useApi';