import {Fragment} from 'react'; import styled from '@emotion/styled'; import sortBy from 'lodash/sortBy'; import * as qs from 'query-string'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import {openModal} from 'sentry/actionCreators/modal'; import AsyncComponent from 'sentry/components/asyncComponent'; import Button from 'sentry/components/button'; import EmptyMessage from 'sentry/components/emptyMessage'; import ExternalLink from 'sentry/components/links/externalLink'; import Pagination, {CursorHandler} from 'sentry/components/pagination'; import {Panel, PanelBody, PanelHeader, PanelItem} from 'sentry/components/panels'; import RepositoryProjectPathConfigForm from 'sentry/components/repositoryProjectPathConfigForm'; import RepositoryProjectPathConfigRow, { ButtonWrapper, InputPathColumn, NameRepoColumn, OutputPathColumn, } from 'sentry/components/repositoryProjectPathConfigRow'; import {IconAdd} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import space from 'sentry/styles/space'; import { Integration, Organization, Project, Repository, RepositoryProjectPathConfig, } from 'sentry/types'; import { getIntegrationIcon, trackIntegrationAnalytics, } from 'sentry/utils/integrationUtil'; import withOrganization from 'sentry/utils/withOrganization'; import withProjects from 'sentry/utils/withProjects'; import TextBlock from 'sentry/views/settings/components/text/textBlock'; type Props = AsyncComponent['props'] & { integration: Integration; organization: Organization; projects: Project[]; }; type State = AsyncComponent['state'] & { pathConfigs: RepositoryProjectPathConfig[]; repos: Repository[]; }; class IntegrationCodeMappings extends AsyncComponent { getDefaultState(): State { return { ...super.getDefaultState(), pathConfigs: [], repos: [], }; } get integrationId() { return this.props.integration.id; } get pathConfigs() { // we want to sort by the project slug and the // id of the config return sortBy(this.state.pathConfigs, [ ({projectSlug}) => projectSlug, ({id}) => parseInt(id, 10), ]); } get repos() { // endpoint doesn't support loading only the repos for this integration // but most people only have one source code repo so this should be fine return this.state.repos.filter(repo => repo.integrationId === this.integrationId); } getEndpoints(): ReturnType { const orgSlug = this.props.organization.slug; return [ [ 'pathConfigs', `/organizations/${orgSlug}/code-mappings/`, {query: {integrationId: this.integrationId}}, ], ['repos', `/organizations/${orgSlug}/repos/`, {query: {status: 'active'}}], ]; } getMatchingProject(pathConfig: RepositoryProjectPathConfig) { return this.props.projects.find(project => project.id === pathConfig.projectId); } componentDidMount() { const {referrer} = qs.parse(window.location.search) || {}; // We don't start new session if the user was coming from choosing // the manual setup option flow from the issue details page const startSession = referrer === 'stacktrace-issue-details' ? false : true; trackIntegrationAnalytics( 'integrations.code_mappings_viewed', { integration: this.props.integration.provider.key, integration_type: 'first_party', organization: this.props.organization, }, {startSession} ); } handleDelete = async (pathConfig: RepositoryProjectPathConfig) => { const {organization} = this.props; const endpoint = `/organizations/${organization.slug}/code-mappings/${pathConfig.id}/`; try { await this.api.requestPromise(endpoint, { method: 'DELETE', }); // remove config and update state let {pathConfigs} = this.state; pathConfigs = pathConfigs.filter(config => config.id !== pathConfig.id); this.setState({pathConfigs}); addSuccessMessage(t('Deletion successful')); } catch (err) { addErrorMessage( tct('[status]: [text]', { status: err.statusText, text: err.responseText, }) ); } }; handleSubmitSuccess = (pathConfig: RepositoryProjectPathConfig) => { trackIntegrationAnalytics('integrations.stacktrace_complete_setup', { setup_type: 'manual', view: 'integration_configuration_detail', provider: this.props.integration.provider.key, organization: this.props.organization, }); let {pathConfigs} = this.state; pathConfigs = pathConfigs.filter(config => config.id !== pathConfig.id); // our getter handles the order of the configs pathConfigs = pathConfigs.concat([pathConfig]); this.setState({pathConfigs}); this.setState({pathConfig: undefined}); }; openModal = (pathConfig?: RepositoryProjectPathConfig) => { const {organization, projects, integration} = this.props; trackIntegrationAnalytics('integrations.stacktrace_start_setup', { setup_type: 'manual', view: 'integration_configuration_detail', provider: this.props.integration.provider.key, organization: this.props.organization, }); openModal(({Body, Header, closeModal}) => (
{t('Configure code path mapping')}
{ this.handleSubmitSuccess(config); closeModal(); }} existingConfig={pathConfig} onCancel={closeModal} />
)); }; /** * This is a workaround to paginate without affecting browserHistory or modifiying the URL * It's necessary because we don't want to affect the pagination state of other tabs on the page. */ handleCursor: CursorHandler = async (cursor, _path, query, _direction) => { const orgSlug = this.props.organization.slug; const [pathConfigs, _, responseMeta] = await this.api.requestPromise( `/organizations/${orgSlug}/code-mappings/`, {includeAllArgs: true, query: {...query, cursor}} ); this.setState({ pathConfigs, pathConfigsPageLinks: responseMeta?.getResponseHeader('link'), }); }; renderBody() { const pathConfigs = this.pathConfigs; const {integration} = this.props; const {pathConfigsPageLinks} = this.state; return ( {tct( `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].`, { link: ( ), } )} {t('Code Mappings')} {t('Stack Trace Root')} {t('Source Code Root')} {pathConfigs.length === 0 && ( { trackIntegrationAnalytics('integrations.stacktrace_docs_clicked', { view: 'integration_configuration_detail', provider: this.props.integration.provider.key, organization: this.props.organization, }); }} > View Documentation } > Set up stack trace linking by adding a code mapping. )} {pathConfigs .map(pathConfig => { const project = this.getMatchingProject(pathConfig); // this should never happen since our pathConfig would be deleted // if project was deleted if (!project) { return null; } return ( ); }) .filter(item => !!item)} {pathConfigsPageLinks && ( )} ); } } export default withProjects(withOrganization(IntegrationCodeMappings)); const Layout = styled('div')` display: grid; grid-column-gap: ${space(1)}; width: 100%; align-items: center; grid-template-columns: 4.5fr 2.5fr 2.5fr max-content; grid-template-areas: 'name-repo input-path output-path button'; `; const HeaderLayout = styled(Layout)` align-items: center; margin: 0 ${space(1)} 0 ${space(2)}; `; const ConfigPanelItem = styled(PanelItem)``;