teamUnresolvedIssues.tsx 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import AsyncComponent from 'sentry/components/asyncComponent';
  4. import {BarChart} from 'sentry/components/charts/barChart';
  5. import {DateTimeObject} from 'sentry/components/charts/utils';
  6. import CollapsePanel, {COLLAPSE_COUNT} from 'sentry/components/collapsePanel';
  7. import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
  8. import PanelTable from 'sentry/components/panels/panelTable';
  9. import Placeholder from 'sentry/components/placeholder';
  10. import {IconArrow} from 'sentry/icons';
  11. import {t, tct} from 'sentry/locale';
  12. import space from 'sentry/styles/space';
  13. import {Organization, Project} from 'sentry/types';
  14. import {formatPercentage} from 'sentry/utils/formatters';
  15. import type {Color} from 'sentry/utils/theme';
  16. import {ProjectBadge, ProjectBadgeContainer} from './styles';
  17. import {
  18. barAxisLabel,
  19. convertDayValueObjectToSeries,
  20. groupByTrend,
  21. sortSeriesByDay,
  22. } from './utils';
  23. type Props = AsyncComponent['props'] & {
  24. organization: Organization;
  25. projects: Project[];
  26. teamSlug: string;
  27. environment?: string;
  28. } & DateTimeObject;
  29. type UnresolvedCount = {unresolved: number};
  30. type ProjectReleaseCount = Record<string, Record<string, UnresolvedCount>>;
  31. type State = AsyncComponent['state'] & {
  32. expandTable: boolean;
  33. /** weekly selected date range */
  34. periodIssues: ProjectReleaseCount | null;
  35. };
  36. class TeamUnresolvedIssues extends AsyncComponent<Props, State> {
  37. shouldRenderBadRequests = true;
  38. getDefaultState(): State {
  39. return {
  40. ...super.getDefaultState(),
  41. periodIssues: null,
  42. expandTable: false,
  43. };
  44. }
  45. getEndpoints() {
  46. const {organization, start, end, period, utc, teamSlug, environment} = this.props;
  47. const datetime = {start, end, period, utc};
  48. const endpoints: ReturnType<AsyncComponent['getEndpoints']> = [
  49. [
  50. 'periodIssues',
  51. `/teams/${organization.slug}/${teamSlug}/all-unresolved-issues/`,
  52. {
  53. query: {
  54. ...normalizeDateTimeParams(datetime),
  55. environment,
  56. },
  57. },
  58. ],
  59. ];
  60. return endpoints;
  61. }
  62. componentDidUpdate(prevProps: Props) {
  63. const {teamSlug, start, end, period, utc, environment} = this.props;
  64. if (
  65. prevProps.start !== start ||
  66. prevProps.end !== end ||
  67. prevProps.period !== period ||
  68. prevProps.utc !== utc ||
  69. prevProps.environment !== environment ||
  70. prevProps.teamSlug !== teamSlug
  71. ) {
  72. this.remountComponent();
  73. }
  74. }
  75. getTotalUnresolved(projectId: number): number {
  76. const {periodIssues} = this.state;
  77. const entries = Object.values(periodIssues?.[projectId] ?? {});
  78. const total = entries.reduce((acc, current) => acc + current.unresolved, 0);
  79. return Math.round(total / entries.length);
  80. }
  81. handleExpandTable = () => {
  82. this.setState({expandTable: true});
  83. };
  84. renderLoading() {
  85. return this.renderBody();
  86. }
  87. renderBody() {
  88. const {projects, period} = this.props;
  89. const {loading} = this.state;
  90. const periodIssues = this.state.periodIssues ?? {};
  91. const projectTotals: Record<
  92. string,
  93. {percentChange: number; periodAvg: number; projectId: string; today: number}
  94. > = {};
  95. for (const projectId of Object.keys(periodIssues)) {
  96. const periodAvg = this.getTotalUnresolved(Number(projectId));
  97. const projectPeriodEntries = Object.values(periodIssues?.[projectId] ?? {});
  98. const today =
  99. projectPeriodEntries[projectPeriodEntries.length - 1]?.unresolved ?? 0;
  100. const percentChange = Math.abs((today - periodAvg) / periodAvg);
  101. projectTotals[projectId] = {
  102. projectId,
  103. periodAvg,
  104. today,
  105. percentChange: Number.isNaN(percentChange) ? 0 : percentChange,
  106. };
  107. }
  108. const sortedProjects = projects
  109. .map(project => ({project, trend: projectTotals[project.id]?.percentChange ?? 0}))
  110. .sort((a, b) => Math.abs(b.trend) - Math.abs(a.trend));
  111. const groupedProjects = groupByTrend(sortedProjects);
  112. // All data will contain all pairs of [day, unresolved_count].
  113. const allData = Object.values(periodIssues).flatMap(data =>
  114. Object.entries(data).map(
  115. ([bucket, {unresolved}]) => [bucket, unresolved] as [string, number]
  116. )
  117. );
  118. // Total by day for all projects
  119. const totalByDay = allData.reduce((acc, [bucket, unresolved]) => {
  120. if (acc[bucket] === undefined) {
  121. acc[bucket] = 0;
  122. }
  123. acc[bucket] += unresolved;
  124. return acc;
  125. }, {});
  126. const seriesData = sortSeriesByDay(convertDayValueObjectToSeries(totalByDay));
  127. return (
  128. <div>
  129. <ChartWrapper>
  130. {loading && <Placeholder height="200px" />}
  131. {!loading && (
  132. <BarChart
  133. style={{height: 190}}
  134. isGroupedByDate
  135. useShortDate
  136. legend={{right: 3, top: 0}}
  137. yAxis={{minInterval: 1}}
  138. xAxis={barAxisLabel(seriesData.length)}
  139. series={[
  140. {
  141. seriesName: t('Unresolved Issues'),
  142. silent: true,
  143. data: seriesData,
  144. barCategoryGap: '6%',
  145. },
  146. ]}
  147. />
  148. )}
  149. </ChartWrapper>
  150. <CollapsePanel items={groupedProjects.length}>
  151. {({isExpanded, showMoreButton}) => (
  152. <Fragment>
  153. <StyledPanelTable
  154. isEmpty={projects.length === 0}
  155. isLoading={loading}
  156. headers={[
  157. t('Project'),
  158. <RightAligned key="last">
  159. {tct('Last [period] Average', {period})}
  160. </RightAligned>,
  161. <RightAligned key="curr">{t('Today')}</RightAligned>,
  162. <RightAligned key="diff">{t('Change')}</RightAligned>,
  163. ]}
  164. >
  165. {groupedProjects.map(({project}, idx) => {
  166. const totals = projectTotals[project.id] ?? {};
  167. if (idx >= COLLAPSE_COUNT && !isExpanded) {
  168. return null;
  169. }
  170. return (
  171. <Fragment key={project.id}>
  172. <ProjectBadgeContainer>
  173. <ProjectBadge avatarSize={18} project={project} />
  174. </ProjectBadgeContainer>
  175. <ScoreWrapper>{totals.periodAvg}</ScoreWrapper>
  176. <ScoreWrapper>{totals.today}</ScoreWrapper>
  177. <ScoreWrapper>
  178. <SubText
  179. color={
  180. totals.percentChange === 0
  181. ? 'gray300'
  182. : totals.percentChange > 0
  183. ? 'red300'
  184. : 'green300'
  185. }
  186. >
  187. {formatPercentage(
  188. Number.isNaN(totals.percentChange) ? 0 : totals.percentChange,
  189. 0
  190. )}
  191. <PaddedIconArrow
  192. direction={totals.percentChange > 0 ? 'up' : 'down'}
  193. size="xs"
  194. />
  195. </SubText>
  196. </ScoreWrapper>
  197. </Fragment>
  198. );
  199. })}
  200. </StyledPanelTable>
  201. {!loading && showMoreButton}
  202. </Fragment>
  203. )}
  204. </CollapsePanel>
  205. </div>
  206. );
  207. }
  208. }
  209. export default TeamUnresolvedIssues;
  210. const ChartWrapper = styled('div')`
  211. padding: ${space(2)} ${space(2)} 0 ${space(2)};
  212. border-bottom: 1px solid ${p => p.theme.border};
  213. `;
  214. const StyledPanelTable = styled(PanelTable)`
  215. grid-template-columns: 1fr 0.2fr 0.2fr 0.2fr;
  216. white-space: nowrap;
  217. margin-bottom: 0;
  218. border: 0;
  219. font-size: ${p => p.theme.fontSizeMedium};
  220. box-shadow: unset;
  221. & > div {
  222. padding: ${space(1)} ${space(2)};
  223. }
  224. `;
  225. const RightAligned = styled('span')`
  226. text-align: right;
  227. `;
  228. const ScoreWrapper = styled('div')`
  229. display: flex;
  230. align-items: center;
  231. justify-content: flex-end;
  232. text-align: right;
  233. `;
  234. const PaddedIconArrow = styled(IconArrow)`
  235. margin: 0 ${space(0.5)};
  236. `;
  237. const SubText = styled('div')<{color: Color}>`
  238. color: ${p => p.theme[p.color]};
  239. `;