integrationCodeMappings.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import sortBy from 'lodash/sortBy';
  4. import * as qs from 'query-string';
  5. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  6. import {openModal} from 'sentry/actionCreators/modal';
  7. import AsyncComponent from 'sentry/components/asyncComponent';
  8. import Button from 'sentry/components/button';
  9. import EmptyMessage from 'sentry/components/emptyMessage';
  10. import ExternalLink from 'sentry/components/links/externalLink';
  11. import Pagination, {CursorHandler} from 'sentry/components/pagination';
  12. import {Panel, PanelBody, PanelHeader, PanelItem} from 'sentry/components/panels';
  13. import RepositoryProjectPathConfigForm from 'sentry/components/repositoryProjectPathConfigForm';
  14. import RepositoryProjectPathConfigRow, {
  15. ButtonWrapper,
  16. InputPathColumn,
  17. NameRepoColumn,
  18. OutputPathColumn,
  19. } from 'sentry/components/repositoryProjectPathConfigRow';
  20. import {IconAdd} from 'sentry/icons';
  21. import {t, tct} from 'sentry/locale';
  22. import space from 'sentry/styles/space';
  23. import {
  24. Integration,
  25. Organization,
  26. Project,
  27. Repository,
  28. RepositoryProjectPathConfig,
  29. } from 'sentry/types';
  30. import {
  31. getIntegrationIcon,
  32. trackIntegrationAnalytics,
  33. } from 'sentry/utils/integrationUtil';
  34. import withOrganization from 'sentry/utils/withOrganization';
  35. import withProjects from 'sentry/utils/withProjects';
  36. import TextBlock from 'sentry/views/settings/components/text/textBlock';
  37. type Props = AsyncComponent['props'] & {
  38. integration: Integration;
  39. organization: Organization;
  40. projects: Project[];
  41. };
  42. type State = AsyncComponent['state'] & {
  43. pathConfigs: RepositoryProjectPathConfig[];
  44. repos: Repository[];
  45. };
  46. class IntegrationCodeMappings extends AsyncComponent<Props, State> {
  47. getDefaultState(): State {
  48. return {
  49. ...super.getDefaultState(),
  50. pathConfigs: [],
  51. repos: [],
  52. };
  53. }
  54. get integrationId() {
  55. return this.props.integration.id;
  56. }
  57. get pathConfigs() {
  58. // we want to sort by the project slug and the
  59. // id of the config
  60. return sortBy(this.state.pathConfigs, [
  61. ({projectSlug}) => projectSlug,
  62. ({id}) => parseInt(id, 10),
  63. ]);
  64. }
  65. get repos() {
  66. // endpoint doesn't support loading only the repos for this integration
  67. // but most people only have one source code repo so this should be fine
  68. return this.state.repos.filter(repo => repo.integrationId === this.integrationId);
  69. }
  70. getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
  71. const orgSlug = this.props.organization.slug;
  72. return [
  73. [
  74. 'pathConfigs',
  75. `/organizations/${orgSlug}/code-mappings/`,
  76. {query: {integrationId: this.integrationId}},
  77. ],
  78. ['repos', `/organizations/${orgSlug}/repos/`, {query: {status: 'active'}}],
  79. ];
  80. }
  81. getMatchingProject(pathConfig: RepositoryProjectPathConfig) {
  82. return this.props.projects.find(project => project.id === pathConfig.projectId);
  83. }
  84. componentDidMount() {
  85. const {referrer} = qs.parse(window.location.search) || {};
  86. // We don't start new session if the user was coming from choosing
  87. // the manual setup option flow from the issue details page
  88. const startSession = referrer === 'stacktrace-issue-details' ? false : true;
  89. trackIntegrationAnalytics(
  90. 'integrations.code_mappings_viewed',
  91. {
  92. integration: this.props.integration.provider.key,
  93. integration_type: 'first_party',
  94. organization: this.props.organization,
  95. },
  96. {startSession}
  97. );
  98. }
  99. handleDelete = async (pathConfig: RepositoryProjectPathConfig) => {
  100. const {organization} = this.props;
  101. const endpoint = `/organizations/${organization.slug}/code-mappings/${pathConfig.id}/`;
  102. try {
  103. await this.api.requestPromise(endpoint, {
  104. method: 'DELETE',
  105. });
  106. // remove config and update state
  107. let {pathConfigs} = this.state;
  108. pathConfigs = pathConfigs.filter(config => config.id !== pathConfig.id);
  109. this.setState({pathConfigs});
  110. addSuccessMessage(t('Deletion successful'));
  111. } catch (err) {
  112. addErrorMessage(
  113. tct('[status]: [text]', {
  114. status: err.statusText,
  115. text: err.responseText,
  116. })
  117. );
  118. }
  119. };
  120. handleSubmitSuccess = (pathConfig: RepositoryProjectPathConfig) => {
  121. trackIntegrationAnalytics('integrations.stacktrace_complete_setup', {
  122. setup_type: 'manual',
  123. view: 'integration_configuration_detail',
  124. provider: this.props.integration.provider.key,
  125. organization: this.props.organization,
  126. });
  127. let {pathConfigs} = this.state;
  128. pathConfigs = pathConfigs.filter(config => config.id !== pathConfig.id);
  129. // our getter handles the order of the configs
  130. pathConfigs = pathConfigs.concat([pathConfig]);
  131. this.setState({pathConfigs});
  132. this.setState({pathConfig: undefined});
  133. };
  134. openModal = (pathConfig?: RepositoryProjectPathConfig) => {
  135. const {organization, projects, integration} = this.props;
  136. trackIntegrationAnalytics('integrations.stacktrace_start_setup', {
  137. setup_type: 'manual',
  138. view: 'integration_configuration_detail',
  139. provider: this.props.integration.provider.key,
  140. organization: this.props.organization,
  141. });
  142. openModal(({Body, Header, closeModal}) => (
  143. <Fragment>
  144. <Header closeButton>{t('Configure code path mapping')}</Header>
  145. <Body>
  146. <RepositoryProjectPathConfigForm
  147. organization={organization}
  148. integration={integration}
  149. projects={projects}
  150. repos={this.repos}
  151. onSubmitSuccess={config => {
  152. this.handleSubmitSuccess(config);
  153. closeModal();
  154. }}
  155. existingConfig={pathConfig}
  156. onCancel={closeModal}
  157. />
  158. </Body>
  159. </Fragment>
  160. ));
  161. };
  162. /**
  163. * This is a workaround to paginate without affecting browserHistory or modifiying the URL
  164. * It's necessary because we don't want to affect the pagination state of other tabs on the page.
  165. */
  166. handleCursor: CursorHandler = async (cursor, _path, query, _direction) => {
  167. const orgSlug = this.props.organization.slug;
  168. const [pathConfigs, _, responseMeta] = await this.api.requestPromise(
  169. `/organizations/${orgSlug}/code-mappings/`,
  170. {includeAllArgs: true, query: {...query, cursor}}
  171. );
  172. this.setState({
  173. pathConfigs,
  174. pathConfigsPageLinks: responseMeta?.getResponseHeader('link'),
  175. });
  176. };
  177. renderBody() {
  178. const pathConfigs = this.pathConfigs;
  179. const {integration} = this.props;
  180. const {pathConfigsPageLinks} = this.state;
  181. return (
  182. <Fragment>
  183. <TextBlock>
  184. {tct(
  185. `Code Mappings are used to map stack trace file paths to source code file paths. These mappings are the basis for features like Stack Trace Linking. To learn more, [link: read the docs].`,
  186. {
  187. link: (
  188. <ExternalLink href="https://docs.sentry.io/product/integrations/source-code-mgmt/gitlab/#stack-trace-linking" />
  189. ),
  190. }
  191. )}
  192. </TextBlock>
  193. <Panel>
  194. <PanelHeader disablePadding hasButtons>
  195. <HeaderLayout>
  196. <NameRepoColumn>{t('Code Mappings')}</NameRepoColumn>
  197. <InputPathColumn>{t('Stack Trace Root')}</InputPathColumn>
  198. <OutputPathColumn>{t('Source Code Root')}</OutputPathColumn>
  199. <ButtonWrapper>
  200. <Button
  201. data-test-id="add-mapping-button"
  202. onClick={() => this.openModal()}
  203. size="xs"
  204. icon={<IconAdd size="xs" isCircled />}
  205. >
  206. {t('Add Code Mapping')}
  207. </Button>
  208. </ButtonWrapper>
  209. </HeaderLayout>
  210. </PanelHeader>
  211. <PanelBody>
  212. {pathConfigs.length === 0 && (
  213. <EmptyMessage
  214. icon={getIntegrationIcon(integration.provider.key, 'lg')}
  215. action={
  216. <Button
  217. href={`https://docs.sentry.io/product/integrations/${integration.provider.key}/#stack-trace-linking`}
  218. size="sm"
  219. onClick={() => {
  220. trackIntegrationAnalytics('integrations.stacktrace_docs_clicked', {
  221. view: 'integration_configuration_detail',
  222. provider: this.props.integration.provider.key,
  223. organization: this.props.organization,
  224. });
  225. }}
  226. >
  227. View Documentation
  228. </Button>
  229. }
  230. >
  231. Set up stack trace linking by adding a code mapping.
  232. </EmptyMessage>
  233. )}
  234. {pathConfigs
  235. .map(pathConfig => {
  236. const project = this.getMatchingProject(pathConfig);
  237. // this should never happen since our pathConfig would be deleted
  238. // if project was deleted
  239. if (!project) {
  240. return null;
  241. }
  242. return (
  243. <ConfigPanelItem key={pathConfig.id}>
  244. <Layout>
  245. <RepositoryProjectPathConfigRow
  246. pathConfig={pathConfig}
  247. project={project}
  248. onEdit={this.openModal}
  249. onDelete={this.handleDelete}
  250. />
  251. </Layout>
  252. </ConfigPanelItem>
  253. );
  254. })
  255. .filter(item => !!item)}
  256. </PanelBody>
  257. </Panel>
  258. {pathConfigsPageLinks && (
  259. <Pagination pageLinks={pathConfigsPageLinks} onCursor={this.handleCursor} />
  260. )}
  261. </Fragment>
  262. );
  263. }
  264. }
  265. export default withProjects(withOrganization(IntegrationCodeMappings));
  266. const Layout = styled('div')`
  267. display: grid;
  268. grid-column-gap: ${space(1)};
  269. width: 100%;
  270. align-items: center;
  271. grid-template-columns: 4.5fr 2.5fr 2.5fr max-content;
  272. grid-template-areas: 'name-repo input-path output-path button';
  273. `;
  274. const HeaderLayout = styled(Layout)`
  275. align-items: center;
  276. margin: 0 ${space(1)} 0 ${space(2)};
  277. `;
  278. const ConfigPanelItem = styled(PanelItem)``;