quickContext.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  1. import {Fragment, useEffect} from 'react';
  2. import styled from '@emotion/styled';
  3. import {Client} from 'sentry/api';
  4. import AvatarList from 'sentry/components/avatar/avatarList';
  5. import {QuickContextCommitRow} from 'sentry/components/discover/quickContextCommitRow';
  6. import EventCause from 'sentry/components/events/eventCause';
  7. import {CauseHeader, DataSection} from 'sentry/components/events/styles';
  8. import FeatureBadge from 'sentry/components/featureBadge';
  9. import AssignedTo from 'sentry/components/group/assignedTo';
  10. import {Body, Hovercard} from 'sentry/components/hovercard';
  11. import {KeyValueTable, KeyValueTableRow} from 'sentry/components/keyValueTable';
  12. import LoadingIndicator from 'sentry/components/loadingIndicator';
  13. import {Panel} from 'sentry/components/panels';
  14. import * as SidebarSection from 'sentry/components/sidebarSection';
  15. import TimeSince from 'sentry/components/timeSince';
  16. import {IconCheckmark, IconInfo, IconMute, IconNot} from 'sentry/icons';
  17. import {t, tct} from 'sentry/locale';
  18. import ConfigStore from 'sentry/stores/configStore';
  19. import GroupStore from 'sentry/stores/groupStore';
  20. import space from 'sentry/styles/space';
  21. import {Group, Organization, ReleaseWithHealth, User} from 'sentry/types';
  22. import {EventData} from 'sentry/utils/discover/eventView';
  23. import {useQuery, useQueryClient} from 'sentry/utils/queryClient';
  24. import useApi from 'sentry/utils/useApi';
  25. // Will extend this enum as we add contexts for more columns
  26. export enum ContextType {
  27. ISSUE = 'issue',
  28. RELEASE = 'release',
  29. }
  30. const HOVER_DELAY: number = 400;
  31. function isIssueContext(contextType: ContextType): boolean {
  32. return contextType === ContextType.ISSUE;
  33. }
  34. function isReleaseContext(contextType: ContextType): boolean {
  35. return contextType === ContextType.RELEASE;
  36. }
  37. const fiveMinutesInMs = 5 * 60 * 1000;
  38. type IssueContextProps = {
  39. api: Client;
  40. dataRow: EventData;
  41. eventID?: string;
  42. };
  43. function IssueContext(props: IssueContextProps) {
  44. const statusTitle = t('Issue Status');
  45. const {isLoading, isError, data} = useQuery<Group>(
  46. [
  47. `/issues/${props.dataRow['issue.id']}/`,
  48. {
  49. query: {
  50. collapse: 'release',
  51. expand: 'inbox',
  52. },
  53. },
  54. ],
  55. undefined,
  56. {
  57. onSuccess: group => {
  58. GroupStore.add([group]);
  59. },
  60. staleTime: fiveMinutesInMs,
  61. retry: false,
  62. }
  63. );
  64. const renderStatus = () =>
  65. data && (
  66. <IssueContextContainer data-test-id="quick-context-issue-status-container">
  67. <ContextTitle>
  68. {statusTitle}
  69. <FeatureBadge type="alpha" />
  70. </ContextTitle>
  71. <ContextBody>
  72. {data.status === 'ignored' ? (
  73. <IconMute
  74. data-test-id="quick-context-ignored-icon"
  75. color="gray500"
  76. size="sm"
  77. />
  78. ) : data.status === 'resolved' ? (
  79. <IconCheckmark color="gray500" size="sm" />
  80. ) : (
  81. <IconNot
  82. data-test-id="quick-context-unresolved-icon"
  83. color="gray500"
  84. size="sm"
  85. />
  86. )}
  87. <StatusText>{data.status}</StatusText>
  88. </ContextBody>
  89. </IssueContextContainer>
  90. );
  91. const renderAssigneeSelector = () =>
  92. data && (
  93. <IssueContextContainer data-test-id="quick-context-assigned-to-container">
  94. <AssignedTo group={data} projectId={data.project.id} />
  95. </IssueContextContainer>
  96. );
  97. const renderSuspectCommits = () =>
  98. props.eventID &&
  99. data && (
  100. <IssueContextContainer data-test-id="quick-context-suspect-commits-container">
  101. <EventCause
  102. project={data.project}
  103. eventId={props.eventID}
  104. commitRow={QuickContextCommitRow}
  105. />
  106. </IssueContextContainer>
  107. );
  108. if (isLoading || isError) {
  109. return <NoContext isLoading={isLoading} />;
  110. }
  111. return (
  112. <Fragment>
  113. {renderStatus()}
  114. {renderAssigneeSelector()}
  115. {renderSuspectCommits()}
  116. </Fragment>
  117. );
  118. }
  119. type NoContextProps = {
  120. isLoading: boolean;
  121. };
  122. function NoContext({isLoading}: NoContextProps) {
  123. return isLoading ? (
  124. <NoContextWrapper>
  125. <LoadingIndicator
  126. data-test-id="quick-context-loading-indicator"
  127. hideMessage
  128. size={32}
  129. />
  130. </NoContextWrapper>
  131. ) : (
  132. <NoContextWrapper>{t('Failed to load context for column.')}</NoContextWrapper>
  133. );
  134. }
  135. type ReleaseContextProps = {
  136. api: Client;
  137. dataRow: EventData;
  138. organization: Organization;
  139. };
  140. function ReleaseContext(props: ReleaseContextProps) {
  141. const {isLoading, isError, data} = useQuery<ReleaseWithHealth>(
  142. [`/organizations/${props.organization.slug}/releases/${props.dataRow.release}/`],
  143. undefined,
  144. {
  145. staleTime: fiveMinutesInMs,
  146. retry: false,
  147. }
  148. );
  149. const getCommitAuthorTitle = () => {
  150. const user = ConfigStore.get('user');
  151. const commitCount = data?.commitCount || 0;
  152. let authorsCount = data?.authors.length || 0;
  153. const userInAuthors =
  154. data &&
  155. data.authors.length >= 1 &&
  156. data.authors.find((author: User) => author.id && user.id && author.id === user.id);
  157. if (userInAuthors) {
  158. authorsCount = authorsCount - 1;
  159. return authorsCount !== 1 && commitCount !== 1
  160. ? tct('[commitCount] commits by you and [authorsCount] others', {
  161. commitCount,
  162. authorsCount,
  163. })
  164. : commitCount !== 1
  165. ? tct('[commitCount] commits by you and 1 other', {
  166. commitCount,
  167. })
  168. : authorsCount !== 1
  169. ? tct('1 commit by you and [authorsCount] others', {
  170. authorsCount,
  171. })
  172. : t('1 commit by you and 1 other');
  173. }
  174. return (
  175. data &&
  176. (authorsCount !== 1 && commitCount !== 1
  177. ? tct('[commitCount] commits by [authorsCount] authors', {
  178. commitCount,
  179. authorsCount,
  180. })
  181. : commitCount !== 1
  182. ? tct('[commitCount] commits by 1 author', {
  183. commitCount,
  184. })
  185. : authorsCount !== 1
  186. ? tct('1 commit by [authorsCount] authors', {
  187. authorsCount,
  188. })
  189. : t('1 commit by 1 author'))
  190. );
  191. };
  192. const renderReleaseDetails = () => {
  193. const statusText = data?.status === 'open' ? t('Active') : t('Archived');
  194. return (
  195. <ReleaseContextContainer data-test-id="quick-context-release-details-container">
  196. <ContextTitle>
  197. {t('Release Details')}
  198. <FeatureBadge type="alpha" />
  199. </ContextTitle>
  200. <ContextBody>
  201. <StyledKeyValueTable>
  202. <KeyValueTableRow keyName={t('Status')} value={statusText} />
  203. {data?.status === 'open' && (
  204. <Fragment>
  205. <KeyValueTableRow
  206. keyName={t('Created')}
  207. value={<TimeSince date={data.dateCreated} />}
  208. />
  209. <KeyValueTableRow
  210. keyName={t('First Event')}
  211. value={
  212. data.firstEvent ? <TimeSince date={data.firstEvent} /> : '\u2014'
  213. }
  214. />
  215. <KeyValueTableRow
  216. keyName={t('Last Event')}
  217. value={data.lastEvent ? <TimeSince date={data.lastEvent} /> : '\u2014'}
  218. />
  219. </Fragment>
  220. )}
  221. </StyledKeyValueTable>
  222. </ContextBody>
  223. </ReleaseContextContainer>
  224. );
  225. };
  226. const renderLastCommit = () =>
  227. data &&
  228. data.lastCommit && (
  229. <ReleaseContextContainer data-test-id="quick-context-release-last-commit-container">
  230. <ContextTitle>{t('Last Commit')}</ContextTitle>
  231. <DataSection>
  232. <Panel>
  233. <QuickContextCommitRow commit={data.lastCommit} />
  234. </Panel>
  235. </DataSection>
  236. </ReleaseContextContainer>
  237. );
  238. const renderIssueCountAndAuthors = () =>
  239. data && (
  240. <ReleaseContextContainer data-test-id="quick-context-release-issues-and-authors-container">
  241. <ContextRow>
  242. <div>
  243. <ContextTitle>{t('New Issues')}</ContextTitle>
  244. <ReleaseStatusBody>{data.newGroups}</ReleaseStatusBody>
  245. </div>
  246. <div>
  247. <ReleaseAuthorsTitle>{getCommitAuthorTitle()}</ReleaseAuthorsTitle>
  248. <ReleaseAuthorsBody>
  249. {data.commitCount === 0 ? (
  250. <IconNot color="gray500" size="md" />
  251. ) : (
  252. <AvatarList users={data.authors} />
  253. )}
  254. </ReleaseAuthorsBody>
  255. </div>
  256. </ContextRow>
  257. </ReleaseContextContainer>
  258. );
  259. if (isLoading || isError) {
  260. return <NoContext isLoading={isLoading} />;
  261. }
  262. return (
  263. <Fragment>
  264. {renderReleaseDetails()}
  265. {renderIssueCountAndAuthors()}
  266. {renderLastCommit()}
  267. </Fragment>
  268. );
  269. }
  270. type ContextProps = {
  271. children: React.ReactNode;
  272. contextType: ContextType;
  273. dataRow: EventData;
  274. organization?: Organization;
  275. };
  276. export function QuickContextHoverWrapper(props: ContextProps) {
  277. const api = useApi();
  278. const queryClient = useQueryClient();
  279. useEffect(() => {
  280. return () => {
  281. GroupStore.reset();
  282. queryClient.clear();
  283. };
  284. }, [queryClient]);
  285. return (
  286. <HoverWrapper>
  287. {props.children}
  288. <StyledHovercard
  289. skipWrapper
  290. delay={HOVER_DELAY}
  291. body={
  292. <Wrapper data-test-id="quick-context-hover-body">
  293. {isIssueContext(props.contextType) ? (
  294. <IssueContext
  295. api={api}
  296. dataRow={props.dataRow}
  297. eventID={props.dataRow.id}
  298. />
  299. ) : isReleaseContext(props.contextType) && props.organization ? (
  300. <ReleaseContext
  301. api={api}
  302. dataRow={props.dataRow}
  303. organization={props.organization}
  304. />
  305. ) : (
  306. <NoContextWrapper>{t('There is no context available.')}</NoContextWrapper>
  307. )}
  308. </Wrapper>
  309. }
  310. >
  311. <StyledIconInfo
  312. data-test-id="quick-context-hover-trigger"
  313. onClick={e => e.preventDefault()}
  314. />
  315. </StyledHovercard>
  316. </HoverWrapper>
  317. );
  318. }
  319. const ContextContainer = styled('div')`
  320. display: flex;
  321. flex-direction: column;
  322. `;
  323. const StyledHovercard = styled(Hovercard)`
  324. ${Body} {
  325. padding: 0;
  326. }
  327. min-width: 300px;
  328. `;
  329. const StyledIconInfo = styled(IconInfo)`
  330. color: ${p => p.theme.gray200};
  331. min-width: max-content;
  332. &:hover {
  333. color: ${p => p.theme.gray300};
  334. }
  335. `;
  336. const HoverWrapper = styled('div')`
  337. display: flex;
  338. align-items: center;
  339. gap: ${space(0.75)};
  340. `;
  341. const IssueContextContainer = styled(ContextContainer)`
  342. ${SidebarSection.Wrap}, ${Panel}, h6 {
  343. margin: 0;
  344. }
  345. ${Panel} {
  346. border: none;
  347. box-shadow: none;
  348. }
  349. ${DataSection} {
  350. padding: 0;
  351. }
  352. ${CauseHeader}, ${SidebarSection.Title} {
  353. margin-top: ${space(2)};
  354. }
  355. ${CauseHeader} > h3,
  356. ${CauseHeader} > button {
  357. font-size: ${p => p.theme.fontSizeExtraSmall};
  358. font-weight: 600;
  359. text-transform: uppercase;
  360. }
  361. `;
  362. const ContextTitle = styled('h6')`
  363. color: ${p => p.theme.subText};
  364. display: flex;
  365. justify-content: space-between;
  366. align-items: center;
  367. margin: 0;
  368. `;
  369. const ContextBody = styled('div')`
  370. margin: ${space(1)} 0 0;
  371. width: 100%;
  372. text-align: left;
  373. font-size: ${p => p.theme.fontSizeLarge};
  374. display: flex;
  375. align-items: center;
  376. `;
  377. const StatusText = styled('span')`
  378. margin-left: ${space(1)};
  379. text-transform: capitalize;
  380. `;
  381. const Wrapper = styled('div')`
  382. background: ${p => p.theme.background};
  383. border-radius: ${p => p.theme.borderRadius};
  384. width: 300px;
  385. padding: ${space(1.5)};
  386. `;
  387. const NoContextWrapper = styled('div')`
  388. color: ${p => p.theme.subText};
  389. height: 50px;
  390. padding: ${space(1)};
  391. font-size: ${p => p.theme.fontSizeLarge};
  392. display: flex;
  393. flex-direction: column;
  394. align-items: center;
  395. justify-content: center;
  396. white-space: nowrap;
  397. `;
  398. const StyledKeyValueTable = styled(KeyValueTable)`
  399. width: 100%;
  400. margin: 0;
  401. `;
  402. const ReleaseContextContainer = styled(ContextContainer)`
  403. ${Panel} {
  404. margin: 0;
  405. border: none;
  406. box-shadow: none;
  407. }
  408. ${DataSection} {
  409. padding: 0;
  410. }
  411. & + & {
  412. margin-top: ${space(2)};
  413. }
  414. `;
  415. const ReleaseAuthorsTitle = styled(ContextTitle)`
  416. max-width: 200px;
  417. text-align: right;
  418. `;
  419. const ContextRow = styled('div')`
  420. display: flex;
  421. justify-content: space-between;
  422. `;
  423. const ReleaseAuthorsBody = styled(ContextBody)`
  424. justify-content: right;
  425. `;
  426. const ReleaseStatusBody = styled('h4')`
  427. margin-bottom: 0;
  428. `;