]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-9414 Display a preview of the project activity graph on the project page
authorGrégoire Aubert <gregoire.aubert@sonarsource.com>
Fri, 23 Jun 2017 15:34:10 +0000 (17:34 +0200)
committerGrégoire Aubert <gregoire.aubert@sonarsource.com>
Tue, 4 Jul 2017 12:15:34 +0000 (14:15 +0200)
22 files changed:
server/sonar-web/src/main/js/apps/overview/components/OverviewApp.js
server/sonar-web/src/main/js/apps/overview/events/AnalysesList.js
server/sonar-web/src/main/js/apps/overview/events/Analysis.js
server/sonar-web/src/main/js/apps/overview/events/Event.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/overview/events/PreviewGraph.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/overview/events/__tests__/Analysis-test.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/overview/events/__tests__/Event-test.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/overview/events/__tests__/__snapshots__/Analysis-test.js.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/overview/events/__tests__/__snapshots__/Event-test.js.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/overview/meta/Meta.js
server/sonar-web/src/main/js/apps/overview/styles.css
server/sonar-web/src/main/js/apps/overview/types.js
server/sonar-web/src/main/js/apps/overview/utils.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectActivity/components/Events.js
server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysesList.js
server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysis.js
server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAppContainer.js
server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphs.js
server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphs.js
server/sonar-web/src/main/js/apps/projectActivity/utils.js
server/sonar-web/src/main/js/components/charts/AdvancedTimeline.js
server/sonar-web/src/main/js/helpers/storage.js

index 04d85e28e95e3c313262f220292ab56c93a89279..b9071b92d7431008d037c1a9cf41ff43bc8da18e 100644 (file)
@@ -17,6 +17,7 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+// @flow
 import React from 'react';
 import moment from 'moment';
 import QualityGate from '../qualityGate/QualityGate';
@@ -26,73 +27,46 @@ import Coverage from '../main/Coverage';
 import Duplications from '../main/Duplications';
 import Meta from './../meta/Meta';
 import { getMeasuresAndMeta } from '../../../api/measures';
-import { getTimeMachineData } from '../../../api/time-machine';
+import { getAllTimeMachineData } from '../../../api/time-machine';
 import { enhanceMeasuresWithMetrics } from '../../../helpers/measures';
 import { getLeakPeriod } from '../../../helpers/periods';
-import { ComponentType } from '../propTypes';
 import { TooltipsContainer } from '../../../components/mixins/tooltips-mixin';
+import { getGraph } from '../../../helpers/storage';
+import { METRICS, HISTORY_METRICS_LIST } from '../utils';
+import { GRAPHS_METRICS } from '../../projectActivity/utils';
+import type { Component, History, MeasuresList, Period } from '../types';
 import '../styles.css';
 
-const METRICS = [
-  // quality gate
-  'alert_status',
-  'quality_gate_details',
-
-  // bugs
-  'bugs',
-  'new_bugs',
-  'reliability_rating',
-  'new_reliability_rating',
-
-  // vulnerabilities
-  'vulnerabilities',
-  'new_vulnerabilities',
-  'security_rating',
-  'new_security_rating',
-
-  // code smells
-  'code_smells',
-  'new_code_smells',
-  'sqale_rating',
-  'new_maintainability_rating',
-  'sqale_index',
-  'new_technical_debt',
-
-  // coverage
-  'coverage',
-  'new_coverage',
-  'new_lines_to_cover',
-  'tests',
-
-  // duplications
-  'duplicated_lines_density',
-  'new_duplicated_lines_density',
-  'duplicated_blocks',
-
-  // size
-  'ncloc',
-  'ncloc_language_distribution',
-  'new_lines'
-];
-
-const HISTORY_METRICS_LIST = ['sqale_index', 'duplicated_lines_density', 'ncloc', 'coverage'];
+type Props = {
+  component: Component
+};
 
-export default class OverviewApp extends React.PureComponent {
-  static propTypes = {
-    component: ComponentType.isRequired
-  };
+type State = {
+  history?: History,
+  historyStartDate?: Date,
+  loading: boolean,
+  measures: MeasuresList,
+  periods?: Array<Period>
+};
 
-  state = {
-    loading: true
+export default class OverviewApp extends React.PureComponent {
+  mounted: boolean;
+  props: Props;
+  state: State = {
+    loading: true,
+    measures: []
   };
 
   componentDidMount() {
     this.mounted = true;
-    document.querySelector('html').classList.add('dashboard-page');
+    const domElement = document.querySelector('html');
+    if (domElement) {
+      domElement.classList.add('dashboard-page');
+    }
     this.loadMeasures(this.props.component.key).then(() => this.loadHistory(this.props.component));
   }
 
-  componentDidUpdate(prevProps) {
+  componentDidUpdate(prevProps: Props) {
     if (this.props.component.key !== prevProps.component.key) {
       this.loadMeasures(this.props.component.key).then(() =>
         this.loadHistory(this.props.component)
@@ -102,10 +76,13 @@ export default class OverviewApp extends React.PureComponent {
 
   componentWillUnmount() {
     this.mounted = false;
-    document.querySelector('html').classList.remove('dashboard-page');
+    const domElement = document.querySelector('html');
+    if (domElement) {
+      domElement.classList.remove('dashboard-page');
+    }
   }
 
-  loadMeasures(componentKey) {
+  loadMeasures(componentKey: string) {
     this.setState({ loading: true });
 
     return getMeasuresAndMeta(componentKey, METRICS, {
@@ -121,10 +98,11 @@ export default class OverviewApp extends React.PureComponent {
     });
   }
 
-  loadHistory(component) {
-    return getTimeMachineData(component.key, HISTORY_METRICS_LIST).then(r => {
+  loadHistory(component: Component) {
+    const metrics = HISTORY_METRICS_LIST.concat(GRAPHS_METRICS[getGraph()]);
+    return getAllTimeMachineData(component.key, metrics).then(r => {
       if (this.mounted) {
-        const history = {};
+        const history: History = {};
         r.measures.forEach(measure => {
           const measureHistory = measure.history.map(analysis => ({
             date: moment(analysis.date).toDate(),
@@ -174,7 +152,7 @@ export default class OverviewApp extends React.PureComponent {
           </div>
 
           <div className="page-sidebar-fixed">
-            <Meta component={component} measures={measures} />
+            <Meta component={component} history={history} measures={measures} />
           </div>
         </div>
       </div>
index 664a9b41b50f4ddcbe0b3d4b373209edea39e2f4..9b199036fb294f6b1439df8335a4e2edc33c52a3 100644 (file)
 import React from 'react';
 import { Link } from 'react-router';
 import Analysis from './Analysis';
+import PreviewGraph from './PreviewGraph';
 import throwGlobalError from '../../../app/utils/throwGlobalError';
+import { getMetrics } from '../../../api/metrics';
 import { getProjectActivity } from '../../../api/projectActivity';
 import { translate } from '../../../helpers/l10n';
 import type { Analysis as AnalysisType } from '../../projectActivity/types';
+import type { History, Metric } from '../types';
 
 type Props = {
+  history: History,
   project: string
 };
 
 type State = {
   analyses: Array<AnalysisType>,
-  loading: boolean
+  loading: boolean,
+  metrics: Array<Metric>
 };
 
 const PAGE_SIZE = 5;
@@ -40,7 +45,7 @@ const PAGE_SIZE = 5;
 export default class AnalysesList extends React.PureComponent {
   mounted: boolean;
   props: Props;
-  state: State = { analyses: [], loading: true };
+  state: State = { analyses: [], loading: true, metrics: [] };
 
   componentDidMount() {
     this.mounted = true;
@@ -59,9 +64,12 @@ export default class AnalysesList extends React.PureComponent {
 
   fetchData() {
     this.setState({ loading: true });
-    getProjectActivity({ project: this.props.project, ps: PAGE_SIZE }).then(({ analyses }) => {
+    Promise.all([
+      getProjectActivity({ project: this.props.project, ps: PAGE_SIZE }),
+      getMetrics()
+    ]).then(response => {
       if (this.mounted) {
-        this.setState({ analyses, loading: false });
+        this.setState({ analyses: response[0].analyses, metrics: response[1], loading: false });
       }
     }, throwGlobalError);
   }
@@ -95,6 +103,12 @@ export default class AnalysesList extends React.PureComponent {
           {translate('project_activity.page')}
         </h4>
 
+        <PreviewGraph
+          history={this.props.history}
+          project={this.props.project}
+          metrics={this.state.metrics}
+        />
+
         {this.renderList(analyses)}
 
         <div className="spacer-top small">
index 959338bb2df554bc2d4f6f4947cbf36d483bfc77..592c91de41bc2e3a9e276ea83a4bca84bdbd0e68 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+// @flow
 import React from 'react';
-import Events from '../../projectActivity/components/Events';
+import { sortBy } from 'lodash';
+import Event from './Event';
 import FormattedDate from '../../../components/ui/FormattedDate';
 import { TooltipsContainer } from '../../../components/mixins/tooltips-mixin';
 import { translate } from '../../../helpers/l10n';
-import type { Analysis as AnalysisType } from '../../projectActivity/types';
+import type { Analysis as AnalysisType, Event as EventType } from '../../projectActivity/types';
 
 export default function Analysis(props: { analysis: AnalysisType }) {
   const { analysis } = props;
+  const sortedEvents: Array<EventType> = sortBy(
+    analysis.events,
+    // versions first
+    (event: EventType) => (event.category === 'VERSION' ? 0 : 1),
+    // then the rest sorted by category
+    'category'
+  );
 
   return (
     <TooltipsContainer>
@@ -36,8 +45,10 @@ export default function Analysis(props: { analysis: AnalysisType }) {
           </strong>
         </div>
 
-        {analysis.events.length > 0
-          ? <Events events={analysis.events} canAdmin={false} />
+        {sortedEvents.length > 0
+          ? <div className="project-activity-events">
+              {sortedEvents.map(event => <Event event={event} key={event.key} />)}
+            </div>
           : <span className="note">{translate('project_activity.project_analyzed')}</span>}
       </li>
     </TooltipsContainer>
diff --git a/server/sonar-web/src/main/js/apps/overview/events/Event.js b/server/sonar-web/src/main/js/apps/overview/events/Event.js
new file mode 100644 (file)
index 0000000..1d377ec
--- /dev/null
@@ -0,0 +1,44 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+// @flow
+import React from 'react';
+import { TooltipsContainer } from '../../../components/mixins/tooltips-mixin';
+import type { Event as EventType } from '../../projectActivity/types';
+import { translate } from '../../../helpers/l10n';
+
+export default function Event(props: { event: EventType }) {
+  const { event } = props;
+
+  if (event.category === 'VERSION') {
+    return <span className="overview-analysis-event badge">{props.event.name}</span>;
+  }
+
+  return (
+    <div className="overview-analysis-event">
+      <TooltipsContainer>
+        <span>
+          <span className="note">{translate('event.category', event.category)}:</span>
+          {' '}
+          <strong title={event.description} data-toggle="tooltip">{event.name}</strong>
+        </span>
+      </TooltipsContainer>
+    </div>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/overview/events/PreviewGraph.js b/server/sonar-web/src/main/js/apps/overview/events/PreviewGraph.js
new file mode 100644 (file)
index 0000000..db45d7e
--- /dev/null
@@ -0,0 +1,111 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+// @flow
+import React from 'react';
+import { map } from 'lodash';
+import { Link } from 'react-router';
+import { AutoSizer } from 'react-virtualized';
+import { generateSeries, GRAPHS_METRICS } from '../../projectActivity/utils';
+import { getGraph } from '../../../helpers/storage';
+import AdvancedTimeline from '../../../components/charts/AdvancedTimeline';
+import type { Serie } from '../../../components/charts/AdvancedTimeline';
+import type { History, Metric } from '../types';
+
+type Props = {
+  history: History,
+  metrics: Array<Metric>,
+  project: string
+};
+
+type State = {
+  graph: string,
+  metricsType: string,
+  series: Array<Serie>
+};
+
+export default class PreviewGraph extends React.PureComponent {
+  props: Props;
+  state: State;
+
+  constructor(props: Props) {
+    super(props);
+    const graph = getGraph();
+    const metricsType = this.getMetricType(props.metrics, graph);
+    this.state = {
+      graph,
+      metricsType,
+      series: this.getSeries(props.history, graph, metricsType)
+    };
+  }
+
+  componentWillReceiveProps(nextProps: Props) {
+    if (nextProps.history !== this.props.history || nextProps.metrics !== this.props.metrics) {
+      const graph = getGraph();
+      const metricsType = this.getMetricType(nextProps.metrics, graph);
+      this.setState({
+        graph,
+        metricsType,
+        series: this.getSeries(nextProps.history, graph, metricsType)
+      });
+    }
+  }
+
+  getSeries = (history: History, graph: string, metricsType: string): Array<Serie> => {
+    const measureHistory = map(history, (item, key) => ({
+      metric: key,
+      history: item.filter(p => p.value != null)
+    })).filter(item => GRAPHS_METRICS[graph].indexOf(item.metric) >= 0);
+    return generateSeries(measureHistory, graph, metricsType);
+  };
+
+  getMetricType = (metrics: Array<Metric>, graph: string) => {
+    const metricKey = GRAPHS_METRICS[graph][0];
+    const metric = metrics.find(metric => metric.key === metricKey);
+    return metric ? metric.type : 'INT';
+  };
+
+  render() {
+    return (
+      <div className="big-spacer-bottom spacer-top">
+        <Link
+          className="overview-analysis-graph"
+          to={{ pathname: '/project/activity', query: { id: this.props.project } }}>
+          <AutoSizer disableHeight={true}>
+            {({ width }) => (
+              <AdvancedTimeline
+                endDate={null}
+                startDate={null}
+                height={80}
+                width={width}
+                hideGrid={true}
+                hideXAxis={true}
+                interpolate="linear"
+                metricType={this.state.metricsType}
+                padding={[4, 0, 4, 0]}
+                series={this.state.series}
+                showAreas={['coverage', 'duplications'].includes(this.state.graph)}
+              />
+            )}
+          </AutoSizer>
+        </Link>
+      </div>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/overview/events/__tests__/Analysis-test.js b/server/sonar-web/src/main/js/apps/overview/events/__tests__/Analysis-test.js
new file mode 100644 (file)
index 0000000..7130ad2
--- /dev/null
@@ -0,0 +1,35 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import React from 'react';
+import { shallow } from 'enzyme';
+import Analysis from '../Analysis';
+
+const ANALYSIS = {
+  key: '1',
+  date: '2017-06-10T16:10:59+0200',
+  events: [
+    { key: '1', category: 'OTHER', name: 'test' },
+    { key: '2', category: 'VERSION', name: '6.5-SNAPSHOT' }
+  ]
+};
+
+it('should sort the events with version first', () => {
+  expect(shallow(<Analysis analysis={ANALYSIS} />)).toMatchSnapshot();
+});
diff --git a/server/sonar-web/src/main/js/apps/overview/events/__tests__/Event-test.js b/server/sonar-web/src/main/js/apps/overview/events/__tests__/Event-test.js
new file mode 100644 (file)
index 0000000..ec2d975
--- /dev/null
@@ -0,0 +1,33 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import React from 'react';
+import { shallow } from 'enzyme';
+import Event from '../Event';
+
+const EVENT = { key: '1', category: 'OTHER', name: 'test' };
+const VERSION = { key: '2', category: 'VERSION', name: '6.5-SNAPSHOT' };
+
+it('should render an event correctly', () => {
+  expect(shallow(<Event event={EVENT} />)).toMatchSnapshot();
+});
+
+it('should render a version correctly', () => {
+  expect(shallow(<Event event={VERSION} />)).toMatchSnapshot();
+});
diff --git a/server/sonar-web/src/main/js/apps/overview/events/__tests__/__snapshots__/Analysis-test.js.snap b/server/sonar-web/src/main/js/apps/overview/events/__tests__/__snapshots__/Analysis-test.js.snap
new file mode 100644 (file)
index 0000000..d7a40d0
--- /dev/null
@@ -0,0 +1,42 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should sort the events with version first 1`] = `
+<TooltipsContainer>
+  <li
+    className="overview-analysis"
+  >
+    <div
+      className="small little-spacer-bottom"
+    >
+      <strong>
+        <FormattedDate
+          date="2017-06-10T16:10:59+0200"
+          format="LL"
+        />
+      </strong>
+    </div>
+    <div
+      className="project-activity-events"
+    >
+      <Event
+        event={
+          Object {
+            "category": "VERSION",
+            "key": "2",
+            "name": "6.5-SNAPSHOT",
+          }
+        }
+      />
+      <Event
+        event={
+          Object {
+            "category": "OTHER",
+            "key": "1",
+            "name": "test",
+          }
+        }
+      />
+    </div>
+  </li>
+</TooltipsContainer>
+`;
diff --git a/server/sonar-web/src/main/js/apps/overview/events/__tests__/__snapshots__/Event-test.js.snap b/server/sonar-web/src/main/js/apps/overview/events/__tests__/__snapshots__/Event-test.js.snap
new file mode 100644 (file)
index 0000000..b04b139
--- /dev/null
@@ -0,0 +1,32 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render a version correctly 1`] = `
+<span
+  className="overview-analysis-event badge"
+>
+  6.5-SNAPSHOT
+</span>
+`;
+
+exports[`should render an event correctly 1`] = `
+<div
+  className="overview-analysis-event"
+>
+  <TooltipsContainer>
+    <span>
+      <span
+        className="note"
+      >
+        event.category.OTHER
+        :
+      </span>
+       
+      <strong
+        data-toggle="tooltip"
+      >
+        test
+      </strong>
+    </span>
+  </TooltipsContainer>
+</div>
+`;
index a18e1385f1b54e99d38b1e3851999644b3594281..3f8cd994a1eaa2e616ef3e4603f10635fe422d4a 100644 (file)
@@ -29,7 +29,7 @@ import MetaSize from './MetaSize';
 import MetaTags from './MetaTags';
 import { areThereCustomOrganizations } from '../../../store/rootReducer';
 
-const Meta = ({ component, measures, areThereCustomOrganizations }) => {
+const Meta = ({ component, history, measures, areThereCustomOrganizations }) => {
   const { qualifier, description, qualityProfiles, qualityGate } = component;
 
   const isProject = qualifier === 'TRK';
@@ -70,7 +70,7 @@ const Meta = ({ component, measures, areThereCustomOrganizations }) => {
 
       {shouldShowOrganizationKey && <MetaOrganizationKey component={component} />}
 
-      {isProject && <AnalysesList project={component.key} />}
+      {isProject && <AnalysesList project={component.key} history={history} />}
     </div>
   );
 };
index 5ffdc19cd634a78379ce04b3ac25437d6e563651..575ff4a859f4273561d16bea488c1fb8b5dfcba6 100644 (file)
   border-top: 1px solid #e6e6e6;
 }
 
+.overview-analysis-graph {
+  display: block;
+  outline: none;
+  border: none;
+}
+
+.overview-analysis-event {}
+
+.overview-analysis-event.badge {
+  vertical-align: middle;
+  padding: 4px 14px;
+  border-radius: 2px;
+  font-weight: bold;
+  font-size: 12px;
+  letter-spacing: 0;
+}
+
+.overview-analysis-event + .overview-analysis-event {
+  margin-top: 4px;
+}
+
 /*
  * Other
  */
index af71d5ae70c97c37455ecb79d0ed9b8c68c9a8be..9cb99b9780c8be4025c9cf5b4df408c3baeb7e53 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 //@flow
+
 export type Component = {
   id: string,
   key: string,
   qualifier: string
 };
 
+export type History = { [string]: Array<{ date: Date, value: string }> };
+
 export type Metric = {
   key: string,
   name: string,
diff --git a/server/sonar-web/src/main/js/apps/overview/utils.js b/server/sonar-web/src/main/js/apps/overview/utils.js
new file mode 100644 (file)
index 0000000..2ea3133
--- /dev/null
@@ -0,0 +1,69 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+//@flow
+
+export const METRICS = [
+  // quality gate
+  'alert_status',
+  'quality_gate_details',
+
+  // bugs
+  'bugs',
+  'new_bugs',
+  'reliability_rating',
+  'new_reliability_rating',
+
+  // vulnerabilities
+  'vulnerabilities',
+  'new_vulnerabilities',
+  'security_rating',
+  'new_security_rating',
+
+  // code smells
+  'code_smells',
+  'new_code_smells',
+  'sqale_rating',
+  'new_maintainability_rating',
+  'sqale_index',
+  'new_technical_debt',
+
+  // coverage
+  'coverage',
+  'new_coverage',
+  'new_lines_to_cover',
+  'tests',
+
+  // duplications
+  'duplicated_lines_density',
+  'new_duplicated_lines_density',
+  'duplicated_blocks',
+
+  // size
+  'ncloc',
+  'ncloc_language_distribution',
+  'new_lines'
+];
+
+export const HISTORY_METRICS_LIST = [
+  'sqale_index',
+  'duplicated_lines_density',
+  'ncloc',
+  'coverage'
+];
index 6f2725388d9024a78f8c28e7ed8c2ff8c0dbe296..0dd5f89c054f030d11503497d98d3eb43f3c3194 100644 (file)
  */
 // @flow
 import React from 'react';
-import { sortBy } from 'lodash';
 import Event from './Event';
+import './projectActivity.css';
 import type { Event as EventType } from '../types';
 
 type Props = {
-  analysis: string,
-  canAdmin: boolean,
-  changeEvent: (event: string, name: string) => Promise<*>,
-  deleteEvent: (analysis: string, event: string) => Promise<*>,
+  analysis?: string,
+  canAdmin?: boolean,
+  changeEvent?: (event: string, name: string) => Promise<*>,
+  deleteEvent?: (analysis: string, event: string) => Promise<*>,
   events: Array<EventType>,
-  isFirst: boolean
+  isFirst?: boolean
 };
 
 export default function Events(props: Props) {
-  const sortedEvents: Array<EventType> = sortBy(
-    props.events,
-    // versions last
-    (event: EventType) => (event.category === 'VERSION' ? 1 : 0),
-    // then the rest sorted by category
-    'category'
-  );
-
   return (
     <div className="project-activity-events">
-      {sortedEvents.map(event => (
+      {props.events.map(event => (
         <Event
           analysis={props.analysis}
           canAdmin={props.canAdmin}
index e6dd14181e0cfe4926de873beb6b57938b33a355..c44ac7e4e792ef7f4967eb9dc2cd30352a6ed629 100644 (file)
@@ -83,6 +83,7 @@ export default function ProjectActivityAnalysesList(props: Props) {
                           deleteEvent={props.deleteEvent}
                           isFirst={analysis.key === firstAnalysisKey}
                           key={analysis.key}
+                          version={version.version}
                         />
                       ))}
                   </ul>
index 406f963f0a354bc259a2f2da6897b384d9669ea6..41133f90f55b8b550091a7242a556f1c32db3da8 100644 (file)
@@ -30,19 +30,18 @@ type Props = {
   addCustomEvent: (analysis: string, name: string, category?: string) => Promise<*>,
   addVersion: (analysis: string, version: string) => Promise<*>,
   analysis: Analysis,
+  canAdmin: boolean,
   changeEvent: (event: string, name: string) => Promise<*>,
   deleteAnalysis: (analysis: string) => Promise<*>,
   deleteEvent: (analysis: string, event: string) => Promise<*>,
   isFirst: boolean,
-  canAdmin: boolean
+  version: ?string
 };
 
 export default function ProjectActivityAnalysis(props: Props) {
   const { date, events } = props.analysis;
   const { isFirst, canAdmin } = props;
 
-  const version = events.find(event => event.category === 'VERSION');
-
   return (
     <li className="project-activity-analysis clearfix">
       <div className="project-activity-time spacer-right">
@@ -64,7 +63,7 @@ export default function ProjectActivityAnalysis(props: Props) {
               <i className="icon-dropdown" />
             </button>
             <ul className="dropdown-menu dropdown-menu-right">
-              {version == null &&
+              {props.version == null &&
                 <li>
                   <AddEventForm
                     addEvent={props.addVersion}
index 58b855b49f0d9bee7ac2bdb73e5cdb0bfe7ea556..e7a4e2505ef7e7ab249878d06f40155394d778be 100644 (file)
@@ -258,7 +258,8 @@ class ProjectActivityAppContainer extends React.PureComponent {
       );
 
       // if there is no filter, but there are saved preferences in the localStorage
-      return !filtered && getGraph();
+      const graph = getGraph();
+      return !filtered && graph != null && graph !== 'overview';
     }
   };
 
index 004ef7091974cfe26a74901874fdafef74b58b8d..008cbc7869abc0a34f441ca9d495beb08cba3b0e 100644 (file)
@@ -23,13 +23,7 @@ import { debounce, sortBy } from 'lodash';
 import ProjectActivityGraphsHeader from './ProjectActivityGraphsHeader';
 import GraphsZoom from './GraphsZoom';
 import StaticGraphs from './StaticGraphs';
-import {
-  GRAPHS_METRICS,
-  datesQueryChanged,
-  generateCoveredLinesMetric,
-  historyQueryChanged
-} from '../utils';
-import { translate } from '../../../helpers/l10n';
+import { datesQueryChanged, generateSeries, historyQueryChanged } from '../utils';
 import type { RawQuery } from '../../../helpers/query';
 import type { Analysis, MeasureHistory, Query } from '../types';
 import type { Serie } from '../../../components/charts/AdvancedTimeline';
@@ -57,7 +51,7 @@ export default class ProjectActivityGraphs extends React.PureComponent {
 
   constructor(props: Props) {
     super(props);
-    const series = this.getSeries(props.measuresHistory);
+    const series = generateSeries(props.measuresHistory, props.query.graph, props.metricsType);
     this.state = {
       graphStartDate: props.query.from || null,
       graphEndDate: props.query.to || null,
@@ -71,8 +65,13 @@ export default class ProjectActivityGraphs extends React.PureComponent {
       nextProps.measuresHistory !== this.props.measuresHistory ||
       historyQueryChanged(this.props.query, nextProps.query)
     ) {
-      const series = this.getSeries(nextProps.measuresHistory);
-      this.setState({ series });
+      this.setState({
+        series: generateSeries(
+          nextProps.measuresHistory,
+          nextProps.query.graph,
+          nextProps.metricsType
+        )
+      });
     }
     if (
       nextProps.query !== this.props.query &&
@@ -82,26 +81,6 @@ export default class ProjectActivityGraphs extends React.PureComponent {
     }
   }
 
-  getSeries = (measuresHistory: Array<MeasureHistory>): Array<Serie> =>
-    measuresHistory.map(measure => {
-      if (measure.metric === 'uncovered_lines') {
-        return generateCoveredLinesMetric(
-          measure,
-          measuresHistory,
-          GRAPHS_METRICS[this.props.query.graph].indexOf(measure.metric)
-        );
-      }
-      return {
-        name: measure.metric,
-        translatedName: translate('metric', measure.metric, 'name'),
-        style: GRAPHS_METRICS[this.props.query.graph].indexOf(measure.metric),
-        data: measure.history.map(analysis => ({
-          x: analysis.date,
-          y: this.props.metricsType === 'LEVEL' ? analysis.value : Number(analysis.value)
-        }))
-      };
-    });
-
   updateGraphZoom = (graphStartDate: ?Date, graphEndDate: ?Date) => {
     if (graphEndDate != null && graphStartDate != null) {
       const msDiff = Math.abs(graphEndDate.valueOf() - graphStartDate.valueOf());
index 7428bdd328299f18f33d3e67494d819ac4a65f2f..863796917687148b2756833eb871622acf90cf5e 100644 (file)
@@ -47,8 +47,6 @@ export default class StaticGraphs extends React.PureComponent {
 
   formatYTick = tick => formatMeasure(tick, getShortType(this.props.metricsType));
 
-  formatValue = value => formatMeasure(value, this.props.metricsType);
-
   getEvents = () => {
     const { analyses, eventFilter } = this.props;
     const filteredEvents = analyses.reduce((acc, analysis) => {
@@ -73,7 +71,7 @@ export default class StaticGraphs extends React.PureComponent {
     return sortBy(filteredEvents, 'date');
   };
 
-  hasHistoryData = () => some(this.props.series, serie => serie.data && serie.data.length > 2);
+  hasSeriesData = () => some(this.props.series, serie => serie.data && serie.data.length > 2);
 
   render() {
     const { loading } = this.props;
@@ -88,7 +86,7 @@ export default class StaticGraphs extends React.PureComponent {
       );
     }
 
-    if (!this.hasHistoryData()) {
+    if (!this.hasSeriesData()) {
       return (
         <div className="project-activity-graph-container">
           <div className="note text-center">
@@ -111,7 +109,6 @@ export default class StaticGraphs extends React.PureComponent {
                 height={height}
                 width={width}
                 interpolate="linear"
-                formatValue={this.formatValue}
                 formatYTick={this.formatYTick}
                 leakPeriodDate={this.props.leakPeriodDate}
                 metricType={this.props.metricsType}
index 70447874ed1fe543762dd24a4bb70794fce01215..2c0c3c818bcdd90fea06e33604e6a54e723e993a 100644 (file)
@@ -19,6 +19,7 @@
  */
 // @flow
 import moment from 'moment';
+import { sortBy } from 'lodash';
 import {
   cleanQuery,
   parseAsDate,
@@ -29,6 +30,7 @@ import {
 import { translate } from '../../helpers/l10n';
 import type { Analysis, MeasureHistory, Query } from './types';
 import type { RawQuery } from '../../helpers/query';
+import type { Serie } from '../../components/charts/AdvancedTimeline';
 
 export const EVENT_TYPES = ['VERSION', 'QUALITY_GATE', 'QUALITY_PROFILE', 'OTHER'];
 export const GRAPH_TYPES = ['overview', 'coverage', 'duplications', 'remediation'];
@@ -69,6 +71,30 @@ export const generateCoveredLinesMetric = (
   };
 };
 
+export const generateSeries = (
+  measuresHistory: Array<MeasureHistory>,
+  graph: string,
+  dataType: string
+): Array<Serie> =>
+  measuresHistory.map(measure => {
+    if (measure.metric === 'uncovered_lines') {
+      return generateCoveredLinesMetric(
+        measure,
+        measuresHistory,
+        GRAPHS_METRICS[graph].indexOf(measure.metric)
+      );
+    }
+    return {
+      name: measure.metric,
+      translatedName: translate('metric', measure.metric, 'name'),
+      style: GRAPHS_METRICS[graph].indexOf(measure.metric),
+      data: measure.history.map(analysis => ({
+        x: analysis.date,
+        y: dataType === 'LEVEL' ? analysis.value : Number(analysis.value)
+      }))
+    };
+  });
+
 export const getAnalysesByVersionByDay = (
   analyses: Array<Analysis>
 ): Array<{
@@ -84,10 +110,18 @@ export const getAnalysesByVersionByDay = (
     if (!currentVersion.byDay[day]) {
       currentVersion.byDay[day] = [];
     }
-    currentVersion.byDay[day].push(analysis);
-    const versionEvent = analysis.events.find(event => event.category === 'VERSION');
-    if (versionEvent) {
-      currentVersion.version = versionEvent.name;
+    const sortedEvents = sortBy(
+      analysis.events,
+      // versions last
+      event => (event.category === 'VERSION' ? 1 : 0),
+      // then the rest sorted by category
+      'category'
+    );
+    currentVersion.byDay[day].push({ ...analysis, events: sortedEvents });
+
+    const lastEvent = sortedEvents[sortedEvents.length - 1];
+    if (lastEvent && lastEvent.category === 'VERSION') {
+      currentVersion.version = lastEvent.name;
       acc.push({ version: undefined, byDay: {} });
     }
     return acc;
index fa495a1ae8cf89b263e14e44ce413aff32d88848..41c909420996a34c7861e165ad70d84a5e6ab7c8 100644 (file)
@@ -35,17 +35,19 @@ type Props = {
   endDate: ?Date,
   events?: Array<Event>,
   eventSize?: number,
-  formatYTick: number => string,
-  formatValue: number => string,
+  disableZoom?: boolean,
+  formatYTick?: number => string,
+  hideGrid?: boolean,
+  hideXAxis?: boolean,
   height: number,
   width: number,
-  leakPeriodDate: Date,
+  leakPeriodDate?: Date,
   padding: Array<number>,
   series: Array<Serie>,
   showAreas?: boolean,
   showEventMarkers?: boolean,
   startDate: ?Date,
-  updateZoom: (start: ?Date, endDate: ?Date) => void,
+  updateZoom?: (start: ?Date, endDate: ?Date) => void,
   zoomSpeed: number
 };
 
@@ -112,10 +114,12 @@ export default class AdvancedTimeline extends React.PureComponent {
     const rightPos = xRange[1] + Math.round(speed * evt.deltaY * (1 - mouseXPos));
     const startDate = leftPos > maxXRange[0] ? xScale.invert(leftPos) : null;
     const endDate = rightPos < maxXRange[1] ? xScale.invert(rightPos) : null;
+    // $FlowFixMe updateZoom can't be undefined at this point
     this.props.updateZoom(startDate, endDate);
   };
 
   renderHorizontalGrid = (xScale: Scale, yScale: Scale) => {
+    const { formatYTick } = this.props;
     const hasTicks = typeof yScale.ticks === 'function';
     const ticks = hasTicks ? yScale.ticks(4) : yScale.domain();
 
@@ -127,15 +131,16 @@ export default class AdvancedTimeline extends React.PureComponent {
       <g>
         {ticks.map(tick => (
           <g key={tick}>
-            <text
-              className="line-chart-tick line-chart-tick-x"
-              dx="-1em"
-              dy="0.3em"
-              textAnchor="end"
-              x={xScale.range()[0]}
-              y={yScale(tick)}>
-              {this.props.formatYTick(tick)}
-            </text>
+            {formatYTick != null &&
+              <text
+                className="line-chart-tick line-chart-tick-x"
+                dx="-1em"
+                dy="0.3em"
+                textAnchor="end"
+                x={xScale.range()[0]}
+                y={yScale(tick)}>
+                {formatYTick(tick)}
+              </text>}
             <line
               className="line-chart-grid"
               x1={xScale.range()[0]}
@@ -149,7 +154,7 @@ export default class AdvancedTimeline extends React.PureComponent {
     );
   };
 
-  renderTicks = (xScale: Scale, yScale: Scale) => {
+  renderXAxisTicks = (xScale: Scale, yScale: Scale) => {
     const format = xScale.tickFormat(7);
     const ticks = xScale.ticks(7);
     const y = yScale.range()[0];
@@ -169,9 +174,6 @@ export default class AdvancedTimeline extends React.PureComponent {
   };
 
   renderLeak = (xScale: Scale, yScale: Scale) => {
-    if (!this.props.leakPeriodDate) {
-      return null;
-    }
     const yRange = yScale.range();
     const xRange = xScale.range();
     const leakWidth = xRange[xRange.length - 1] - xScale(this.props.leakPeriodDate);
@@ -282,20 +284,21 @@ export default class AdvancedTimeline extends React.PureComponent {
     }
 
     const { maxXRange, xScale, yScale } = this.getScales();
+    const zoomEnabled = !this.props.disableZoom && this.props.updateZoom != null;
     const isZoomed = this.props.startDate || this.props.endDate;
     return (
       <svg
         className={classNames('line-chart', { 'chart-zoomed': isZoomed })}
         width={this.props.width}
         height={this.props.height}>
-        {this.renderClipPath(xScale, yScale)}
+        {zoomEnabled && this.renderClipPath(xScale, yScale)}
         <g transform={`translate(${this.props.padding[3]}, ${this.props.padding[0]})`}>
-          {this.renderLeak(xScale, yScale)}
-          {this.renderHorizontalGrid(xScale, yScale)}
-          {this.renderTicks(xScale, yScale)}
+          {this.props.leakPeriodDate != null && this.renderLeak(xScale, yScale)}
+          {!this.props.hideGrid && this.renderHorizontalGrid(xScale, yScale)}
+          {!this.props.hideXAxis && this.renderXAxisTicks(xScale, yScale)}
           {this.props.showAreas && this.renderAreas(xScale, yScale)}
           {this.renderLines(xScale, yScale)}
-          {this.renderZoomOverlay(xScale, yScale, maxXRange)}
+          {zoomEnabled && this.renderZoomOverlay(xScale, yScale, maxXRange)}
           {this.props.showEventMarkers && this.renderEvents(xScale, yScale)}
         </g>
       </svg>
index ac6fa964f5f9f361ba4f700ea574901301150d13..fc7ec4d0ad54133ff0704a1ddfe6c622fabcc6d0 100644 (file)
@@ -64,4 +64,4 @@ export const saveSort = (sort: ?string) => save(PROJECTS_SORT, sort);
 export const getSort = () => window.localStorage.getItem(PROJECTS_SORT);
 
 export const saveGraph = (graph: ?string) => save(PROJECT_ACTIVITY_GRAPH, graph);
-export const getGraph = () => window.localStorage.getItem(PROJECT_ACTIVITY_GRAPH);
+export const getGraph = () => window.localStorage.getItem(PROJECT_ACTIVITY_GRAPH) || 'overview';