Просмотр исходного кода

ref(ecosystem): Convert stacktrace link component to hooks (#41902)

Scott Cooper 2 лет назад
Родитель
Сommit
4f488f078d

+ 23 - 1
static/app/actionCreators/prompts.tsx

@@ -1,4 +1,5 @@
-import {Client} from 'sentry/api';
+import type {Client} from 'sentry/api';
+import {useQuery} from 'sentry/utils/queryClient';
 
 type PromptsUpdateParams = {
   /**
@@ -87,6 +88,27 @@ export async function promptsCheck(
   return null;
 }
 
+export const makePromptsCheckQueryKey = ({
+  feature,
+  organizationId,
+  projectId,
+}: PromptCheckParams): [string, Record<string, string | undefined>] => [
+  '/prompts-activity/',
+  {feature, organizationId, projectId},
+];
+
+/**
+ * @param organizationId org numerical id, not the slug
+ */
+export function usePromptsCheck({feature, organizationId, projectId}: PromptCheckParams) {
+  return useQuery<PromptResponse>(
+    makePromptsCheckQueryKey({feature, organizationId, projectId}),
+    {
+      staleTime: 120000,
+    }
+  );
+}
+
 /**
  * Get the status of many prompt
  */

+ 1 - 2
static/app/components/events/interfaces/frame/context.tsx

@@ -17,7 +17,7 @@ import ContextLine from './contextLine';
 import {FrameRegisters} from './frameRegisters';
 import {FrameVariables} from './frameVariables';
 import {OpenInContextLine} from './openInContextLine';
-import StacktraceLink from './stacktraceLink';
+import {StacktraceLink} from './stacktraceLink';
 
 type Props = {
   components: Array<SentryAppComponent>;
@@ -108,7 +108,6 @@ const Context = ({
                   <StacktraceLink
                     key={index}
                     line={line[1]}
-                    lineNo={line[0]}
                     frame={frame}
                     event={event}
                   />

+ 0 - 142
static/app/components/events/interfaces/frame/stacktraceLink.spec.jsx

@@ -1,142 +0,0 @@
-import {render, screen} from 'sentry-test/reactTestingLibrary';
-
-import {StacktraceLink} from './stacktraceLink';
-
-describe('StacktraceLink', function () {
-  const org = TestStubs.Organization();
-  const project = TestStubs.Project();
-  const event = TestStubs.Event({projectID: project.id});
-  const integration = TestStubs.GitHubIntegration();
-  const repo = TestStubs.Repository({integrationId: integration.id});
-
-  const frame = {filename: '/sentry/app.py', lineNo: 233};
-  const platform = 'python';
-  const config = TestStubs.RepositoryProjectPathConfig({project, repo, integration});
-
-  beforeEach(function () {
-    MockApiClient.clearMockResponses();
-    MockApiClient.addMockResponse({
-      method: 'GET',
-      url: '/prompts-activity/',
-      body: {},
-    });
-  });
-
-  it('renders ask to setup integration', async function () {
-    MockApiClient.addMockResponse({
-      url: `/projects/${org.slug}/${project.slug}/stacktrace-link/`,
-      query: {file: frame.filename, commitId: 'master', platform},
-      body: {config: null, sourceUrl: null, integrations: []},
-    });
-    render(
-      <StacktraceLink
-        frame={frame}
-        event={event}
-        projects={[project]}
-        organization={org}
-        lineNo={frame.lineNo}
-      />,
-      {context: TestStubs.routerContext()}
-    );
-    expect(
-      await screen.findByText(
-        'Add a GitHub, Bitbucket, or similar integration to make sh*t easier for your team'
-      )
-    ).toBeInTheDocument();
-  });
-
-  it('renders setup CTA with integration but no configs', async function () {
-    MockApiClient.addMockResponse({
-      url: `/projects/${org.slug}/${project.slug}/stacktrace-link/`,
-      query: {file: frame.filename, commitId: 'master', platform},
-      body: {config: null, sourceUrl: null, integrations: [integration]},
-    });
-    render(
-      <StacktraceLink
-        frame={frame}
-        event={event}
-        projects={[project]}
-        organization={org}
-        line="foo()"
-        lineNo={frame.lineNo}
-      />,
-      {context: TestStubs.routerContext()}
-    );
-    expect(
-      await screen.findByText('Tell us where your source code is')
-    ).toBeInTheDocument();
-  });
-
-  it('renders source url link', function () {
-    MockApiClient.addMockResponse({
-      url: `/projects/${org.slug}/${project.slug}/stacktrace-link/`,
-      query: {file: frame.filename, commitId: 'master', platform},
-      body: {config, sourceUrl: 'https://something.io', integrations: [integration]},
-    });
-    render(
-      <StacktraceLink
-        frame={frame}
-        event={event}
-        projects={[project]}
-        organization={org}
-        line="foo()"
-        lineNo={frame.lineNo}
-      />,
-      {context: TestStubs.routerContext()}
-    );
-    expect(screen.getByRole('link')).toHaveAttribute('href', 'https://something.io#L233');
-    expect(screen.getByText('Open this line in GitHub')).toBeInTheDocument();
-  });
-
-  it('displays fix modal on error', function () {
-    MockApiClient.addMockResponse({
-      url: `/projects/${org.slug}/${project.slug}/stacktrace-link/`,
-      query: {file: frame.filename, commitId: 'master', platform},
-      body: {
-        config,
-        sourceUrl: null,
-        integrations: [integration],
-      },
-    });
-    render(
-      <StacktraceLink
-        frame={frame}
-        event={event}
-        projects={[project]}
-        organization={org}
-        line="foo()"
-        lineNo={frame.lineNo}
-      />,
-      {context: TestStubs.routerContext()}
-    );
-    expect(
-      screen.getByRole('button', {
-        name: 'Tell us where your source code is',
-      })
-    ).toBeInTheDocument();
-  });
-
-  it('should hide stacktrace link error state on minified javascript frames', function () {
-    MockApiClient.addMockResponse({
-      url: `/projects/${org.slug}/${project.slug}/stacktrace-link/`,
-      query: {file: frame.filename, commitId: 'master', platform},
-      body: {
-        config,
-        sourceUrl: null,
-        integrations: [integration],
-      },
-    });
-    const {container} = render(
-      <StacktraceLink
-        frame={frame}
-        event={{...event, platform: 'javascript'}}
-        projects={[project]}
-        organization={org}
-        line="{snip} somethingInsane=e.IsNotFound {snip}"
-        lineNo={frame.lineNo}
-      />,
-      {context: TestStubs.routerContext()}
-    );
-    expect(container).toBeEmptyDOMElement();
-  });
-});

+ 171 - 0
static/app/components/events/interfaces/frame/stacktraceLink.spec.tsx

@@ -0,0 +1,171 @@
+import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
+
+import ProjectsStore from 'sentry/stores/projectsStore';
+import type {Frame} from 'sentry/types';
+import * as analytics from 'sentry/utils/integrationUtil';
+
+import {StacktraceLink} from './stacktraceLink';
+
+describe('StacktraceLink', function () {
+  const org = TestStubs.Organization();
+  const platform = 'python';
+  const project = TestStubs.Project({});
+  const event = TestStubs.Event({
+    projectID: project.id,
+    release: TestStubs.Release({lastCommit: TestStubs.Commit()}),
+    platform,
+  });
+  const integration = TestStubs.GitHubIntegration();
+  const repo = TestStubs.Repository({integrationId: integration.id});
+
+  const frame = {filename: '/sentry/app.py', lineNo: 233} as Frame;
+  const config = TestStubs.RepositoryProjectPathConfig({project, repo, integration});
+  let promptActivity: jest.Mock;
+  const analyticsSpy = jest.spyOn(analytics, 'trackIntegrationAnalytics');
+
+  beforeEach(function () {
+    jest.clearAllMocks();
+    MockApiClient.clearMockResponses();
+    promptActivity = MockApiClient.addMockResponse({
+      method: 'GET',
+      url: '/prompts-activity/',
+      body: {},
+    });
+    ProjectsStore.loadInitialData([project]);
+  });
+
+  it('renders ask to setup integration', async function () {
+    const stacktraceLinkMock = MockApiClient.addMockResponse({
+      url: `/projects/${org.slug}/${project.slug}/stacktrace-link/`,
+      body: {config: null, sourceUrl: null, integrations: []},
+    });
+    render(<StacktraceLink frame={frame} event={event} line="" />, {
+      context: TestStubs.routerContext(),
+    });
+    expect(
+      await screen.findByText(
+        'Add a GitHub, Bitbucket, or similar integration to make sh*t easier for your team'
+      )
+    ).toBeInTheDocument();
+    expect(stacktraceLinkMock).toHaveBeenCalledTimes(1);
+    expect(stacktraceLinkMock).toHaveBeenCalledWith(
+      `/projects/${org.slug}/${project.slug}/stacktrace-link/`,
+      expect.objectContaining({
+        query: {
+          commitId: event.release?.lastCommit?.id,
+          file: frame.filename,
+          platform,
+        },
+      })
+    );
+    expect(promptActivity).toHaveBeenCalledTimes(1);
+    expect(analyticsSpy).toHaveBeenCalledTimes(1);
+  });
+
+  it('can dismiss stacktrace link CTA', async function () {
+    MockApiClient.addMockResponse({
+      url: `/projects/${org.slug}/${project.slug}/stacktrace-link/`,
+      body: {config: null, sourceUrl: null, integrations: []},
+    });
+    const dismissPrompt = MockApiClient.addMockResponse({
+      method: 'PUT',
+      url: `/prompts-activity/`,
+      body: {},
+    });
+    const {container} = render(<StacktraceLink frame={frame} event={event} line="" />, {
+      context: TestStubs.routerContext(),
+    });
+    expect(
+      await screen.findByText(
+        'Add a GitHub, Bitbucket, or similar integration to make sh*t easier for your team'
+      )
+    ).toBeInTheDocument();
+
+    userEvent.click(screen.getByRole('button'));
+
+    await waitFor(() => {
+      expect(container).toBeEmptyDOMElement();
+    });
+
+    expect(dismissPrompt).toHaveBeenCalledWith(
+      `/prompts-activity/`,
+      expect.objectContaining({
+        data: {
+          feature: 'stacktrace_link',
+          status: 'dismissed',
+          organization_id: org.id,
+          project_id: project.id,
+        },
+      })
+    );
+  });
+
+  it('renders setup CTA with integration but no configs', async function () {
+    MockApiClient.addMockResponse({
+      url: `/projects/${org.slug}/${project.slug}/stacktrace-link/`,
+      body: {config: null, sourceUrl: null, integrations: [integration]},
+    });
+    render(<StacktraceLink frame={frame} event={event} line="foo()" />, {
+      context: TestStubs.routerContext(),
+    });
+    expect(
+      await screen.findByText('Tell us where your source code is')
+    ).toBeInTheDocument();
+  });
+
+  it('renders source url link', async function () {
+    MockApiClient.addMockResponse({
+      url: `/projects/${org.slug}/${project.slug}/stacktrace-link/`,
+      body: {config, sourceUrl: 'https://something.io', integrations: [integration]},
+    });
+    render(<StacktraceLink frame={frame} event={event} line="foo()" />, {
+      context: TestStubs.routerContext(),
+    });
+    expect(await screen.findByRole('link')).toHaveAttribute(
+      'href',
+      'https://something.io#L233'
+    );
+    expect(screen.getByText('Open this line in GitHub')).toBeInTheDocument();
+  });
+
+  it('displays fix modal on error', async function () {
+    MockApiClient.addMockResponse({
+      url: `/projects/${org.slug}/${project.slug}/stacktrace-link/`,
+      body: {
+        config,
+        sourceUrl: null,
+        integrations: [integration],
+      },
+    });
+    render(<StacktraceLink frame={frame} event={event} line="foo()" />, {
+      context: TestStubs.routerContext(),
+    });
+    expect(
+      await screen.findByRole('button', {
+        name: 'Tell us where your source code is',
+      })
+    ).toBeInTheDocument();
+  });
+
+  it('should hide stacktrace link error state on minified javascript frames', async function () {
+    MockApiClient.addMockResponse({
+      url: `/projects/${org.slug}/${project.slug}/stacktrace-link/`,
+      body: {
+        config,
+        sourceUrl: null,
+        integrations: [integration],
+      },
+    });
+    const {container} = render(
+      <StacktraceLink
+        frame={frame}
+        event={{...event, platform: 'javascript'}}
+        line="{snip} somethingInsane=e.IsNotFound {snip}"
+      />,
+      {context: TestStubs.routerContext()}
+    );
+    await waitFor(() => {
+      expect(container).toBeEmptyDOMElement();
+    });
+  });
+});

+ 166 - 197
static/app/components/events/interfaces/frame/stacktraceLink.tsx

@@ -1,10 +1,14 @@
+import {useEffect, useMemo} from 'react';
 import {css} from '@emotion/react';
 import styled from '@emotion/styled';
 
 import {openModal} from 'sentry/actionCreators/modal';
-import {promptsCheck, promptsUpdate} from 'sentry/actionCreators/prompts';
-import type {ResponseMeta} from 'sentry/api';
-import AsyncComponent from 'sentry/components/asyncComponent';
+import {
+  makePromptsCheckQueryKey,
+  PromptResponse,
+  promptsUpdate,
+  usePromptsCheck,
+} from 'sentry/actionCreators/prompts';
 import Button from 'sentry/components/button';
 import ExternalLink from 'sentry/components/links/externalLink';
 import Link from 'sentry/components/links/link';
@@ -21,187 +25,207 @@ import type {
 } from 'sentry/types';
 import {StacktraceLinkEvents} from 'sentry/utils/analytics/integrations/stacktraceLinkAnalyticsEvents';
 import {getAnalyicsDataForEvent} from 'sentry/utils/events';
-import handleXhrErrorResponse from 'sentry/utils/handleXhrErrorResponse';
 import {
   getIntegrationIcon,
   trackIntegrationAnalytics,
 } from 'sentry/utils/integrationUtil';
 import {promptIsDismissed} from 'sentry/utils/promptIsDismissed';
-import withOrganization from 'sentry/utils/withOrganization';
-import withProjects from 'sentry/utils/withProjects';
+import {useQuery, useQueryClient} from 'sentry/utils/queryClient';
+import useApi from 'sentry/utils/useApi';
+import useOrganization from 'sentry/utils/useOrganization';
+import useProjects from 'sentry/utils/useProjects';
 
 import {OpenInContainer} from './openInContextLine';
 import StacktraceLinkModal from './stacktraceLinkModal';
 
-type Props = AsyncComponent['props'] & {
+interface StacktraceLinkSetupProps {
   event: Event;
-  frame: Frame;
-  line: string;
-  lineNo: number;
   organization: Organization;
-  projects: Project[];
-};
-
-type State = AsyncComponent['state'] & {
-  isDismissed: boolean;
-  match: StacktraceLinkResult;
-  promptLoaded: boolean;
-};
-
-class StacktraceLink extends AsyncComponent<Props, State> {
-  get project() {
-    // we can't use the withProject HoC on an the issue page
-    // so we ge around that by using the withProjects HoC
-    // and look up the project from the list
-    const {projects, event} = this.props;
-    return projects.find(project => project.id === event.projectID);
-  }
-
-  componentDidMount() {
-    this.promptsCheck();
-  }
-
-  async promptsCheck() {
-    const {organization} = this.props;
-
-    const prompt = await promptsCheck(this.api, {
-      organizationId: organization.id,
-      projectId: this.project?.id,
-      feature: 'stacktrace_link',
-    });
+  project?: Project;
+}
 
-    this.setState({
-      isDismissed: promptIsDismissed(prompt),
-      promptLoaded: true,
-    });
-  }
+function StacktraceLinkSetup({organization, project, event}: StacktraceLinkSetupProps) {
+  const api = useApi();
+  const queryClient = useQueryClient();
 
-  dismissPrompt = () => {
-    const {organization} = this.props;
-    promptsUpdate(this.api, {
+  const dismissPrompt = () => {
+    promptsUpdate(api, {
       organizationId: organization.id,
-      projectId: this.project?.id,
+      projectId: project?.id,
       feature: 'stacktrace_link',
       status: 'dismissed',
     });
 
+    // Update cached query data
+    // Will set prompt to dismissed
+    queryClient.setQueryData<PromptResponse>(
+      makePromptsCheckQueryKey({
+        feature: 'stacktrace_link',
+        organizationId: organization.id,
+        projectId: project?.id,
+      }),
+      () => {
+        const dimissedTs = new Date().getTime() / 1000;
+        return {
+          data: {dismissed_ts: dimissedTs},
+          features: {stacktrace_link: {dismissed_ts: dimissedTs}},
+        };
+      }
+    );
+
     trackIntegrationAnalytics('integrations.stacktrace_link_cta_dismissed', {
       view: 'stacktrace_issue_details',
       organization,
-      ...getAnalyicsDataForEvent(this.props.event),
+      ...getAnalyicsDataForEvent(event),
     });
-
-    this.setState({isDismissed: true});
   };
 
-  getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
-    const {organization, frame, event} = this.props;
-    const project = this.project;
-    if (!project) {
-      throw new Error('Unable to find project');
-    }
+  return (
+    <CodeMappingButtonContainer columnQuantity={2}>
+      <StyledLink to={`/settings/${organization.slug}/integrations/`}>
+        <StyledIconWrapper>{getIntegrationIcon('github', 'sm')}</StyledIconWrapper>
+        {t(
+          'Add a GitHub, Bitbucket, or similar integration to make sh*t easier for your team'
+        )}
+      </StyledLink>
+      <CloseButton type="button" priority="link" onClick={dismissPrompt}>
+        <IconClose size="xs" aria-label={t('Close')} />
+      </CloseButton>
+    </CodeMappingButtonContainer>
+  );
+}
 
-    const commitId = event.release?.lastCommit?.id;
-    const platform = event.platform;
-    const sdkName = event.sdk?.name;
-    return [
-      [
-        'match',
-        `/projects/${organization.slug}/${project.slug}/stacktrace-link/`,
-        {
-          query: {
-            file: frame.filename,
-            platform,
-            commitId,
-            ...(sdkName && {sdkName}),
-            ...(frame.absPath && {absPath: frame.absPath}),
-            ...(frame.module && {module: frame.module}),
-            ...(frame.package && {package: frame.package}),
-          },
-        },
-      ],
-    ];
-  }
+interface StacktraceLinkProps {
+  event: Event;
+  frame: Frame;
+  /**
+   * The line of code being linked
+   */
+  line: string;
+}
+
+export function StacktraceLink({frame, event, line}: StacktraceLinkProps) {
+  const organization = useOrganization();
+  const {projects} = useProjects();
+  const project = useMemo(
+    () => projects.find(p => p.id === event.projectID),
+    [projects, event]
+  );
+  const prompt = usePromptsCheck({
+    feature: 'stacktrace_link',
+    organizationId: organization.id,
+    projectId: project?.id,
+  });
+  const isPromptDismissed =
+    prompt.isSuccess && prompt.data.data
+      ? promptIsDismissed({
+          dismissedTime: prompt.data.data.dismissed_ts,
+          snoozedTime: prompt.data.data.snoozed_ts,
+        })
+      : false;
+
+  const query = {
+    file: frame.filename,
+    platform: event.platform,
+    commitId: event.release?.lastCommit?.id,
+    ...(event.sdk?.name && {sdkName: event.sdk.name}),
+    ...(frame.absPath && {absPath: frame.absPath}),
+    ...(frame.module && {module: frame.module}),
+    ...(frame.package && {package: frame.package}),
+  };
+  const {
+    data: match,
+    isLoading,
+    refetch,
+  } = useQuery<StacktraceLinkResult>([
+    `/projects/${organization.slug}/${project?.slug}/stacktrace-link/`,
+    {query},
+  ]);
+
+  // Track stacktrace analytics after loaded
+  useEffect(() => {
+    if (isLoading || prompt.isLoading || !match) {
+      return;
+    }
 
-  onRequestSuccess(resp: {data: StacktraceLinkResult; stateKey: 'match'}) {
-    const {error, integrations, sourceUrl} = resp.data;
     trackIntegrationAnalytics('integrations.stacktrace_link_viewed', {
       view: 'stacktrace_issue_details',
-      organization: this.props.organization,
-      platform: this.project?.platform,
-      project_id: this.project?.id,
+      organization,
+      platform: project?.platform,
+      project_id: project?.id,
       state:
         // Should follow the same logic in render
-        sourceUrl
+        match.sourceUrl
           ? 'match'
-          : error || integrations.length > 0
+          : match.error || match.integrations.length > 0
           ? 'no_match'
-          : !this.state.isDismissed
+          : !isPromptDismissed
           ? 'prompt'
           : 'empty',
-      ...getAnalyicsDataForEvent(this.props.event),
+      ...getAnalyicsDataForEvent(event),
     });
-  }
+    // excluding isPromptDismissed because we want this only to record once
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [isLoading, prompt.isLoading, match, organization, project, event]);
 
-  onRequestError(resp: ResponseMeta) {
-    handleXhrErrorResponse('Unable to fetch stack trace link')(resp);
-  }
-
-  getDefaultState(): State {
-    return {
-      ...super.getDefaultState(),
-      showModal: false,
-      sourceCodeInput: '',
-      match: {integrations: []},
-      isDismissed: false,
-      promptLoaded: false,
-    };
-  }
-
-  onOpenLink = () => {
-    const provider = this.state.match.config?.provider;
+  const onOpenLink = () => {
+    const provider = match!.config?.provider;
     if (provider) {
       trackIntegrationAnalytics(
         StacktraceLinkEvents.OPEN_LINK,
         {
           view: 'stacktrace_issue_details',
           provider: provider.key,
-          organization: this.props.organization,
-          ...getAnalyicsDataForEvent(this.props.event),
+          organization,
+          ...getAnalyicsDataForEvent(event),
         },
         {startSession: true}
       );
     }
   };
 
-  handleSubmit = () => {
-    this.reloadData();
+  const handleSubmit = () => {
+    refetch();
   };
 
-  // don't show the error boundary if the component fails.
-  // capture the endpoint error on onRequestError
-  renderError(): React.ReactNode {
-    return null;
+  if (isLoading || !match) {
+    return (
+      <CodeMappingButtonContainer columnQuantity={2}>
+        <Placeholder height="24px" width="60px" />
+      </CodeMappingButtonContainer>
+    );
   }
 
-  renderLoading() {
+  // Match found - display link to source
+  if (match.config && match.sourceUrl) {
     return (
       <CodeMappingButtonContainer columnQuantity={2}>
-        <Placeholder height="24px" width="60px" />
+        <OpenInLink
+          onClick={onOpenLink}
+          href={`${match!.sourceUrl}#L${frame.lineNo}`}
+          openInNewTab
+        >
+          <StyledIconWrapper>
+            {getIntegrationIcon(match.config.provider.key, 'sm')}
+          </StyledIconWrapper>
+          {t('Open this line in %s', match.config.provider.name)}
+        </OpenInLink>
       </CodeMappingButtonContainer>
     );
   }
 
-  renderNoMatch() {
-    const filename = this.props.frame.filename;
-    const {integrations} = this.state.match;
-    if (!this.project || !integrations.length || !filename) {
+  // Hide stacktrace link errors if the stacktrace might be minified javascript
+  // Check if the line starts and ends with {snip}
+  const hideErrors = event.platform === 'javascript' && /(\{snip\}).*\1/.test(line);
+
+  // No match found - Has integration but no code mappings
+  if (!hideErrors && (match.error || match.integrations.length > 0)) {
+    const filename = frame.filename;
+    if (!project || !match.integrations.length || !filename) {
       return null;
     }
 
-    const {organization} = this.props;
-    const platform = this.props.event.platform;
-    const sourceCodeProviders = integrations.filter(integration =>
+    const sourceCodeProviders = match.integrations.filter(integration =>
       ['github', 'gitlab'].includes(integration.provider?.key)
     );
     return (
@@ -219,25 +243,22 @@ class StacktraceLink extends AsyncComponent<Props, State> {
               'integrations.stacktrace_start_setup',
               {
                 view: 'stacktrace_issue_details',
-                platform,
+                platform: event.platform,
                 organization,
-                ...getAnalyicsDataForEvent(this.props.event),
+                ...getAnalyicsDataForEvent(event),
               },
               {startSession: true}
             );
-            openModal(
-              deps =>
-                this.project && (
-                  <StacktraceLinkModal
-                    onSubmit={this.handleSubmit}
-                    filename={filename}
-                    project={this.project}
-                    organization={organization}
-                    integrations={integrations}
-                    {...deps}
-                  />
-                )
-            );
+            openModal(deps => (
+              <StacktraceLinkModal
+                onSubmit={handleSubmit}
+                filename={filename}
+                project={project}
+                organization={organization}
+                integrations={match.integrations}
+                {...deps}
+              />
+            ));
           }}
         >
           {t('Tell us where your source code is')}
@@ -246,69 +267,17 @@ class StacktraceLink extends AsyncComponent<Props, State> {
     );
   }
 
-  renderNoIntegrations() {
-    const {organization} = this.props;
-    return (
-      <CodeMappingButtonContainer columnQuantity={2}>
-        <StyledLink to={`/settings/${organization.slug}/integrations/`}>
-          <StyledIconWrapper>{getIntegrationIcon('github', 'sm')}</StyledIconWrapper>
-          {t(
-            'Add a GitHub, Bitbucket, or similar integration to make sh*t easier for your team'
-          )}
-        </StyledLink>
-        <CloseButton type="button" priority="link" onClick={this.dismissPrompt}>
-          <IconClose size="xs" aria-label={t('Close')} />
-        </CloseButton>
-      </CodeMappingButtonContainer>
-    );
-  }
-
-  renderLink() {
-    const {config, sourceUrl} = this.state.match;
-    const url = `${sourceUrl}#L${this.props.frame.lineNo}`;
-    return (
-      <CodeMappingButtonContainer columnQuantity={2}>
-        <OpenInLink onClick={this.onOpenLink} href={url} openInNewTab>
-          <StyledIconWrapper>
-            {getIntegrationIcon(config!.provider.key, 'sm')}
-          </StyledIconWrapper>
-          {t('Open this line in %s', config!.provider.name)}
-        </OpenInLink>
-      </CodeMappingButtonContainer>
-    );
+  // No integrations, but prompt is dismissed or hidden
+  if (hideErrors || isPromptDismissed) {
+    return null;
   }
 
-  renderBody() {
-    const {config, error, sourceUrl, integrations} = this.state.match || {};
-    const {isDismissed, promptLoaded} = this.state;
-    const {event, line} = this.props;
-
-    // Success state
-    if (config && sourceUrl) {
-      return this.renderLink();
-    }
-
-    // Hide stacktrace link errors if the stacktrace might be minified javascript
-    // Check if the line starts and ends with {snip}
-    const hideErrors = event.platform === 'javascript' && /(\{snip\}).*\1/.test(line);
-
-    // Code mapping does not match
-    // Has integration but no code mappings
-    if (!hideErrors && (error || integrations.length > 0)) {
-      return this.renderNoMatch();
-    }
-
-    if (hideErrors || !promptLoaded || (promptLoaded && isDismissed)) {
-      return null;
-    }
-
-    return this.renderNoIntegrations();
-  }
+  // No integrations
+  return (
+    <StacktraceLinkSetup event={event} project={project} organization={organization} />
+  );
 }
 
-export default withProjects(withOrganization(StacktraceLink));
-export {StacktraceLink};
-
 export const CodeMappingButtonContainer = styled(OpenInContainer)`
   justify-content: space-between;
   min-height: 28px;

+ 1 - 1
static/app/components/events/interfaces/frame/utils.tsx

@@ -1,6 +1,6 @@
 import {IconQuestion, IconWarning} from 'sentry/icons';
 import {t} from 'sentry/locale';
-import {Frame, PlatformType} from 'sentry/types';
+import type {Frame, PlatformType} from 'sentry/types';
 import {defined, objectIsEmpty} from 'sentry/utils';
 
 import {SymbolicatorStatus} from '../types';