integrationCodeMappings.tsx 9.9 KB

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