externalIssueList.tsx 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import {Button, type ButtonProps, LinkButton} from 'sentry/components/button';
  4. import {AlertLink} from 'sentry/components/core/alert/alertLink';
  5. import DropdownButton from 'sentry/components/dropdownButton';
  6. import {DropdownMenu} from 'sentry/components/dropdownMenu';
  7. import ErrorBoundary from 'sentry/components/errorBoundary';
  8. import type {ExternalIssueAction} from 'sentry/components/group/externalIssuesList/hooks/types';
  9. import useGroupExternalIssues from 'sentry/components/group/externalIssuesList/hooks/useGroupExternalIssues';
  10. import Placeholder from 'sentry/components/placeholder';
  11. import {Tooltip} from 'sentry/components/tooltip';
  12. import {t} from 'sentry/locale';
  13. import {space} from 'sentry/styles/space';
  14. import type {Event} from 'sentry/types/event';
  15. import type {Group} from 'sentry/types/group';
  16. import type {Project} from 'sentry/types/project';
  17. import useOrganization from 'sentry/utils/useOrganization';
  18. import {SectionKey} from 'sentry/views/issueDetails/streamline/context';
  19. import {SidebarFoldSection} from 'sentry/views/issueDetails/streamline/foldSection';
  20. function getActionLabelAndTextValue({
  21. action,
  22. integrationDisplayName,
  23. }: {
  24. action: ExternalIssueAction;
  25. integrationDisplayName: string;
  26. }): {label: string | JSX.Element; textValue: string} {
  27. // If there's no subtext or subtext matches name, just show name
  28. if (!action.nameSubText || action.nameSubText === action.name) {
  29. return {
  30. label: action.name,
  31. textValue: action.name,
  32. };
  33. }
  34. // If action name matches integration name, just show subtext
  35. if (action.name === integrationDisplayName) {
  36. return {
  37. label: action.nameSubText,
  38. textValue: `${action.name} ${action.nameSubText}`,
  39. };
  40. }
  41. // Otherwise show both name and subtext
  42. return {
  43. label: (
  44. <div>
  45. <strong>{action.name}</strong>
  46. <div>{action.nameSubText}</div>
  47. </div>
  48. ),
  49. textValue: `${action.name} ${action.nameSubText}`,
  50. };
  51. }
  52. interface ExternalIssueListProps {
  53. event: Event;
  54. group: Group;
  55. project: Project;
  56. }
  57. export function ExternalIssueList({group, event, project}: ExternalIssueListProps) {
  58. const organization = useOrganization();
  59. const {isLoading, integrations, linkedIssues} = useGroupExternalIssues({
  60. group,
  61. event,
  62. project,
  63. });
  64. const hasLinkedIssuesOrIntegrations = integrations.length || linkedIssues.length;
  65. return (
  66. <SidebarFoldSection
  67. data-test-id="linked-issues"
  68. title={<Title>{t('Issue Tracking')}</Title>}
  69. sectionKey={SectionKey.EXTERNAL_ISSUES}
  70. >
  71. {isLoading ? (
  72. <Placeholder height="25px" testId="issue-tracking-loading" />
  73. ) : hasLinkedIssuesOrIntegrations ? (
  74. <Fragment>
  75. {linkedIssues.length > 0 && (
  76. <IssueActionWrapper>
  77. {linkedIssues.map(linkedIssue => (
  78. <ErrorBoundary key={linkedIssue.key} mini>
  79. <Tooltip
  80. overlayStyle={{maxWidth: '400px'}}
  81. position="bottom"
  82. title={
  83. <LinkedIssueTooltipWrapper>
  84. <LinkedIssueName>{linkedIssue.title}</LinkedIssueName>
  85. <HorizontalSeparator />
  86. <UnlinkButton
  87. priority="link"
  88. size="zero"
  89. onClick={linkedIssue.onUnlink}
  90. >
  91. {t('Unlink issue')}
  92. </UnlinkButton>
  93. </LinkedIssueTooltipWrapper>
  94. }
  95. isHoverable
  96. >
  97. <LinkedIssue
  98. href={linkedIssue.url}
  99. external
  100. size="zero"
  101. icon={linkedIssue.displayIcon}
  102. >
  103. <IssueActionName>{linkedIssue.displayName}</IssueActionName>
  104. </LinkedIssue>
  105. </Tooltip>
  106. </ErrorBoundary>
  107. ))}
  108. </IssueActionWrapper>
  109. )}
  110. {integrations.length > 0 && (
  111. <IssueActionWrapper>
  112. {integrations.map(integration => {
  113. const sharedButtonProps: ButtonProps = {
  114. size: 'zero',
  115. icon: integration.displayIcon,
  116. children: <IssueActionName>{integration.displayName}</IssueActionName>,
  117. };
  118. if (integration.actions.length === 1) {
  119. const action = integration.actions[0]!;
  120. return (
  121. <ErrorBoundary key={integration.key} mini>
  122. {action.href ? (
  123. // Exclusively used for group.pluginActions
  124. <IssueActionLinkButton
  125. size="zero"
  126. icon={integration.displayIcon}
  127. disabled={integration.disabled}
  128. title={
  129. integration.disabled ? integration.disabledText : undefined
  130. }
  131. onClick={action.onClick}
  132. href={action.href}
  133. external
  134. >
  135. <IssueActionName>{integration.displayName}</IssueActionName>
  136. </IssueActionLinkButton>
  137. ) : (
  138. <IssueActionButton
  139. {...sharedButtonProps}
  140. disabled={integration.disabled}
  141. title={
  142. integration.disabled ? integration.disabledText : undefined
  143. }
  144. onClick={action.onClick}
  145. />
  146. )}
  147. </ErrorBoundary>
  148. );
  149. }
  150. return (
  151. <ErrorBoundary key={integration.key} mini>
  152. <DropdownMenu
  153. trigger={triggerProps => (
  154. <IssueActionDropdownMenu
  155. {...sharedButtonProps}
  156. {...triggerProps}
  157. showChevron={false}
  158. />
  159. )}
  160. items={integration.actions.map(action => ({
  161. key: action.id,
  162. ...getActionLabelAndTextValue({
  163. action,
  164. integrationDisplayName: integration.displayName,
  165. }),
  166. onAction: action.onClick,
  167. disabled: integration.disabled,
  168. }))}
  169. />
  170. </ErrorBoundary>
  171. );
  172. })}
  173. </IssueActionWrapper>
  174. )}
  175. </Fragment>
  176. ) : (
  177. <AlertLink
  178. type="muted"
  179. to={`/settings/${organization.slug}/integrations/?category=issue%20tracking`}
  180. >
  181. {t('Track this issue in Jira, GitHub, etc.')}
  182. </AlertLink>
  183. )}
  184. </SidebarFoldSection>
  185. );
  186. }
  187. const Title = styled('div')`
  188. font-size: ${p => p.theme.fontSizeMedium};
  189. `;
  190. const IssueActionWrapper = styled('div')`
  191. display: flex;
  192. flex-wrap: wrap;
  193. gap: ${space(1)};
  194. line-height: 1.2;
  195. &:not(:last-child) {
  196. margin-bottom: ${space(1)};
  197. }
  198. `;
  199. const LinkedIssue = styled(LinkButton)`
  200. display: flex;
  201. align-items: center;
  202. padding: ${space(0.5)} ${space(0.75)};
  203. border: 1px solid ${p => p.theme.border};
  204. border-radius: ${p => p.theme.borderRadius};
  205. font-weight: normal;
  206. `;
  207. const IssueActionButton = styled(Button)`
  208. display: flex;
  209. align-items: center;
  210. padding: ${space(0.5)} ${space(0.75)};
  211. border: 1px dashed ${p => p.theme.border};
  212. border-radius: ${p => p.theme.borderRadius};
  213. font-weight: normal;
  214. `;
  215. const IssueActionLinkButton = styled(LinkButton)`
  216. display: flex;
  217. align-items: center;
  218. padding: ${space(0.5)} ${space(0.75)};
  219. border: 1px dashed ${p => p.theme.border};
  220. border-radius: ${p => p.theme.borderRadius};
  221. font-weight: normal;
  222. `;
  223. const IssueActionDropdownMenu = styled(DropdownButton)`
  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. &[aria-expanded='true'] {
  231. border: 1px solid ${p => p.theme.border};
  232. }
  233. `;
  234. const IssueActionName = styled('div')`
  235. ${p => p.theme.overflowEllipsis}
  236. max-width: 200px;
  237. `;
  238. const LinkedIssueTooltipWrapper = styled('div')`
  239. display: flex;
  240. align-items: center;
  241. gap: ${space(0.5)};
  242. white-space: nowrap;
  243. `;
  244. const LinkedIssueName = styled('div')`
  245. ${p => p.theme.overflowEllipsis}
  246. margin-right: ${space(0.25)};
  247. `;
  248. const HorizontalSeparator = styled('div')`
  249. width: 1px;
  250. height: 14px;
  251. background: ${p => p.theme.border};
  252. `;
  253. const UnlinkButton = styled(Button)`
  254. color: ${p => p.theme.subText};
  255. `;