Skip to content

Commit 4525724

Browse files
authored
feat(Codecov): add test analytics table (#91832)
This PR creates the base table layout for the Test Analytics Codecov page.
1 parent 3be3c80 commit 4525724

File tree

6 files changed

+356
-0
lines changed

6 files changed

+356
-0
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import type {ValidSort} from 'sentry/views/codecov/tests/testAnalyticsTable/testAnalyticsTable';
2+
3+
export const DEFAULT_SORT: ValidSort = {
4+
field: 'commitsFailed',
5+
kind: 'desc',
6+
};
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import type {ReactNode} from 'react';
2+
import styled from '@emotion/styled';
3+
4+
import Link from 'sentry/components/links/link';
5+
import QuestionTooltip from 'sentry/components/questionTooltip';
6+
import {IconArrow} from 'sentry/icons';
7+
import {space} from 'sentry/styles/space';
8+
import type {Sort} from 'sentry/utils/discover/fields';
9+
import {useLocation} from 'sentry/utils/useLocation';
10+
11+
type HeaderParams = {
12+
alignment: string;
13+
fieldName: string;
14+
label: string;
15+
sort: undefined | Sort;
16+
tooltip?: string | ReactNode;
17+
};
18+
19+
function SortableHeader({fieldName, label, sort, tooltip, alignment}: HeaderParams) {
20+
const location = useLocation();
21+
22+
const arrowDirection = sort?.kind === 'asc' ? 'up' : 'down';
23+
const sortArrow = <IconArrow size="xs" direction={arrowDirection} />;
24+
25+
return (
26+
<HeaderCell alignment={alignment}>
27+
<StyledLink
28+
role="columnheader"
29+
aria-sort={
30+
sort?.field.endsWith(fieldName)
31+
? sort?.kind === 'asc'
32+
? 'ascending'
33+
: 'descending'
34+
: 'none'
35+
}
36+
to={{
37+
pathname: location.pathname,
38+
query: {
39+
...location.query,
40+
sort: sort?.field.endsWith(fieldName)
41+
? sort?.kind === 'desc'
42+
? fieldName
43+
: '-' + fieldName
44+
: '-' + fieldName,
45+
},
46+
}}
47+
>
48+
{label} {sort?.field === fieldName && sortArrow}
49+
</StyledLink>
50+
{tooltip ? (
51+
<StyledQuestionTooltip size="xs" position="top" title={tooltip} isHoverable />
52+
) : null}
53+
</HeaderCell>
54+
);
55+
}
56+
57+
const HeaderCell = styled('div')<{alignment: string}>`
58+
display: block;
59+
width: 100%;
60+
text-align: ${p => (p.alignment === 'left' ? 'left' : 'right')};
61+
`;
62+
63+
const StyledLink = styled(Link)`
64+
color: inherit;
65+
66+
:hover {
67+
color: inherit;
68+
}
69+
70+
svg {
71+
vertical-align: top;
72+
}
73+
`;
74+
75+
const StyledQuestionTooltip = styled(QuestionTooltip)`
76+
margin-left: ${space(0.5)};
77+
`;
78+
79+
export default SortableHeader;
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import styled from '@emotion/styled';
2+
3+
import {Tag} from 'sentry/components/core/badge/tag';
4+
import {DateTime} from 'sentry/components/dateTime';
5+
import PerformanceDuration from 'sentry/components/performanceDuration';
6+
import {space} from 'sentry/styles/space';
7+
import {
8+
type Column,
9+
RIGHT_ALIGNED_FIELDS,
10+
type Row,
11+
} from 'sentry/views/codecov/tests/testAnalyticsTable/testAnalyticsTable';
12+
13+
interface TableBodyProps {
14+
column: Column;
15+
row: Row;
16+
}
17+
18+
export function renderTableBody({column, row}: TableBodyProps) {
19+
const key = column.key;
20+
const value = row[key];
21+
const alignment = RIGHT_ALIGNED_FIELDS.has(key) ? 'right' : 'left';
22+
23+
if (key === 'testName') {
24+
return <Container alignment={alignment}>{value}</Container>;
25+
}
26+
27+
if (key === 'averageDurationMs') {
28+
return (
29+
<NumberContainer>
30+
<PerformanceDuration milliseconds={Number(value)} abbreviation />
31+
</NumberContainer>
32+
);
33+
}
34+
35+
if (key === 'flakeRate') {
36+
const isBrokenTest = row.isBrokenTest;
37+
return (
38+
<NumberContainer>
39+
{isBrokenTest && <StyledTag type={'highlight'}>Broken test</StyledTag>}
40+
{value}%
41+
</NumberContainer>
42+
);
43+
}
44+
45+
if (key === 'commitsFailed') {
46+
return <Container alignment={alignment}>{value}</Container>;
47+
}
48+
49+
if (key === 'lastRun') {
50+
return (
51+
<DateContainer>
52+
<DateTime date={value} year seconds timeZone />
53+
</DateContainer>
54+
);
55+
}
56+
57+
return <Container alignment={alignment}>{value}</Container>;
58+
}
59+
60+
export const Container = styled('div')<{alignment: string}>`
61+
${p => p.theme.overflowEllipsis};
62+
font-family: ${p => p.theme.text.familyMono};
63+
text-align: ${p => (p.alignment === 'left' ? 'left' : 'right')};
64+
`;
65+
66+
const DateContainer = styled('div')`
67+
color: ${p => p.theme.tokens.content.muted};
68+
text-align: 'left';
69+
`;
70+
71+
const NumberContainer = styled('div')`
72+
text-align: right;
73+
font-variant-numeric: tabular-nums;
74+
${p => p.theme.overflowEllipsis};
75+
`;
76+
77+
const StyledTag = styled(Tag)`
78+
margin-right: ${space(1.5)};
79+
`;
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import {tct} from 'sentry/locale';
2+
import type {Sort} from 'sentry/utils/discover/fields';
3+
import SortableHeader from 'sentry/views/codecov/tests/testAnalyticsTable/sortableHeader';
4+
import type {Column} from 'sentry/views/codecov/tests/testAnalyticsTable/testAnalyticsTable';
5+
import {RIGHT_ALIGNED_FIELDS} from 'sentry/views/codecov/tests/testAnalyticsTable/testAnalyticsTable';
6+
7+
type TableHeaderParams = {
8+
column: Column;
9+
sort?: Sort;
10+
};
11+
12+
type FlakyTestTooltipProps = {
13+
date: string;
14+
};
15+
16+
function FlakyTestsTooltip({date}: FlakyTestTooltipProps) {
17+
return (
18+
<p>
19+
{tct(
20+
`Shows how often a flake occurs by tracking how many times a test goes from fail to pass or pass to fail on a given branch and commit within the last [date]`,
21+
{date}
22+
)}
23+
.
24+
</p>
25+
);
26+
}
27+
28+
export const renderTableHeader = ({column, sort}: TableHeaderParams) => {
29+
const {key, name} = column;
30+
// TODO: adjust when the date selector is completed
31+
const date = '30 days';
32+
33+
const alignment = RIGHT_ALIGNED_FIELDS.has(key) ? 'right' : 'left';
34+
35+
return (
36+
<SortableHeader
37+
alignment={alignment}
38+
sort={sort}
39+
fieldName={key}
40+
label={name}
41+
{...(key === 'flakeRate' && {
42+
tooltip: <FlakyTestsTooltip date={date} />,
43+
})}
44+
/>
45+
);
46+
};
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import type {GridColumnHeader} from 'sentry/components/gridEditable';
2+
import GridEditable, {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable';
3+
import {t} from 'sentry/locale';
4+
import type {Sort} from 'sentry/utils/discover/fields';
5+
import {renderTableBody} from 'sentry/views/codecov/tests/testAnalyticsTable/tableBody';
6+
import {renderTableHeader} from 'sentry/views/codecov/tests/testAnalyticsTable/tableHeader';
7+
8+
type TestAnalyticsTableResponse = {
9+
averageDurationMs: number;
10+
commitsFailed: number;
11+
flakeRate: number;
12+
isBrokenTest: boolean;
13+
lastRun: string;
14+
testName: string;
15+
};
16+
17+
export type Row = Pick<
18+
TestAnalyticsTableResponse,
19+
| 'testName'
20+
| 'averageDurationMs'
21+
| 'flakeRate'
22+
| 'commitsFailed'
23+
| 'lastRun'
24+
| 'isBrokenTest'
25+
>;
26+
export type Column = GridColumnHeader<
27+
'testName' | 'averageDurationMs' | 'flakeRate' | 'commitsFailed' | 'lastRun'
28+
>;
29+
30+
export type ValidSort = Sort & {
31+
field: (typeof SORTABLE_FIELDS)[number];
32+
};
33+
34+
const COLUMNS_ORDER: Column[] = [
35+
{key: 'testName', name: t('Test Name'), width: COL_WIDTH_UNDEFINED},
36+
{key: 'averageDurationMs', name: t('Avg. Duration'), width: COL_WIDTH_UNDEFINED},
37+
{key: 'flakeRate', name: t('Flake Rate'), width: COL_WIDTH_UNDEFINED},
38+
{key: 'commitsFailed', name: t('Commits Failed'), width: COL_WIDTH_UNDEFINED},
39+
{key: 'lastRun', name: t('Last Run'), width: COL_WIDTH_UNDEFINED},
40+
];
41+
42+
export const RIGHT_ALIGNED_FIELDS = new Set([
43+
'averageDurationMs',
44+
'flakeRate',
45+
'commitsFailed',
46+
]);
47+
48+
export const SORTABLE_FIELDS = [
49+
'testName',
50+
'averageDurationMs',
51+
'flakeRate',
52+
'commitsFailed',
53+
'lastRun',
54+
] as const;
55+
56+
export function isAValidSort(sort: Sort): sort is ValidSort {
57+
return (SORTABLE_FIELDS as unknown as string[]).includes(sort.field);
58+
}
59+
60+
interface Props {
61+
response: {
62+
data: Row[];
63+
isLoading: boolean;
64+
error?: Error | null;
65+
};
66+
sort: ValidSort;
67+
}
68+
69+
export default function TestAnalyticsTable({response, sort}: Props) {
70+
const {data, isLoading} = response;
71+
72+
return (
73+
<GridEditable
74+
aria-label={t('Test Analytics')}
75+
isLoading={isLoading}
76+
error={response.error}
77+
data={data}
78+
columnOrder={COLUMNS_ORDER}
79+
// TODO: This isn't used as per the docs but is still required. Test if
80+
// it affects sorting when backend is ready.
81+
columnSortBy={[
82+
{
83+
key: sort.field,
84+
order: sort.kind,
85+
},
86+
]}
87+
grid={{
88+
renderHeadCell: column =>
89+
renderTableHeader({
90+
column,
91+
sort,
92+
}),
93+
renderBodyCell: (column, row) => renderTableBody({column, row}),
94+
}}
95+
/>
96+
);
97+
}

static/app/views/codecov/tests/tests.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
import styled from '@emotion/styled';
22

33
import {DatePicker} from 'sentry/components/codecov/datePicker/datePicker';
4+
// import TestAnalyticsTable from 'sentry/components/codecov/testAnalytics/testAnalyticsTable';
45
import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
56
import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
67
import {space} from 'sentry/styles/space';
8+
import {decodeSorts} from 'sentry/utils/queryString';
9+
import {useLocation} from 'sentry/utils/useLocation';
10+
import {DEFAULT_SORT} from 'sentry/views/codecov/tests/settings';
711
import {Summaries} from 'sentry/views/codecov/tests/summaries/summaries';
12+
import type {ValidSort} from 'sentry/views/codecov/tests/testAnalyticsTable/testAnalyticsTable';
13+
import TestAnalyticsTable, {
14+
isAValidSort,
15+
} from 'sentry/views/codecov/tests/testAnalyticsTable/testAnalyticsTable';
816

917
const DEFAULT_CODECOV_DATETIME_SELECTION = {
1018
start: null,
@@ -13,7 +21,47 @@ const DEFAULT_CODECOV_DATETIME_SELECTION = {
1321
period: '24h',
1422
};
1523

24+
// TODO: Sorting will only work once this is connected to the API
25+
const fakeApiResponse = {
26+
data: [
27+
{
28+
testName:
29+
'tests.symbolicator.test_unreal_full.SymbolicatorUnrealIntegrationTest::test_unreal_crash_with_attachments',
30+
averageDurationMs: 4,
31+
flakeRate: 0.4,
32+
commitsFailed: 1,
33+
lastRun: '2025-04-17T22:26:19.486793+00:00',
34+
isBrokenTest: false,
35+
},
36+
{
37+
testName:
38+
'graphql_api/tests/test_owner.py::TestOwnerType::test_fetch_current_user_is_not_okta_authenticated',
39+
averageDurationMs: 4370,
40+
flakeRate: 0,
41+
commitsFailed: 5,
42+
lastRun: '2025-04-16T22:26:19.486793+00:00',
43+
isBrokenTest: true,
44+
},
45+
{
46+
testName: 'graphql_api/tests/test_owner.py',
47+
averageDurationMs: 10032,
48+
flakeRate: 1,
49+
commitsFailed: 3,
50+
lastRun: '2025-02-16T22:26:19.486793+00:00',
51+
isBrokenTest: false,
52+
},
53+
],
54+
isLoading: false,
55+
isError: false,
56+
};
57+
1658
export default function TestsPage() {
59+
const location = useLocation();
60+
61+
const sorts: [ValidSort] = [
62+
decodeSorts(location.query?.sort).find(isAValidSort) ?? DEFAULT_SORT,
63+
];
64+
1765
return (
1866
<LayoutGap>
1967
<p>Test Analytics</p>
@@ -26,6 +74,7 @@ export default function TestsPage() {
2674
</PageFiltersContainer>
2775
{/* TODO: Conditionally show these if the branch we're in is the main branch */}
2876
<Summaries />
77+
<TestAnalyticsTable response={fakeApiResponse} sort={sorts[0]} />
2978
</LayoutGap>
3079
);
3180
}

0 commit comments

Comments
 (0)