1
- import { Fragment } from 'react' ;
1
+ import { Fragment , useContext } from 'react' ;
2
2
import styled from '@emotion/styled' ;
3
3
4
- import { AlertLink } from 'sentry/components/core/alert/alertLink' ;
5
4
import { Button , type ButtonProps } from 'sentry/components/core/button' ;
6
5
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' ;
7
10
import { Tooltip } from 'sentry/components/core/tooltip' ;
8
- import DropdownButton from 'sentry/components/dropdownButton' ;
9
- import { DropdownMenu } from 'sentry/components/dropdownMenu' ;
10
11
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' ;
12
16
import useGroupExternalIssues from 'sentry/components/group/externalIssuesList/hooks/useGroupExternalIssues' ;
13
17
import Placeholder from 'sentry/components/placeholder' ;
18
+ import { IconAdd } from 'sentry/icons' ;
14
19
import { t } from 'sentry/locale' ;
15
20
import { space } from 'sentry/styles/space' ;
16
21
import type { Event } from 'sentry/types/event' ;
@@ -25,7 +30,11 @@ function getActionLabelAndTextValue({
25
30
} : {
26
31
action : ExternalIssueAction ;
27
32
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
+ } {
29
38
// If there's no subtext or subtext matches name, just show name
30
39
if ( ! action . nameSubText || action . nameSubText === action . name ) {
31
40
return {
@@ -44,12 +53,8 @@ function getActionLabelAndTextValue({
44
53
45
54
// Otherwise show both name and subtext
46
55
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 ,
53
58
textValue : `${ action . name } ${ action . nameSubText } ` ,
54
59
} ;
55
60
}
@@ -65,7 +70,6 @@ export function StreamlinedExternalIssueList({
65
70
event,
66
71
project,
67
72
} : ExternalIssueListProps ) {
68
- const organization = useOrganization ( ) ;
69
73
const { isLoading, integrations, linkedIssues} = useGroupExternalIssues ( {
70
74
group,
71
75
event,
@@ -76,20 +80,8 @@ export function StreamlinedExternalIssueList({
76
80
return < Placeholder height = "25px" testId = "issue-tracking-loading" /> ;
77
81
}
78
82
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
-
91
83
return (
92
- < Fragment >
84
+ < Flex direction = "row" wrap = "wrap" gap = { space ( 1 ) } flex = { 1 } >
93
85
{ linkedIssues . length > 0 && (
94
86
< IssueActionWrapper >
95
87
{ linkedIssues . map ( linkedIssue => (
@@ -112,143 +104,157 @@ export function StreamlinedExternalIssueList({
112
104
}
113
105
isHoverable
114
106
>
115
- < LinkedIssue
107
+ < LinkButton
116
108
href = { linkedIssue . url }
117
109
external
118
110
size = "zero"
119
111
icon = { linkedIssue . displayIcon }
120
112
>
121
113
< IssueActionName > { linkedIssue . displayName } </ IssueActionName >
122
- </ LinkedIssue >
114
+ </ LinkButton >
123
115
</ Tooltip >
124
116
</ ErrorBoundary >
125
117
) ) }
126
118
</ IssueActionWrapper >
127
119
) }
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
+ }
136
124
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
+ }
137
156
if ( integration . actions . length === 1 ) {
138
157
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 ;
176
164
}
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 >
203
187
</ Fragment >
204
188
) ;
205
189
}
206
190
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
+ }
213
217
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
+ }
222
232
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 } ) ;
231
235
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 } ;
239
251
` ;
240
252
241
- const IssueActionDropdownMenu = styled ( DropdownButton ) `
253
+ const IssueActionWrapper = styled ( 'div' ) `
242
254
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;
252
258
` ;
253
259
254
260
const IssueActionName = styled ( 'div' ) `
0 commit comments