Browse Source

feat(codecov): Add a legend for codecov line data (#43201)

Add a legend for the colors of the coverage report, per [these
designs](https://www.figma.com/file/TUVJrwgDvH7q7tkXjskoIv/Codecov-Integration?node-id=112%3A6707&t=fuB9apo5WVShfP89-0).

<img width="922" alt="image"
src="https://user-images.githubusercontent.com/16563948/212184701-df6ea661-14a9-496f-9154-5138b5e38c5d.png">

WOR-2592
Snigdha Sharma 2 years ago
parent
commit
1afb0d023f

+ 53 - 0
static/app/components/events/interfaces/frame/codecovLegend.spec.tsx

@@ -0,0 +1,53 @@
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import ProjectsStore from 'sentry/stores/projectsStore';
+import {CodecovStatusCode, Frame} from 'sentry/types';
+
+import {CodecovLegend} from './codecovLegend';
+
+describe('Frame - Codecov Legend', function () {
+  const organization = TestStubs.Organization({
+    features: ['codecov-stacktrace-integration'],
+    codecovAccess: true,
+  });
+  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});
+
+  beforeEach(function () {
+    jest.clearAllMocks();
+    MockApiClient.clearMockResponses();
+    ProjectsStore.loadInitialData([project]);
+  });
+
+  it('should render codecov legend', async function () {
+    MockApiClient.addMockResponse({
+      url: `/projects/${organization.slug}/${project.slug}/stacktrace-link/`,
+      body: {
+        config,
+        sourceUrl: null,
+        integrations: [integration],
+        codecovStatusCode: CodecovStatusCode.COVERAGE_EXISTS,
+      },
+    });
+
+    render(<CodecovLegend event={event} frame={frame} organization={organization} />, {
+      context: TestStubs.routerContext(),
+      organization,
+      project,
+    });
+
+    expect(await screen.findByText('Covered')).toBeInTheDocument();
+    expect(await screen.findByText('Uncovered')).toBeInTheDocument();
+    expect(await screen.findByText('Partial')).toBeInTheDocument();
+  });
+});

+ 87 - 0
static/app/components/events/interfaces/frame/codecovLegend.tsx

@@ -0,0 +1,87 @@
+import {useMemo} from 'react';
+import styled from '@emotion/styled';
+
+import {IconCircle, IconCircleFill} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import space from 'sentry/styles/space';
+import {Event, Frame, Organization} from 'sentry/types';
+import {CodecovStatusCode} from 'sentry/types/integrations';
+import useProjects from 'sentry/utils/useProjects';
+
+import useStacktraceLink from './useStacktraceLink';
+
+interface CodecovLegendProps {
+  event: Event;
+  frame: Frame;
+  organization?: Organization;
+}
+
+export function CodecovLegend({event, frame, organization}: CodecovLegendProps) {
+  const {projects} = useProjects();
+  const project = useMemo(
+    () => projects.find(p => p.id === event.projectID),
+    [projects, event]
+  );
+
+  const {data, isLoading} = useStacktraceLink({
+    event,
+    frame,
+    orgSlug: organization?.slug || '',
+    projectSlug: project?.slug,
+  });
+
+  if (isLoading || !data) {
+    return null;
+  }
+
+  if (
+    data.codecovStatusCode !== CodecovStatusCode.COVERAGE_EXISTS ||
+    data.config?.provider.key !== 'github'
+  ) {
+    return null;
+  }
+
+  return (
+    <CodeCovLegendContainer>
+      <LegendIcon>
+        <IconCircleFill size="xs" color="green100" style={{position: 'absolute'}} />
+        <IconCircle size="xs" color="green300" />
+      </LegendIcon>
+      <LegendLabel>{t('Covered')}</LegendLabel>
+      <LegendIcon>
+        <IconCircleFill size="xs" color="red100" style={{position: 'absolute'}} />
+        <IconCircle size="xs" color="red300" />
+      </LegendIcon>
+      <LegendLabel>{t('Uncovered')}</LegendLabel>
+      <LegendIcon>
+        <IconCircleFill size="xs" color="yellow100" style={{position: 'absolute'}} />
+        <IconCircle size="xs" color="yellow300" />
+      </LegendIcon>
+      <LegendLabel>{t('Partial')}</LegendLabel>
+    </CodeCovLegendContainer>
+  );
+}
+
+const LegendLabel = styled('span')`
+  line-height: 0;
+  padding-right: 4px;
+`;
+const LegendIcon = styled('span')`
+  display: flex;
+  gap: ${space(0.75)};
+`;
+
+const CodeCovLegendContainer = styled('div')`
+  gap: ${space(1)};
+  color: ${p => p.theme.subText};
+  background-color: ${p => p.theme.background};
+  font-family: ${p => p.theme.text.family};
+  border-bottom: 1px solid ${p => p.theme.border};
+  padding: ${space(0.25)} ${space(3)};
+  box-shadow: ${p => p.theme.dropShadowLight};
+  display: flex;
+  justify-content: end;
+  flex-direction: row;
+  align-items: center;
+  min-height: 28px;
+`;

+ 13 - 0
static/app/components/events/interfaces/frame/line.tsx

@@ -19,6 +19,7 @@ import DebugImage from '../debugMeta/debugImage';
 import {combineStatus} from '../debugMeta/utils';
 import {SymbolicatorStatus} from '../types';
 
+import {CodecovLegend} from './codecovLegend';
 import Context from './context';
 import DefaultTitle from './defaultTitle';
 import PackageLink from './packageLink';
@@ -371,8 +372,20 @@ export class Line extends Component<Props, State> {
     });
     const props = {className};
 
+    const shouldShowCodecovLegend =
+      this.props.organization?.features.includes('codecov-stacktrace-integration') &&
+      this.props.organization?.codecovAccess &&
+      !this.props.nextFrame;
+
     return (
       <StyledLi data-test-id="line" {...props}>
+        {shouldShowCodecovLegend && (
+          <CodecovLegend
+            event={this.props.event}
+            frame={this.props.data}
+            organization={this.props.organization}
+          />
+        )}
         {this.renderLine()}
         <Context
           frame={data}

+ 15 - 0
static/app/icons/iconCircleFill.tsx

@@ -0,0 +1,15 @@
+import {forwardRef} from 'react';
+
+import {SvgIcon, SVGIconProps} from './svgIcon';
+
+const IconCircleFill = forwardRef<SVGSVGElement, SVGIconProps>((props, ref) => {
+  return (
+    <SvgIcon {...props} ref={ref} viewBox="0 0 24 24">
+      <circle cx="12" cy="12" r="10" />
+    </SvgIcon>
+  );
+});
+
+IconCircleFill.displayName = 'IconCircleFill';
+
+export {IconCircleFill};

+ 1 - 0
static/app/icons/index.tsx

@@ -11,6 +11,7 @@ export {IconChat} from './iconChat';
 export {IconCheckmark} from './iconCheckmark';
 export {IconChevron} from './iconChevron';
 export {IconCircle} from './iconCircle';
+export {IconCircleFill} from './iconCircleFill';
 export {IconClock} from './iconClock';
 export {IconClose} from './iconClose';
 export {IconCode} from './iconCode';