suggestedOwnerHovercard.tsx 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. import {Component, Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import moment from 'moment';
  4. import {openInviteMembersModal} from 'sentry/actionCreators/modal';
  5. import Alert from 'sentry/components/alert';
  6. import ActorAvatar from 'sentry/components/avatar/actorAvatar';
  7. import Button from 'sentry/components/button';
  8. import CommitLink from 'sentry/components/commitLink';
  9. import {Divider, Hovercard} from 'sentry/components/hovercard';
  10. import Link from 'sentry/components/links/link';
  11. import Version from 'sentry/components/version';
  12. import {IconCommit} from 'sentry/icons';
  13. import {t, tct} from 'sentry/locale';
  14. import space from 'sentry/styles/space';
  15. import type {Actor, Commit, Organization, Release} from 'sentry/types';
  16. import {defined} from 'sentry/utils';
  17. import theme from 'sentry/utils/theme';
  18. type Props = {
  19. /**
  20. * The suggested actor.
  21. */
  22. actor: Actor;
  23. /**
  24. * Children are required, as they are passed to the hovercard component, without it,
  25. * we will not be able to trigger any hovercard actions
  26. */
  27. children: React.ReactNode;
  28. organization: Organization;
  29. /**
  30. * The list of commits the actor is suggested for. May be left blank if the
  31. * actor is not suggested for commits.
  32. */
  33. commits?: Commit[];
  34. /**
  35. * Used to pre-select release project
  36. */
  37. projectId?: string;
  38. release?: Release;
  39. /**
  40. * The list of ownership rules the actor is suggested for. May be left blank
  41. * if the actor is not suggested based on ownership rules.
  42. */
  43. rules?: any[] | null;
  44. };
  45. type State = {
  46. commitsExpanded: boolean;
  47. rulesExpanded: boolean;
  48. };
  49. class SuggestedOwnerHovercard extends Component<Props, State> {
  50. state: State = {
  51. commitsExpanded: false,
  52. rulesExpanded: false,
  53. };
  54. render() {
  55. const {organization, actor, commits, rules, release, projectId, ...props} =
  56. this.props;
  57. const {commitsExpanded, rulesExpanded} = this.state;
  58. const modalData = {
  59. initialData: [
  60. {
  61. emails: actor.email ? new Set([actor.email]) : new Set([]),
  62. },
  63. ],
  64. source: 'suggested_assignees',
  65. };
  66. return (
  67. <StyledHovercard
  68. skipWrapper
  69. header={
  70. <Fragment>
  71. <HovercardHeader>
  72. <ActorAvatar size={20} hasTooltip={false} actor={actor} />
  73. {actor.name || actor.email}
  74. </HovercardHeader>
  75. {actor.id === undefined && (
  76. <EmailAlert type="warning" showIcon>
  77. {tct(
  78. 'The email [actorEmail] is not a member of your organization. [inviteUser:Invite] them or link additional emails in [accountSettings:account settings].',
  79. {
  80. actorEmail: <strong>{actor.email}</strong>,
  81. accountSettings: <Link to="/settings/account/emails/" />,
  82. inviteUser: <a onClick={() => openInviteMembersModal(modalData)} />,
  83. }
  84. )}
  85. </EmailAlert>
  86. )}
  87. </Fragment>
  88. }
  89. body={
  90. <HovercardBody>
  91. {commits !== undefined && !release && (
  92. <Fragment>
  93. <Divider>
  94. <h6>{t('Commits')}</h6>
  95. </Divider>
  96. <div>
  97. {commits
  98. .slice(0, commitsExpanded ? commits.length : 3)
  99. .map(({message, dateCreated}, i) => (
  100. <CommitReasonItem key={i}>
  101. <CommitIcon />
  102. <CommitMessage
  103. message={message ?? undefined}
  104. date={dateCreated}
  105. />
  106. </CommitReasonItem>
  107. ))}
  108. </div>
  109. {commits.length > 3 && !commitsExpanded ? (
  110. <ViewMoreButton
  111. priority="link"
  112. size="zero"
  113. onClick={() => this.setState({commitsExpanded: true})}
  114. >
  115. {t('View more')}
  116. </ViewMoreButton>
  117. ) : null}
  118. </Fragment>
  119. )}
  120. {commits !== undefined && release && (
  121. <Fragment>
  122. <Divider>
  123. <h6>{t('Suspect Release')}</h6>
  124. </Divider>
  125. <div>
  126. <CommitReasonItem>
  127. <OwnershipTag tagType="release" />
  128. <ReleaseValue>
  129. {tct('[actor] [verb] [commits] in [release]', {
  130. actor: actor.name,
  131. verb: commits.length > 1 ? t('made') : t('last committed'),
  132. commits:
  133. commits.length > 1 ? (
  134. // Link to release commits
  135. <Link
  136. to={{
  137. pathname: `/organizations/${
  138. organization?.slug
  139. }/releases/${encodeURIComponent(
  140. release.version
  141. )}/commits/`,
  142. query: {project: projectId},
  143. }}
  144. >
  145. {t('%s commits', commits.length)}
  146. </Link>
  147. ) : (
  148. <CommitLink
  149. inline
  150. showIcon={false}
  151. commitId={commits[0].id}
  152. repository={commits[0].repository}
  153. />
  154. ),
  155. release: (
  156. <Version version={release.version} projectId={projectId} />
  157. ),
  158. })}
  159. </ReleaseValue>
  160. </CommitReasonItem>
  161. </div>
  162. </Fragment>
  163. )}
  164. {defined(rules) && (
  165. <Fragment>
  166. <Divider>
  167. <h6>{t('Matching Ownership Rules')}</h6>
  168. </Divider>
  169. <div>
  170. {rules
  171. .slice(0, rulesExpanded ? rules.length : 3)
  172. .map(([type, matched], i) => (
  173. <RuleReasonItem key={i}>
  174. <OwnershipTag tagType={type} />
  175. <OwnershipValue>{matched}</OwnershipValue>
  176. </RuleReasonItem>
  177. ))}
  178. </div>
  179. {rules.length > 3 && !rulesExpanded ? (
  180. <ViewMoreButton
  181. priority="link"
  182. size="zero"
  183. onClick={() => this.setState({rulesExpanded: true})}
  184. >
  185. {t('View more')}
  186. </ViewMoreButton>
  187. ) : null}
  188. </Fragment>
  189. )}
  190. </HovercardBody>
  191. }
  192. {...props}
  193. />
  194. );
  195. }
  196. }
  197. const tagColors = {
  198. url: theme.green200,
  199. path: theme.purple300,
  200. tag: theme.blue300,
  201. codeowners: theme.pink300,
  202. release: theme.pink200,
  203. };
  204. const StyledHovercard = styled(Hovercard)`
  205. width: 400px;
  206. `;
  207. const CommitIcon = styled(IconCommit)`
  208. margin-right: ${space(0.5)};
  209. flex-shrink: 0;
  210. `;
  211. const CommitMessage = styled(({message = '', date, ...props}) => (
  212. <div {...props}>
  213. {message.split('\n')[0]}
  214. <CommitDate date={date} />
  215. </div>
  216. ))`
  217. color: ${p => p.theme.textColor};
  218. font-size: ${p => p.theme.fontSizeExtraSmall};
  219. margin-top: ${space(0.25)};
  220. hyphens: auto;
  221. `;
  222. const CommitDate = styled(({date, ...props}) => (
  223. <div {...props}>{moment(date).fromNow()}</div>
  224. ))`
  225. margin-top: ${space(0.5)};
  226. color: ${p => p.theme.gray300};
  227. `;
  228. const CommitReasonItem = styled('div')`
  229. display: flex;
  230. align-items: flex-start;
  231. gap: ${space(1)};
  232. `;
  233. const RuleReasonItem = styled('div')`
  234. display: flex;
  235. align-items: flex-start;
  236. gap: ${space(1)};
  237. `;
  238. const OwnershipTag = styled(({tagType, ...props}) => <div {...props}>{tagType}</div>)`
  239. background: ${p => tagColors[p.tagType.indexOf('tags') === -1 ? p.tagType : 'tag']};
  240. color: ${p => p.theme.white};
  241. font-size: ${p => p.theme.fontSizeExtraSmall};
  242. padding: ${space(0.25)} ${space(0.5)};
  243. margin: ${space(0.25)} ${space(0.5)} ${space(0.25)} 0;
  244. border-radius: 2px;
  245. font-weight: bold;
  246. text-align: center;
  247. `;
  248. const ViewMoreButton = styled(Button)`
  249. border: none;
  250. color: ${p => p.theme.gray300};
  251. font-size: ${p => p.theme.fontSizeExtraSmall};
  252. padding: ${space(0.25)} ${space(0.5)};
  253. margin: ${space(1)} ${space(0.25)} ${space(0.25)} 0;
  254. width: 100%;
  255. min-width: 34px;
  256. `;
  257. const OwnershipValue = styled('code')`
  258. word-break: break-all;
  259. font-size: ${p => p.theme.fontSizeExtraSmall};
  260. margin-top: ${space(0.25)};
  261. `;
  262. const ReleaseValue = styled('div')`
  263. font-size: ${p => p.theme.fontSizeSmall};
  264. margin-top: ${space(0.5)};
  265. `;
  266. const EmailAlert = styled(Alert)`
  267. margin: 10px -13px -13px;
  268. border-radius: 0;
  269. border-color: #ece0b0;
  270. font-size: ${p => p.theme.fontSizeSmall};
  271. font-weight: normal;
  272. box-shadow: none;
  273. `;
  274. const HovercardHeader = styled('div')`
  275. display: flex;
  276. align-items: center;
  277. gap: ${space(1)};
  278. `;
  279. const HovercardBody = styled('div')`
  280. margin-top: -${space(2)};
  281. `;
  282. export default SuggestedOwnerHovercard;