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 && (
-
- )}
-
- ) : (
-
- {resolveCap.enabled && (
-
- )}
-
-
- )}
- : }
- size="sm"
- />
- }
- aria-label={t('Share')}
- tooltipProps={{title: t('Share Issue')}}
- disabled={disabled}
- analyticsEventKey="issue_details.share_action_clicked"
- analyticsEventName="Issue Details: Share Action Clicked"
- />
- ,
- showChevron: false,
- size: 'sm',
- }}
- items={[
- {
- key: 'mark-review',
- label: t('Mark reviewed'),
- disabled: !group.inbox || disabled,
- details: !group.inbox || disabled ? t('Issue has been reviewed') : undefined,
- onAction: () => onUpdate({inbox: false}),
- },
- {
- key: bookmarkKey,
- label: bookmarkTitle,
- onAction: onToggleBookmark,
- },
- {
- key: 'reprocess',
- label: t('Reprocess events'),
- hidden: !displayReprocessEventAction(event),
- onAction: onReprocessEvent,
- },
- {
- key: 'delete-issue',
- priority: 'danger',
- label: t('Delete'),
- disabled: !hasDeleteAccess || !deleteCap.enabled,
- details: hasDeleteAccess
- ? deleteCap.disabledReason
- : t('Only admins can delete issues'),
- onAction: openDeleteModal,
- },
- {
- key: 'delete-and-discard',
- priority: 'danger',
- label: t('Delete and discard future events'),
- hidden: !hasDeleteAccess,
- disabled: !deleteDiscardCap.enabled,
- details: deleteDiscardCap.disabledReason,
- onAction: openDiscardModal,
- },
- ]}
- />
-
+
+ )}
+ : }
+ size="sm"
+ />
+ }
+ aria-label={t('Share')}
+ tooltipProps={{title: t('Share Issue')}}
+ disabled={disabled}
+ analyticsEventKey="issue_details.share_action_clicked"
+ analyticsEventName="Issue Details: Share Action Clicked"
+ />
+ ,
+ showChevron: false,
+ size: 'sm',
+ }}
+ items={[
+ {
+ key: 'mark-review',
+ label: t('Mark reviewed'),
+ disabled: !group.inbox || disabled,
+ details:
+ !group.inbox || disabled ? t('Issue has been reviewed') : undefined,
+ onAction: () => onUpdate({inbox: false}),
+ },
+ {
+ key: bookmarkKey,
+ label: bookmarkTitle,
+ onAction: onToggleBookmark,
+ },
+ {
+ key: 'reprocess',
+ label: t('Reprocess events'),
+ hidden: !displayReprocessEventAction(event),
+ onAction: onReprocessEvent,
+ },
+ {
+ key: 'delete-issue',
+ priority: 'danger',
+ label: t('Delete'),
+ disabled: !hasDeleteAccess || !deleteCap.enabled,
+ details: hasDeleteAccess
+ ? deleteCap.disabledReason
+ : t('Only admins can delete issues'),
+ onAction: openDeleteModal,
+ },
+ {
+ key: 'delete-and-discard',
+ priority: 'danger',
+ label: t('Delete and discard future events'),
+ hidden: !hasDeleteAccess,
+ disabled: !deleteDiscardCap.enabled,
+ details: deleteDiscardCap.disabledReason,
+ onAction: openDiscardModal,
+ },
+ ]}
+ />
+
+
);
}
diff --git a/static/app/views/issueDetails/groupPriority.tsx b/static/app/views/issueDetails/groupPriority.tsx
index 1eb48370f556..613a2661e997 100644
--- a/static/app/views/issueDetails/groupPriority.tsx
+++ b/static/app/views/issueDetails/groupPriority.tsx
@@ -2,9 +2,12 @@ import {bulkUpdate} from 'sentry/actionCreators/group';
import {
addErrorMessage,
addLoadingMessage,
+ addSuccessMessage,
clearIndicators,
} from 'sentry/actionCreators/indicator';
import {GroupPriorityDropdown} from 'sentry/components/badge/groupPriority';
+import {IconCellSignal} from 'sentry/components/badge/iconCellSignal';
+import {CMDKAction} from 'sentry/components/commandPalette/ui/cmdk';
import {t} from 'sentry/locale';
import {IssueListCacheStore} from 'sentry/stores/IssueListCacheStore';
import {PriorityLevel, type Group} from 'sentry/types/group';
@@ -18,12 +21,21 @@ type GroupDetailsPriorityProps = {
onChange?: (priority: PriorityLevel) => void;
};
-export function GroupPriority({group, onChange}: GroupDetailsPriorityProps) {
+const PRIORITY_BARS: Record = {
+ [PriorityLevel.HIGH]: 3,
+ [PriorityLevel.MEDIUM]: 2,
+ [PriorityLevel.LOW]: 1,
+};
+
+const getPriorityUpdateSuccessMessage = (priority: PriorityLevel) =>
+ t('Priority updated to %s', priority);
+
+function useChangePriority(group: Group, onChange?: (priority: PriorityLevel) => void) {
const api = useApi({persistInFlight: true});
const organization = useOrganization();
- const onChangePriority = (priority: PriorityLevel) => {
- if (priority === group.priority) {
+ return (nextPriority: PriorityLevel) => {
+ if (nextPriority === group.priority) {
return;
}
@@ -31,7 +43,7 @@ export function GroupPriority({group, onChange}: GroupDetailsPriorityProps) {
organization,
...getAnalyticsDataForGroup(group),
from_priority: group.priority,
- to_priority: priority,
+ to_priority: nextPriority,
});
addLoadingMessage(t('Saving changes\u2026'));
@@ -42,14 +54,15 @@ export function GroupPriority({group, onChange}: GroupDetailsPriorityProps) {
{
orgId: organization.slug,
itemIds: [group.id],
- data: {priority},
+ data: {priority: nextPriority},
failSilently: true,
project: [group.project.id],
},
{
success: () => {
clearIndicators();
- onChange?.(priority);
+ addSuccessMessage(getPriorityUpdateSuccessMessage(nextPriority));
+ onChange?.(nextPriority);
},
error: () => {
clearIndicators();
@@ -58,6 +71,10 @@ export function GroupPriority({group, onChange}: GroupDetailsPriorityProps) {
}
);
};
+}
+
+export function GroupPriority({group, onChange}: GroupDetailsPriorityProps) {
+ const onChangePriority = useChangePriority(group, onChange);
// We can assume that when there is not `priorityLockedAt`, there were no
// user edits to the priority.
@@ -73,3 +90,32 @@ export function GroupPriority({group, onChange}: GroupDetailsPriorityProps) {
/>
);
}
+
+export function GroupPriorityCommandPaletteAction({
+ group,
+}: Pick) {
+ const onChangePriority = useChangePriority(group);
+ const priority = group.priority ?? PriorityLevel.MEDIUM;
+
+ return (
+ ,
+ }}
+ >
+ }}
+ onAction={() => onChangePriority(PriorityLevel.HIGH)}
+ />
+ }}
+ onAction={() => onChangePriority(PriorityLevel.MEDIUM)}
+ />
+ }}
+ onAction={() => onChangePriority(PriorityLevel.LOW)}
+ />
+
+ );
+}
diff --git a/static/app/views/issueDetails/streamline/header/assigneeSelector.tsx b/static/app/views/issueDetails/streamline/header/assigneeSelector.tsx
index 99ebe460aa5d..f1ee912d2720 100644
--- a/static/app/views/issueDetails/streamline/header/assigneeSelector.tsx
+++ b/static/app/views/issueDetails/streamline/header/assigneeSelector.tsx
@@ -1,25 +1,46 @@
-import {useEffect} from 'react';
import {useTheme} from '@emotion/react';
+import {useQuery} from '@tanstack/react-query';
+import {ActorAvatar} from '@sentry/scraps/avatar';
+import {TeamAvatar} from '@sentry/scraps/avatar';
import {MenuComponents} from '@sentry/scraps/compactSelect';
-import {fetchOrgMembers} from 'sentry/actionCreators/members';
import {openIssueOwnershipRuleModal} from 'sentry/actionCreators/modal';
+import {CMDKAction} from 'sentry/components/commandPalette/ui/cmdk';
import {
AssigneeSelector,
useHandleAssigneeChange,
} from 'sentry/components/group/assigneeSelector';
-import {IconSettings} from 'sentry/icons';
+import {IconSettings, IconUser} from 'sentry/icons';
import {t} from 'sentry/locale';
+import {ProjectsStore} from 'sentry/stores/projectsStore';
import type {Event} from 'sentry/types/event';
import type {Group} from 'sentry/types/group';
+import type {Member} from 'sentry/types/organization';
import type {Project} from 'sentry/types/project';
-import {useApi} from 'sentry/utils/useApi';
+import {buildTeamId} from 'sentry/utils';
+import {apiOptions} from 'sentry/utils/api/apiOptions';
import {useCommitters} from 'sentry/utils/useCommitters';
import {useIssueEventOwners} from 'sentry/utils/useIssueEventOwners';
import {useOrganization} from 'sentry/utils/useOrganization';
+import {useUser} from 'sentry/utils/useUser';
import {getOwnerList} from 'sentry/views/issueDetails/streamline/header/getOwnerList';
+function useProjectMembers(projectId: string) {
+ const organization = useOrganization();
+ return useQuery({
+ ...apiOptions.as()('/organizations/$organizationIdOrSlug/users/', {
+ path: {organizationIdOrSlug: organization.slug},
+ query: {project: [projectId]},
+ staleTime: 30_000,
+ }),
+ select: data =>
+ data.json
+ .filter((m): m is Member & {user: NonNullable} => m.user !== null)
+ .map(m => ({...m.user, role: m.role})),
+ });
+}
+
interface GroupHeaderAssigneeSelectorProps {
event: Event | null;
group: Group;
@@ -31,7 +52,6 @@ export function GroupHeaderAssigneeSelector({
project,
event,
}: GroupHeaderAssigneeSelectorProps) {
- const api = useApi();
const theme = useTheme();
const organization = useOrganization();
const {handleAssigneeChange, assigneeLoading} = useHandleAssigneeChange({
@@ -47,11 +67,7 @@ export function GroupHeaderAssigneeSelector({
projectSlug: project.slug,
group,
});
-
- useEffect(() => {
- // TODO: We should check if this is already loaded
- fetchOrgMembers(api, organization.slug, [project.id]);
- }, [api, organization, project]);
+ const {data: members} = useProjectMembers(project.id);
const owners = getOwnerList(
committersResponse?.committers ?? [],
@@ -65,6 +81,7 @@ export function GroupHeaderAssigneeSelector({
owners={owners}
assigneeLoading={assigneeLoading}
handleAssigneeChange={handleAssigneeChange}
+ memberList={members}
showLabel
additionalMenuFooterItems={
);
}
+
+export function GroupHeaderAssigneeCommandPaletteAction({
+ group,
+ project,
+ event,
+}: GroupHeaderAssigneeSelectorProps) {
+ const organization = useOrganization();
+ const user = useUser();
+ const {handleAssigneeChange} = useHandleAssigneeChange({
+ organization,
+ group,
+ });
+ const {data: eventOwners} = useIssueEventOwners({
+ eventId: event?.id ?? '',
+ projectSlug: project.slug,
+ });
+ const {data: committersResponse} = useCommitters({
+ eventId: event?.id ?? '',
+ projectSlug: project.slug,
+ group,
+ });
+
+ const owners = getOwnerList(
+ committersResponse?.committers ?? [],
+ eventOwners,
+ group.assignedTo
+ );
+ const {data: members} = useProjectMembers(project.id);
+ const currentAssigneeIcon = group.assignedTo ? (
+
+ ) : (
+
+ );
+ const assignableUsers = (members ?? []).filter(member => member.id !== user?.id);
+ const assignableTeams = (ProjectsStore.getBySlug(project.slug)?.teams ?? []).sort(
+ (a, b) => a.slug.localeCompare(b.slug)
+ );
+ const assignableActorKeys = new Set([
+ ...assignableUsers.map(member => `user:${member.id}`),
+ ...assignableTeams.map(team => `team:${team.id}`),
+ ]);
+ const additionalOwners = owners.filter(
+ owner => !assignableActorKeys.has(`${owner.type}:${owner.id}`)
+ );
+
+ return (
+
+ {user && (
+
+ ),
+ }}
+ onAction={() =>
+ handleAssigneeChange({
+ assignee: user,
+ id: user.id,
+ type: 'user',
+ })
+ }
+ />
+ )}
+ {assignableUsers.map(member => (
+
+ ),
+ }}
+ onAction={() =>
+ handleAssigneeChange({
+ assignee: member,
+ id: member.id,
+ type: 'user',
+ })
+ }
+ />
+ ))}
+ {assignableTeams.map(team => (
+ ,
+ }}
+ onAction={() =>
+ handleAssigneeChange({
+ assignee: {id: buildTeamId(team.id), name: team.slug, type: 'team'},
+ id: team.id,
+ type: 'team',
+ })
+ }
+ />
+ ))}
+ {additionalOwners.map(owner => (
+
+ ),
+ }}
+ onAction={() =>
+ handleAssigneeChange({
+ assignee: owner,
+ id: owner.id,
+ type: owner.type,
+ })
+ }
+ />
+ ))}
+
+ );
+}
diff --git a/static/app/views/issueDetails/useAssignIssueMutation.ts b/static/app/views/issueDetails/useAssignIssueMutation.ts
index 3ecc75b21ee3..29e6d2d95b3d 100644
--- a/static/app/views/issueDetails/useAssignIssueMutation.ts
+++ b/static/app/views/issueDetails/useAssignIssueMutation.ts
@@ -1,6 +1,8 @@
import * as Sentry from '@sentry/react';
import {useQueryClient} from '@tanstack/react-query';
+import {addSuccessMessage} from 'sentry/actionCreators/indicator';
+import {t} from 'sentry/locale';
import {GroupStore} from 'sentry/stores/groupStore';
import type {Actor} from 'sentry/types/core';
import type {Group} from 'sentry/types/group';
@@ -29,6 +31,18 @@ type AssignIssueContext = {
changeId: string;
};
+function getAssignIssueSuccessMessage(assignedTo: Group['assignedTo']) {
+ if (!assignedTo) {
+ return t('Issue unassigned');
+ }
+
+ if (assignedTo.type === 'team') {
+ return t('Assigned issue to #%s', assignedTo.name);
+ }
+
+ return t('Assigned issue to %s', assignedTo.name || assignedTo.email);
+}
+
function makeActorId(actor: Pick) {
switch (actor.type) {
case 'user':
@@ -87,6 +101,7 @@ export function useAssignIssueMutation(
// Dual-write to GroupStore
// TODO: Remove this when we no longer rely on GroupStore for updates
GroupStore.onAssignToSuccess(onMutateResult.changeId, variables.groupId, response);
+ addSuccessMessage(getAssignIssueSuccessMessage(response.assignedTo));
options.onSuccess?.(response, variables, onMutateResult, context);
},
onError: (error, variables, onMutateResult, context) => {