projectVelocityScoreCard.tsx 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. import {Fragment} from 'react';
  2. import {fetchAnyReleaseExistence} from 'sentry/actionCreators/projects';
  3. import {shouldFetchPreviousPeriod} from 'sentry/components/charts/utils';
  4. import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent';
  5. import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
  6. import {parseStatsPeriod} from 'sentry/components/organizations/timeRangeSelector/utils';
  7. import ScoreCard from 'sentry/components/scoreCard';
  8. import {IconArrow} from 'sentry/icons';
  9. import {t} from 'sentry/locale';
  10. import {Organization, PageFilters} from 'sentry/types';
  11. import {defined} from 'sentry/utils';
  12. import {getPeriod} from 'sentry/utils/getPeriod';
  13. import MissingReleasesButtons from '../missingFeatureButtons/missingReleasesButtons';
  14. const API_LIMIT = 1000;
  15. type Release = {date: string; version: string};
  16. type Props = DeprecatedAsyncComponent['props'] & {
  17. isProjectStabilized: boolean;
  18. organization: Organization;
  19. selection: PageFilters;
  20. query?: string;
  21. };
  22. type State = DeprecatedAsyncComponent['state'] & {
  23. currentReleases: Release[] | null;
  24. noReleaseEver: boolean;
  25. previousReleases: Release[] | null;
  26. };
  27. class ProjectVelocityScoreCard extends DeprecatedAsyncComponent<Props, State> {
  28. shouldRenderBadRequests = true;
  29. getDefaultState() {
  30. return {
  31. ...super.getDefaultState(),
  32. currentReleases: null,
  33. previousReleases: null,
  34. noReleaseEver: false,
  35. };
  36. }
  37. getEndpoints() {
  38. const {organization, selection, isProjectStabilized, query} = this.props;
  39. if (!isProjectStabilized) {
  40. return [];
  41. }
  42. const {projects, environments, datetime} = selection;
  43. const {period} = datetime;
  44. const commonQuery = {
  45. environment: environments,
  46. project: projects[0],
  47. query,
  48. };
  49. const endpoints: ReturnType<DeprecatedAsyncComponent['getEndpoints']> = [
  50. [
  51. 'currentReleases',
  52. `/organizations/${organization.slug}/releases/stats/`,
  53. {
  54. includeAllArgs: true,
  55. method: 'GET',
  56. query: {
  57. ...commonQuery,
  58. ...normalizeDateTimeParams(datetime),
  59. },
  60. },
  61. ],
  62. ];
  63. if (
  64. shouldFetchPreviousPeriod({
  65. start: datetime.start,
  66. end: datetime.end,
  67. period: datetime.period,
  68. })
  69. ) {
  70. const {start: previousStart} = parseStatsPeriod(
  71. getPeriod({period, start: undefined, end: undefined}, {shouldDoublePeriod: true})
  72. .statsPeriod!
  73. );
  74. const {start: previousEnd} = parseStatsPeriod(
  75. getPeriod({period, start: undefined, end: undefined}, {shouldDoublePeriod: false})
  76. .statsPeriod!
  77. );
  78. endpoints.push([
  79. 'previousReleases',
  80. `/organizations/${organization.slug}/releases/stats/`,
  81. {
  82. query: {
  83. ...commonQuery,
  84. start: previousStart,
  85. end: previousEnd,
  86. },
  87. },
  88. ]);
  89. }
  90. return endpoints;
  91. }
  92. /**
  93. * If our releases are empty, determine if we had a release in the last 90 days (empty message differs then)
  94. */
  95. async onLoadAllEndpointsSuccess() {
  96. const {currentReleases, previousReleases} = this.state;
  97. const {organization, selection, isProjectStabilized} = this.props;
  98. if (!isProjectStabilized) {
  99. return;
  100. }
  101. if ([...(currentReleases ?? []), ...(previousReleases ?? [])].length !== 0) {
  102. this.setState({noReleaseEver: false});
  103. return;
  104. }
  105. this.setState({loading: true});
  106. const hasOlderReleases = await fetchAnyReleaseExistence(
  107. this.api,
  108. organization.slug,
  109. selection.projects[0]
  110. );
  111. this.setState({noReleaseEver: !hasOlderReleases, loading: false});
  112. }
  113. get cardTitle() {
  114. return t('Number of Releases');
  115. }
  116. get cardHelp() {
  117. return this.trend
  118. ? t(
  119. 'The number of releases for this project and how it has changed since the last period.'
  120. )
  121. : t('The number of releases for this project.');
  122. }
  123. get trend() {
  124. const {currentReleases, previousReleases} = this.state;
  125. if (!defined(currentReleases) || !defined(previousReleases)) {
  126. return null;
  127. }
  128. return currentReleases.length - previousReleases.length;
  129. }
  130. get trendStatus(): React.ComponentProps<typeof ScoreCard>['trendStatus'] {
  131. if (!this.trend) {
  132. return undefined;
  133. }
  134. return this.trend > 0 ? 'good' : 'bad';
  135. }
  136. componentDidUpdate(prevProps: Props) {
  137. const {selection, isProjectStabilized, query} = this.props;
  138. if (
  139. prevProps.selection !== selection ||
  140. prevProps.isProjectStabilized !== isProjectStabilized ||
  141. prevProps.query !== query
  142. ) {
  143. this.remountComponent();
  144. }
  145. }
  146. renderLoading() {
  147. return this.renderBody();
  148. }
  149. renderMissingFeatureCard() {
  150. const {organization} = this.props;
  151. return (
  152. <ScoreCard
  153. title={this.cardTitle}
  154. help={this.cardHelp}
  155. score={<MissingReleasesButtons organization={organization} />}
  156. />
  157. );
  158. }
  159. renderScore() {
  160. const {currentReleases, loading} = this.state;
  161. if (loading || !defined(currentReleases)) {
  162. return '\u2014';
  163. }
  164. return currentReleases.length === API_LIMIT
  165. ? `${API_LIMIT - 1}+`
  166. : currentReleases.length;
  167. }
  168. renderTrend() {
  169. const {loading, currentReleases} = this.state;
  170. if (loading || !defined(this.trend) || currentReleases?.length === API_LIMIT) {
  171. return null;
  172. }
  173. return (
  174. <Fragment>
  175. {this.trend >= 0 ? (
  176. <IconArrow direction="up" size="xs" />
  177. ) : (
  178. <IconArrow direction="down" size="xs" />
  179. )}
  180. {Math.abs(this.trend)}
  181. </Fragment>
  182. );
  183. }
  184. renderBody() {
  185. const {noReleaseEver} = this.state;
  186. if (noReleaseEver) {
  187. return this.renderMissingFeatureCard();
  188. }
  189. return (
  190. <ScoreCard
  191. title={this.cardTitle}
  192. help={this.cardHelp}
  193. score={this.renderScore()}
  194. trend={this.renderTrend()}
  195. trendStatus={this.trendStatus}
  196. />
  197. );
  198. }
  199. }
  200. export default ProjectVelocityScoreCard;