You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

ProjectActivityGraphs.tsx 9.6KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. /*
  2. * SonarQube
  3. * Copyright (C) 2009-2024 SonarSource SA
  4. * mailto:info AT sonarsource DOT com
  5. *
  6. * This program is free software; you can redistribute it and/or
  7. * modify it under the terms of the GNU Lesser General Public
  8. * License as published by the Free Software Foundation; either
  9. * version 3 of the License, or (at your option) any later version.
  10. *
  11. * This program is distributed in the hope that it will be useful,
  12. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  14. * Lesser General Public License for more details.
  15. *
  16. * You should have received a copy of the GNU Lesser General Public License
  17. * along with this program; if not, write to the Free Software Foundation,
  18. * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  19. */
  20. import { FlagMessage } from 'design-system';
  21. import { debounce, findLast, maxBy, minBy, sortBy } from 'lodash';
  22. import * as React from 'react';
  23. import { FormattedMessage } from 'react-intl';
  24. import GraphsHeader from '../../../components/activity-graph/GraphsHeader';
  25. import GraphsHistory from '../../../components/activity-graph/GraphsHistory';
  26. import GraphsZoom from '../../../components/activity-graph/GraphsZoom';
  27. import {
  28. generateSeries,
  29. getActivityGraph,
  30. getDisplayedHistoryMetrics,
  31. getSeriesMetricType,
  32. isCustomGraph,
  33. saveActivityGraph,
  34. splitSeriesInGraphs,
  35. } from '../../../components/activity-graph/utils';
  36. import DocumentationLink from '../../../components/common/DocumentationLink';
  37. import { CCT_SOFTWARE_QUALITY_METRICS } from '../../../helpers/constants';
  38. import { translate } from '../../../helpers/l10n';
  39. import {
  40. GraphType,
  41. MeasureHistory,
  42. ParsedAnalysis,
  43. Point,
  44. Serie,
  45. } from '../../../types/project-activity';
  46. import { Metric } from '../../../types/types';
  47. import { Query, datesQueryChanged, historyQueryChanged } from '../utils';
  48. import { PROJECT_ACTIVITY_GRAPH } from './ProjectActivityApp';
  49. interface Props {
  50. analyses: ParsedAnalysis[];
  51. leakPeriodDate?: Date;
  52. loading: boolean;
  53. measuresHistory: MeasureHistory[];
  54. metrics: Metric[];
  55. project: string;
  56. query: Query;
  57. updateQuery: (changes: Partial<Query>) => void;
  58. }
  59. interface State {
  60. graphStartDate?: Date;
  61. graphEndDate?: Date;
  62. series: Serie[];
  63. graphs: Serie[][];
  64. }
  65. const MAX_GRAPH_NB = 2;
  66. const MAX_SERIES_PER_GRAPH = 3;
  67. export default class ProjectActivityGraphs extends React.PureComponent<Props, State> {
  68. constructor(props: Props) {
  69. super(props);
  70. const series = generateSeries(
  71. props.measuresHistory,
  72. props.query.graph,
  73. props.metrics,
  74. getDisplayedHistoryMetrics(props.query.graph, props.query.customMetrics),
  75. );
  76. this.state = {
  77. series,
  78. graphs: splitSeriesInGraphs(series, MAX_GRAPH_NB, MAX_SERIES_PER_GRAPH),
  79. ...this.getStateZoomDates(undefined, props, series),
  80. };
  81. this.updateQueryDateRange = debounce(this.updateQueryDateRange, 500);
  82. }
  83. componentDidUpdate(prevProps: Props) {
  84. let newSeries;
  85. let newGraphs;
  86. if (
  87. prevProps.measuresHistory !== this.props.measuresHistory ||
  88. historyQueryChanged(prevProps.query, this.props.query)
  89. ) {
  90. newSeries = generateSeries(
  91. this.props.measuresHistory,
  92. this.props.query.graph,
  93. this.props.metrics,
  94. getDisplayedHistoryMetrics(this.props.query.graph, this.props.query.customMetrics),
  95. );
  96. newGraphs = splitSeriesInGraphs(newSeries, MAX_GRAPH_NB, MAX_SERIES_PER_GRAPH);
  97. }
  98. const newDates = this.getStateZoomDates(prevProps, this.props, newSeries);
  99. if (newSeries || newDates) {
  100. let newState = {} as State;
  101. if (newSeries) {
  102. newState.series = newSeries;
  103. }
  104. if (newGraphs) {
  105. newState.graphs = newGraphs;
  106. }
  107. if (newDates) {
  108. newState = { ...newState, ...newDates };
  109. }
  110. this.setState(newState);
  111. }
  112. }
  113. getStateZoomDates = (prevProps: Props | undefined, props: Props, newSeries?: Serie[]) => {
  114. const newDates = {
  115. from: props.query.from || undefined,
  116. to: props.query.to || undefined,
  117. };
  118. if (!prevProps || datesQueryChanged(prevProps.query, props.query)) {
  119. return { graphEndDate: newDates.to, graphStartDate: newDates.from };
  120. }
  121. if (newDates.to === undefined && newDates.from === undefined && newSeries !== undefined) {
  122. const firstValid = minBy(
  123. newSeries.map((serie) => serie.data.find((p) => Boolean(p.y || p.y === 0))),
  124. 'x',
  125. );
  126. const lastValid = maxBy<Point>(
  127. newSeries.map((serie) => findLast(serie.data, (p) => Boolean(p.y || p.y === 0))!),
  128. 'x',
  129. );
  130. return {
  131. graphEndDate: lastValid?.x,
  132. graphStartDate: firstValid?.x,
  133. };
  134. }
  135. return null;
  136. };
  137. getMetricsTypeFilter = () => {
  138. if (this.state.graphs.length < MAX_GRAPH_NB) {
  139. return undefined;
  140. }
  141. return this.state.graphs
  142. .filter((graph) => graph.length < MAX_SERIES_PER_GRAPH)
  143. .map((graph) => graph[0].type);
  144. };
  145. handleAddCustomMetric = (metric: string) => {
  146. const customMetrics = [...this.props.query.customMetrics, metric];
  147. saveActivityGraph(PROJECT_ACTIVITY_GRAPH, this.props.project, GraphType.custom, customMetrics);
  148. this.props.updateQuery({ customMetrics });
  149. };
  150. handleRemoveCustomMetric = (removedMetric: string) => {
  151. const customMetrics = this.props.query.customMetrics.filter(
  152. (metric) => metric !== removedMetric,
  153. );
  154. saveActivityGraph(PROJECT_ACTIVITY_GRAPH, this.props.project, GraphType.custom, customMetrics);
  155. this.props.updateQuery({ customMetrics });
  156. };
  157. handleUpdateGraph = (graph: GraphType) => {
  158. saveActivityGraph(PROJECT_ACTIVITY_GRAPH, this.props.project, graph);
  159. if (isCustomGraph(graph) && this.props.query.customMetrics.length <= 0) {
  160. const { customGraphs } = getActivityGraph(PROJECT_ACTIVITY_GRAPH, this.props.project);
  161. this.props.updateQuery({ graph, customMetrics: customGraphs });
  162. } else {
  163. this.props.updateQuery({ graph, customMetrics: [] });
  164. }
  165. };
  166. handleUpdateGraphZoom = (graphStartDate?: Date, graphEndDate?: Date) => {
  167. if (graphEndDate !== undefined && graphStartDate !== undefined) {
  168. const msDiff = Math.abs(graphEndDate.valueOf() - graphStartDate.valueOf());
  169. // 12 hours minimum between the two dates
  170. if (msDiff < 1000 * 60 * 60 * 12) {
  171. return;
  172. }
  173. }
  174. this.setState({ graphStartDate, graphEndDate });
  175. this.updateQueryDateRange([graphStartDate, graphEndDate]);
  176. };
  177. handleUpdateSelectedDate = (selectedDate?: Date) => {
  178. this.props.updateQuery({ selectedDate });
  179. };
  180. updateQueryDateRange = (dates: Array<Date | undefined>) => {
  181. if (dates[0] === undefined || dates[1] === undefined) {
  182. this.props.updateQuery({ from: dates[0], to: dates[1] });
  183. } else {
  184. const sortedDates = sortBy(dates);
  185. this.props.updateQuery({ from: sortedDates[0], to: sortedDates[1] });
  186. }
  187. };
  188. renderQualitiesMetricInfoMessage = () => {
  189. const { measuresHistory } = this.props;
  190. const qualityMeasuresHistory = measuresHistory.find((history) =>
  191. CCT_SOFTWARE_QUALITY_METRICS.includes(history.metric),
  192. );
  193. const indexOfFirstMeasureWithValue = qualityMeasuresHistory?.history.findIndex(
  194. (item) => item.value,
  195. );
  196. const hasGaps =
  197. indexOfFirstMeasureWithValue === -1
  198. ? false
  199. : qualityMeasuresHistory?.history
  200. .slice(indexOfFirstMeasureWithValue)
  201. .some((item) => item.value === undefined);
  202. if (hasGaps) {
  203. return (
  204. <FlagMessage variant="info">
  205. <FormattedMessage
  206. id="project_activity.graphs.data_table.data_gap"
  207. tagName="div"
  208. values={{
  209. learn_more: (
  210. <DocumentationLink
  211. className="sw-whitespace-nowrap"
  212. to="/user-guide/clean-code/code-analysis/"
  213. >
  214. {translate('learn_more')}
  215. </DocumentationLink>
  216. ),
  217. }}
  218. />
  219. </FlagMessage>
  220. );
  221. }
  222. return null;
  223. };
  224. render() {
  225. const { analyses, leakPeriodDate, loading, measuresHistory, metrics, query } = this.props;
  226. const { graphEndDate, graphStartDate, series } = this.state;
  227. return (
  228. <div className="sw-px-5 sw-py-4 sw-h-full sw-flex sw-flex-col sw-box-border">
  229. <GraphsHeader
  230. onAddCustomMetric={this.handleAddCustomMetric}
  231. className="sw-mb-4"
  232. graph={query.graph}
  233. metrics={metrics}
  234. metricsTypeFilter={this.getMetricsTypeFilter()}
  235. onRemoveCustomMetric={this.handleRemoveCustomMetric}
  236. selectedMetrics={query.customMetrics}
  237. onUpdateGraph={this.handleUpdateGraph}
  238. />
  239. {this.renderQualitiesMetricInfoMessage()}
  240. <GraphsHistory
  241. analyses={analyses}
  242. graph={query.graph}
  243. graphEndDate={graphEndDate}
  244. graphStartDate={graphStartDate}
  245. graphs={this.state.graphs}
  246. leakPeriodDate={leakPeriodDate}
  247. loading={loading}
  248. measuresHistory={measuresHistory}
  249. removeCustomMetric={this.handleRemoveCustomMetric}
  250. selectedDate={query.selectedDate}
  251. series={series}
  252. updateGraphZoom={this.handleUpdateGraphZoom}
  253. updateSelectedDate={this.handleUpdateSelectedDate}
  254. />
  255. <GraphsZoom
  256. graphEndDate={graphEndDate}
  257. graphStartDate={graphStartDate}
  258. leakPeriodDate={leakPeriodDate}
  259. loading={loading}
  260. metricsType={getSeriesMetricType(series)}
  261. series={series}
  262. showAreas={[GraphType.coverage, GraphType.duplications].includes(query.graph)}
  263. onUpdateGraphZoom={this.handleUpdateGraphZoom}
  264. />
  265. </div>
  266. );
  267. }
  268. }