From b45145c2961296f87370785ec08cffcc5662401e Mon Sep 17 00:00:00 2001 From: JonasBa Date: Tue, 14 Apr 2026 23:36:20 +0200 Subject: [PATCH 01/10] feat(cmdk): Improve issue detail actions Polish the issue details command palette actions so the top-level issue entry mirrors the shared title and message formatting, action labels stay concise, and long labels truncate cleanly in the palette UI. Also update assign and priority actions to reflect current state more clearly. The assign submenu now uses the current assignee avatar, includes project teams alongside individual members, and drops redundant suffix text and unassign actions to keep the flow focused. Co-Authored-By: Codex --- .../commandPalette/ui/commandPalette.tsx | 16 +- .../views/issueDetails/actions/index.spec.tsx | 157 ++++++++ .../app/views/issueDetails/actions/index.tsx | 358 ++++++++++-------- .../app/views/issueDetails/groupPriority.tsx | 80 +++- .../streamline/header/assigneeSelector.tsx | 162 +++++++- 5 files changed, 617 insertions(+), 156 deletions(-) diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index b049008a47d3..37265cd66ad5 100644 --- a/static/app/components/commandPalette/ui/commandPalette.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.tsx @@ -132,11 +132,23 @@ 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..62b494c49213 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,19 +14,36 @@ import { within, } from 'sentry-test/reactTestingLibrary'; +import { + CMDKCollection, + CommandPaletteProvider, +} from 'sentry/components/commandPalette/ui/cmdk'; +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 {ProjectsStore} from 'sentry/stores/projectsStore'; import {GroupStatus, IssueCategory} 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'; +jest.mock('sentry/utils/useCommitters', () => ({ + useCommitters: () => ({data: {committers: []}}), +})); + +jest.mock('sentry/utils/useIssueEventOwners', () => ({ + useIssueEventOwners: () => ({data: {owners: [], rules: []}}), +})); + const project = ProjectFixture({ id: '2448', name: 'project name', slug: 'project', + teams: [TeamFixture({id: '3', slug: 'frontend', name: 'Frontend'})], }); const group = GroupFixture({ @@ -51,11 +70,40 @@ jest.mock('sentry/views/issueDetails/issueDetailsTour', () => ({ useIssueDetailsTour: () => mockTour(), })); +function CommandPaletteTree({ + onTree, +}: { + onTree: (tree: ReturnType['tree']>) => 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]); }); afterEach(() => { MockApiClient.clearMockResponses(); @@ -413,4 +461,113 @@ describe('GroupActions', () => { expect(groupFetchApi).toHaveBeenCalledTimes(2); }); }); + + describe('command palette labels', () => { + beforeEach(() => { + 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: ReturnType['tree']>; + } = {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: '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..09e18b3422e5 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'; @@ -103,10 +109,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 {title: issueTitle} = getTitle(group); + const issueMessage = getMessage(group); + const issueCommandLabel = + issueMessage && issueMessage !== issueTitle + ? `${issueTitle}: ${issueMessage}` + : issueTitle; const { actions: { @@ -358,156 +369,205 @@ 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" - /> -