Files
2024-02-15 17:01:50 +00:00

237 lines
7.1 KiB
TypeScript

import {Fragment} from 'react';
import styled from '@emotion/styled';
import {BarChart} from 'sentry/components/charts/barChart';
import type {DateTimeObject} from 'sentry/components/charts/utils';
import CollapsePanel, {COLLAPSE_COUNT} from 'sentry/components/collapsePanel';
import LoadingError from 'sentry/components/loadingError';
import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
import PanelTable from 'sentry/components/panels/panelTable';
import Placeholder from 'sentry/components/placeholder';
import {IconArrow} from 'sentry/icons';
import {t, tct} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {Organization, Project} from 'sentry/types';
import {formatPercentage} from 'sentry/utils/formatters';
import {useApiQuery} from 'sentry/utils/queryClient';
import type {ColorOrAlias} from 'sentry/utils/theme';
import {ProjectBadge, ProjectBadgeContainer} from './styles';
import {
barAxisLabel,
convertDayValueObjectToSeries,
groupByTrend,
sortSeriesByDay,
} from './utils';
interface TeamUnresolvedIssuesProps extends DateTimeObject {
organization: Organization;
projects: Project[];
teamSlug: string;
environment?: string;
}
type UnresolvedCount = {unresolved: number};
type ProjectReleaseCount = Record<string, Record<string, UnresolvedCount>>;
export function TeamUnresolvedIssues({
organization,
teamSlug,
projects,
start,
end,
period,
utc,
environment,
}: TeamUnresolvedIssuesProps) {
const {
data: periodIssues = {},
isLoading,
isError,
refetch,
} = useApiQuery<ProjectReleaseCount>(
[
`/teams/${organization.slug}/${teamSlug}/all-unresolved-issues/`,
{
query: {
...normalizeDateTimeParams({start, end, period, utc}),
environment,
},
},
],
{staleTime: 0}
);
function getTotalUnresolved(projectId: number): number {
const entries = Object.values(periodIssues?.[projectId] ?? {});
const total = entries.reduce((acc, current) => acc + current.unresolved, 0);
return Math.round(total / entries.length);
}
const projectTotals: Record<
string,
{percentChange: number; periodAvg: number; projectId: string; today: number}
> = {};
for (const projectId of Object.keys(periodIssues)) {
const periodAvg = getTotalUnresolved(Number(projectId));
const projectPeriodEntries = Object.values(periodIssues?.[projectId] ?? {});
const today = projectPeriodEntries[projectPeriodEntries.length - 1]?.unresolved ?? 0;
const percentChange = (today - periodAvg) / periodAvg;
projectTotals[projectId] = {
projectId,
periodAvg,
today,
percentChange: Number.isNaN(percentChange) ? 0 : percentChange,
};
}
const sortedProjects = projects
.map(project => ({project, trend: projectTotals[project.id]?.percentChange ?? 0}))
.sort((a, b) => Math.abs(b.trend) - Math.abs(a.trend));
const groupedProjects = groupByTrend(sortedProjects);
// All data will contain all pairs of [day, unresolved_count].
const allData = Object.values(periodIssues).flatMap(data =>
Object.entries(data).map(
([bucket, {unresolved}]) => [bucket, unresolved] as [string, number]
)
);
// Total by day for all projects
const totalByDay = allData.reduce((acc, [bucket, unresolved]) => {
if (acc[bucket] === undefined) {
acc[bucket] = 0;
}
acc[bucket] += unresolved;
return acc;
}, {});
const seriesData = sortSeriesByDay(convertDayValueObjectToSeries(totalByDay));
if (isError) {
return <LoadingError onRetry={refetch} />;
}
return (
<div>
<ChartWrapper>
{isLoading && <Placeholder height="200px" />}
{!isLoading && (
<BarChart
style={{height: 190}}
isGroupedByDate
useShortDate
legend={{right: 3, top: 0}}
yAxis={{minInterval: 1}}
xAxis={barAxisLabel()}
series={[
{
seriesName: t('Unresolved Issues'),
silent: true,
data: seriesData,
barCategoryGap: '6%',
},
]}
/>
)}
</ChartWrapper>
<CollapsePanel items={groupedProjects.length}>
{({isExpanded, showMoreButton}) => (
<Fragment>
<StyledPanelTable
isEmpty={projects.length === 0}
isLoading={isLoading}
headers={[
t('Project'),
<RightAligned key="last">
{tct('Last [period] Average', {period})}
</RightAligned>,
<RightAligned key="curr">{t('Today')}</RightAligned>,
<RightAligned key="diff">{t('Change')}</RightAligned>,
]}
>
{groupedProjects.map(({project}, idx) => {
const totals = projectTotals[project.id] ?? {};
if (idx >= COLLAPSE_COUNT && !isExpanded) {
return null;
}
return (
<Fragment key={project.id}>
<ProjectBadgeContainer>
<ProjectBadge avatarSize={18} project={project} />
</ProjectBadgeContainer>
<ScoreWrapper>{totals.periodAvg}</ScoreWrapper>
<ScoreWrapper>{totals.today}</ScoreWrapper>
<ScoreWrapper>
<SubText
color={
totals.percentChange === 0
? 'subText'
: totals.percentChange > 0
? 'errorText'
: 'successText'
}
>
{formatPercentage(
Number.isNaN(totals.percentChange) ? 0 : totals.percentChange,
0
)}
<PaddedIconArrow
direction={totals.percentChange > 0 ? 'up' : 'down'}
size="xs"
/>
</SubText>
</ScoreWrapper>
</Fragment>
);
})}
</StyledPanelTable>
{!isLoading && showMoreButton}
</Fragment>
)}
</CollapsePanel>
</div>
);
}
const ChartWrapper = styled('div')`
padding: ${space(2)} ${space(2)} 0 ${space(2)};
border-bottom: 1px solid ${p => p.theme.border};
`;
const StyledPanelTable = styled(PanelTable)`
grid-template-columns: 1fr 0.2fr 0.2fr 0.2fr;
white-space: nowrap;
margin-bottom: 0;
border: 0;
font-size: ${p => p.theme.fontSizeMedium};
box-shadow: unset;
& > div {
padding: ${space(1)} ${space(2)};
}
`;
const RightAligned = styled('span')`
text-align: right;
`;
const ScoreWrapper = styled('div')`
display: flex;
align-items: center;
justify-content: flex-end;
text-align: right;
`;
const PaddedIconArrow = styled(IconArrow)`
margin: 0 ${space(0.5)};
`;
const SubText = styled('div')<{color: ColorOrAlias}>`
color: ${p => p.theme[p.color]};
`;