integrationCodeMappings.tsx 11 KB

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