Skip to content

Commit 518a5b7

Browse files
authored
feat(issue): convert external issue tracking to dropdown (#94509)
Updates the streamlined external issue tracking experience to use a dropdown rather than a row of buttons, per the [Issue Tracking v2 Mockups](https://www.figma.com/design/p2DOzd1RwAHQbq0v6KaYmF/Issue-Details?node-id=8061-42500&t=vu6zKtqxQbn9kqu3-11) in Figma. This closes DE-14, which we hit in UI2 because of the dashed button border. @vuluongj20 put together a new design for this interaction as a solution. ## Pages <details><summary><strong>Issue Details</strong></summary> <img width="318" alt="issue-detail-0" src="https://github.com/user-attachments/assets/298fdaca-7a0e-4bba-985d-e7e989ce6c01" /> <br/> <img width="290" alt="issue-detail-1" src="https://github.com/user-attachments/assets/e99bc17b-aac0-4649-a981-38f5d590c291" /> <img width="478" alt="issue-detail-2" src="https://github.com/user-attachments/assets/c1f88295-a72b-46e1-a47c-e8b46428c230" /> <img width="318" alt="issue-detail-3" src="https://github.com/user-attachments/assets/742ff728-6b5b-4fc6-806a-73ad98e539ba" /> <img width="283" alt="issue-detail-4" src="https://github.com/user-attachments/assets/96c8c01f-8d54-4c8d-8e61-e99d8a2a093a" /> </details> <details><summary><strong>User Feedback</strong></summary> <img width="715" alt="feedback-issue-0" src="https://github.com/user-attachments/assets/1c613928-ca9b-4ba9-8be5-18f0c6ac0a95" /> <img width="323" alt="feedback-issue-1" src="https://github.com/user-attachments/assets/6df68c90-01ef-40b6-9ddb-0dba53dac655" /> <img width="502" alt="feedback-issue-2" src="https://github.com/user-attachments/assets/f33e01d3-6ace-47cf-b840-c05acda7bf27" /> <img width="509" alt="feedback-issue-3" src="https://github.com/user-attachments/assets/346adb8c-f57b-449c-a38c-7e340e0cab6e" /> <img width="597" alt="feedback-issue-4" src="https://github.com/user-attachments/assets/7a22f53d-732a-4f75-8c21-facaa110a5ca" /> </details>
1 parent 8f3b48b commit 518a5b7

File tree

5 files changed

+188
-157
lines changed

5 files changed

+188
-157
lines changed

static/app/components/group/externalIssuesList/hooks/types.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export interface ExternalIssueAction {
4646
* Integrations, apps, or plugins that can create external issues.
4747
* Each integration can have one or more configurations.
4848
*/
49-
interface ExternalIssueIntegration extends BaseIssueAction {
49+
export interface ExternalIssueIntegration extends BaseIssueAction {
5050
actions: ExternalIssueAction[];
5151
}
5252

static/app/components/group/externalIssuesList/hooks/usePluginExternalIssues.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
2+
import {openNavigateToExternalLinkModal} from 'sentry/actionCreators/modal';
23
import {openPluginActionModal} from 'sentry/components/group/pluginActions';
34
import {t} from 'sentry/locale';
45
import plugins from 'sentry/plugins';
@@ -129,7 +130,7 @@ export function usePluginExternalIssues({
129130
name: action,
130131
href: action,
131132
onClick: () => {
132-
// Do nothing
133+
openNavigateToExternalLinkModal({linkText: action});
133134
},
134135
},
135136
],

static/app/components/group/externalIssuesList/streamlinedExternalIssueList.tsx

Lines changed: 144 additions & 138 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
1-
import {Fragment} from 'react';
1+
import {Fragment, useContext} from 'react';
22
import styled from '@emotion/styled';
33

4-
import {AlertLink} from 'sentry/components/core/alert/alertLink';
54
import {Button, type ButtonProps} from 'sentry/components/core/button';
65
import {LinkButton} from 'sentry/components/core/button/linkButton';
6+
import {CompositeSelect} from 'sentry/components/core/compactSelect/composite';
7+
import {SelectContext} from 'sentry/components/core/compactSelect/control';
8+
import {Flex} from 'sentry/components/core/layout';
9+
import {MenuListItem, type MenuListItemProps} from 'sentry/components/core/menuListItem';
710
import {Tooltip} from 'sentry/components/core/tooltip';
8-
import DropdownButton from 'sentry/components/dropdownButton';
9-
import {DropdownMenu} from 'sentry/components/dropdownMenu';
1011
import ErrorBoundary from 'sentry/components/errorBoundary';
11-
import type {ExternalIssueAction} from 'sentry/components/group/externalIssuesList/hooks/types';
12+
import type {
13+
ExternalIssueAction,
14+
ExternalIssueIntegration,
15+
} from 'sentry/components/group/externalIssuesList/hooks/types';
1216
import useGroupExternalIssues from 'sentry/components/group/externalIssuesList/hooks/useGroupExternalIssues';
1317
import Placeholder from 'sentry/components/placeholder';
18+
import {IconAdd} from 'sentry/icons';
1419
import {t} from 'sentry/locale';
1520
import {space} from 'sentry/styles/space';
1621
import type {Event} from 'sentry/types/event';
@@ -25,7 +30,11 @@ function getActionLabelAndTextValue({
2530
}: {
2631
action: ExternalIssueAction;
2732
integrationDisplayName: string;
28-
}): {label: string | React.JSX.Element; textValue: string} {
33+
}): {
34+
label: string | React.JSX.Element;
35+
textValue: string;
36+
details?: string | React.JSX.Element;
37+
} {
2938
// If there's no subtext or subtext matches name, just show name
3039
if (!action.nameSubText || action.nameSubText === action.name) {
3140
return {
@@ -44,12 +53,8 @@ function getActionLabelAndTextValue({
4453

4554
// Otherwise show both name and subtext
4655
return {
47-
label: (
48-
<div>
49-
<strong>{action.name}</strong>
50-
<div>{action.nameSubText}</div>
51-
</div>
52-
),
56+
label: action.name,
57+
details: action.nameSubText,
5358
textValue: `${action.name} ${action.nameSubText}`,
5459
};
5560
}
@@ -65,7 +70,6 @@ export function StreamlinedExternalIssueList({
6570
event,
6671
project,
6772
}: ExternalIssueListProps) {
68-
const organization = useOrganization();
6973
const {isLoading, integrations, linkedIssues} = useGroupExternalIssues({
7074
group,
7175
event,
@@ -76,20 +80,8 @@ export function StreamlinedExternalIssueList({
7680
return <Placeholder height="25px" testId="issue-tracking-loading" />;
7781
}
7882

79-
const hasLinkedIssuesOrIntegrations = integrations.length || linkedIssues.length;
80-
if (!hasLinkedIssuesOrIntegrations) {
81-
return (
82-
<AlertLink
83-
type="muted"
84-
to={`/settings/${organization.slug}/integrations/?category=issue%20tracking`}
85-
>
86-
{t('Track this issue in Jira, GitHub, etc.')}
87-
</AlertLink>
88-
);
89-
}
90-
9183
return (
92-
<Fragment>
84+
<Flex direction="row" wrap="wrap" gap={space(1)} flex={1}>
9385
{linkedIssues.length > 0 && (
9486
<IssueActionWrapper>
9587
{linkedIssues.map(linkedIssue => (
@@ -112,143 +104,157 @@ export function StreamlinedExternalIssueList({
112104
}
113105
isHoverable
114106
>
115-
<LinkedIssue
107+
<LinkButton
116108
href={linkedIssue.url}
117109
external
118110
size="zero"
119111
icon={linkedIssue.displayIcon}
120112
>
121113
<IssueActionName>{linkedIssue.displayName}</IssueActionName>
122-
</LinkedIssue>
114+
</LinkButton>
123115
</Tooltip>
124116
</ErrorBoundary>
125117
))}
126118
</IssueActionWrapper>
127119
)}
128-
{integrations.length > 0 && (
129-
<IssueActionWrapper>
130-
{integrations.map(integration => {
131-
const sharedButtonProps: ButtonProps = {
132-
size: 'zero',
133-
icon: integration.displayIcon,
134-
children: <IssueActionName>{integration.displayName}</IssueActionName>,
135-
};
120+
<ExternalIssueMenu linkedIssues={linkedIssues} integrations={integrations} />
121+
</Flex>
122+
);
123+
}
136124

125+
function ExternalIssueMenu(props: ReturnType<typeof useGroupExternalIssues>) {
126+
const organization = useOrganization({allowNull: false});
127+
128+
return (
129+
<Fragment>
130+
<CompositeSelect
131+
trigger={triggerProps => (
132+
<Button {...triggerProps} size="zero" icon={<IconAdd />}>
133+
{props.linkedIssues.length === 0 ? t('Add Linked Issue') : null}
134+
</Button>
135+
)}
136+
// Required for submenu interactions
137+
isDismissable={false}
138+
menuTitle={t('Add Linked Issue')}
139+
hideOptions={props.integrations.length === 0}
140+
menuBody={props.integrations.length === 0 && <ExternalIssueMenuEmpty />}
141+
menuFooter={props.integrations.length > 0 && <ExternalIssueManageLink />}
142+
>
143+
<CompositeSelect.Region
144+
closeOnSelect={({value}) => {
145+
const integration = props.integrations.find(({key}) => key === value);
146+
if (!integration) {
147+
return true;
148+
}
149+
return integration.actions.length === 1;
150+
}}
151+
onChange={({value}) => {
152+
const integration = props.integrations.find(({key}) => key === value);
153+
if (!integration) {
154+
return;
155+
}
137156
if (integration.actions.length === 1) {
138157
const action = integration.actions[0]!;
139-
return (
140-
<ErrorBoundary key={integration.key} mini>
141-
{action.href ? (
142-
// Exclusively used for group.pluginActions
143-
<IssueActionLinkButton
144-
size="zero"
145-
icon={integration.displayIcon}
146-
disabled={integration.disabled}
147-
title={integration.disabled ? integration.disabledText : undefined}
148-
onClick={() => {
149-
action.onClick();
150-
trackAnalytics('feedback.details-integration-issue-clicked', {
151-
organization,
152-
integration_key: integration.key,
153-
});
154-
}}
155-
href={action.href}
156-
external
157-
>
158-
<IssueActionName>{integration.displayName}</IssueActionName>
159-
</IssueActionLinkButton>
160-
) : (
161-
<IssueActionButton
162-
{...sharedButtonProps}
163-
disabled={integration.disabled}
164-
title={integration.disabled ? integration.disabledText : undefined}
165-
onClick={() => {
166-
action.onClick();
167-
trackAnalytics('feedback.details-integration-issue-clicked', {
168-
organization,
169-
integration_key: integration.key,
170-
});
171-
}}
172-
/>
173-
)}
174-
</ErrorBoundary>
175-
);
158+
trackAnalytics('feedback.details-integration-issue-clicked', {
159+
organization,
160+
integration_key: integration.key,
161+
});
162+
action.onClick();
163+
return;
176164
}
177-
178-
return (
179-
<ErrorBoundary key={integration.key} mini>
180-
<DropdownMenu
181-
trigger={triggerProps => (
182-
<IssueActionDropdownMenu
183-
{...sharedButtonProps}
184-
{...triggerProps}
185-
showChevron={false}
186-
/>
187-
)}
188-
items={integration.actions.map(action => ({
189-
key: action.id,
190-
...getActionLabelAndTextValue({
191-
action,
192-
integrationDisplayName: integration.displayName,
193-
}),
194-
onAction: action.onClick,
195-
disabled: integration.disabled,
196-
}))}
197-
/>
198-
</ErrorBoundary>
199-
);
200-
})}
201-
</IssueActionWrapper>
202-
)}
165+
}}
166+
options={props.integrations.map(integration => ({
167+
key: integration.key,
168+
disabled: integration.disabled,
169+
leadingItems: (
170+
<Flex align="center" justify="center" style={{minHeight: 19}}>
171+
{integration.displayIcon}
172+
</Flex>
173+
),
174+
tooltip: integration.disabled ? integration.disabledText : undefined,
175+
label: integration.displayName,
176+
hideCheck: true,
177+
value: integration.key,
178+
textValue: integration.key,
179+
details:
180+
integration.actions.length > 1 ? (
181+
<ExternalIssueSubmenu integration={integration} />
182+
) : undefined,
183+
showDetailsInOverlay: true,
184+
}))}
185+
/>
186+
</CompositeSelect>
203187
</Fragment>
204188
);
205189
}
206190

207-
const IssueActionWrapper = styled('div')`
208-
display: flex;
209-
flex-wrap: wrap;
210-
gap: ${space(1)};
211-
line-height: 1.2;
212-
`;
191+
function ExternalIssueSubmenu(props: {integration: ExternalIssueIntegration}) {
192+
const organization = useOrganization({allowNull: false});
193+
const {integration} = props;
194+
const {overlayState} = useContext(SelectContext);
195+
return integration.actions.map(action => {
196+
const itemProps: MenuListItemProps = {
197+
tooltip: action.disabled ? action.disabledText : undefined,
198+
disabled: action.disabled,
199+
...getActionLabelAndTextValue({
200+
action,
201+
integrationDisplayName: integration.displayName,
202+
}),
203+
};
204+
const callbackProps: Record<string, () => void> = {
205+
onPointerDown: () => {
206+
overlayState?.close();
207+
trackAnalytics('feedback.details-integration-issue-clicked', {
208+
organization,
209+
integration_key: integration.key,
210+
});
211+
action.onClick();
212+
},
213+
};
214+
return <MenuListItem key={action.id} {...callbackProps} {...itemProps} />;
215+
});
216+
}
213217

214-
const LinkedIssue = styled(LinkButton)`
215-
display: flex;
216-
align-items: center;
217-
padding: ${space(0.5)} ${space(0.75)};
218-
border: 1px solid ${p => p.theme.border};
219-
border-radius: ${p => p.theme.borderRadius};
220-
font-weight: normal;
221-
`;
218+
function ExternalIssueMenuEmpty() {
219+
return (
220+
<Flex
221+
style={{padding: space(3)}}
222+
direction="column"
223+
align="center"
224+
justify="center"
225+
gap={space(2)}
226+
>
227+
<EmptyStateText>{t('No issue linking integration installed')}</EmptyStateText>
228+
<ExternalIssueManageLink size="sm" priority="primary" />
229+
</Flex>
230+
);
231+
}
222232

223-
const IssueActionButton = styled(Button)`
224-
display: flex;
225-
align-items: center;
226-
padding: ${space(0.5)} ${space(0.75)};
227-
border: 1px dashed ${p => p.theme.border};
228-
border-radius: ${p => p.theme.borderRadius};
229-
font-weight: normal;
230-
`;
233+
function ExternalIssueManageLink(props: Pick<ButtonProps, 'size' | 'priority'>) {
234+
const organization = useOrganization({allowNull: false});
231235

232-
const IssueActionLinkButton = styled(LinkButton)`
233-
display: flex;
234-
align-items: center;
235-
padding: ${space(0.5)} ${space(0.75)};
236-
border: 1px dashed ${p => p.theme.border};
237-
border-radius: ${p => p.theme.borderRadius};
238-
font-weight: normal;
236+
return (
237+
<LinkButton
238+
size="zero"
239+
priority="default"
240+
{...props}
241+
to={`/settings/${organization.slug}/integrations/?category=issue%20tracking`}
242+
>
243+
{t('Manage Integrations')}
244+
</LinkButton>
245+
);
246+
}
247+
248+
const EmptyStateText = styled('span')`
249+
text-align: center;
250+
color: ${p => p.theme.tokens.content.muted};
239251
`;
240252

241-
const IssueActionDropdownMenu = styled(DropdownButton)`
253+
const IssueActionWrapper = styled('div')`
242254
display: flex;
243-
align-items: center;
244-
padding: ${space(0.5)} ${space(0.75)};
245-
border: 1px dashed ${p => p.theme.border};
246-
border-radius: ${p => p.theme.borderRadius};
247-
font-weight: normal;
248-
249-
&[aria-expanded='true'] {
250-
border: 1px solid ${p => p.theme.border};
251-
}
255+
flex-wrap: wrap;
256+
gap: ${space(1)};
257+
line-height: 1.2;
252258
`;
253259

254260
const IssueActionName = styled('div')`

static/app/views/issueDetails/streamline/groupDetailsLayout.spec.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,13 +113,13 @@ describe('GroupDetailsLayout', () => {
113113

114114
expect(await screen.findByTestId('children')).toBeInTheDocument();
115115
expect(
116-
await screen.findByText('Track this issue in Jira, GitHub, etc.')
116+
await screen.findByRole('button', {name: 'Add Linked Issue'})
117117
).toBeInTheDocument();
118118

119119
await userEvent.click(screen.getByRole('button', {name: 'Close sidebar'}));
120120
expect(await screen.findByTestId('children')).toBeInTheDocument();
121121
expect(
122-
screen.queryByText('Track this issue in Jira, GitHub, etc.')
122+
screen.queryByRole('button', {name: 'Add Linked Issue'})
123123
).not.toBeInTheDocument();
124124
});
125125
});

0 commit comments

Comments
 (0)