quickContext.tsx 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. import {Fragment, useRef, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {RequestOptions} from 'sentry/api';
  4. import {QuickContextCommitRow} from 'sentry/components/discover/quickContextCommitRow';
  5. import EventCause from 'sentry/components/events/eventCause';
  6. import {CauseHeader, DataSection} from 'sentry/components/events/styles';
  7. import FeatureBadge from 'sentry/components/featureBadge';
  8. import AssignedTo from 'sentry/components/group/assignedTo';
  9. import {Body, Hovercard} from 'sentry/components/hovercard';
  10. import LoadingIndicator from 'sentry/components/loadingIndicator';
  11. import {Panel} from 'sentry/components/panels';
  12. import * as SidebarSection from 'sentry/components/sidebarSection';
  13. import {IconCheckmark, IconInfo, IconMute, IconNot} from 'sentry/icons';
  14. import {t} from 'sentry/locale';
  15. import GroupStore from 'sentry/stores/groupStore';
  16. import space from 'sentry/styles/space';
  17. import {Group, Organization} from 'sentry/types';
  18. import {EventData} from 'sentry/utils/discover/eventView';
  19. import useApi from 'sentry/utils/useApi';
  20. // Will extend this enum as we add contexts for more columns
  21. export enum ContextType {
  22. ISSUE = 'issue',
  23. RELEASE = 'release',
  24. }
  25. const HOVER_DELAY: number = 400;
  26. const DATA_FETCH_DELAY: number = 200;
  27. function isIssueContext(contextType: ContextType): boolean {
  28. return contextType === ContextType.ISSUE;
  29. }
  30. type RequestParams = {
  31. path: string;
  32. options?: RequestOptions;
  33. };
  34. // NOTE: Will extend when we add more type of contexts. Context is only relevant to issue and release columns for now.
  35. function getRequestParams(
  36. dataRow: EventData,
  37. contextType: ContextType,
  38. organization?: Organization
  39. ): RequestParams {
  40. return isIssueContext(contextType)
  41. ? {
  42. path: `/issues/${dataRow['issue.id']}/`,
  43. options: {
  44. method: 'GET',
  45. query: {
  46. collapse: 'release',
  47. expand: 'inbox',
  48. },
  49. },
  50. }
  51. : {
  52. path: `/organizations/${organization?.slug}/releases/${dataRow.release}/`,
  53. };
  54. }
  55. type QuickContextProps = {
  56. contextType: ContextType;
  57. data: Group | null;
  58. dataRow: EventData;
  59. error: boolean;
  60. loading: boolean;
  61. };
  62. export default function QuickContext({
  63. loading,
  64. error,
  65. data,
  66. contextType,
  67. dataRow,
  68. }: QuickContextProps) {
  69. return (
  70. <Wrapper>
  71. {loading ? (
  72. <NoContextWrapper>
  73. <LoadingIndicator
  74. data-test-id="quick-context-loading-indicator"
  75. hideMessage
  76. size={32}
  77. />
  78. </NoContextWrapper>
  79. ) : error ? (
  80. <NoContextWrapper>{t('Failed to load context for column.')}</NoContextWrapper>
  81. ) : isIssueContext(contextType) && data ? (
  82. <IssueContext data={data} eventID={dataRow.id} />
  83. ) : (
  84. <NoContextWrapper>{t('There is no context available.')}</NoContextWrapper>
  85. )}
  86. </Wrapper>
  87. );
  88. }
  89. type IssueContextProps = {
  90. data: Group;
  91. eventID?: string;
  92. };
  93. function IssueContext(props: IssueContextProps) {
  94. const statusTitle = t('Issue Status');
  95. const {status} = props.data;
  96. const renderStatus = () => (
  97. <IssueContextContainer data-test-id="quick-context-issue-status-container">
  98. <ContextTitle>
  99. {statusTitle}
  100. <FeatureBadge type="alpha" />
  101. </ContextTitle>
  102. <ContextBody>
  103. {status === 'ignored' ? (
  104. <IconMute data-test-id="quick-context-ignored-icon" color="gray500" size="sm" />
  105. ) : status === 'resolved' ? (
  106. <IconCheckmark color="gray500" size="sm" />
  107. ) : (
  108. <IconNot
  109. data-test-id="quick-context-unresolved-icon"
  110. color="gray500"
  111. size="sm"
  112. />
  113. )}
  114. <StatusText>{status}</StatusText>
  115. </ContextBody>
  116. </IssueContextContainer>
  117. );
  118. const renderAssigneeSelector = () => (
  119. <IssueContextContainer data-test-id="quick-context-assigned-to-container">
  120. <AssignedTo group={props.data} projectId={props.data.project.id} />
  121. </IssueContextContainer>
  122. );
  123. const renderSuspectCommits = () =>
  124. props.eventID && (
  125. <IssueContextContainer data-test-id="quick-context-suspect-commits-container">
  126. <EventCause
  127. project={props.data.project}
  128. eventId={props.eventID}
  129. commitRow={QuickContextCommitRow}
  130. />
  131. </IssueContextContainer>
  132. );
  133. return (
  134. <Fragment>
  135. {renderStatus()}
  136. {renderAssigneeSelector()}
  137. {renderSuspectCommits()}
  138. </Fragment>
  139. );
  140. }
  141. type ContextProps = {
  142. children: React.ReactNode;
  143. contextType: ContextType;
  144. dataRow: EventData;
  145. organization?: Organization;
  146. };
  147. export function QuickContextHoverWrapper(props: ContextProps) {
  148. const api = useApi();
  149. const [ishovering, setisHovering] = useState<boolean>(false);
  150. const [error, setError] = useState<boolean>(false);
  151. const [loading, setLoading] = useState<boolean>(true);
  152. const [data, setData] = useState<Group | null>(null);
  153. const delayOpenTimeoutRef = useRef<number | undefined>(undefined);
  154. const handleHoverState = () => {
  155. setisHovering(prevState => !prevState);
  156. };
  157. const fetchData = () => {
  158. if (!data) {
  159. const params = getRequestParams(
  160. props.dataRow,
  161. props.contextType,
  162. props.organization
  163. );
  164. api
  165. .requestPromise(params.path, params.options)
  166. .then(response => {
  167. setData(response);
  168. if (isIssueContext(props.contextType)) {
  169. GroupStore.add([response]);
  170. }
  171. })
  172. .catch(() => {
  173. setError(true);
  174. })
  175. .finally(() => {
  176. setLoading(false);
  177. });
  178. }
  179. };
  180. const handleMouseEnter = () => {
  181. handleHoverState();
  182. delayOpenTimeoutRef.current = window.setTimeout(() => {
  183. fetchData();
  184. }, DATA_FETCH_DELAY);
  185. };
  186. const handleMouseLeave = () => {
  187. handleHoverState();
  188. window.clearTimeout(delayOpenTimeoutRef.current);
  189. };
  190. return (
  191. <HoverWrapper>
  192. {props.children}
  193. <StyledHovercard
  194. skipWrapper
  195. delay={HOVER_DELAY}
  196. body={
  197. <QuickContext
  198. loading={loading}
  199. error={error}
  200. data={data}
  201. contextType={props.contextType}
  202. dataRow={props.dataRow}
  203. />
  204. }
  205. >
  206. <StyledIconInfo
  207. data-test-id="quick-context-hover-trigger"
  208. onMouseEnter={handleMouseEnter}
  209. onMouseLeave={handleMouseLeave}
  210. ishovering={ishovering ? 1 : 0}
  211. onClick={e => e.preventDefault()}
  212. />
  213. </StyledHovercard>
  214. </HoverWrapper>
  215. );
  216. }
  217. const ContextContainer = styled('div')`
  218. display: flex;
  219. flex-direction: column;
  220. `;
  221. const StyledHovercard = styled(Hovercard)`
  222. ${Body} {
  223. padding: 0;
  224. }
  225. min-width: 300px;
  226. `;
  227. const StyledIconInfo = styled(IconInfo)<{ishovering: number}>`
  228. color: ${p => (p.ishovering ? p.theme.gray300 : p.theme.gray200)};
  229. `;
  230. const HoverWrapper = styled('div')`
  231. display: flex;
  232. align-items: center;
  233. gap: ${space(0.75)};
  234. `;
  235. const IssueContextContainer = styled(ContextContainer)`
  236. ${SidebarSection.Wrap}, ${Panel}, h6 {
  237. margin: 0;
  238. }
  239. ${Panel} {
  240. border: none;
  241. box-shadow: none;
  242. }
  243. ${DataSection} {
  244. padding: 0;
  245. }
  246. ${CauseHeader}, ${SidebarSection.Title} {
  247. margin-top: ${space(2)};
  248. }
  249. `;
  250. const ContextTitle = styled('h6')`
  251. color: ${p => p.theme.subText};
  252. display: flex;
  253. justify-content: space-between;
  254. align-items: center;
  255. font-size: ${p => p.theme.fontSizeMedium};
  256. margin: 0;
  257. `;
  258. const ContextBody = styled('div')`
  259. margin: ${space(1)} 0 0;
  260. width: 100%;
  261. text-align: left;
  262. font-size: ${p => p.theme.fontSizeLarge};
  263. display: flex;
  264. align-items: center;
  265. `;
  266. const StatusText = styled('span')`
  267. margin-left: ${space(1)};
  268. text-transform: capitalize;
  269. `;
  270. const Wrapper = styled('div')`
  271. background: ${p => p.theme.background};
  272. border-radius: ${p => p.theme.borderRadius};
  273. width: 300px;
  274. padding: ${space(1.5)};
  275. `;
  276. const NoContextWrapper = styled('div')`
  277. color: ${p => p.theme.subText};
  278. height: 50px;
  279. padding: ${space(1)};
  280. font-size: ${p => p.theme.fontSizeMedium};
  281. display: flex;
  282. flex-direction: column;
  283. align-items: center;
  284. justify-content: center;
  285. white-space: nowrap;
  286. `;