modal.tsx 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import uniq from 'lodash/uniq';
  4. import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent';
  5. import ExternalLink from 'sentry/components/links/externalLink';
  6. import {t, tct} from 'sentry/locale';
  7. import ConfigStore from 'sentry/stores/configStore';
  8. import {space} from 'sentry/styles/space';
  9. import {Frame, Organization, Project, TagWithTopValues} from 'sentry/types';
  10. import type {Event} from 'sentry/types/event';
  11. import OwnerInput from 'sentry/views/settings/project/projectOwnership/ownerInput';
  12. type IssueOwnershipResponse = {
  13. autoAssignment: boolean;
  14. dateCreated: string;
  15. fallthrough: boolean;
  16. isActive: boolean;
  17. lastUpdated: string;
  18. raw: string;
  19. };
  20. type Props = DeprecatedAsyncComponent['props'] & {
  21. issueId: string;
  22. onCancel: () => void;
  23. organization: Organization;
  24. project: Project;
  25. eventData?: Event;
  26. };
  27. type State = {
  28. ownership: null | IssueOwnershipResponse;
  29. urlTagData: null | TagWithTopValues;
  30. } & DeprecatedAsyncComponent['state'];
  31. function getFrameSuggestions(eventData?: Event) {
  32. // pull frame data out of exception or the stacktrace
  33. const entry = eventData?.entries?.find(({type}) =>
  34. ['exception', 'stacktrace'].includes(type)
  35. );
  36. let frames: Frame[] = [];
  37. if (entry?.type === 'exception') {
  38. frames = entry?.data?.values?.[0]?.stacktrace?.frames ?? [];
  39. } else if (entry?.type === 'stacktrace') {
  40. frames = entry?.data?.frames ?? [];
  41. }
  42. // Only display in-app frames
  43. frames = frames.filter(frame => frame && frame.inApp).reverse();
  44. return uniq(frames.map(frame => frame.filename || frame.absPath || ''));
  45. }
  46. /**
  47. * Attempt to remove the origin from a URL
  48. */
  49. function getUrlPath(maybeUrl?: string) {
  50. if (!maybeUrl) {
  51. return '';
  52. }
  53. try {
  54. const url = new URL(maybeUrl);
  55. return `*${url.pathname}`;
  56. } catch {
  57. return maybeUrl;
  58. }
  59. }
  60. function OwnershipSuggestions({
  61. paths,
  62. urls,
  63. eventData,
  64. }: {
  65. paths: string[];
  66. urls: string[];
  67. eventData?: Event;
  68. }) {
  69. const email = ConfigStore.get('user')?.email;
  70. if (!email) {
  71. return null;
  72. }
  73. const pathSuggestion = paths.length ? `path:${paths[0]} ${email}` : null;
  74. const urlSuggestion = urls.length ? `url:${getUrlPath(urls[0])} ${email}` : null;
  75. const transactionTag = eventData?.tags?.find(({key}) => key === 'transaction');
  76. const transactionSuggestion = transactionTag
  77. ? `tags.transaction:${transactionTag.value} ${email}`
  78. : null;
  79. return (
  80. <StyledPre>
  81. # {t('Here’s some suggestions based on this issue')}
  82. <br />
  83. {[pathSuggestion, urlSuggestion, transactionSuggestion]
  84. .filter(x => x)
  85. .map(suggestion => (
  86. <Fragment key={suggestion}>
  87. {suggestion}
  88. <br />
  89. </Fragment>
  90. ))}
  91. </StyledPre>
  92. );
  93. }
  94. class ProjectOwnershipModal extends DeprecatedAsyncComponent<Props, State> {
  95. getEndpoints(): ReturnType<DeprecatedAsyncComponent['getEndpoints']> {
  96. const {organization, project, issueId} = this.props;
  97. return [
  98. ['ownership', `/projects/${organization.slug}/${project.slug}/ownership/`],
  99. [
  100. 'urlTagData',
  101. `/issues/${issueId}/tags/url/`,
  102. {},
  103. {
  104. allowError: error =>
  105. // Allow for 404s
  106. error.status === 404,
  107. },
  108. ],
  109. ];
  110. }
  111. renderBody() {
  112. const {ownership, urlTagData} = this.state;
  113. const {eventData, organization, project, onCancel} = this.props;
  114. if (!ownership) {
  115. return null;
  116. }
  117. const urls = urlTagData
  118. ? urlTagData.topValues
  119. .sort((a, b) => a.count - b.count)
  120. .map(i => i.value)
  121. .slice(0, 5)
  122. : [];
  123. const hasStreamlineTargetingFeature = organization.features.includes(
  124. 'streamline-targeting-context'
  125. );
  126. const paths = getFrameSuggestions(eventData);
  127. return (
  128. <Fragment>
  129. {hasStreamlineTargetingFeature ? (
  130. <Fragment>
  131. <Description>
  132. {tct(
  133. 'Assign issues based on custom rules. To learn more, [docs:read the docs].',
  134. {
  135. docs: (
  136. <ExternalLink href="https://docs.sentry.io/product/issues/issue-owners/" />
  137. ),
  138. }
  139. )}
  140. </Description>
  141. <OwnershipSuggestions paths={paths} urls={urls} eventData={eventData} />
  142. </Fragment>
  143. ) : (
  144. <p>{t('Match against Issue Data: (globbing syntax *, ? supported)')}</p>
  145. )}
  146. <OwnerInput
  147. organization={organization}
  148. project={project}
  149. initialText={ownership?.raw || ''}
  150. urls={urls}
  151. paths={paths}
  152. dateUpdated={ownership.lastUpdated}
  153. onCancel={onCancel}
  154. page="issue_details"
  155. />
  156. </Fragment>
  157. );
  158. }
  159. }
  160. const Description = styled('p')`
  161. margin-bottom: ${space(1)};
  162. `;
  163. const StyledPre = styled('pre')`
  164. word-break: break-word;
  165. padding: ${space(2)};
  166. line-height: 1.6;
  167. color: ${p => p.theme.subText};
  168. `;
  169. export default ProjectOwnershipModal;