previewIssues.tsx 6.4 KB

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