]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-9418 Display tooltips when hovering the project activity graphs
authorGrégoire Aubert <gregoire.aubert@sonarsource.com>
Mon, 3 Jul 2017 15:22:06 +0000 (17:22 +0200)
committerGrégoire Aubert <gregoire.aubert@sonarsource.com>
Thu, 13 Jul 2017 12:34:17 +0000 (14:34 +0200)
30 files changed:
server/sonar-web/src/main/js/apps/overview/components/OverviewApp.js
server/sonar-web/src/main/js/apps/overview/events/PreviewGraph.js
server/sonar-web/src/main/js/apps/projectActivity/__tests__/__snapshots__/utils-test.js.snap
server/sonar-web/src/main/js/apps/projectActivity/components/Events.js
server/sonar-web/src/main/js/apps/projectActivity/components/GraphsTooltips.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectActivity/components/GraphsTooltipsContent.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectActivity/components/GraphsTooltipsContentCoverage.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectActivity/components/GraphsTooltipsContentDuplication.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectActivity/components/GraphsTooltipsContentOverview.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityApp.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/components/__tests__/GraphsTooltips-test.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/GraphsTooltipsContent-test.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/GraphsTooltipsContentCoverage-test.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/GraphsTooltipsContentDuplication-test.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/GraphsTooltipsContentOverview-test.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/StaticGraphs-test.js
server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/__snapshots__/GraphsTooltips-test.js.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/__snapshots__/GraphsTooltipsContent-test.js.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/__snapshots__/GraphsTooltipsContentCoverage-test.js.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/__snapshots__/GraphsTooltipsContentDuplication-test.js.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/__snapshots__/GraphsTooltipsContentOverview-test.js.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/__snapshots__/ProjectActivityGraphs-test.js.snap
server/sonar-web/src/main/js/apps/projectActivity/components/projectActivity.css
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/components/ui/Rating.js
server/sonar-web/src/main/less/components/bubble-popup.less
server/sonar-web/src/main/less/components/graphics.less

index 2be253ef9f6997fcff4309e404ba098f932313f7..3ca38235265dcfa0a296d1c59a415f9eb871bcaa 100644 (file)
@@ -35,7 +35,7 @@ import { getLeakPeriod } from '../../../helpers/periods';
 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 { GRAPHS_METRICS_DISPLAYED } from '../../projectActivity/utils';
 import type { Component, History, MeasuresList, Period } from '../types';
 import '../styles.css';
 
@@ -101,7 +101,7 @@ export default class OverviewApp extends React.PureComponent {
   }
 
   loadHistory(component: Component) {
-    const metrics = uniq(HISTORY_METRICS_LIST.concat(GRAPHS_METRICS[getGraph()]));
+    const metrics = uniq(HISTORY_METRICS_LIST.concat(GRAPHS_METRICS_DISPLAYED[getGraph()]));
     return getAllTimeMachineData(component.key, metrics).then(r => {
       if (this.mounted) {
         const history: History = {};
index db45d7e51b1b05c6446de57c7775a3f621aee9cd..39cbfbeb7c4b9011c2f70d647b8c217088b33497 100644 (file)
@@ -22,7 +22,7 @@ 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 { generateSeries, GRAPHS_METRICS_DISPLAYED } from '../../projectActivity/utils';
 import { getGraph } from '../../../helpers/storage';
 import AdvancedTimeline from '../../../components/charts/AdvancedTimeline';
 import type { Serie } from '../../../components/charts/AdvancedTimeline';
@@ -71,12 +71,12 @@ export default class PreviewGraph extends React.PureComponent {
     const measureHistory = map(history, (item, key) => ({
       metric: key,
       history: item.filter(p => p.value != null)
-    })).filter(item => GRAPHS_METRICS[graph].indexOf(item.metric) >= 0);
+    })).filter(item => GRAPHS_METRICS_DISPLAYED[graph].indexOf(item.metric) >= 0);
     return generateSeries(measureHistory, graph, metricsType);
   };
 
   getMetricType = (metrics: Array<Metric>, graph: string) => {
-    const metricKey = GRAPHS_METRICS[graph][0];
+    const metricKey = GRAPHS_METRICS_DISPLAYED[graph][0];
     const metric = metrics.find(metric => metric.key === metricKey);
     return metric ? metric.type : 'INT';
   };
index 8a78d060aa78707860468046b6afd71fbedad3c8..6929c54a2e2f7db98f62adfc5f79279af4eb7535 100644 (file)
@@ -85,16 +85,16 @@ Array [
         Object {
           "date": 2017-05-16T05:09:59.000Z,
           "events": Array [
-            Object {
-              "category": "QUALITY_PROFILE",
-              "key": "AVwQF7zXl-nNFgFWOJ3W",
-              "name": "Changes in 'Default - SonarSource conventions' (Java)",
-            },
             Object {
               "category": "VERSION",
               "key": "AVyM9oI1HjR_PLDzRciU",
               "name": "1.0",
             },
+            Object {
+              "category": "QUALITY_PROFILE",
+              "key": "AVwQF7zXl-nNFgFWOJ3W",
+              "name": "Changes in 'Default - SonarSource conventions' (Java)",
+            },
           ],
           "key": "AVwQF7kwl-nNFgFWOJ3V",
         },
index 8be0cb581653d91351b0bdec19a4e9a6fb5211d1..b1e03e066569331d2c8217c2c4a337d35850afd1 100644 (file)
@@ -19,6 +19,7 @@
  */
 // @flow
 import React from 'react';
+import { sortBy } from 'lodash';
 import Event from './Event';
 import type { Event as EventType } from '../types';
 
@@ -32,9 +33,17 @@ type Props = {
 };
 
 export default function Events(props: Props) {
+  const sortedEvents = sortBy(
+    props.events,
+    // versions last
+    event => (event.category === 'VERSION' ? 1 : 0),
+    // then the rest sorted by category
+    'category'
+  );
+
   return (
     <div className="project-activity-events">
-      {props.events.map(event => (
+      {sortedEvents.map(event => (
         <Event
           analysis={props.analysis}
           canAdmin={props.canAdmin}
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/GraphsTooltips.js b/server/sonar-web/src/main/js/apps/projectActivity/components/GraphsTooltips.js
new file mode 100644 (file)
index 0000000..eda9919
--- /dev/null
@@ -0,0 +1,99 @@
+/*
+ * 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 BubblePopup from '../../../components/common/BubblePopup';
+import FormattedDate from '../../../components/ui/FormattedDate';
+import GraphsTooltipsContent from './GraphsTooltipsContent';
+import GraphsTooltipsContentCoverage from './GraphsTooltipsContentCoverage';
+import GraphsTooltipsContentDuplication from './GraphsTooltipsContentDuplication';
+import GraphsTooltipsContentOverview from './GraphsTooltipsContentOverview';
+import type { MeasureHistory } from '../types';
+import type { Serie } from '../../../components/charts/AdvancedTimeline';
+
+type Props = {
+  formatValue: (number | string) => string,
+  graph: string,
+  graphWidth: number,
+  measuresHistory: Array<MeasureHistory>,
+  selectedDate: Date,
+  series: Array<Serie & { translatedName: string }>,
+  tooltipIdx: number,
+  tooltipPos: number
+};
+
+const TOOLTIP_WIDTH = 250;
+
+export default class GraphsTooltips extends React.PureComponent {
+  props: Props;
+
+  render() {
+    const { measuresHistory, tooltipIdx } = this.props;
+    const top = 50;
+    let left = this.props.tooltipPos + 60;
+    let customClass;
+    if (left > this.props.graphWidth - TOOLTIP_WIDTH - 50) {
+      left -= TOOLTIP_WIDTH;
+      customClass = 'bubble-popup-right';
+    }
+    return (
+      <BubblePopup customClass={customClass} position={{ top, left, width: TOOLTIP_WIDTH }}>
+        <div className="project-activity-graph-tooltip">
+          <div className="project-activity-graph-tooltip-title spacer-bottom">
+            <FormattedDate date={this.props.selectedDate} format="LL" />
+          </div>
+          <table className="width-100">
+            <tbody>
+              {this.props.series.map(serie => {
+                const point = serie.data[tooltipIdx];
+                if (!point || (!point.y && point.y !== 0)) {
+                  return null;
+                }
+                return this.props.graph === 'overview'
+                  ? <GraphsTooltipsContentOverview
+                      key={serie.name}
+                      measuresHistory={measuresHistory}
+                      serie={serie}
+                      tooltipIdx={tooltipIdx}
+                      value={this.props.formatValue(point.y)}
+                    />
+                  : <GraphsTooltipsContent
+                      key={serie.name}
+                      serie={serie}
+                      value={this.props.formatValue(point.y)}
+                    />;
+              })}
+            </tbody>
+            {this.props.graph === 'coverage' &&
+              <GraphsTooltipsContentCoverage
+                measuresHistory={measuresHistory}
+                tooltipIdx={tooltipIdx}
+              />}
+            {this.props.graph === 'duplications' &&
+              <GraphsTooltipsContentDuplication
+                measuresHistory={measuresHistory}
+                tooltipIdx={tooltipIdx}
+              />}
+          </table>
+        </div>
+      </BubblePopup>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/GraphsTooltipsContent.js b/server/sonar-web/src/main/js/apps/projectActivity/components/GraphsTooltipsContent.js
new file mode 100644 (file)
index 0000000..43eebc4
--- /dev/null
@@ -0,0 +1,48 @@
+/*
+ * 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 classNames from 'classnames';
+import ChartLegendIcon from '../../../components/icons-components/ChartLegendIcon';
+import type { Serie } from '../../../components/charts/AdvancedTimeline';
+
+type Props = {
+  serie: Serie & { translatedName: string },
+  value: string
+};
+
+export default function GraphsTooltipsContent({ serie, value }: Props) {
+  return (
+    <tr key={serie.name} className="project-activity-graph-tooltip-line">
+      <td className="thin">
+        <ChartLegendIcon
+          className={classNames(
+            'spacer-right line-chart-legend',
+            'line-chart-legend-' + serie.style
+          )}
+        />
+      </td>
+      <td className="project-activity-graph-tooltip-value text-right spacer-right thin">
+        {value}
+      </td>
+      <td>{serie.translatedName}</td>
+    </tr>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/GraphsTooltipsContentCoverage.js b/server/sonar-web/src/main/js/apps/projectActivity/components/GraphsTooltipsContentCoverage.js
new file mode 100644 (file)
index 0000000..ca2219e
--- /dev/null
@@ -0,0 +1,62 @@
+/*
+ * 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 { formatMeasure } from '../../../helpers/measures';
+import { translate } from '../../../helpers/l10n';
+import type { MeasureHistory } from '../types';
+
+type Props = {
+  measuresHistory: Array<MeasureHistory>,
+  tooltipIdx: number
+};
+
+export default function GraphsTooltipsContentCoverage({ measuresHistory, tooltipIdx }: Props) {
+  const uncovered = measuresHistory.find(measure => measure.metric === 'uncovered_lines');
+  const coverage = measuresHistory.find(measure => measure.metric === 'coverage');
+  if (!uncovered || !uncovered.history[tooltipIdx] || !coverage || !coverage.history[tooltipIdx]) {
+    return null;
+  }
+  const uncoveredValue = uncovered.history[tooltipIdx].value;
+  const coverageValue = coverage.history[tooltipIdx].value;
+  return (
+    <tbody>
+      <tr><td className="project-activity-graph-tooltip-separator" colSpan="3"><hr /></td></tr>
+      {uncoveredValue &&
+        <tr className="project-activity-graph-tooltip-line">
+          <td
+            colSpan="2"
+            className="project-activity-graph-tooltip-value text-right spacer-right thin">
+            {formatMeasure(uncoveredValue, 'SHORT_INT')}
+          </td>
+          <td>{translate('metric.uncovered_lines.name')}</td>
+        </tr>}
+      {coverageValue &&
+        <tr className="project-activity-graph-tooltip-line">
+          <td
+            colSpan="2"
+            className="project-activity-graph-tooltip-value text-right spacer-right thin">
+            {formatMeasure(coverageValue, 'PERCENT')}
+          </td>
+          <td>{translate('metric.coverage.name')}</td>
+        </tr>}
+    </tbody>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/GraphsTooltipsContentDuplication.js b/server/sonar-web/src/main/js/apps/projectActivity/components/GraphsTooltipsContentDuplication.js
new file mode 100644 (file)
index 0000000..cea4d9d
--- /dev/null
@@ -0,0 +1,55 @@
+/*
+ * 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 { formatMeasure } from '../../../helpers/measures';
+import { translate } from '../../../helpers/l10n';
+import type { MeasureHistory } from '../types';
+
+type Props = {
+  measuresHistory: Array<MeasureHistory>,
+  tooltipIdx: number
+};
+
+export default function GraphsTooltipsContentDuplication({ measuresHistory, tooltipIdx }: Props) {
+  const duplicationDensity = measuresHistory.find(
+    measure => measure.metric === 'duplicated_lines_density'
+  );
+  if (!duplicationDensity || !duplicationDensity.history[tooltipIdx]) {
+    return null;
+  }
+  const duplicationDensityValue = duplicationDensity.history[tooltipIdx].value;
+  if (!duplicationDensityValue) {
+    return null;
+  }
+  return (
+    <tbody>
+      <tr><td className="project-activity-graph-tooltip-separator" colSpan="3"><hr /></td></tr>
+      <tr className="project-activity-graph-tooltip-line">
+        <td
+          colSpan="2"
+          className="project-activity-graph-tooltip-value text-right spacer-right thin">
+          {formatMeasure(duplicationDensityValue, 'PERCENT')}
+        </td>
+        <td>{translate('metric.duplicated_lines_density.name')}</td>
+      </tr>
+    </tbody>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/GraphsTooltipsContentOverview.js b/server/sonar-web/src/main/js/apps/projectActivity/components/GraphsTooltipsContentOverview.js
new file mode 100644 (file)
index 0000000..cd9c643
--- /dev/null
@@ -0,0 +1,66 @@
+/*
+ * 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 classNames from 'classnames';
+import ChartLegendIcon from '../../../components/icons-components/ChartLegendIcon';
+import Rating from '../../../components/ui/Rating';
+import type { Serie } from '../../../components/charts/AdvancedTimeline';
+import type { MeasureHistory } from '../types';
+
+type Props = {
+  measuresHistory: Array<MeasureHistory>,
+  serie: Serie & { translatedName: string },
+  tooltipIdx: number,
+  value: string
+};
+
+const METRIC_RATING = {
+  bugs: 'reliability_rating',
+  vulnerabilities: 'security_rating',
+  code_smells: 'sqale_rating'
+};
+
+export default function GraphsTooltipsContentOverview(props: Props) {
+  const rating = props.measuresHistory.find(
+    measure => measure.metric === METRIC_RATING[props.serie.name]
+  );
+  if (!rating || !rating.history[props.tooltipIdx]) {
+    return null;
+  }
+  const ratingValue = rating.history[props.tooltipIdx].value;
+  return (
+    <tr key={props.serie.name} className="project-activity-graph-tooltip-overview-line">
+      <td className="thin">
+        <ChartLegendIcon
+          className={classNames(
+            'spacer-right line-chart-legend',
+            'line-chart-legend-' + props.serie.style
+          )}
+        />
+      </td>
+      <td className="text-right spacer-right thin">
+        <span className="project-activity-graph-tooltip-value">{props.value}</span>
+        {ratingValue && <Rating className="spacer-left" small={true} value={ratingValue} />}
+      </td>
+      <td>{props.serie.translatedName}</td>
+    </tr>
+  );
+}
index f7d094778eb099eb634e70ce0d0f43bb71d269af..dc7d213f30f38bec959e13c3e133abb7b76bd877 100644 (file)
@@ -24,7 +24,7 @@ import moment from 'moment';
 import ProjectActivityPageHeader from './ProjectActivityPageHeader';
 import ProjectActivityAnalysesList from './ProjectActivityAnalysesList';
 import ProjectActivityGraphs from './ProjectActivityGraphs';
-import { GRAPHS_METRICS, activityQueryChanged } from '../utils';
+import { GRAPHS_METRICS_DISPLAYED, activityQueryChanged } from '../utils';
 import { translate } from '../../../helpers/l10n';
 import './projectActivity.css';
 import type { Analysis, MeasureHistory, Metric, Query } from '../types';
@@ -82,7 +82,7 @@ export default class ProjectActivityApp extends React.PureComponent {
   };
 
   getMetricType = () => {
-    const metricKey = GRAPHS_METRICS[this.props.query.graph][0];
+    const metricKey = GRAPHS_METRICS_DISPLAYED[this.props.query.graph][0];
     const metric = this.props.metrics.find(metric => metric.key === metricKey);
     return metric ? metric.type : 'INT';
   };
index c7e2c4567a86b158b1b859a36abb968afa4b9683..4baa9a82dbaf0314a6c0755ef25401652221b910 100644 (file)
@@ -40,6 +40,7 @@ type Props = {
 };
 
 type State = {
+  selectedDate?: ?Date,
   graphStartDate: ?Date,
   graphEndDate: ?Date,
   series: Array<Serie>
@@ -66,7 +67,6 @@ export default class ProjectActivityGraphs extends React.PureComponent {
         nextProps.query.graph,
         nextProps.metricsType
       );
-
       const newDates = this.getStateZoomDates(this.props, nextProps, series);
       if (newDates) {
         this.setState({ series, ...newDates });
@@ -97,6 +97,8 @@ export default class ProjectActivityGraphs extends React.PureComponent {
     }
   };
 
+  updateSelectedDate = (selectedDate: ?Date) => this.setState({ selectedDate });
+
   updateGraphZoom = (graphStartDate: ?Date, graphEndDate: ?Date) => {
     if (graphEndDate != null && graphStartDate != null) {
       const msDiff = Math.abs(graphEndDate.valueOf() - graphStartDate.valueOf());
@@ -128,15 +130,18 @@ export default class ProjectActivityGraphs extends React.PureComponent {
         <StaticGraphs
           analyses={this.props.analyses}
           eventFilter={query.category}
+          graph={query.graph}
           graphEndDate={this.state.graphEndDate}
           graphStartDate={this.state.graphStartDate}
           leakPeriodDate={leakPeriodDate}
           loading={loading}
+          measuresHistory={this.props.measuresHistory}
           metricsType={metricsType}
           project={this.props.project}
+          selectedDate={this.state.selectedDate}
           series={series}
-          showAreas={['coverage', 'duplications'].includes(query.graph)}
           updateGraphZoom={this.updateGraphZoom}
+          updateSelectedDate={this.updateSelectedDate}
         />
         <GraphsZoom
           graphEndDate={this.state.graphEndDate}
index 863796917687148b2756833eb871622acf90cf5e..fbea7a4d2c85890d183b4aba0a61e85121b8fa26 100644 (file)
@@ -22,30 +22,43 @@ import moment from 'moment';
 import { some, sortBy } from 'lodash';
 import { AutoSizer } from 'react-virtualized';
 import AdvancedTimeline from '../../../components/charts/AdvancedTimeline';
+import GraphsTooltips from './GraphsTooltips';
 import StaticGraphsLegend from './StaticGraphsLegend';
 import { formatMeasure, getShortType } from '../../../helpers/measures';
 import { EVENT_TYPES } from '../utils';
 import { translate } from '../../../helpers/l10n';
-import type { Analysis } from '../types';
+import type { Analysis, MeasureHistory } from '../types';
 import type { Serie } from '../../../components/charts/AdvancedTimeline';
 
 type Props = {
   analyses: Array<Analysis>,
   eventFilter: string,
+  graph: string,
   graphEndDate: ?Date,
   graphStartDate: ?Date,
   leakPeriodDate: Date,
   loading: boolean,
+  measuresHistory: Array<MeasureHistory>,
   metricsType: string,
+  selectedDate?: ?Date => void,
   series: Array<Serie>,
-  showAreas?: boolean,
-  updateGraphZoom: (from: ?Date, to: ?Date) => void
+  updateGraphZoom: (from: ?Date, to: ?Date) => void,
+  updateSelectedDate: (selectedDate: ?Date) => void
+};
+
+type State = {
+  tooltipIdx: ?number,
+  tooltipXPos: ?number
 };
 
 export default class StaticGraphs extends React.PureComponent {
   props: Props;
+  state: State = {
+    tooltipIdx: null,
+    tooltipXPos: null
+  };
 
-  formatYTick = tick => formatMeasure(tick, getShortType(this.props.metricsType));
+  formatValue = tick => formatMeasure(tick, getShortType(this.props.metricsType));
 
   getEvents = () => {
     const { analyses, eventFilter } = this.props;
@@ -73,6 +86,9 @@ export default class StaticGraphs extends React.PureComponent {
 
   hasSeriesData = () => some(this.props.series, serie => serie.data && serie.data.length > 2);
 
+  updateTooltipPos = (tooltipXPos: ?number, tooltipIdx: ?number) =>
+    this.setState({ tooltipXPos, tooltipIdx });
+
   render() {
     const { loading } = this.props;
 
@@ -96,27 +112,44 @@ export default class StaticGraphs extends React.PureComponent {
       );
     }
 
-    const { series } = this.props;
+    const { graph, selectedDate, series } = this.props;
     return (
       <div className="project-activity-graph-container">
         <StaticGraphsLegend series={series} />
         <div className="project-activity-graph">
           <AutoSizer>
             {({ height, width }) => (
-              <AdvancedTimeline
-                endDate={this.props.graphEndDate}
-                events={this.getEvents()}
-                height={height}
-                width={width}
-                interpolate="linear"
-                formatYTick={this.formatYTick}
-                leakPeriodDate={this.props.leakPeriodDate}
-                metricType={this.props.metricsType}
-                series={series}
-                showAreas={this.props.showAreas}
-                startDate={this.props.graphStartDate}
-                updateZoom={this.props.updateGraphZoom}
-              />
+              <div>
+                <AdvancedTimeline
+                  endDate={this.props.graphEndDate}
+                  events={this.getEvents()}
+                  height={height}
+                  width={width}
+                  interpolate="linear"
+                  formatYTick={this.formatValue}
+                  leakPeriodDate={this.props.leakPeriodDate}
+                  metricType={this.props.metricsType}
+                  selectedDate={selectedDate}
+                  series={series}
+                  showAreas={['coverage', 'duplications'].includes(graph)}
+                  startDate={this.props.graphStartDate}
+                  updateSelectedDate={this.props.updateSelectedDate}
+                  updateTooltipPos={this.updateTooltipPos}
+                  updateZoom={this.props.updateGraphZoom}
+                />
+                {selectedDate != null &&
+                  this.state.tooltipXPos != null &&
+                  <GraphsTooltips
+                    formatValue={this.formatValue}
+                    graph={graph}
+                    graphWidth={width}
+                    measuresHistory={this.props.measuresHistory}
+                    selectedDate={selectedDate}
+                    series={series}
+                    tooltipIdx={this.state.tooltipIdx}
+                    tooltipPos={this.state.tooltipXPos}
+                  />}
+              </div>
             )}
           </AutoSizer>
         </div>
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/GraphsTooltips-test.js b/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/GraphsTooltips-test.js
new file mode 100644 (file)
index 0000000..206491e
--- /dev/null
@@ -0,0 +1,98 @@
+/*
+ * 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 GraphsTooltips from '../GraphsTooltips';
+
+const SERIES_OVERVIEW = [
+  {
+    name: 'code_smells',
+    translatedName: 'Code Smells',
+    style: 1,
+    data: [
+      {
+        x: '2011-10-01T22:01:00.000Z',
+        y: 18
+      },
+      {
+        x: '2011-10-25T10:27:41.000Z',
+        y: 15
+      }
+    ]
+  },
+  {
+    name: 'bugs',
+    translatedName: 'Bugs',
+    style: 0,
+    data: [
+      {
+        x: '2011-10-01T22:01:00.000Z',
+        y: 3
+      },
+      {
+        x: '2011-10-25T10:27:41.000Z',
+        y: 0
+      }
+    ]
+  },
+  {
+    name: 'vulnerabilities',
+    translatedName: 'Vulnerabilities',
+    style: 2,
+    data: [
+      {
+        x: '2011-10-01T22:01:00.000Z',
+        y: 0
+      },
+      {
+        x: '2011-10-25T10:27:41.000Z',
+        y: 1
+      }
+    ]
+  }
+];
+
+const DEFAULT_PROPS = {
+  formatValue: val => 'Formated.' + val,
+  graph: 'overview',
+  graphWidth: 500,
+  measuresHistory: [],
+  selectedDate: new Date('2011-10-01T22:01:00.000Z'),
+  series: SERIES_OVERVIEW,
+  tooltipIdx: 0,
+  tooltipPos: 666
+};
+
+it('should render correctly for overview graphs', () => {
+  expect(shallow(<GraphsTooltips {...DEFAULT_PROPS} />)).toMatchSnapshot();
+});
+
+it('should render correctly for random graphs', () => {
+  expect(
+    shallow(
+      <GraphsTooltips
+        {...DEFAULT_PROPS}
+        graph="random"
+        selectedDate={new Date('2011-10-25T10:27:41.000Z')}
+        tooltipIdx={1}
+      />
+    )
+  ).toMatchSnapshot();
+});
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/GraphsTooltipsContent-test.js b/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/GraphsTooltipsContent-test.js
new file mode 100644 (file)
index 0000000..46de44d
--- /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 GraphsTooltipsContent from '../GraphsTooltipsContent';
+
+const DEFAULT_PROPS = {
+  serie: {
+    name: 'code_smells',
+    translatedName: 'Code Smells',
+    style: 1
+  },
+  value: '1.2k'
+};
+
+it('should render correctly', () => {
+  expect(shallow(<GraphsTooltipsContent {...DEFAULT_PROPS} />)).toMatchSnapshot();
+});
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/GraphsTooltipsContentCoverage-test.js b/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/GraphsTooltipsContentCoverage-test.js
new file mode 100644 (file)
index 0000000..8d1893f
--- /dev/null
@@ -0,0 +1,78 @@
+/*
+ * 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 GraphsTooltipsContentCoverage from '../GraphsTooltipsContentCoverage';
+
+const MEASURES_COVERAGE = [
+  {
+    metric: 'coverage',
+    history: [
+      {
+        date: '2011-10-01T22:01:00.000Z'
+      },
+      {
+        date: '2011-10-25T10:27:41.000Z',
+        value: '80.3'
+      }
+    ]
+  },
+  {
+    metric: 'lines_to_cover',
+    history: [
+      {
+        date: '2011-10-01T22:01:00.000Z',
+        value: '60545'
+      },
+      {
+        date: '2011-10-25T10:27:41.000Z',
+        value: '65215'
+      }
+    ]
+  },
+  {
+    metric: 'uncovered_lines',
+    history: [
+      {
+        date: '2011-10-01T22:01:00.000Z',
+        value: '40564'
+      },
+      {
+        date: '2011-10-25T10:27:41.000Z',
+        value: '10245'
+      }
+    ]
+  }
+];
+
+const DEFAULT_PROPS = {
+  measuresHistory: MEASURES_COVERAGE,
+  tooltipIdx: 1
+};
+
+it('should render correctly', () => {
+  expect(shallow(<GraphsTooltipsContentCoverage {...DEFAULT_PROPS} />)).toMatchSnapshot();
+});
+
+it('should render correctly when data is missing', () => {
+  expect(
+    shallow(<GraphsTooltipsContentCoverage {...DEFAULT_PROPS} tooltipIdx={0} />)
+  ).toMatchSnapshot();
+});
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/GraphsTooltipsContentDuplication-test.js b/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/GraphsTooltipsContentDuplication-test.js
new file mode 100644 (file)
index 0000000..13aa885
--- /dev/null
@@ -0,0 +1,52 @@
+/*
+ * 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 GraphsTooltipsContentDuplication from '../GraphsTooltipsContentDuplication';
+
+const MEASURES_DUPLICATION = [
+  {
+    metric: 'duplicated_lines_density',
+    history: [
+      {
+        date: '2011-10-01T22:01:00.000Z'
+      },
+      {
+        date: '2011-10-25T10:27:41.000Z',
+        value: '10245'
+      }
+    ]
+  }
+];
+
+const DEFAULT_PROPS = {
+  measuresHistory: MEASURES_DUPLICATION,
+  tooltipIdx: 1
+};
+
+it('should render correctly', () => {
+  expect(shallow(<GraphsTooltipsContentDuplication {...DEFAULT_PROPS} />)).toMatchSnapshot();
+});
+
+it('should render null when data is missing', () => {
+  expect(
+    shallow(<GraphsTooltipsContentDuplication {...DEFAULT_PROPS} tooltipIdx={0} />)
+  ).toMatchSnapshot();
+});
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/GraphsTooltipsContentOverview-test.js b/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/GraphsTooltipsContentOverview-test.js
new file mode 100644 (file)
index 0000000..cae7b7b
--- /dev/null
@@ -0,0 +1,71 @@
+/*
+ * 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 GraphsTooltipsContentOverview from '../GraphsTooltipsContentOverview';
+
+const MEASURES_OVERVIEW = [
+  {
+    metric: 'bugs',
+    history: [
+      {
+        date: '2011-10-01T22:01:00.000Z',
+        value: '500'
+      },
+      {
+        date: '2011-10-25T10:27:41.000Z',
+        value: '1.2k'
+      }
+    ]
+  },
+  {
+    metric: 'reliability_rating',
+    history: [
+      {
+        date: '2011-10-01T22:01:00.000Z'
+      },
+      {
+        date: '2011-10-25T10:27:41.000Z',
+        value: '5.0'
+      }
+    ]
+  }
+];
+
+const DEFAULT_PROPS = {
+  measuresHistory: MEASURES_OVERVIEW,
+  serie: {
+    name: 'bugs',
+    translatedName: 'Bugs',
+    style: 2
+  },
+  tooltipIdx: 1,
+  value: '1.2k'
+};
+
+it('should render correctly', () => {
+  expect(shallow(<GraphsTooltipsContentOverview {...DEFAULT_PROPS} />)).toMatchSnapshot();
+});
+
+it('should render correctly when rating data is missing', () => {
+  expect(
+    shallow(<GraphsTooltipsContentOverview {...DEFAULT_PROPS} tooltipIdx={0} value="500" />)
+  ).toMatchSnapshot();
+});
index 3b8a4526cf470a936551b3a863a773e26df4134d..dacaff2025fd79a65a1188be9ef3337f02577212 100644 (file)
@@ -81,11 +81,17 @@ const EMPTY_SERIES = [
 const DEFAULT_PROPS = {
   analyses: ANALYSES,
   eventFilter: '',
-  filteredSeries: SERIES,
+  graph: 'overview',
+  graphEndDate: null,
+  graphStartDate: null,
   leakPeriodDate: '2017-05-16T13:50:02+0200',
   loading: false,
+  measuresHistory: [],
+  metricsType: 'INT',
+  selectedDate: null,
   series: SERIES,
-  metricsType: 'INT'
+  updateGraphZoom: () => {},
+  updateSelectedDate: () => {}
 };
 
 it('should show a loading view', () => {
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/__snapshots__/GraphsTooltips-test.js.snap b/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/__snapshots__/GraphsTooltips-test.js.snap
new file mode 100644 (file)
index 0000000..a43f2d4
--- /dev/null
@@ -0,0 +1,191 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly for overview graphs 1`] = `
+<BubblePopup
+  customClass="bubble-popup-right"
+  position={
+    Object {
+      "left": 476,
+      "top": 50,
+      "width": 250,
+    }
+  }
+>
+  <div
+    className="project-activity-graph-tooltip"
+  >
+    <div
+      className="project-activity-graph-tooltip-title spacer-bottom"
+    >
+      <FormattedDate
+        date={2011-10-01T22:01:00.000Z}
+        format="LL"
+      />
+    </div>
+    <table
+      className="width-100"
+    >
+      <tbody>
+        <GraphsTooltipsContentOverview
+          measuresHistory={Array []}
+          serie={
+            Object {
+              "data": Array [
+                Object {
+                  "x": "2011-10-01T22:01:00.000Z",
+                  "y": 18,
+                },
+                Object {
+                  "x": "2011-10-25T10:27:41.000Z",
+                  "y": 15,
+                },
+              ],
+              "name": "code_smells",
+              "style": 1,
+              "translatedName": "Code Smells",
+            }
+          }
+          tooltipIdx={0}
+          value="Formated.18"
+        />
+        <GraphsTooltipsContentOverview
+          measuresHistory={Array []}
+          serie={
+            Object {
+              "data": Array [
+                Object {
+                  "x": "2011-10-01T22:01:00.000Z",
+                  "y": 3,
+                },
+                Object {
+                  "x": "2011-10-25T10:27:41.000Z",
+                  "y": 0,
+                },
+              ],
+              "name": "bugs",
+              "style": 0,
+              "translatedName": "Bugs",
+            }
+          }
+          tooltipIdx={0}
+          value="Formated.3"
+        />
+        <GraphsTooltipsContentOverview
+          measuresHistory={Array []}
+          serie={
+            Object {
+              "data": Array [
+                Object {
+                  "x": "2011-10-01T22:01:00.000Z",
+                  "y": 0,
+                },
+                Object {
+                  "x": "2011-10-25T10:27:41.000Z",
+                  "y": 1,
+                },
+              ],
+              "name": "vulnerabilities",
+              "style": 2,
+              "translatedName": "Vulnerabilities",
+            }
+          }
+          tooltipIdx={0}
+          value="Formated.0"
+        />
+      </tbody>
+    </table>
+  </div>
+</BubblePopup>
+`;
+
+exports[`should render correctly for random graphs 1`] = `
+<BubblePopup
+  customClass="bubble-popup-right"
+  position={
+    Object {
+      "left": 476,
+      "top": 50,
+      "width": 250,
+    }
+  }
+>
+  <div
+    className="project-activity-graph-tooltip"
+  >
+    <div
+      className="project-activity-graph-tooltip-title spacer-bottom"
+    >
+      <FormattedDate
+        date={2011-10-25T10:27:41.000Z}
+        format="LL"
+      />
+    </div>
+    <table
+      className="width-100"
+    >
+      <tbody>
+        <GraphsTooltipsContent
+          serie={
+            Object {
+              "data": Array [
+                Object {
+                  "x": "2011-10-01T22:01:00.000Z",
+                  "y": 18,
+                },
+                Object {
+                  "x": "2011-10-25T10:27:41.000Z",
+                  "y": 15,
+                },
+              ],
+              "name": "code_smells",
+              "style": 1,
+              "translatedName": "Code Smells",
+            }
+          }
+          value="Formated.15"
+        />
+        <GraphsTooltipsContent
+          serie={
+            Object {
+              "data": Array [
+                Object {
+                  "x": "2011-10-01T22:01:00.000Z",
+                  "y": 3,
+                },
+                Object {
+                  "x": "2011-10-25T10:27:41.000Z",
+                  "y": 0,
+                },
+              ],
+              "name": "bugs",
+              "style": 0,
+              "translatedName": "Bugs",
+            }
+          }
+          value="Formated.0"
+        />
+        <GraphsTooltipsContent
+          serie={
+            Object {
+              "data": Array [
+                Object {
+                  "x": "2011-10-01T22:01:00.000Z",
+                  "y": 0,
+                },
+                Object {
+                  "x": "2011-10-25T10:27:41.000Z",
+                  "y": 1,
+                },
+              ],
+              "name": "vulnerabilities",
+              "style": 2,
+              "translatedName": "Vulnerabilities",
+            }
+          }
+          value="Formated.1"
+        />
+      </tbody>
+    </table>
+  </div>
+</BubblePopup>
+`;
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/__snapshots__/GraphsTooltipsContent-test.js.snap b/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/__snapshots__/GraphsTooltipsContent-test.js.snap
new file mode 100644 (file)
index 0000000..353f07c
--- /dev/null
@@ -0,0 +1,23 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<tr
+  className="project-activity-graph-tooltip-line"
+>
+  <td
+    className="thin"
+  >
+    <ChartLegendIcon
+      className="spacer-right line-chart-legend line-chart-legend-1"
+    />
+  </td>
+  <td
+    className="project-activity-graph-tooltip-value text-right spacer-right thin"
+  >
+    1.2k
+  </td>
+  <td>
+    Code Smells
+  </td>
+</tr>
+`;
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/__snapshots__/GraphsTooltipsContentCoverage-test.js.snap b/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/__snapshots__/GraphsTooltipsContentCoverage-test.js.snap
new file mode 100644 (file)
index 0000000..e40f09b
--- /dev/null
@@ -0,0 +1,66 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<tbody>
+  <tr>
+    <td
+      className="project-activity-graph-tooltip-separator"
+      colSpan="3"
+    >
+      <hr />
+    </td>
+  </tr>
+  <tr
+    className="project-activity-graph-tooltip-line"
+  >
+    <td
+      className="project-activity-graph-tooltip-value text-right spacer-right thin"
+      colSpan="2"
+    >
+      10k
+    </td>
+    <td>
+      metric.uncovered_lines.name
+    </td>
+  </tr>
+  <tr
+    className="project-activity-graph-tooltip-line"
+  >
+    <td
+      className="project-activity-graph-tooltip-value text-right spacer-right thin"
+      colSpan="2"
+    >
+      80.3%
+    </td>
+    <td>
+      metric.coverage.name
+    </td>
+  </tr>
+</tbody>
+`;
+
+exports[`should render correctly when data is missing 1`] = `
+<tbody>
+  <tr>
+    <td
+      className="project-activity-graph-tooltip-separator"
+      colSpan="3"
+    >
+      <hr />
+    </td>
+  </tr>
+  <tr
+    className="project-activity-graph-tooltip-line"
+  >
+    <td
+      className="project-activity-graph-tooltip-value text-right spacer-right thin"
+      colSpan="2"
+    >
+      41k
+    </td>
+    <td>
+      metric.uncovered_lines.name
+    </td>
+  </tr>
+</tbody>
+`;
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/__snapshots__/GraphsTooltipsContentDuplication-test.js.snap b/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/__snapshots__/GraphsTooltipsContentDuplication-test.js.snap
new file mode 100644 (file)
index 0000000..bfc1487
--- /dev/null
@@ -0,0 +1,29 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<tbody>
+  <tr>
+    <td
+      className="project-activity-graph-tooltip-separator"
+      colSpan="3"
+    >
+      <hr />
+    </td>
+  </tr>
+  <tr
+    className="project-activity-graph-tooltip-line"
+  >
+    <td
+      className="project-activity-graph-tooltip-value text-right spacer-right thin"
+      colSpan="2"
+    >
+      10,245.0%
+    </td>
+    <td>
+      metric.duplicated_lines_density.name
+    </td>
+  </tr>
+</tbody>
+`;
+
+exports[`should render null when data is missing 1`] = `null`;
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/__snapshots__/GraphsTooltipsContentOverview-test.js.snap b/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/__snapshots__/GraphsTooltipsContentOverview-test.js.snap
new file mode 100644 (file)
index 0000000..71b89ce
--- /dev/null
@@ -0,0 +1,59 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<tr
+  className="project-activity-graph-tooltip-overview-line"
+>
+  <td
+    className="thin"
+  >
+    <ChartLegendIcon
+      className="spacer-right line-chart-legend line-chart-legend-2"
+    />
+  </td>
+  <td
+    className="text-right spacer-right thin"
+  >
+    <span
+      className="project-activity-graph-tooltip-value"
+    >
+      1.2k
+    </span>
+    <Rating
+      className="spacer-left"
+      muted={false}
+      small={true}
+      value="5.0"
+    />
+  </td>
+  <td>
+    Bugs
+  </td>
+</tr>
+`;
+
+exports[`should render correctly when rating data is missing 1`] = `
+<tr
+  className="project-activity-graph-tooltip-overview-line"
+>
+  <td
+    className="thin"
+  >
+    <ChartLegendIcon
+      className="spacer-right line-chart-legend line-chart-legend-2"
+    />
+  </td>
+  <td
+    className="text-right spacer-right thin"
+  >
+    <span
+      className="project-activity-graph-tooltip-value"
+    >
+      500
+    </span>
+  </td>
+  <td>
+    Bugs
+  </td>
+</tr>
+`;
index df18cd6f6c2c24d235041a228a240c9a349b595e..8fabf12a07cb864837d9eaf75af9c25a0d7ae563 100644 (file)
@@ -46,10 +46,32 @@ exports[`should render correctly the graph and legends 1`] = `
       ]
     }
     eventFilter=""
+    graph="overview"
     graphEndDate={2016-10-27T14:33:50.000Z}
     graphStartDate={2016-10-26T10:17:29.000Z}
     leakPeriodDate="2017-05-16T13:50:02+0200"
     loading={false}
+    measuresHistory={
+      Array [
+        Object {
+          "history": Array [
+            Object {
+              "date": 2016-10-26T10:17:29.000Z,
+              "value": "2286",
+            },
+            Object {
+              "date": 2016-10-27T10:21:15.000Z,
+              "value": "1749",
+            },
+            Object {
+              "date": 2016-10-27T14:33:50.000Z,
+              "value": "500",
+            },
+          ],
+          "metric": "code_smells",
+        },
+      ]
+    }
     metricsType="INT"
     project="org.sonarsource.sonarqube:sonarqube"
     series={
@@ -75,8 +97,8 @@ exports[`should render correctly the graph and legends 1`] = `
         },
       ]
     }
-    showAreas={false}
     updateGraphZoom={[Function]}
+    updateSelectedDate={[Function]}
   />
   <GraphsZoom
     graphEndDate={2016-10-27T14:33:50.000Z}
index cb4e58554f6261bebb2ad0d20e7edcb7903833f8..1702b791a096c0c0040ac39a20776da27b7e2d58 100644 (file)
   text-align: center;
 }
 
+.project-activity-graph-tooltip {
+  padding: 8px;
+  pointer-events: none;
+}
+
+.project-activity-graph-tooltip-line {
+  height: 20px;
+  padding-bottom: 4px;
+}
+
+.project-activity-graph-tooltip-overview-line {
+  height: 26px;
+  padding-bottom: 4px;
+}
+
+.project-activity-graph-tooltip-separator {
+  padding-left: 16px;
+  padding-right: 16px;
+}
+
+.project-activity-graph-tooltip-separator hr {
+  margin-top: 8px;
+  margin-bottom: 8px;
+}
+
+.project-activity-graph-tooltip-title, .project-activity-graph-tooltip-value {
+  font-weight: bold;
+}
+
 .project-activity-days-list {}
 
 .project-activity-day {
   padding: 4px;
   border-top: 1px solid #e6e6e6;
   border-bottom: 1px solid #e6e6e6;
+  cursor: pointer;
 }
 
 .project-activity-analysis:hover {
index 7348c9a5bfdaf5f7b4db1d28d8cc4a6f14aed3f0..50b3ab32929acce0b36bcca7b5c1a8acb7e32eea 100644 (file)
@@ -19,7 +19,6 @@
  */
 // @flow
 import moment from 'moment';
-import { sortBy } from 'lodash';
 import {
   cleanQuery,
   parseAsDate,
@@ -34,11 +33,20 @@ import type { Serie } from '../../components/charts/AdvancedTimeline';
 
 export const EVENT_TYPES = ['VERSION', 'QUALITY_GATE', 'QUALITY_PROFILE', 'OTHER'];
 export const GRAPH_TYPES = ['overview', 'coverage', 'duplications'];
-export const GRAPHS_METRICS = {
+export const GRAPHS_METRICS_DISPLAYED = {
   overview: ['bugs', 'code_smells', 'vulnerabilities'],
   coverage: ['uncovered_lines', 'lines_to_cover'],
   duplications: ['duplicated_lines', 'ncloc']
 };
+export const GRAPHS_METRICS = {
+  overview: GRAPHS_METRICS_DISPLAYED['overview'].concat([
+    'reliability_rating',
+    'security_rating',
+    'sqale_rating'
+  ]),
+  coverage: GRAPHS_METRICS_DISPLAYED['coverage'].concat(['coverage']),
+  duplications: GRAPHS_METRICS_DISPLAYED['duplications'].concat(['duplicated_lines_density'])
+};
 
 export const activityQueryChanged = (prevQuery: Query, nextQuery: Query): boolean =>
   prevQuery.category !== nextQuery.category ||
@@ -75,24 +83,26 @@ export const generateSeries = (
   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)
-      }))
-    };
-  });
+  measuresHistory
+    .filter(measure => GRAPHS_METRICS_DISPLAYED[graph].indexOf(measure.metric) >= 0)
+    .map(measure => {
+      if (measure.metric === 'uncovered_lines') {
+        return generateCoveredLinesMetric(
+          measure,
+          measuresHistory,
+          GRAPHS_METRICS_DISPLAYED[graph].indexOf(measure.metric)
+        );
+      }
+      return {
+        name: measure.metric,
+        translatedName: translate('metric', measure.metric, 'name'),
+        style: GRAPHS_METRICS_DISPLAYED[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>
@@ -110,19 +120,12 @@ export const getAnalysesByVersionByDay = (
     if (!currentVersion.byDay[day]) {
       currentVersion.byDay[day] = [];
     }
-    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 });
+    currentVersion.byDay[day].push(analysis);
 
-    const lastEvent = sortedEvents[sortedEvents.length - 1];
-    if (lastEvent && lastEvent.category === 'VERSION') {
-      currentVersion.version = lastEvent.name;
-      currentVersion.key = lastEvent.key;
+    const versionEvent = analysis.events.find(event => event.category === 'VERSION');
+    if (versionEvent && versionEvent.category === 'VERSION') {
+      currentVersion.version = versionEvent.name;
+      currentVersion.key = versionEvent.key;
       acc.push({ version: undefined, key: undefined, byDay: {} });
     }
     return acc;
index 41c909420996a34c7861e165ad70d84a5e6ab7c8..fc2123d1fe430c76c8f27fda3dca1e41056274c2 100644 (file)
@@ -20,8 +20,8 @@
 // @flow
 import React from 'react';
 import classNames from 'classnames';
-import { flatten, sortBy } from 'lodash';
-import { extent, max } from 'd3-array';
+import { throttle, flatten, sortBy } from 'lodash';
+import { bisector, extent, max } from 'd3-array';
 import { scaleLinear, scalePoint, scaleTime } from 'd3-scale';
 import { line as d3Line, area, curveBasis } from 'd3-shape';
 
@@ -42,17 +42,32 @@ type Props = {
   height: number,
   width: number,
   leakPeriodDate?: Date,
+  metricType: string,
   padding: Array<number>,
+  selectedDate?: Date,
   series: Array<Serie>,
   showAreas?: boolean,
   showEventMarkers?: boolean,
   startDate: ?Date,
+  updateSelectedDate?: (selectedDate: ?Date) => void,
+  updateTooltipPos?: (tooltipXPos: ?number, tooltipIdx: ?number) => void,
   updateZoom?: (start: ?Date, endDate: ?Date) => void,
   zoomSpeed: number
 };
 
+type State = {
+  maxXRange: Array<number>,
+  mouseOver?: boolean,
+  mouseOverlayPos?: { [string]: number },
+  selectedDateXPos: ?number,
+  selectedDateIdx: ?number,
+  yScale: Scale,
+  xScale: Scale
+};
+
 export default class AdvancedTimeline extends React.PureComponent {
   props: Props;
+  state: State;
 
   static defaultProps = {
     eventSize: 8,
@@ -60,26 +75,60 @@ export default class AdvancedTimeline extends React.PureComponent {
     zoomSpeed: 1
   };
 
+  constructor(props: Props) {
+    super(props);
+    const scales = this.getScales(props);
+    this.state = { ...scales, ...this.getSelectedDatePos(scales.xScale, props.selectedDate) };
+    this.updateSelectedDate = throttle(this.updateSelectedDate, 40);
+  }
+
+  componentWillReceiveProps(nextProps: Props) {
+    let scales;
+    if (
+      nextProps.metricType !== this.props.metricType ||
+      nextProps.startDate !== this.props.startDate ||
+      nextProps.endDate !== this.props.endDate ||
+      nextProps.width !== this.props.width ||
+      nextProps.padding !== this.props.padding ||
+      nextProps.height !== this.props.height ||
+      nextProps.series !== this.props.series
+    ) {
+      scales = this.getScales(nextProps);
+    }
+
+    if (scales || nextProps.selectedDate !== this.props.selectedDate) {
+      const xScale = scales ? scales.xScale : this.state.xScale;
+      const selectedDatePos = this.getSelectedDatePos(xScale, nextProps.selectedDate);
+      this.setState({ ...scales, ...selectedDatePos });
+      if (nextProps.updateTooltipPos) {
+        nextProps.updateTooltipPos(
+          selectedDatePos.selectedDateXPos,
+          selectedDatePos.selectedDateIdx
+        );
+      }
+    }
+  }
+
   getRatingScale = (availableHeight: number) =>
     scalePoint().domain([5, 4, 3, 2, 1]).range([availableHeight, 0]);
 
   getLevelScale = (availableHeight: number) =>
     scalePoint().domain(['ERROR', 'WARN', 'OK']).range([availableHeight, 0]);
 
-  getYScale = (availableHeight: number, flatData: Array<Point>) => {
-    if (this.props.metricType === 'RATING') {
+  getYScale = (props: Props, availableHeight: number, flatData: Array<Point>) => {
+    if (props.metricType === 'RATING') {
       return this.getRatingScale(availableHeight);
-    } else if (this.props.metricType === 'LEVEL') {
+    } else if (props.metricType === 'LEVEL') {
       return this.getLevelScale(availableHeight);
     } else {
       return scaleLinear().range([availableHeight, 0]).domain([0, max(flatData, d => d.y)]).nice();
     }
   };
 
-  getXScale = (availableWidth: number, flatData: Array<Point>) => {
+  getXScale = (props: Props, availableWidth: number, flatData: Array<Point>) => {
     const dateRange = extent(flatData, d => d.x);
-    const start = this.props.startDate ? this.props.startDate : dateRange[0];
-    const end = this.props.endDate ? this.props.endDate : dateRange[1];
+    const start = props.startDate ? props.startDate : dateRange[0];
+    const end = props.endDate ? props.endDate : dateRange[1];
     const xScale = scaleTime().domain(sortBy([start, end])).range([0, availableWidth]).clamp(false);
     return {
       xScale,
@@ -87,27 +136,55 @@ export default class AdvancedTimeline extends React.PureComponent {
     };
   };
 
-  getScales = () => {
-    const availableWidth = this.props.width - this.props.padding[1] - this.props.padding[3];
-    const availableHeight = this.props.height - this.props.padding[0] - this.props.padding[2];
-    const flatData = flatten(this.props.series.map((serie: Serie) => serie.data));
+  getScales = (props: Props) => {
+    const availableWidth = props.width - props.padding[1] - props.padding[3];
+    const availableHeight = props.height - props.padding[0] - props.padding[2];
+    const flatData = flatten(props.series.map((serie: Serie) => serie.data));
     return {
-      ...this.getXScale(availableWidth, flatData),
-      yScale: this.getYScale(availableHeight, flatData)
+      ...this.getXScale(props, availableWidth, flatData),
+      yScale: this.getYScale(props, availableHeight, flatData)
     };
   };
 
+  getSelectedDatePos = (xScale: Scale, selectedDate: ?Date) => {
+    const firstSerie = this.props.series[0];
+    if (selectedDate && firstSerie) {
+      const idx = firstSerie.data.findIndex(
+        // $FlowFixMe selectedDate can't be null there
+        p => p.x.valueOf() === selectedDate.valueOf()
+      );
+      if (
+        idx >= 0 &&
+        this.props.series.some(serie => serie.data[idx].y || serie.data[idx].y === 0)
+      ) {
+        return {
+          selectedDateXPos: xScale(selectedDate),
+          selectedDateIdx: idx
+        };
+      }
+    }
+    return { selectedDateXPos: null, selectedDateIdx: null };
+  };
+
   getEventMarker = (size: number) => {
     const half = size / 2;
     return `M${half} 0 L${size} ${half} L ${half} ${size} L0 ${half} L${half} 0 L${size} ${half}`;
   };
 
-  handleWheel = (xScale: Scale, maxXRange: Array<number>) => (
-    evt: WheelEvent & { target: HTMLElement }
-  ) => {
+  getMouseOverlayPos = (target: HTMLElement) => {
+    if (this.state.mouseOverlayPos) {
+      return this.state.mouseOverlayPos;
+    }
+    const pos = target.getBoundingClientRect();
+    this.setState({ mouseOverlayPos: pos });
+    return pos;
+  };
+
+  handleWheel = (evt: WheelEvent & { target: HTMLElement }) => {
     evt.preventDefault();
-    const parentBbox = evt.target.getBoundingClientRect();
-    const mouseXPos = (evt.clientX - parentBbox.left) / parentBbox.width;
+    const { maxXRange, xScale } = this.state;
+    const parentBbox = this.getMouseOverlayPos(evt.target);
+    const mouseXPos = (evt.pageX - parentBbox.left) / parentBbox.width;
     const xRange = xScale.range();
     const speed = evt.deltaMode ? 25 / evt.deltaMode * this.props.zoomSpeed : this.props.zoomSpeed;
     const leftPos = xRange[0] - Math.round(speed * evt.deltaY * mouseXPos);
@@ -118,8 +195,50 @@ export default class AdvancedTimeline extends React.PureComponent {
     this.props.updateZoom(startDate, endDate);
   };
 
-  renderHorizontalGrid = (xScale: Scale, yScale: Scale) => {
+  handleMouseMove = (evt: MouseEvent & { target: HTMLElement }) => {
+    const parentBbox = this.getMouseOverlayPos(evt.target);
+    this.updateSelectedDate(evt.pageX - parentBbox.left);
+  };
+
+  handleMouseEnter = () => this.setState({ mouseOver: true });
+
+  handleMouseOut = (evt: Event & { relatedTarget: HTMLElement }) => {
+    const { updateSelectedDate } = this.props;
+    const targetClass = evt.relatedTarget && typeof evt.relatedTarget.className === 'string'
+      ? evt.relatedTarget.className
+      : '';
+    if (
+      !updateSelectedDate ||
+      targetClass.includes('bubble-popup') ||
+      targetClass.includes('graph-tooltip')
+    ) {
+      return;
+    }
+    this.setState({ mouseOver: false });
+    updateSelectedDate(null);
+  };
+
+  updateSelectedDate = (xPos: number) => {
+    const { updateSelectedDate } = this.props;
+    const firstSerie = this.props.series[0];
+    if (this.state.mouseOver && firstSerie && updateSelectedDate) {
+      const date = this.state.xScale.invert(xPos);
+      const bisectX = bisector(d => d.x).right;
+      let idx = bisectX(firstSerie.data, date);
+      if (idx >= 0) {
+        const previousPoint = firstSerie.data[idx - 1];
+        const nextPoint = firstSerie.data[idx];
+        if (!nextPoint || (previousPoint && date - previousPoint.x <= nextPoint.x - date)) {
+          idx--;
+        }
+        updateSelectedDate(firstSerie.data[idx].x);
+      }
+    }
+  };
+
+  renderHorizontalGrid = () => {
     const { formatYTick } = this.props;
+    const { xScale, yScale } = this.state;
     const hasTicks = typeof yScale.ticks === 'function';
     const ticks = hasTicks ? yScale.ticks(4) : yScale.domain();
 
@@ -154,7 +273,8 @@ export default class AdvancedTimeline extends React.PureComponent {
     );
   };
 
-  renderXAxisTicks = (xScale: Scale, yScale: Scale) => {
+  renderXAxisTicks = () => {
+    const { xScale, yScale } = this.state;
     const format = xScale.tickFormat(7);
     const ticks = xScale.ticks(7);
     const y = yScale.range()[0];
@@ -173,16 +293,16 @@ export default class AdvancedTimeline extends React.PureComponent {
     );
   };
 
-  renderLeak = (xScale: Scale, yScale: Scale) => {
-    const yRange = yScale.range();
-    const xRange = xScale.range();
-    const leakWidth = xRange[xRange.length - 1] - xScale(this.props.leakPeriodDate);
+  renderLeak = () => {
+    const yRange = this.state.yScale.range();
+    const xRange = this.state.xScale.range();
+    const leakWidth = xRange[xRange.length - 1] - this.state.xScale(this.props.leakPeriodDate);
     if (leakWidth < 0) {
       return null;
     }
     return (
       <rect
-        x={xScale(this.props.leakPeriodDate)}
+        x={this.state.xScale(this.props.leakPeriodDate)}
         y={yRange[yRange.length - 1]}
         width={leakWidth}
         height={yRange[0] - yRange[yRange.length - 1]}
@@ -191,19 +311,19 @@ export default class AdvancedTimeline extends React.PureComponent {
     );
   };
 
-  renderLines = (xScale: Scale, yScale: Scale) => {
+  renderLines = () => {
     const lineGenerator = d3Line()
       .defined(d => d.y || d.y === 0)
-      .x(d => xScale(d.x))
-      .y(d => yScale(d.y));
+      .x(d => this.state.xScale(d.x))
+      .y(d => this.state.yScale(d.y));
     if (this.props.basisCurve) {
       lineGenerator.curve(curveBasis);
     }
     return (
       <g>
-        {this.props.series.map((serie, idx) => (
+        {this.props.series.map(serie => (
           <path
-            key={`${idx}-${serie.name}`}
+            key={serie.name}
             className={classNames('line-chart-path', 'line-chart-path-' + serie.style)}
             d={lineGenerator(serie.data)}
           />
@@ -212,20 +332,20 @@ export default class AdvancedTimeline extends React.PureComponent {
     );
   };
 
-  renderAreas = (xScale: Scale, yScale: Scale) => {
+  renderAreas = () => {
     const areaGenerator = area()
       .defined(d => d.y || d.y === 0)
-      .x(d => xScale(d.x))
-      .y1(d => yScale(d.y))
-      .y0(yScale(0));
+      .x(d => this.state.xScale(d.x))
+      .y1(d => this.state.yScale(d.y))
+      .y0(this.state.yScale(0));
     if (this.props.basisCurve) {
       areaGenerator.curve(curveBasis);
     }
     return (
       <g>
-        {this.props.series.map((serie, idx) => (
+        {this.props.series.map(serie => (
           <path
-            key={`${idx}-${serie.name}`}
+            key={serie.name}
             className={classNames('line-chart-area', 'line-chart-area-' + serie.style)}
             d={areaGenerator(serie.data)}
           />
@@ -234,11 +354,12 @@ export default class AdvancedTimeline extends React.PureComponent {
     );
   };
 
-  renderEvents = (xScale: Scale, yScale: Scale) => {
+  renderEvents = () => {
     const { events, eventSize } = this.props;
     if (!events || !eventSize) {
       return null;
     }
+    const { xScale, yScale } = this.state;
     const inRangeEvents = events.filter(
       event => event.date >= xScale.domain()[0] && event.date <= xScale.domain()[1]
     );
@@ -257,23 +378,67 @@ export default class AdvancedTimeline extends React.PureComponent {
     );
   };
 
-  renderClipPath = (xScale: Scale, yScale: Scale) => {
+  renderSelectedDate = () => {
+    const { selectedDateIdx, selectedDateXPos, yScale } = this.state;
+    const firstSerie = this.props.series[0];
+    if (selectedDateIdx == null || selectedDateXPos == null || !firstSerie) {
+      return null;
+    }
+
+    return (
+      <g>
+        <line
+          className="line-tooltip"
+          x1={selectedDateXPos}
+          x2={selectedDateXPos}
+          y1={yScale.range()[0]}
+          y2={yScale.range()[1]}
+        />
+        {this.props.series.map(serie => {
+          const point = serie.data[selectedDateIdx];
+          if (!point || (!point.y && point.y !== 0)) {
+            return null;
+          }
+          return (
+            <circle
+              key={serie.name}
+              cx={selectedDateXPos}
+              cy={yScale(point.y)}
+              r="4"
+              className={classNames('line-chart-dot', 'line-chart-dot-' + serie.style)}
+            />
+          );
+        })}
+      </g>
+    );
+  };
+
+  renderClipPath = () => {
     return (
       <defs>
         <clipPath id="chart-clip">
-          <rect width={xScale.range()[1]} height={yScale.range()[0] + 10} />
+          <rect width={this.state.xScale.range()[1]} height={this.state.yScale.range()[0] + 10} />
         </clipPath>
       </defs>
     );
   };
 
-  renderZoomOverlay = (xScale: Scale, yScale: Scale, maxXRange: Array<number>) => {
+  renderMouseEventsOverlay = (zoomEnabled: boolean) => {
+    const mouseEvents = {};
+    if (zoomEnabled) {
+      mouseEvents.onWheel = this.handleWheel;
+    }
+    if (this.props.updateSelectedDate) {
+      mouseEvents.onMouseEnter = this.handleMouseEnter;
+      mouseEvents.onMouseMove = this.handleMouseMove;
+      mouseEvents.onMouseOut = this.handleMouseOut;
+    }
     return (
       <rect
-        className="chart-wheel-zoom-overlay"
-        width={xScale.range()[1]}
-        height={yScale.range()[0]}
-        onWheel={this.handleWheel(xScale, maxXRange)}
+        className="chart-mouse-events-overlay"
+        width={this.state.xScale.range()[1]}
+        height={this.state.yScale.range()[0]}
+        {...mouseEvents}
       />
     );
   };
@@ -282,8 +447,6 @@ export default class AdvancedTimeline extends React.PureComponent {
     if (!this.props.width || !this.props.height) {
       return <div />;
     }
-
-    const { maxXRange, xScale, yScale } = this.getScales();
     const zoomEnabled = !this.props.disableZoom && this.props.updateZoom != null;
     const isZoomed = this.props.startDate || this.props.endDate;
     return (
@@ -291,15 +454,16 @@ export default class AdvancedTimeline extends React.PureComponent {
         className={classNames('line-chart', { 'chart-zoomed': isZoomed })}
         width={this.props.width}
         height={this.props.height}>
-        {zoomEnabled && this.renderClipPath(xScale, yScale)}
+        {zoomEnabled && this.renderClipPath()}
         <g transform={`translate(${this.props.padding[3]}, ${this.props.padding[0]})`}>
-          {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)}
-          {zoomEnabled && this.renderZoomOverlay(xScale, yScale, maxXRange)}
-          {this.props.showEventMarkers && this.renderEvents(xScale, yScale)}
+          {this.props.leakPeriodDate != null && this.renderLeak()}
+          {!this.props.hideGrid && this.renderHorizontalGrid()}
+          {!this.props.hideXAxis && this.renderXAxisTicks()}
+          {this.props.showAreas && this.renderAreas()}
+          {this.renderLines()}
+          {this.props.showEventMarkers && this.renderEvents()}
+          {this.renderSelectedDate()}
+          {this.renderMouseEventsOverlay(zoomEnabled)}
         </g>
       </svg>
     );
index 87904d41041b5f7af01d804897692bb83a523883..eb6a35dcb32bda10b9007899469dc6d65b73bc29 100644 (file)
@@ -24,6 +24,7 @@ import './Rating.css';
 
 export default class Rating extends React.PureComponent {
   static propTypes = {
+    className: React.PropTypes.string,
     value: (props, propName, componentName) => {
       // allow both numbers and strings
       const numberValue = Number(props[propName]);
@@ -42,10 +43,15 @@ export default class Rating extends React.PureComponent {
 
   render() {
     const formatted = formatMeasure(this.props.value, 'RATING');
-    const className = classNames('rating', 'rating-' + formatted, {
-      'rating-small': this.props.small,
-      'rating-muted': this.props.muted
-    });
+    const className = classNames(
+      'rating',
+      'rating-' + formatted,
+      {
+        'rating-small': this.props.small,
+        'rating-muted': this.props.muted
+      },
+      this.props.className
+    );
     return <span className={className}>{formatted}</span>;
   }
 }
index ee39d02449cd620d490dc6c4516ab1cde73a7541..ae9f5cd3770d78ad8e55654ff248641fe43d783c 100644 (file)
 .bubble-popup-bottom-right {
   .bubble-popup-bottom;
   margin-left: 0;
-  margin-right: -8px;
+  margin-right: -@popupArrowSize;
 
   .bubble-popup-arrow {
     left: auto;
     right: 15px;
+    border-right-width: 0;
+    border-left-color: barBorderColor;
+  }
+}
+
+.bubble-popup-right {
+  margin-left: -@popupArrowSize;
+
+  .bubble-popup-arrow {
+    right: -@popupArrowSize;
+    left: auto;
+    border-right-width: 0;
+    border-left-width: @popupArrowSize;
+    border-left-color: @barBorderColor;
+    border-right-color: transparent;
+
+    &:after {
+      left: auto;
+      right: 1px;
+      bottom: -@popupArrowSize;
+      border-right-width: 0;
+      border-left-width: @popupArrowSize;
+      border-left-color: @white;
+      border-right-color: transparent;
+    }
   }
 }
 
index 07e7b5857e33af75fe302baf1dd71d896d6a3399..14f9d2d1e0e01a2e78142960ab89aa4bf5e2bec8 100644 (file)
   &.line-chart-path-2 {
     stroke: @serieColor2;
   }
-
-  &:hover {
-    z-index: 120;
-  }
 }
 
 .line-chart-area {
   }
 }
 
+.line-chart-dot {
+  fill: @defaultSerieColor;
+
+  &.line-chart-dot-1 {
+    fill: @serieColor1;
+  }
+
+  &.line-chart-dot-2 {
+    fill: @serieColor2;
+  }
+}
+
 .line-chart-point {
   fill: #fff;
   stroke: @defaultSerieColor;
   text-anchor: middle;
 }
 
-.chart-wheel-zoom-overlay {
+.chart-mouse-events-overlay {
   fill: none;
   stroke: none;
   pointer-events: all;
     stroke: none;
   }
 }
+
+/*
+ * Charts tooltips
+ */
+
+.line-tooltip {
+  fill: none;
+  stroke: @secondFontColor;
+  stroke-width: 1px;
+  shape-rendering: crispEdges;
+}