teamIssuesBreakdown.tsx 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import isEqual from 'lodash/isEqual';
  4. import AsyncComponent from 'sentry/components/asyncComponent';
  5. import {BarChart, BarChartSeries} from 'sentry/components/charts/barChart';
  6. import {DateTimeObject} from 'sentry/components/charts/utils';
  7. import CollapsePanel, {COLLAPSE_COUNT} from 'sentry/components/collapsePanel';
  8. import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
  9. import PanelTable from 'sentry/components/panels/panelTable';
  10. import Placeholder from 'sentry/components/placeholder';
  11. import {IconArrow} from 'sentry/icons';
  12. import {t} from 'sentry/locale';
  13. import ProjectsStore from 'sentry/stores/projectsStore';
  14. import {space} from 'sentry/styles/space';
  15. import {Organization, Project} from 'sentry/types';
  16. import {ProjectBadge, ProjectBadgeContainer} from './styles';
  17. import {barAxisLabel, convertDayValueObjectToSeries, sortSeriesByDay} from './utils';
  18. type StatusCounts = {
  19. total: number;
  20. deleted?: number;
  21. ignored?: number;
  22. new?: number;
  23. regressed?: number;
  24. resolved?: number;
  25. unignored?: number;
  26. };
  27. type IssuesBreakdown = Record<string, Record<string, StatusCounts>>;
  28. type Statuses = keyof Omit<StatusCounts, 'total'>;
  29. type Props = AsyncComponent['props'] & {
  30. organization: Organization;
  31. projects: Project[];
  32. statuses: Statuses[];
  33. teamSlug: string;
  34. environment?: string;
  35. } & DateTimeObject;
  36. type State = AsyncComponent['state'] & {
  37. issuesBreakdown: IssuesBreakdown | null;
  38. };
  39. const keys = ['deleted', 'ignored', 'resolved', 'unignored', 'regressed', 'new', 'total'];
  40. class TeamIssuesBreakdown extends AsyncComponent<Props, State> {
  41. shouldRenderBadRequests = true;
  42. getDefaultState(): State {
  43. return {
  44. ...super.getDefaultState(),
  45. issuesBreakdown: null,
  46. };
  47. }
  48. getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
  49. const {organization, start, end, period, utc, teamSlug, statuses, environment} =
  50. this.props;
  51. const datetime = {start, end, period, utc};
  52. return [
  53. [
  54. 'issuesBreakdown',
  55. `/teams/${organization.slug}/${teamSlug}/issue-breakdown/`,
  56. {
  57. query: {
  58. ...normalizeDateTimeParams(datetime),
  59. statuses,
  60. environment,
  61. },
  62. },
  63. ],
  64. ];
  65. }
  66. componentDidUpdate(prevProps: Props) {
  67. const {start, end, period, utc, teamSlug, projects, environment} = this.props;
  68. if (
  69. prevProps.start !== start ||
  70. prevProps.end !== end ||
  71. prevProps.period !== period ||
  72. prevProps.utc !== utc ||
  73. prevProps.teamSlug !== teamSlug ||
  74. prevProps.environment !== environment ||
  75. !isEqual(prevProps.projects, projects)
  76. ) {
  77. this.remountComponent();
  78. }
  79. }
  80. renderLoading() {
  81. return this.renderBody();
  82. }
  83. renderBody() {
  84. const {loading} = this.state;
  85. const issuesBreakdown = this.state.issuesBreakdown ?? {};
  86. const {projects, statuses} = this.props;
  87. const allReviewedByDay: Record<string, Record<string, number>> = {};
  88. // Total statuses & total reviewed keyed by project ID
  89. const projectTotals: Record<string, StatusCounts> = {};
  90. // The issues breakdown is keyed by projectId
  91. for (const [projectId, entries] of Object.entries(issuesBreakdown)) {
  92. // Each bucket is 1 day
  93. for (const [bucket, counts] of Object.entries(entries)) {
  94. if (!projectTotals[projectId]) {
  95. projectTotals[projectId] = {
  96. deleted: 0,
  97. ignored: 0,
  98. resolved: 0,
  99. unignored: 0,
  100. regressed: 0,
  101. new: 0,
  102. total: 0,
  103. };
  104. }
  105. for (const key of keys) {
  106. projectTotals[projectId][key] += counts[key];
  107. }
  108. if (!allReviewedByDay[projectId]) {
  109. allReviewedByDay[projectId] = {};
  110. }
  111. if (allReviewedByDay[projectId][bucket] === undefined) {
  112. allReviewedByDay[projectId][bucket] = counts.total;
  113. } else {
  114. allReviewedByDay[projectId][bucket] += counts.total;
  115. }
  116. }
  117. }
  118. const sortedProjectIds = Object.entries(projectTotals)
  119. .map(([projectId, {total}]) => ({projectId, total}))
  120. .sort((a, b) => b.total - a.total);
  121. const allSeries = Object.keys(allReviewedByDay).map(
  122. (projectId, idx): BarChartSeries => ({
  123. seriesName: ProjectsStore.getById(projectId)?.slug ?? projectId,
  124. data: sortSeriesByDay(convertDayValueObjectToSeries(allReviewedByDay[projectId])),
  125. animationDuration: 500,
  126. animationDelay: idx * 500,
  127. silent: true,
  128. barCategoryGap: '5%',
  129. })
  130. );
  131. return (
  132. <Fragment>
  133. <IssuesChartWrapper>
  134. {loading && <Placeholder height="200px" />}
  135. {!loading && (
  136. <BarChart
  137. style={{height: 200}}
  138. stacked
  139. isGroupedByDate
  140. useShortDate
  141. legend={{right: 0, top: 0}}
  142. xAxis={barAxisLabel(allSeries[0]?.data.length ?? 0)}
  143. yAxis={{minInterval: 1}}
  144. series={allSeries}
  145. />
  146. )}
  147. </IssuesChartWrapper>
  148. <CollapsePanel items={sortedProjectIds.length}>
  149. {({isExpanded, showMoreButton}) => (
  150. <Fragment>
  151. <StyledPanelTable
  152. numActions={statuses.length}
  153. headers={[
  154. t('Project'),
  155. ...statuses.map(action => (
  156. <AlignRight key={action}>{action}</AlignRight>
  157. )),
  158. <AlignRight key="total">
  159. {t('total')} <IconArrow direction="down" size="xs" color="gray300" />
  160. </AlignRight>,
  161. ]}
  162. isLoading={loading}
  163. >
  164. {sortedProjectIds.map(({projectId}, idx) => {
  165. const project = projects.find(p => p.id === projectId);
  166. if (idx >= COLLAPSE_COUNT && !isExpanded) {
  167. return null;
  168. }
  169. return (
  170. <Fragment key={projectId}>
  171. <ProjectBadgeContainer>
  172. {project && <ProjectBadge avatarSize={18} project={project} />}
  173. </ProjectBadgeContainer>
  174. {statuses.map(action => (
  175. <AlignRight key={action}>
  176. {projectTotals[projectId][action]}
  177. </AlignRight>
  178. ))}
  179. <AlignRight>{projectTotals[projectId].total}</AlignRight>
  180. </Fragment>
  181. );
  182. })}
  183. </StyledPanelTable>
  184. {!loading && showMoreButton}
  185. </Fragment>
  186. )}
  187. </CollapsePanel>
  188. </Fragment>
  189. );
  190. }
  191. }
  192. export default TeamIssuesBreakdown;
  193. const ChartWrapper = styled('div')`
  194. padding: ${space(2)} ${space(2)} 0 ${space(2)};
  195. `;
  196. const IssuesChartWrapper = styled(ChartWrapper)`
  197. border-bottom: 1px solid ${p => p.theme.border};
  198. `;
  199. const StyledPanelTable = styled(PanelTable)<{numActions: number}>`
  200. grid-template-columns: 1fr ${p => ' 0.2fr'.repeat(p.numActions)} 0.2fr;
  201. font-size: ${p => p.theme.fontSizeMedium};
  202. white-space: nowrap;
  203. margin-bottom: 0;
  204. border: 0;
  205. box-shadow: unset;
  206. & > div {
  207. padding: ${space(1)} ${space(2)};
  208. }
  209. `;
  210. const AlignRight = styled('div')`
  211. text-align: right;
  212. font-variant-numeric: tabular-nums;
  213. `;