diff --git a/static/app/views/codecov/tests/settings.tsx b/static/app/views/codecov/tests/settings.tsx new file mode 100644 index 00000000000000..22cd2e706dd063 --- /dev/null +++ b/static/app/views/codecov/tests/settings.tsx @@ -0,0 +1,6 @@ +import type {ValidSort} from 'sentry/views/codecov/tests/testAnalyticsTable/testAnalyticsTable'; + +export const DEFAULT_SORT: ValidSort = { + field: 'commitsFailed', + kind: 'desc', +}; diff --git a/static/app/views/codecov/tests/testAnalyticsTable/sortableHeader.tsx b/static/app/views/codecov/tests/testAnalyticsTable/sortableHeader.tsx new file mode 100644 index 00000000000000..8ed5c17e4676fa --- /dev/null +++ b/static/app/views/codecov/tests/testAnalyticsTable/sortableHeader.tsx @@ -0,0 +1,79 @@ +import type {ReactNode} from 'react'; +import styled from '@emotion/styled'; + +import Link from 'sentry/components/links/link'; +import QuestionTooltip from 'sentry/components/questionTooltip'; +import {IconArrow} from 'sentry/icons'; +import {space} from 'sentry/styles/space'; +import type {Sort} from 'sentry/utils/discover/fields'; +import {useLocation} from 'sentry/utils/useLocation'; + +type HeaderParams = { + alignment: string; + fieldName: string; + label: string; + sort: undefined | Sort; + tooltip?: string | ReactNode; +}; + +function SortableHeader({fieldName, label, sort, tooltip, alignment}: HeaderParams) { + const location = useLocation(); + + const arrowDirection = sort?.kind === 'asc' ? 'up' : 'down'; + const sortArrow = ; + + return ( + + + {label} {sort?.field === fieldName && sortArrow} + + {tooltip ? ( + + ) : null} + + ); +} + +const HeaderCell = styled('div')<{alignment: string}>` + display: block; + width: 100%; + text-align: ${p => (p.alignment === 'left' ? 'left' : 'right')}; +`; + +const StyledLink = styled(Link)` + color: inherit; + + :hover { + color: inherit; + } + + svg { + vertical-align: top; + } +`; + +const StyledQuestionTooltip = styled(QuestionTooltip)` + margin-left: ${space(0.5)}; +`; + +export default SortableHeader; diff --git a/static/app/views/codecov/tests/testAnalyticsTable/tableBody.tsx b/static/app/views/codecov/tests/testAnalyticsTable/tableBody.tsx new file mode 100644 index 00000000000000..0d48f44aac2df4 --- /dev/null +++ b/static/app/views/codecov/tests/testAnalyticsTable/tableBody.tsx @@ -0,0 +1,79 @@ +import styled from '@emotion/styled'; + +import {Tag} from 'sentry/components/core/badge/tag'; +import {DateTime} from 'sentry/components/dateTime'; +import PerformanceDuration from 'sentry/components/performanceDuration'; +import {space} from 'sentry/styles/space'; +import { + type Column, + RIGHT_ALIGNED_FIELDS, + type Row, +} from 'sentry/views/codecov/tests/testAnalyticsTable/testAnalyticsTable'; + +interface TableBodyProps { + column: Column; + row: Row; +} + +export function renderTableBody({column, row}: TableBodyProps) { + const key = column.key; + const value = row[key]; + const alignment = RIGHT_ALIGNED_FIELDS.has(key) ? 'right' : 'left'; + + if (key === 'testName') { + return {value}; + } + + if (key === 'averageDurationMs') { + return ( + + + + ); + } + + if (key === 'flakeRate') { + const isBrokenTest = row.isBrokenTest; + return ( + + {isBrokenTest && Broken test} + {value}% + + ); + } + + if (key === 'commitsFailed') { + return {value}; + } + + if (key === 'lastRun') { + return ( + + + + ); + } + + return {value}; +} + +export const Container = styled('div')<{alignment: string}>` + ${p => p.theme.overflowEllipsis}; + font-family: ${p => p.theme.text.familyMono}; + text-align: ${p => (p.alignment === 'left' ? 'left' : 'right')}; +`; + +const DateContainer = styled('div')` + color: ${p => p.theme.tokens.content.muted}; + text-align: 'left'; +`; + +const NumberContainer = styled('div')` + text-align: right; + font-variant-numeric: tabular-nums; + ${p => p.theme.overflowEllipsis}; +`; + +const StyledTag = styled(Tag)` + margin-right: ${space(1.5)}; +`; diff --git a/static/app/views/codecov/tests/testAnalyticsTable/tableHeader.tsx b/static/app/views/codecov/tests/testAnalyticsTable/tableHeader.tsx new file mode 100644 index 00000000000000..ce68699141d1c1 --- /dev/null +++ b/static/app/views/codecov/tests/testAnalyticsTable/tableHeader.tsx @@ -0,0 +1,46 @@ +import {tct} from 'sentry/locale'; +import type {Sort} from 'sentry/utils/discover/fields'; +import SortableHeader from 'sentry/views/codecov/tests/testAnalyticsTable/sortableHeader'; +import type {Column} from 'sentry/views/codecov/tests/testAnalyticsTable/testAnalyticsTable'; +import {RIGHT_ALIGNED_FIELDS} from 'sentry/views/codecov/tests/testAnalyticsTable/testAnalyticsTable'; + +type TableHeaderParams = { + column: Column; + sort?: Sort; +}; + +type FlakyTestTooltipProps = { + date: string; +}; + +function FlakyTestsTooltip({date}: FlakyTestTooltipProps) { + return ( +

+ {tct( + `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]`, + {date} + )} + . +

+ ); +} + +export const renderTableHeader = ({column, sort}: TableHeaderParams) => { + const {key, name} = column; + // TODO: adjust when the date selector is completed + const date = '30 days'; + + const alignment = RIGHT_ALIGNED_FIELDS.has(key) ? 'right' : 'left'; + + return ( + , + })} + /> + ); +}; diff --git a/static/app/views/codecov/tests/testAnalyticsTable/testAnalyticsTable.tsx b/static/app/views/codecov/tests/testAnalyticsTable/testAnalyticsTable.tsx new file mode 100644 index 00000000000000..eb9b9de0060b6d --- /dev/null +++ b/static/app/views/codecov/tests/testAnalyticsTable/testAnalyticsTable.tsx @@ -0,0 +1,97 @@ +import type {GridColumnHeader} from 'sentry/components/gridEditable'; +import GridEditable, {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable'; +import {t} from 'sentry/locale'; +import type {Sort} from 'sentry/utils/discover/fields'; +import {renderTableBody} from 'sentry/views/codecov/tests/testAnalyticsTable/tableBody'; +import {renderTableHeader} from 'sentry/views/codecov/tests/testAnalyticsTable/tableHeader'; + +type TestAnalyticsTableResponse = { + averageDurationMs: number; + commitsFailed: number; + flakeRate: number; + isBrokenTest: boolean; + lastRun: string; + testName: string; +}; + +export type Row = Pick< + TestAnalyticsTableResponse, + | 'testName' + | 'averageDurationMs' + | 'flakeRate' + | 'commitsFailed' + | 'lastRun' + | 'isBrokenTest' +>; +export type Column = GridColumnHeader< + 'testName' | 'averageDurationMs' | 'flakeRate' | 'commitsFailed' | 'lastRun' +>; + +export type ValidSort = Sort & { + field: (typeof SORTABLE_FIELDS)[number]; +}; + +const COLUMNS_ORDER: Column[] = [ + {key: 'testName', name: t('Test Name'), width: COL_WIDTH_UNDEFINED}, + {key: 'averageDurationMs', name: t('Avg. Duration'), width: COL_WIDTH_UNDEFINED}, + {key: 'flakeRate', name: t('Flake Rate'), width: COL_WIDTH_UNDEFINED}, + {key: 'commitsFailed', name: t('Commits Failed'), width: COL_WIDTH_UNDEFINED}, + {key: 'lastRun', name: t('Last Run'), width: COL_WIDTH_UNDEFINED}, +]; + +export const RIGHT_ALIGNED_FIELDS = new Set([ + 'averageDurationMs', + 'flakeRate', + 'commitsFailed', +]); + +export const SORTABLE_FIELDS = [ + 'testName', + 'averageDurationMs', + 'flakeRate', + 'commitsFailed', + 'lastRun', +] as const; + +export function isAValidSort(sort: Sort): sort is ValidSort { + return (SORTABLE_FIELDS as unknown as string[]).includes(sort.field); +} + +interface Props { + response: { + data: Row[]; + isLoading: boolean; + error?: Error | null; + }; + sort: ValidSort; +} + +export default function TestAnalyticsTable({response, sort}: Props) { + const {data, isLoading} = response; + + return ( + + renderTableHeader({ + column, + sort, + }), + renderBodyCell: (column, row) => renderTableBody({column, row}), + }} + /> + ); +} diff --git a/static/app/views/codecov/tests/tests.tsx b/static/app/views/codecov/tests/tests.tsx index 17a681b73669d0..5322d844323768 100644 --- a/static/app/views/codecov/tests/tests.tsx +++ b/static/app/views/codecov/tests/tests.tsx @@ -1,10 +1,18 @@ import styled from '@emotion/styled'; import {DatePicker} from 'sentry/components/codecov/datePicker/datePicker'; +// import TestAnalyticsTable from 'sentry/components/codecov/testAnalytics/testAnalyticsTable'; import PageFilterBar from 'sentry/components/organizations/pageFilterBar'; import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container'; import {space} from 'sentry/styles/space'; +import {decodeSorts} from 'sentry/utils/queryString'; +import {useLocation} from 'sentry/utils/useLocation'; +import {DEFAULT_SORT} from 'sentry/views/codecov/tests/settings'; import {Summaries} from 'sentry/views/codecov/tests/summaries/summaries'; +import type {ValidSort} from 'sentry/views/codecov/tests/testAnalyticsTable/testAnalyticsTable'; +import TestAnalyticsTable, { + isAValidSort, +} from 'sentry/views/codecov/tests/testAnalyticsTable/testAnalyticsTable'; const DEFAULT_CODECOV_DATETIME_SELECTION = { start: null, @@ -13,7 +21,47 @@ const DEFAULT_CODECOV_DATETIME_SELECTION = { period: '24h', }; +// TODO: Sorting will only work once this is connected to the API +const fakeApiResponse = { + data: [ + { + testName: + 'tests.symbolicator.test_unreal_full.SymbolicatorUnrealIntegrationTest::test_unreal_crash_with_attachments', + averageDurationMs: 4, + flakeRate: 0.4, + commitsFailed: 1, + lastRun: '2025-04-17T22:26:19.486793+00:00', + isBrokenTest: false, + }, + { + testName: + 'graphql_api/tests/test_owner.py::TestOwnerType::test_fetch_current_user_is_not_okta_authenticated', + averageDurationMs: 4370, + flakeRate: 0, + commitsFailed: 5, + lastRun: '2025-04-16T22:26:19.486793+00:00', + isBrokenTest: true, + }, + { + testName: 'graphql_api/tests/test_owner.py', + averageDurationMs: 10032, + flakeRate: 1, + commitsFailed: 3, + lastRun: '2025-02-16T22:26:19.486793+00:00', + isBrokenTest: false, + }, + ], + isLoading: false, + isError: false, +}; + export default function TestsPage() { + const location = useLocation(); + + const sorts: [ValidSort] = [ + decodeSorts(location.query?.sort).find(isAValidSort) ?? DEFAULT_SORT, + ]; + return (

Test Analytics

@@ -26,6 +74,7 @@ export default function TestsPage() { {/* TODO: Conditionally show these if the branch we're in is the main branch */} +
); }