externalIssueList.tsx 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import AlertLink from 'sentry/components/alertLink';
  4. import {Button, type ButtonProps, LinkButton} from 'sentry/components/button';
  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 * as SidebarSection from 'sentry/components/sidebarSection';
  12. import {Tooltip} from 'sentry/components/tooltip';
  13. import {t} from 'sentry/locale';
  14. import {space} from 'sentry/styles/space';
  15. import type {Event} from 'sentry/types/event';
  16. import type {Group} from 'sentry/types/group';
  17. import type {Project} from 'sentry/types/project';
  18. import useOrganization from 'sentry/utils/useOrganization';
  19. import {SidebarSectionTitle} from 'sentry/views/issueDetails/streamline/sidebar/sidebar';
  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. if (isLoading) {
  65. return (
  66. <div data-test-id="linked-issues">
  67. <SidebarSectionTitle>{t('Issue Tracking')}</SidebarSectionTitle>
  68. <SidebarSection.Content>
  69. <Placeholder height="25px" testId="issue-tracking-loading" />
  70. </SidebarSection.Content>
  71. </div>
  72. );
  73. }
  74. return (
  75. <div data-test-id="linked-issues">
  76. <SidebarSectionTitle>{t('Issue Tracking')}</SidebarSectionTitle>
  77. <SidebarSection.Content>
  78. {integrations.length || linkedIssues.length ? (
  79. <Fragment>
  80. <IssueActionWrapper>
  81. {linkedIssues.map(linkedIssue => (
  82. <ErrorBoundary key={linkedIssue.key} mini>
  83. <Tooltip
  84. overlayStyle={{maxWidth: '400px'}}
  85. position="bottom"
  86. title={
  87. <LinkedIssueTooltipWrapper>
  88. <LinkedIssueName>{linkedIssue.title}</LinkedIssueName>
  89. <HorizontalSeparator />
  90. <UnlinkButton
  91. priority="link"
  92. size="zero"
  93. onClick={linkedIssue.onUnlink}
  94. >
  95. {t('Unlink issue')}
  96. </UnlinkButton>
  97. </LinkedIssueTooltipWrapper>
  98. }
  99. isHoverable
  100. >
  101. <LinkedIssue
  102. href={linkedIssue.url}
  103. external
  104. size="zero"
  105. icon={linkedIssue.displayIcon}
  106. >
  107. <IssueActionName>{linkedIssue.displayName}</IssueActionName>
  108. </LinkedIssue>
  109. </Tooltip>
  110. </ErrorBoundary>
  111. ))}
  112. </IssueActionWrapper>
  113. <IssueActionWrapper>
  114. {integrations.map(integration => {
  115. const sharedButtonProps: ButtonProps = {
  116. size: 'zero',
  117. icon: integration.displayIcon,
  118. children: <IssueActionName>{integration.displayName}</IssueActionName>,
  119. };
  120. if (integration.actions.length === 1) {
  121. return (
  122. <ErrorBoundary key={integration.key} mini>
  123. <IssueActionButton
  124. {...sharedButtonProps}
  125. disabled={integration.disabled}
  126. title={
  127. integration.disabled ? integration.disabledText : undefined
  128. }
  129. onClick={integration.actions[0]!.onClick}
  130. />
  131. </ErrorBoundary>
  132. );
  133. }
  134. return (
  135. <ErrorBoundary key={integration.key} mini>
  136. <DropdownMenu
  137. trigger={triggerProps => (
  138. <IssueActionDropdownMenu
  139. {...sharedButtonProps}
  140. {...triggerProps}
  141. showChevron={false}
  142. />
  143. )}
  144. items={integration.actions.map(action => ({
  145. key: action.id,
  146. ...getActionLabelAndTextValue({
  147. action,
  148. integrationDisplayName: integration.displayName,
  149. }),
  150. onAction: action.onClick,
  151. disabled: integration.disabled,
  152. }))}
  153. />
  154. </ErrorBoundary>
  155. );
  156. })}
  157. </IssueActionWrapper>
  158. </Fragment>
  159. ) : (
  160. <AlertLink
  161. priority="muted"
  162. size="small"
  163. to={`/settings/${organization.slug}/integrations/?category=issue%20tracking`}
  164. withoutMarginBottom
  165. >
  166. {t('Track this issue in Jira, GitHub, etc.')}
  167. </AlertLink>
  168. )}
  169. </SidebarSection.Content>
  170. </div>
  171. );
  172. }
  173. const IssueActionWrapper = styled('div')`
  174. display: flex;
  175. flex-wrap: wrap;
  176. gap: ${space(1)};
  177. line-height: 1.2;
  178. &:not(:last-child) {
  179. margin-bottom: ${space(1)};
  180. }
  181. `;
  182. const LinkedIssue = styled(LinkButton)`
  183. display: flex;
  184. align-items: center;
  185. padding: ${space(0.5)} ${space(0.75)};
  186. border: 1px solid ${p => p.theme.border};
  187. border-radius: ${p => p.theme.borderRadius};
  188. font-weight: normal;
  189. `;
  190. const IssueActionButton = styled(Button)`
  191. display: flex;
  192. align-items: center;
  193. padding: ${space(0.5)} ${space(0.75)};
  194. border: 1px dashed ${p => p.theme.border};
  195. border-radius: ${p => p.theme.borderRadius};
  196. font-weight: normal;
  197. `;
  198. const IssueActionDropdownMenu = styled(DropdownButton)`
  199. display: flex;
  200. align-items: center;
  201. padding: ${space(0.5)} ${space(0.75)};
  202. border: 1px dashed ${p => p.theme.border};
  203. border-radius: ${p => p.theme.borderRadius};
  204. font-weight: normal;
  205. &[aria-expanded='true'] {
  206. border: 1px solid ${p => p.theme.border};
  207. }
  208. `;
  209. const IssueActionName = styled('div')`
  210. ${p => p.theme.overflowEllipsis}
  211. max-width: 200px;
  212. `;
  213. const LinkedIssueTooltipWrapper = styled('div')`
  214. display: flex;
  215. align-items: center;
  216. gap: ${space(0.5)};
  217. white-space: nowrap;
  218. `;
  219. const LinkedIssueName = styled('div')`
  220. ${p => p.theme.overflowEllipsis}
  221. margin-right: ${space(0.25)};
  222. `;
  223. const HorizontalSeparator = styled('div')`
  224. width: 1px;
  225. height: 14px;
  226. background: ${p => p.theme.border};
  227. `;
  228. const UnlinkButton = styled(Button)`
  229. color: ${p => p.theme.subText};
  230. `;