previewIssues.tsx 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. import {Fragment, useCallback, useEffect, useMemo, useRef, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import debounce from 'lodash/debounce';
  4. import FieldHelp from 'sentry/components/forms/fieldGroup/fieldHelp';
  5. import ExternalLink from 'sentry/components/links/externalLink';
  6. import ListItem from 'sentry/components/list/listItem';
  7. import type {CursorHandler} from 'sentry/components/pagination';
  8. import {Tooltip} from 'sentry/components/tooltip';
  9. import {t, tct} from 'sentry/locale';
  10. import GroupStore from 'sentry/stores/groupStore';
  11. import {space} from 'sentry/styles/space';
  12. import type {IssueAlertRule, UnsavedIssueAlertRule} from 'sentry/types/alerts';
  13. import type {Member} from 'sentry/types/organization';
  14. import type {Project} from 'sentry/types/project';
  15. import useApi from 'sentry/utils/useApi';
  16. import {useIsMountedRef} from 'sentry/utils/useIsMountedRef';
  17. import useOrganization from 'sentry/utils/useOrganization';
  18. import PreviewTable from './previewTable';
  19. const SENTRY_ISSUE_ALERT_DOCS_URL =
  20. 'https://docs.sentry.io/product/alerts/alert-types/#issue-alerts';
  21. function PreviewText({issueCount, previewError}) {
  22. if (previewError) {
  23. return (
  24. <Fragment>
  25. {t("Select a condition above to see which issues would've triggered this alert")}
  26. </Fragment>
  27. );
  28. }
  29. return tct(
  30. "[issueCount] issues would have triggered this rule in the past 14 days [approximately:approximately]. If you're looking to reduce noise then make sure to [link:read the docs].",
  31. {
  32. issueCount,
  33. approximately: (
  34. <Tooltip
  35. title={t('Previews that include issue frequency conditions are approximated')}
  36. showUnderline
  37. />
  38. ),
  39. link: <ExternalLink href={SENTRY_ISSUE_ALERT_DOCS_URL} />,
  40. }
  41. );
  42. }
  43. interface PreviewIssuesProps {
  44. members: Member[] | undefined;
  45. project: Project;
  46. rule?: UnsavedIssueAlertRule | IssueAlertRule | null;
  47. }
  48. export function PreviewIssues({members, rule, project}: PreviewIssuesProps) {
  49. const api = useApi();
  50. const organization = useOrganization();
  51. const isMounted = useIsMountedRef();
  52. const [isLoading, setIsLoading] = useState<boolean>(false);
  53. const [previewError, setPreviewError] = useState<boolean>(false);
  54. const [previewGroups, setPreviewGroups] = useState<string[]>([]);
  55. const [previewPage, setPreviewPage] = useState<number>(0);
  56. const [pageLinks, setPageLinks] = useState<string>('');
  57. const [issueCount, setIssueCount] = useState<number>(0);
  58. const endDateRef = useRef<string | null>(null);
  59. /**
  60. * If any of this data changes we'll need to re-fetch the preview
  61. */
  62. const relevantRuleData = useMemo(
  63. () =>
  64. rule
  65. ? {
  66. conditions: rule.conditions || [],
  67. filters: rule.filters || [],
  68. actionMatch: rule.actionMatch || 'all',
  69. filterMatch: rule.filterMatch || 'all',
  70. frequency: rule.frequency || 60,
  71. }
  72. : {},
  73. [rule]
  74. );
  75. /**
  76. * Not using useApiQuery because it makes a post request
  77. */
  78. const fetchApiData = useCallback(
  79. (ruleFields: any, cursor?: string | null, resetCursor?: boolean) => {
  80. setIsLoading(true);
  81. if (resetCursor) {
  82. setPreviewPage(0);
  83. }
  84. // we currently don't have a way to parse objects from query params, so this method is POST for now
  85. api
  86. .requestPromise(`/projects/${organization.slug}/${project.slug}/rules/preview/`, {
  87. method: 'POST',
  88. includeAllArgs: true,
  89. query: {
  90. cursor,
  91. per_page: 5,
  92. },
  93. data: {
  94. ...ruleFields,
  95. // so the end date doesn't change? Not sure.
  96. endpoint: endDateRef.current,
  97. },
  98. })
  99. .then(([data, _, resp]) => {
  100. if (!isMounted.current) {
  101. return;
  102. }
  103. GroupStore.add(data);
  104. const hits = resp?.getResponseHeader('X-Hits');
  105. const count = typeof hits !== 'undefined' && hits ? parseInt(hits, 10) : 0;
  106. setPreviewGroups(data.map(g => g.id));
  107. setPreviewError(false);
  108. setPageLinks(resp?.getResponseHeader('Link') ?? '');
  109. setIssueCount(count);
  110. setIsLoading(false);
  111. endDateRef.current = resp?.getResponseHeader('Endpoint') ?? null;
  112. })
  113. .catch(_ => {
  114. setPreviewError(true);
  115. setIsLoading(false);
  116. });
  117. },
  118. [
  119. setIsLoading,
  120. setPreviewError,
  121. setPreviewGroups,
  122. setIssueCount,
  123. api,
  124. project.slug,
  125. organization.slug,
  126. isMounted,
  127. ]
  128. );
  129. const debouncedFetchApiData = useMemo(
  130. () => debounce(fetchApiData, 500),
  131. [fetchApiData]
  132. );
  133. useEffect(() => {
  134. debouncedFetchApiData(relevantRuleData, null, true);
  135. // eslint-disable-next-line react-hooks/exhaustive-deps
  136. }, [JSON.stringify(relevantRuleData), debouncedFetchApiData]);
  137. useEffect(() => {
  138. return () => {
  139. debouncedFetchApiData.cancel();
  140. // Reset the group store when leaving
  141. GroupStore.reset();
  142. };
  143. }, [debouncedFetchApiData]);
  144. const onPreviewCursor: CursorHandler = (cursor, _1, _2, direction) => {
  145. setPreviewPage(previewPage + direction);
  146. debouncedFetchApiData.cancel();
  147. fetchApiData(relevantRuleData, cursor);
  148. };
  149. const errorMessage = previewError
  150. ? rule?.conditions.length || rule?.filters.length
  151. ? t('Preview is not supported for these conditions')
  152. : t('Select a condition to generate a preview')
  153. : null;
  154. return (
  155. <Fragment>
  156. <StyledListItem>
  157. <StepHeader>{t('Preview')}</StepHeader>
  158. <StyledFieldHelp>
  159. <PreviewText issueCount={issueCount} previewError={previewError} />
  160. </StyledFieldHelp>
  161. </StyledListItem>
  162. <ContentIndent>
  163. <PreviewTable
  164. previewGroups={previewGroups}
  165. members={members}
  166. pageLinks={pageLinks}
  167. onCursor={onPreviewCursor}
  168. issueCount={issueCount}
  169. page={previewPage}
  170. isLoading={isLoading}
  171. error={errorMessage}
  172. />
  173. </ContentIndent>
  174. </Fragment>
  175. );
  176. }
  177. const StyledListItem = styled(ListItem)`
  178. margin: ${space(2)} 0 ${space(1)} 0;
  179. font-size: ${p => p.theme.fontSizeExtraLarge};
  180. `;
  181. const StepHeader = styled('h5')`
  182. margin-bottom: ${space(1)};
  183. `;
  184. const StyledFieldHelp = styled(FieldHelp)`
  185. margin-top: 0;
  186. @media (max-width: ${p => p.theme.breakpoints.small}) {
  187. margin-left: -${space(4)};
  188. }
  189. `;
  190. const ContentIndent = styled('div')`
  191. @media (min-width: ${p => p.theme.breakpoints.small}) {
  192. margin-left: ${space(4)};
  193. }
  194. `;