teamIssuesReviewed.tsx 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import isEqual from 'lodash/isEqual';
  4. import AsyncComponent from 'app/components/asyncComponent';
  5. import BarChart from 'app/components/charts/barChart';
  6. import {DateTimeObject} from 'app/components/charts/utils';
  7. import IdBadge from 'app/components/idBadge';
  8. import {getParams} from 'app/components/organizations/globalSelectionHeader/getParams';
  9. import PanelTable from 'app/components/panels/panelTable';
  10. import Placeholder from 'app/components/placeholder';
  11. import {t} from 'app/locale';
  12. import space from 'app/styles/space';
  13. import {Organization, Project} from 'app/types';
  14. import {formatPercentage} from 'app/utils/formatters';
  15. import {convertDaySeriesToWeeks, convertDayValueObjectToSeries} from './utils';
  16. type IssuesBreakdown = Record<string, Record<string, {reviewed: number; total: number}>>;
  17. type Props = AsyncComponent['props'] & {
  18. organization: Organization;
  19. projects: Project[];
  20. teamSlug: string;
  21. } & DateTimeObject;
  22. type State = AsyncComponent['state'] & {
  23. issuesBreakdown: IssuesBreakdown | null;
  24. };
  25. class TeamIssuesReviewed extends AsyncComponent<Props, State> {
  26. shouldRenderBadRequests = true;
  27. getDefaultState(): State {
  28. return {
  29. ...super.getDefaultState(),
  30. issuesBreakdown: null,
  31. };
  32. }
  33. getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
  34. const {organization, start, end, period, utc, teamSlug} = this.props;
  35. const datetime = {start, end, period, utc};
  36. return [
  37. [
  38. 'issuesBreakdown',
  39. `/teams/${organization.slug}/${teamSlug}/issue-breakdown/`,
  40. {
  41. query: {
  42. ...getParams(datetime),
  43. },
  44. },
  45. ],
  46. ];
  47. }
  48. componentDidUpdate(prevProps: Props) {
  49. const {start, end, period, utc, teamSlug, projects} = this.props;
  50. if (
  51. prevProps.start !== start ||
  52. prevProps.end !== end ||
  53. prevProps.period !== period ||
  54. prevProps.utc !== utc ||
  55. prevProps.teamSlug !== teamSlug ||
  56. !isEqual(prevProps.projects, projects)
  57. ) {
  58. this.remountComponent();
  59. }
  60. }
  61. renderLoading() {
  62. return this.renderBody();
  63. }
  64. renderBody() {
  65. const {issuesBreakdown, loading} = this.state;
  66. const {projects} = this.props;
  67. const allReviewedByDay: Record<string, number> = {};
  68. const allNotReviewedByDay: Record<string, number> = {};
  69. // Total reviewed & total reviewed keyed by project ID
  70. const projectTotals: Record<string, {reviewed: number; total: number}> = {};
  71. if (issuesBreakdown) {
  72. // The issues breakdown is split into projectId ->
  73. for (const [projectId, entries] of Object.entries(issuesBreakdown)) {
  74. for (const [bucket, {reviewed, total}] of Object.entries(entries)) {
  75. if (!projectTotals[projectId]) {
  76. projectTotals[projectId] = {reviewed: 0, total: 0};
  77. }
  78. projectTotals[projectId].reviewed += reviewed;
  79. projectTotals[projectId].total += total;
  80. if (allReviewedByDay[bucket] === undefined) {
  81. allReviewedByDay[bucket] = reviewed;
  82. } else {
  83. allReviewedByDay[bucket] += reviewed;
  84. }
  85. const notReviewed = total - reviewed;
  86. if (allNotReviewedByDay[bucket] === undefined) {
  87. allNotReviewedByDay[bucket] = notReviewed;
  88. } else {
  89. allNotReviewedByDay[bucket] += notReviewed;
  90. }
  91. }
  92. }
  93. }
  94. const reviewedSeries = convertDayValueObjectToSeries(allReviewedByDay);
  95. const notReviewedSeries = convertDayValueObjectToSeries(allNotReviewedByDay);
  96. return (
  97. <Fragment>
  98. <IssuesChartWrapper>
  99. {loading && <Placeholder height="200px" />}
  100. {!loading && (
  101. <BarChart
  102. style={{height: 200}}
  103. stacked
  104. isGroupedByDate
  105. legend={{right: 0, top: 0}}
  106. series={[
  107. {
  108. seriesName: t('Reviewed'),
  109. data: convertDaySeriesToWeeks(reviewedSeries),
  110. },
  111. {
  112. seriesName: t('Not Reviewed'),
  113. data: convertDaySeriesToWeeks(notReviewedSeries),
  114. },
  115. ]}
  116. />
  117. )}
  118. </IssuesChartWrapper>
  119. <StyledPanelTable
  120. headers={[
  121. t('Project'),
  122. <AlignRight key="forReview">{t('For Review')}</AlignRight>,
  123. <AlignRight key="reviewed">{t('Reviewed')}</AlignRight>,
  124. <AlignRight key="change">{t('% Reviewed')}</AlignRight>,
  125. ]}
  126. isLoading={loading}
  127. >
  128. {projects.map(project => {
  129. const {total, reviewed} = projectTotals[project.id] ?? {};
  130. return (
  131. <Fragment key={project.id}>
  132. <ProjectBadgeContainer>
  133. <ProjectBadge avatarSize={18} project={project} />
  134. </ProjectBadgeContainer>
  135. <AlignRight>{total}</AlignRight>
  136. <AlignRight>{reviewed}</AlignRight>
  137. <AlignRight>
  138. {total === 0 ? '\u2014' : formatPercentage(reviewed / total)}
  139. </AlignRight>
  140. </Fragment>
  141. );
  142. })}
  143. </StyledPanelTable>
  144. </Fragment>
  145. );
  146. }
  147. }
  148. export default TeamIssuesReviewed;
  149. const ChartWrapper = styled('div')`
  150. padding: ${space(2)} ${space(2)} 0 ${space(2)};
  151. `;
  152. const IssuesChartWrapper = styled(ChartWrapper)`
  153. border-bottom: 1px solid ${p => p.theme.border};
  154. `;
  155. const StyledPanelTable = styled(PanelTable)`
  156. grid-template-columns: 1fr 0.2fr 0.2fr 0.2fr;
  157. font-size: ${p => p.theme.fontSizeMedium};
  158. white-space: nowrap;
  159. margin-bottom: 0;
  160. border: 0;
  161. box-shadow: unset;
  162. & > div {
  163. padding: ${space(1)} ${space(2)};
  164. }
  165. `;
  166. const ProjectBadgeContainer = styled('div')`
  167. display: flex;
  168. `;
  169. const ProjectBadge = styled(IdBadge)`
  170. flex-shrink: 0;
  171. `;
  172. const AlignRight = styled('div')`
  173. text-align: right;
  174. font-variant-numeric: tabular-nums;
  175. `;