diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index 62fc12030669..84d8da0439ba 100644 --- a/static/app/components/commandPalette/ui/commandPalette.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.tsx @@ -135,11 +135,13 @@ export function CommandPalette(props: CommandPaletteProps) { leadingItems: null, label: ( - + {action.display.icon} - {action.display.label} + + {action.display.label} + ), diff --git a/static/app/views/issueDetails/actions/index.spec.tsx b/static/app/views/issueDetails/actions/index.spec.tsx index b8e6921bbd21..5ca98b05edfd 100644 --- a/static/app/views/issueDetails/actions/index.spec.tsx +++ b/static/app/views/issueDetails/actions/index.spec.tsx @@ -3,6 +3,8 @@ import {EventStacktraceExceptionFixture} from 'sentry-fixture/eventStacktraceExc import {GroupFixture} from 'sentry-fixture/group'; import {OrganizationFixture} from 'sentry-fixture/organization'; import {ProjectFixture} from 'sentry-fixture/project'; +import {TeamFixture} from 'sentry-fixture/team'; +import {UserFixture} from 'sentry-fixture/user'; import { render, @@ -12,12 +14,22 @@ import { within, } from 'sentry-test/reactTestingLibrary'; +import { + CMDKCollection, + CommandPaletteProvider, + type CMDKActionData, +} from 'sentry/components/commandPalette/ui/cmdk'; +import type {CollectionTreeNode} from 'sentry/components/commandPalette/ui/collection'; +import {CommandPaletteSlot} from 'sentry/components/commandPalette/ui/commandPaletteSlot'; import {GlobalModal} from 'sentry/components/globalModal'; import {mockTour} from 'sentry/components/tours/testUtils'; import {ConfigStore} from 'sentry/stores/configStore'; +import {MemberListStore} from 'sentry/stores/memberListStore'; import {ModalStore} from 'sentry/stores/modalStore'; -import {GroupStatus, IssueCategory} from 'sentry/types/group'; +import {ProjectsStore} from 'sentry/stores/projectsStore'; +import {GroupStatus, IssueCategory, PriorityLevel, type Group} from 'sentry/types/group'; import * as analytics from 'sentry/utils/analytics'; +import {getMessage, getTitle} from 'sentry/utils/events'; import {GroupActions} from 'sentry/views/issueDetails/actions'; import {useGroup} from 'sentry/views/issueDetails/useGroup'; @@ -25,6 +37,7 @@ const project = ProjectFixture({ id: '2448', name: 'project name', slug: 'project', + teams: [TeamFixture({id: '3', slug: 'frontend', name: 'Frontend'})], }); const group = GroupFixture({ @@ -51,11 +64,52 @@ jest.mock('sentry/views/issueDetails/issueDetailsTour', () => ({ useIssueDetailsTour: () => mockTour(), })); +function CommandPaletteTree({ + onTree, +}: { + onTree: (tree: Array>) => void; +}) { + const store = CMDKCollection.useStore(); + onTree(store.tree()); + return null; +} + +function SlotOutlets() { + return ( +
+ + {p =>
} + + + {p =>
} + + + {p =>
} + +
+ ); +} + describe('GroupActions', () => { const analyticsSpy = jest.spyOn(analytics, 'trackAnalytics'); beforeEach(() => { ConfigStore.init(); + MemberListStore.reset(); + ProjectsStore.reset(); + ProjectsStore.loadInitialData([project]); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/users/`, + body: [], + }); + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/events//committers/`, + body: {committers: []}, + }); + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/events//owners/`, + body: {owners: [], rules: []}, + }); }); afterEach(() => { MockApiClient.clearMockResponses(); @@ -413,4 +467,116 @@ describe('GroupActions', () => { expect(groupFetchApi).toHaveBeenCalledTimes(2); }); }); + + describe('command palette labels', () => { + beforeEach(() => { + ConfigStore.loadInitialData({ + user: UserFixture({id: '1', name: 'Test User'}), + } as any); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/users/`, + method: 'GET', + body: [], + }); + MemberListStore.loadInitialData([ + UserFixture({id: '1', name: 'Test User'}), + UserFixture({id: '2', name: 'Grace Hopper'}), + UserFixture({id: '7', name: 'Ada Lovelace'}), + ]); + }); + + function renderWithCommandPalette(commandGroup: Group) { + const treeRef: { + current: Array>; + } = {current: []}; + render( + + + + { + treeRef.current = tree; + }} + /> + , + {organization} + ); + + return treeRef; + } + + it('omits stale default state text from task actions', async () => { + const treeRef = renderWithCommandPalette(group); + + await waitFor(() => { + expect(treeRef.current.length).toBeGreaterThan(0); + }); + + const labels = treeRef.current.flatMap(node => [ + node.display.label, + ...node.children.map(child => child.display.label), + ]); + const issueTaskGroup = treeRef.current[0]; + + expect(labels).toContain('Archive'); + expect(labels).toContain('Resolve'); + expect(labels).toContain('Set Priority'); + expect(labels).toContain('Assign to'); + const issueTitle = getTitle(group).title; + const issueMessage = getMessage(group); + expect(issueTaskGroup?.display.label).toBe( + issueMessage && issueMessage !== issueTitle + ? `${issueTitle}: ${issueMessage}` + : issueTitle + ); + expect(issueTaskGroup?.display.details).toBeUndefined(); + + expect(labels).not.toContain('Resolve (Unresolved)'); + expect(labels).not.toContain('Archive (Active)'); + expect(labels).not.toContain('Assign (Unassigned)'); + }); + + it('shows current priority icon and assignee when present', async () => { + const treeRef = renderWithCommandPalette( + GroupFixture({ + ...group, + priority: PriorityLevel.HIGH, + assignedTo: { + id: '7', + type: 'user', + name: 'Ada Lovelace', + email: 'ada@example.com', + }, + }) + ); + + await waitFor(() => { + expect(treeRef.current.length).toBeGreaterThan(0); + }); + + const issueTaskGroup = treeRef.current[0]; + const labels = issueTaskGroup?.children.map(child => child.display.label) ?? []; + const setPriorityAction = issueTaskGroup?.children.find( + child => child.display.label === 'Set Priority' + ); + const assignAction = issueTaskGroup?.children.find( + child => child.display.label === 'Assign to' + ); + const assignLabels = assignAction?.children.map(child => child.display.label) ?? []; + + expect(labels).toContain('Set Priority'); + expect(labels).toContain('Assign to'); + expect(assignLabels).toContain('Assign to me'); + expect(assignLabels).toContain('#frontend'); + expect(setPriorityAction?.display.icon).toMatchObject({props: {bars: 3}}); + expect(assignAction?.display.icon).toMatchObject({ + props: {actor: expect.objectContaining({name: 'Ada Lovelace'})}, + }); + }); + }); }); diff --git a/static/app/views/issueDetails/actions/index.tsx b/static/app/views/issueDetails/actions/index.tsx index f5dc4ab279ba..d6b6572f4139 100644 --- a/static/app/views/issueDetails/actions/index.tsx +++ b/static/app/views/issueDetails/actions/index.tsx @@ -19,11 +19,15 @@ import {FeatureDisabled} from 'sentry/components/acl/featureDisabled'; import {ArchiveActions} from 'sentry/components/actions/archive'; import {ResolveActions} from 'sentry/components/actions/resolve'; import {renderArchiveReason} from 'sentry/components/archivedBox'; +import {CMDKAction} from 'sentry/components/commandPalette/ui/cmdk'; +import {CommandPaletteSlot} from 'sentry/components/commandPalette/ui/commandPaletteSlot'; import {openConfirmModal} from 'sentry/components/confirm'; import {DropdownMenu} from 'sentry/components/dropdownMenu'; +import ProjectBadge from 'sentry/components/idBadge/projectBadge'; import {ResolutionReason} from 'sentry/components/resolutionBox'; import { IconCheckmark, + IconClock, IconEllipsis, IconSubscribed, IconUnsubscribed, @@ -39,7 +43,7 @@ import type {Project} from 'sentry/types/project'; import {trackAnalytics} from 'sentry/utils/analytics'; import {getUtcDateString} from 'sentry/utils/dates'; import {displayReprocessEventAction} from 'sentry/utils/displayReprocessEventAction'; -import {getAnalyticsDataForGroup} from 'sentry/utils/events'; +import {getAnalyticsDataForGroup, getMessage, getTitle} from 'sentry/utils/events'; import {uniqueId} from 'sentry/utils/guid'; import {getConfigForIssueType} from 'sentry/utils/issueTypeConfig'; import {getAnalyicsDataForProject} from 'sentry/utils/projects'; @@ -51,6 +55,8 @@ import {useOrganization} from 'sentry/utils/useOrganization'; import {ShareIssueModal} from 'sentry/views/issueDetails/actions/shareModal'; import {SubscribeAction} from 'sentry/views/issueDetails/actions/subscribeAction'; import {Divider} from 'sentry/views/issueDetails/divider'; +import {GroupPriorityCommandPaletteAction} from 'sentry/views/issueDetails/groupPriority'; +import {GroupHeaderAssigneeCommandPaletteAction} from 'sentry/views/issueDetails/streamline/header/assigneeSelector'; import {makeFetchGroupQueryKey} from 'sentry/views/issueDetails/useGroup'; import {useProjectReleaseVersionIsSemver} from 'sentry/views/issueDetails/useProjectReleaseVersionIsSemver'; import {useEnvironmentsFromUrl} from 'sentry/views/issueDetails/utils'; @@ -66,6 +72,29 @@ const isResolutionStatus = (data: UpdateData): data is GroupStatusResolution => return (data as GroupStatusResolution).status !== undefined; }; +const getUpdateSuccessMessage = (group: Group, data: UpdateData) => { + if (isResolutionStatus(data)) { + switch (data.status) { + case GroupStatus.RESOLVED: + return t('Issue resolved'); + case GroupStatus.IGNORED: + return t('Issue archived'); + case GroupStatus.UNRESOLVED: + return group.status === GroupStatus.IGNORED + ? t('Issue unarchived') + : t('Issue marked unresolved'); + default: + return undefined; + } + } + + if ((data as {inbox: boolean}).inbox === false) { + return t('Issue marked reviewed'); + } + + return undefined; +}; + interface GroupActionsProps { disabled: boolean; event: Event | null; @@ -103,10 +132,15 @@ export function GroupActions({group, project, disabled, event}: GroupActionsProp const isAutoResolved = group.status === 'resolved' ? group.statusDetails.autoResolved : undefined; const isIgnored = group.status === 'ignored'; - const hasDeleteAccess = organization.access.includes('event:admin'); const config = useMemo(() => getConfigForIssueType(group, project), [group, project]); + const issueCommandLabel = useMemo(() => { + const {title: rawIssueTitle} = getTitle(group); + const title = rawIssueTitle ?? ''; + const message = getMessage(group); + return message && message !== title ? `${title}: ${message}` : title; + }, [group]); const { actions: { @@ -177,6 +211,8 @@ export function GroupActions({group, project, disabled, event}: GroupActionsProp }; const onUpdate = (data: UpdateData, onComplete?: () => void) => { + const successMessage = getUpdateSuccessMessage(group, data); + bulkUpdate( api, { @@ -188,6 +224,9 @@ export function GroupActions({group, project, disabled, event}: GroupActionsProp { complete: () => { clearIndicators(); + if (successMessage) { + addSuccessMessage(successMessage); + } onComplete?.(); queryClient.invalidateQueries({ queryKey: makeFetchGroupQueryKey({ @@ -358,156 +397,229 @@ export function GroupActions({group, project, disabled, event}: GroupActionsProp }; return ( - - {isResolved || isIgnored ? ( - - - - - {isResolved ? resolvedCopyCap || t('Resolved') : t('Archived')} - - {group.status === 'resolved' ? ( - - ) : null} - {group.status === 'ignored' - ? renderArchiveReason({ - substatus: group.substatus, - statusDetails: group.statusDetails, - }) - : null} - - - - - - {resolveCap.enabled && isResolved && ( - - )} - {isIgnored && ( - + )} + {isIgnored && ( + + )} + + ) : ( + + {resolveCap.enabled && ( + + )} + - onUpdate({ - status: GroupStatus.UNRESOLVED, - statusDetails: {}, - substatus: GroupSubstatus.ONGOING, - }) - } - > - {t('Unarchive')} - - )} - - ) : ( - - {resolveCap.enabled && ( - - )} - - - )} - : } - size="sm" - /> -