]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-9401 Add the overview chart to the project activity page
authorGrégoire Aubert <gregoire.aubert@sonarsource.com>
Tue, 13 Jun 2017 15:41:46 +0000 (17:41 +0200)
committerGrégoire Aubert <gregoire.aubert@sonarsource.com>
Tue, 4 Jul 2017 12:15:34 +0000 (14:15 +0200)
16 files changed:
server/sonar-web/src/main/js/api/time-machine.js
server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysesList.js
server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityApp.js
server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphs.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphsHeader.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityPageHeader.js
server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphs.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphsLegend.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectActivity/components/projectActivity.css
server/sonar-web/src/main/js/apps/projectActivity/types.js
server/sonar-web/src/main/js/apps/projectActivity/utils.js
server/sonar-web/src/main/js/components/charts/AdvancedTimeline.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/common/ResizeHelper.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/icons-components/ChartLegendIcon.js [new file with mode: 0644]
server/sonar-web/src/main/less/components/graphics.less
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 9104ec1d391d8150447ef0dc0d876a117ce375ee..2378a7263027808cb107c958321eaeb8175dcd19 100644 (file)
@@ -38,7 +38,7 @@ type Response = {
 export const getTimeMachineData = (
   component: string,
   metrics: Array<string>,
-  other?: {}
+  other?: { p?: number, ps?: number, from?: string, to?: string }
 ): Promise<Response> =>
   getJSON('/api/measures/search_history', {
     component,
@@ -46,3 +46,39 @@ export const getTimeMachineData = (
     ps: 1000,
     ...other
   });
+
+export const getAllTimeMachineData = (
+  component: string,
+  metrics: Array<string>,
+  other?: { p?: number, ps?: number, from?: string, to?: string },
+  prev?: Response
+): Promise<Response> =>
+  getTimeMachineData(component, metrics, other).then((r: Response) => {
+    const result = prev
+      ? {
+          measures: prev.measures.map((measure, idx) => ({
+            ...measure,
+            history: measure.history.concat(r.measures[idx].history)
+          })),
+          paging: r.paging
+        }
+      : r;
+
+    if (
+      // TODO Remove the sameAsPrevious condition when the webservice paging is working correctly ?
+      // Or keep it to be sure to not have an infinite loop ?
+      result.measures.every((measure, idx) => {
+        const equalToTotal = measure.history.length >= result.paging.total;
+        const sameAsPrevious = prev && measure.history.length === prev.measures[idx].history.length;
+        return equalToTotal || sameAsPrevious;
+      })
+    ) {
+      return result;
+    }
+    return getAllTimeMachineData(
+      component,
+      metrics,
+      { ...other, p: result.paging.pageIndex + 1 },
+      result
+    );
+  });
index 96ab8cb14adde6a36b824e1195f647abbc531ec2..b74b717909016c8f41d1fe6b091cca57517c044d 100644 (file)
@@ -22,9 +22,10 @@ import React from 'react';
 import { groupBy } from 'lodash';
 import moment from 'moment';
 import ProjectActivityAnalysis from './ProjectActivityAnalysis';
+import ProjectActivityPageFooter from './ProjectActivityPageFooter';
 import FormattedDate from '../../../components/ui/FormattedDate';
 import { translate } from '../../../helpers/l10n';
-import type { Analysis } from '../types';
+import type { Analysis, Paging } from '../types';
 
 type Props = {
   addCustomEvent: (analysis: string, name: string, category?: string) => Promise<*>,
@@ -33,49 +34,63 @@ type Props = {
   canAdmin: boolean,
   changeEvent: (event: string, name: string) => Promise<*>,
   deleteAnalysis: (analysis: string) => Promise<*>,
-  deleteEvent: (analysis: string, event: string) => Promise<*>
+  deleteEvent: (analysis: string, event: string) => Promise<*>,
+  fetchMoreActivity: () => void,
+  paging?: Paging
 };
 
 export default function ProjectActivityAnalysesList(props: Props) {
   if (props.analyses.length === 0) {
-    return <div className="note">{translate('no_results')}</div>;
+    return (
+      <div className="layout-page-side-outer project-activity-page-side-outer">
+        <div className="boxed-group boxed-group-inner">
+          <div className="note">{translate('no_results')}</div>
+        </div>
+      </div>
+    );
   }
 
   const firstAnalysis = props.analyses[0];
-
   const byDay = groupBy(props.analyses, analysis => moment(analysis.date).startOf('day').valueOf());
-
   return (
-    <div className="boxed-group boxed-group-inner">
-      <ul className="project-activity-days-list">
-        {Object.keys(byDay).map(day => (
-          <li
-            key={day}
-            className="project-activity-day"
-            data-day={moment(Number(day)).format('YYYY-MM-DD')}>
-            <div className="project-activity-date">
-              <FormattedDate date={Number(day)} format="LL" />
-            </div>
+    <div className="layout-page-side-outer project-activity-page-side-outer">
+      <div className="boxed-group boxed-group-inner">
+        <ul className="project-activity-days-list">
+          {Object.keys(byDay).map(day => (
+            <li
+              key={day}
+              className="project-activity-day"
+              data-day={moment(Number(day)).format('YYYY-MM-DD')}>
+              <div className="project-activity-date">
+                <FormattedDate date={Number(day)} format="LL" />
+              </div>
+
+              <ul className="project-activity-analyses-list">
+                {byDay[day] != null &&
+                  byDay[day].map(analysis => (
+                    <ProjectActivityAnalysis
+                      addCustomEvent={props.addCustomEvent}
+                      addVersion={props.addVersion}
+                      analysis={analysis}
+                      canAdmin={props.canAdmin}
+                      changeEvent={props.changeEvent}
+                      deleteAnalysis={props.deleteAnalysis}
+                      deleteEvent={props.deleteEvent}
+                      isFirst={analysis === firstAnalysis}
+                      key={analysis.key}
+                    />
+                  ))}
+              </ul>
+            </li>
+          ))}
+        </ul>
 
-            <ul className="project-activity-analyses-list">
-              {byDay[day] != null &&
-                byDay[day].map(analysis => (
-                  <ProjectActivityAnalysis
-                    addCustomEvent={props.addCustomEvent}
-                    addVersion={props.addVersion}
-                    analysis={analysis}
-                    canAdmin={props.canAdmin}
-                    changeEvent={props.changeEvent}
-                    deleteAnalysis={props.deleteAnalysis}
-                    deleteEvent={props.deleteEvent}
-                    isFirst={analysis === firstAnalysis}
-                    key={analysis.key}
-                  />
-                ))}
-            </ul>
-          </li>
-        ))}
-      </ul>
+        <ProjectActivityPageFooter
+          analyses={props.analyses}
+          fetchMoreActivity={props.fetchMoreActivity}
+          paging={props.paging}
+        />
+      </div>
     </div>
   );
 }
index 7289766ece96233d9537d398d2fe2dfae7215bda..e23b67da52e7826ff0550c3f03dd93e2ef3dca32 100644 (file)
 // @flow
 import React from 'react';
 import Helmet from 'react-helmet';
+import moment from 'moment';
 import ProjectActivityPageHeader from './ProjectActivityPageHeader';
 import ProjectActivityAnalysesList from './ProjectActivityAnalysesList';
-import ProjectActivityPageFooter from './ProjectActivityPageFooter';
+import ProjectActivityGraphs from './ProjectActivityGraphs';
 import throwGlobalError from '../../../app/utils/throwGlobalError';
 import * as api from '../../../api/projectActivity';
 import * as actions from '../actions';
-import { parseQuery, serializeQuery, serializeUrlQuery } from '../utils';
+import { getAllTimeMachineData } from '../../../api/time-machine';
+import { getMetrics } from '../../../api/metrics';
+import { GRAPHS_METRICS, parseQuery, serializeQuery, serializeUrlQuery } from '../utils';
 import { translate } from '../../../helpers/l10n';
 import './projectActivity.css';
-import type { Analysis, Query, Paging } from '../types';
+import type { Analysis, LeakPeriod, MeasureHistory, Metric, Query, Paging } from '../types';
 import type { RawQuery } from '../../../helpers/query';
 
 type Props = {
@@ -40,7 +43,11 @@ type Props = {
 
 export type State = {
   analyses: Array<Analysis>,
+  leakPeriod?: LeakPeriod,
   loading: boolean,
+  measures: Array<*>,
+  metrics: Array<Metric>,
+  measuresHistory: Array<MeasureHistory>,
   paging?: Paging,
   query: Query
 };
@@ -52,7 +59,14 @@ export default class ProjectActivityApp extends React.PureComponent {
 
   constructor(props: Props) {
     super(props);
-    this.state = { analyses: [], loading: true, query: parseQuery(props.location.query) };
+    this.state = {
+      analyses: [],
+      loading: true,
+      measures: [],
+      measuresHistory: [],
+      metrics: [],
+      query: parseQuery(props.location.query)
+    };
   }
 
   componentDidMount() {
@@ -85,6 +99,21 @@ export default class ProjectActivityApp extends React.PureComponent {
     return api.getProjectActivity(parameters).catch(throwGlobalError);
   };
 
+  fetchMetrics = (): Promise<Array<Metric>> => getMetrics().catch(throwGlobalError);
+
+  fetchMeasuresHistory = (metrics: Array<string>): Promise<Array<MeasureHistory>> =>
+    getAllTimeMachineData(this.props.project.key, metrics)
+      .then(({ measures }) =>
+        measures.map(measure => ({
+          metric: measure.metric,
+          history: measure.history.map(analysis => ({
+            date: moment(analysis.date).toDate(),
+            value: analysis.value
+          }))
+        }))
+      )
+      .catch(throwGlobalError);
+
   fetchMoreActivity = () => {
     const { paging, query } = this.state;
     if (!paging) {
@@ -136,15 +165,29 @@ export default class ProjectActivityApp extends React.PureComponent {
       .then(() => this.mounted && this.setState(actions.deleteAnalysis(analysis)))
       .catch(throwGlobalError);
 
+  getMetricType = () => {
+    const metricKey = GRAPHS_METRICS[this.state.query.graph][0];
+    const metric = this.state.metrics.find(metric => metric.key === metricKey);
+    return metric ? metric.type : 'INT';
+  };
+
   handleQueryChange() {
     const query = parseQuery(this.props.location.query);
+    const graphMetrics = GRAPHS_METRICS[query.graph];
     this.setState({ loading: true, query });
-    this.fetchActivity(query).then(({ analyses, paging }) => {
+
+    Promise.all([
+      this.fetchActivity(query),
+      this.fetchMetrics(),
+      this.fetchMeasuresHistory(graphMetrics)
+    ]).then(response => {
       if (this.mounted) {
         this.setState({
-          analyses,
+          analyses: response[0].analyses,
           loading: false,
-          paging
+          metrics: response[1],
+          measuresHistory: response[2],
+          paging: response[0].paging
         });
       }
     });
@@ -174,21 +217,30 @@ export default class ProjectActivityApp extends React.PureComponent {
 
         <ProjectActivityPageHeader category={query.category} updateQuery={this.updateQuery} />
 
-        <ProjectActivityAnalysesList
-          addCustomEvent={this.addCustomEvent}
-          addVersion={this.addVersion}
-          analyses={this.state.analyses}
-          canAdmin={canAdmin}
-          changeEvent={this.changeEvent}
-          deleteAnalysis={this.deleteAnalysis}
-          deleteEvent={this.deleteEvent}
-        />
-
-        <ProjectActivityPageFooter
-          analyses={this.state.analyses}
-          fetchMoreActivity={this.fetchMoreActivity}
-          paging={this.state.paging}
-        />
+        <div className="layout-page project-activity-page">
+          <ProjectActivityAnalysesList
+            addCustomEvent={this.addCustomEvent}
+            addVersion={this.addVersion}
+            analyses={this.state.analyses}
+            canAdmin={canAdmin}
+            changeEvent={this.changeEvent}
+            deleteAnalysis={this.deleteAnalysis}
+            deleteEvent={this.deleteEvent}
+            fetchMoreActivity={this.fetchMoreActivity}
+            paging={this.state.paging}
+          />
+
+          <ProjectActivityGraphs
+            analyses={this.state.analyses}
+            leakPeriod={this.state.leakPeriod}
+            loading={this.state.loading}
+            measuresHistory={this.state.measuresHistory}
+            metricsType={this.getMetricType()}
+            project={this.props.project.key}
+            query={query}
+            updateQuery={this.updateQuery}
+          />
+        </div>
       </div>
     );
   }
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphs.js b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphs.js
new file mode 100644 (file)
index 0000000..167567a
--- /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.
+ */
+// @flow
+import React from 'react';
+import ProjectActivityGraphsHeader from './ProjectActivityGraphsHeader';
+import StaticGraphs from './StaticGraphs';
+import type { RawQuery } from '../../../helpers/query';
+import type { Analysis, MeasureHistory, Query } from '../types';
+
+type Props = {
+  analyses: Array<Analysis>,
+  loading: boolean,
+  measuresHistory: Array<MeasureHistory>,
+  metricsType: string,
+  project: string,
+  query: Query,
+  updateQuery: RawQuery => void
+};
+
+export default function ProjectActivityGraphs(props: Props) {
+  return (
+    <div className="project-activity-layout-page-main">
+      <div className="project-activity-layout-page-main-inner boxed-group boxed-group-inner">
+        <ProjectActivityGraphsHeader graph={props.query.graph} updateQuery={props.updateQuery} />
+        <StaticGraphs
+          analyses={props.analyses}
+          loading={props.loading}
+          measuresHistory={props.measuresHistory}
+          metricsType={props.metricsType}
+          project={props.project}
+        />
+      </div>
+    </div>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphsHeader.js b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphsHeader.js
new file mode 100644 (file)
index 0000000..33ee4ff
--- /dev/null
@@ -0,0 +1,60 @@
+/*
+ * 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 Select from 'react-select';
+import { GRAPH_TYPES } from '../utils';
+import { translate } from '../../../helpers/l10n';
+import type { RawQuery } from '../../../helpers/query';
+
+type Props = {
+  updateQuery: RawQuery => void,
+  graph: string
+};
+
+export default class ProjectActivityGraphsHeader extends React.PureComponent {
+  props: Props;
+
+  handleGraphChange = (option: { value: string }) => {
+    if (option.value !== this.props.graph) {
+      this.props.updateQuery({ graph: option.value });
+    }
+  };
+
+  render() {
+    const selectOptions = GRAPH_TYPES.map(graph => ({
+      label: translate('project_activity.graphs', graph),
+      value: graph
+    }));
+
+    return (
+      <header className="page-header">
+        <Select
+          className="input-medium"
+          clearable={false}
+          searchable={false}
+          value={this.props.graph}
+          options={selectOptions}
+          onChange={this.handleGraphChange}
+        />
+      </header>
+    );
+  }
+}
index 8dd16cb5045cb874911dd8cc11ee311d0ccac30f..29e3e16925f0fd8e423e998ac6d0af2996af103e 100644 (file)
@@ -43,21 +43,15 @@ export default class ProjectActivityPageHeader extends React.PureComponent {
 
     return (
       <header className="page-header">
-        <div className="page-actions">
-          <Select
-            className="input-medium"
-            placeholder={translate('filter_verb') + '...'}
-            clearable={true}
-            searchable={false}
-            value={this.props.category}
-            options={selectOptions}
-            onChange={this.handleCategoryChange}
-          />
-        </div>
-
-        <div className="page-description">
-          {translate('project_activity.page.description')}
-        </div>
+        <Select
+          className="input-medium"
+          placeholder={translate('project_activity.filter_events') + '...'}
+          clearable={true}
+          searchable={false}
+          value={this.props.category}
+          options={selectOptions}
+          onChange={this.handleCategoryChange}
+        />
       </header>
     );
   }
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphs.js b/server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphs.js
new file mode 100644 (file)
index 0000000..1207d20
--- /dev/null
@@ -0,0 +1,113 @@
+/*
+ * 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 moment from 'moment';
+import { some, sortBy } from 'lodash';
+import AdvancedTimeline from '../../../components/charts/AdvancedTimeline';
+import StaticGraphsLegend from './StaticGraphsLegend';
+import ResizeHelper from '../../../components/common/ResizeHelper';
+import { formatMeasure, getShortType } from '../../../helpers/measures';
+import { translate } from '../../../helpers/l10n';
+import type { Analysis, MeasureHistory } from '../types';
+
+type Props = {
+  analyses: Array<Analysis>,
+  loading: boolean,
+  measuresHistory: Array<MeasureHistory>,
+  metricsType: string
+};
+
+export default class StaticGraphs extends React.PureComponent {
+  props: Props;
+
+  getEvents = () => {
+    const events = this.props.analyses.reduce((acc, analysis) => {
+      return acc.concat(
+        analysis.events.map(event => ({
+          className: event.category,
+          name: event.name,
+          date: moment(analysis.date).toDate()
+        }))
+      );
+    }, []);
+    return sortBy(events, 'date');
+  };
+
+  getSeries = () =>
+    sortBy(this.props.measuresHistory, 'metric').map(measure => ({
+      name: measure.metric,
+      data: measure.history.map(analysis => ({
+        x: analysis.date,
+        y: this.props.metricsType === 'LEVEL' ? analysis.value : Number(analysis.value)
+      }))
+    }));
+
+  hasHistoryData = () =>
+    some(this.props.measuresHistory, measure => measure.history && measure.history.length > 2);
+
+  render() {
+    const { loading } = this.props;
+
+    if (loading) {
+      return (
+        <div className="project-activity-graph-container">
+          <div className="text-center">
+            <i className="spinner" />
+          </div>
+        </div>
+      );
+    }
+
+    if (!this.hasHistoryData()) {
+      return (
+        <div className="project-activity-graph-container">
+          <div className="note text-center">
+            {translate('component_measures.no_history')}
+          </div>
+        </div>
+      );
+    }
+
+    const { metricsType } = this.props;
+    const formatValue = value => formatMeasure(value, metricsType);
+    const formatYTick = tick => formatMeasure(tick, getShortType(metricsType));
+    const series = this.getSeries();
+    return (
+      <div className="project-activity-graph-container">
+        <StaticGraphsLegend series={series} />
+        <div className="project-activity-graph">
+          <ResizeHelper>
+            <AdvancedTimeline
+              basisCurve={false}
+              series={series}
+              metricType={metricsType}
+              events={this.getEvents()}
+              interpolate="linear"
+              formatValue={formatValue}
+              formatYTick={formatYTick}
+              leakPeriodDate={this.props.leakPeriodDate}
+              padding={[25, 25, 30, 60]}
+            />
+          </ResizeHelper>
+        </div>
+      </div>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphsLegend.js b/server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphsLegend.js
new file mode 100644 (file)
index 0000000..9f76e8c
--- /dev/null
@@ -0,0 +1,42 @@
+/*
+ * 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 classNames from 'classnames';
+import ChartLegendIcon from '../../../components/icons-components/ChartLegendIcon';
+import { translate } from '../../../helpers/l10n';
+
+type Props = {
+  series: Array<{ name: string }>
+};
+
+export default function StaticGraphsLegend({ series }: Props) {
+  return (
+    <div className="project-activity-graph-legends">
+      {series.map((serie, idx) => (
+        <span className="big-spacer-left big-spacer-right" key={serie.name}>
+          <ChartLegendIcon
+            className={classNames('spacer-right line-chart-legend', 'line-chart-legend-' + idx)}
+          />
+          {translate('metric', serie.name, 'name')}
+        </span>
+      ))}
+    </div>
+  );
+}
index 2b69f15e80382840b671a7b9c18b6986eb28a002..a9dc2009ddbdb26c1f2df51e59a892c86b045098 100644 (file)
@@ -1,3 +1,58 @@
+.project-activity-page {
+  min-height: 600px;
+  height: calc(100vh - 250px);
+}
+
+.project-activity-page-side-outer {
+  width: 400px;
+  overflow: auto;
+}
+
+.project-activity-page-side-outer .boxed-group {
+  margin-bottom: 0;
+}
+
+.project-activity-layout-page-main {
+  flex-grow: 1;
+  min-width: 640px;
+  padding-left: 20px;
+  display: flex;
+}
+
+.project-activity-layout-page-main-inner {
+  min-width: 640px;
+  max-width: 880px;
+  margin-bottom: 0px;
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: stretch;
+}
+
+.project-activity-list {
+  max-width: 400px;
+}
+
+.project-activity-graph-container {
+  padding: 10px 0;
+  flex-grow: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: stretch;
+  justify-content: center;
+}
+
+.project-activity-graph {
+  flex: 1;
+  max-height: 500px;
+}
+
+.project-activity-graph-legends {
+  flex-grow: 0;
+  padding-bottom: 16px;
+  text-align: center;
+}
+
 .project-activity-days-list {}
 
 .project-activity-day {
index b3d8211dfc8e8af1e029dbb2ddd1dded19d3b98e..51cc48cbea420e4698e5bfcb6d332dae6459a5cd 100644 (file)
@@ -32,6 +32,23 @@ export type Analysis = {
   events: Array<Event>
 };
 
+export type LeakPeriod = {
+  date: string,
+  index: number,
+  mode: string,
+  parameter: string
+};
+
+export type HistoryItem = { date: Date, value: string };
+
+export type MeasureHistory = { metric: string, history: Array<HistoryItem> };
+
+export type Metric = {
+  key: string,
+  name: string,
+  type: string
+};
+
 export type Paging = {
   pageIndex: number,
   pageSize: number,
@@ -39,6 +56,7 @@ export type Paging = {
 };
 
 export type Query = {
-  project: string,
-  category: string
+  category: string,
+  graph: string,
+  project: string
 };
index be1646db1372b8a4404167711009cd222abdfd06..be198ce067d302f8d867ddd75d5791adeefb24db 100644 (file)
@@ -22,19 +22,26 @@ import { cleanQuery, parseAsString, serializeString } from '../../helpers/query'
 import type { Query } from './types';
 import type { RawQuery } from '../../helpers/query';
 
+export const GRAPH_TYPES = ['overview'];
+export const GRAPHS_METRICS = { overview: ['bugs', 'vulnerabilities', 'code_smells'] };
+
 export const parseQuery = (urlQuery: RawQuery): Query => ({
-  project: parseAsString(urlQuery['id']),
-  category: parseAsString(urlQuery['category'])
+  category: parseAsString(urlQuery['category']),
+  graph: parseAsString(urlQuery['graph']) || 'overview',
+  project: parseAsString(urlQuery['id'])
 });
 
-export const serializeQuery = (query: Query): Query =>
+export const serializeQuery = (query: Query): RawQuery =>
   cleanQuery({
-    project: serializeString(query.project),
-    category: serializeString(query.category)
+    category: serializeString(query.category),
+    project: serializeString(query.project)
   });
 
-export const serializeUrlQuery = (query: Query): RawQuery =>
-  cleanQuery({
-    id: serializeString(query.project),
-    category: serializeString(query.category)
+export const serializeUrlQuery = (query: Query): RawQuery => {
+  const graph = query.graph === 'overview' ? '' : query.graph;
+  return cleanQuery({
+    category: serializeString(query.category),
+    graph: serializeString(graph),
+    id: serializeString(query.project)
   });
+};
diff --git a/server/sonar-web/src/main/js/components/charts/AdvancedTimeline.js b/server/sonar-web/src/main/js/components/charts/AdvancedTimeline.js
new file mode 100644 (file)
index 0000000..a7cfa6b
--- /dev/null
@@ -0,0 +1,217 @@
+/*
+ * 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 { flatten } from 'lodash';
+import { extent, max } from 'd3-array';
+import { scaleLinear, scalePoint, scaleTime } from 'd3-scale';
+import { line as d3Line, curveBasis } from 'd3-shape';
+
+type Point = { x: Date, y: number | string };
+
+type Serie = { name: string, data: Array<Point> };
+
+type Event = { className?: string, name: string, date: Date };
+
+type Scale = Function;
+
+type Props = {
+  basisCurve?: boolean,
+  events?: Array<Event>,
+  eventSize?: number,
+  formatYTick: number => string,
+  formatValue: number => string,
+  height: number,
+  width: number,
+  leakPeriodDate: Date,
+  padding: Array<number>,
+  series: Array<Serie>
+};
+
+export default class AdvancedTimeline extends React.PureComponent {
+  props: Props;
+
+  static defaultProps = {
+    eventSize: 8,
+    padding: [10, 10, 10, 10]
+  };
+
+  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') {
+      return this.getRatingScale(availableHeight);
+    } else if (this.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>) =>
+    scaleTime().domain(extent(flatData, d => d.x)).range([0, availableWidth]).clamp(true);
+
+  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));
+    return {
+      xScale: this.getXScale(availableWidth, flatData),
+      yScale: this.getYScale(availableHeight, flatData)
+    };
+  };
+
+  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}`;
+  };
+
+  renderHorizontalGrid = (xScale: Scale, yScale: Scale) => {
+    const hasTicks = typeof yScale.ticks === 'function';
+    const ticks = hasTicks ? yScale.ticks(4) : yScale.domain();
+
+    if (!ticks.length) {
+      ticks.push(yScale.domain()[1]);
+    }
+
+    return (
+      <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>
+            <line
+              className="line-chart-grid"
+              x1={xScale.range()[0]}
+              x2={xScale.range()[1]}
+              y1={yScale(tick)}
+              y2={yScale(tick)}
+            />
+          </g>
+        ))}
+      </g>
+    );
+  };
+
+  renderTicks = (xScale: Scale, yScale: Scale) => {
+    const format = xScale.tickFormat(7);
+    const ticks = xScale.ticks(7);
+    const y = yScale.range()[0];
+    return (
+      <g>
+        {ticks.slice(0, -1).map((tick, index) => {
+          const nextTick = index + 1 < ticks.length ? ticks[index + 1] : xScale.domain()[1];
+          const x = (xScale(tick) + xScale(nextTick)) / 2;
+          return (
+            <text key={index} className="line-chart-tick" x={x} y={y} dy="2em">
+              {format(tick)}
+            </text>
+          );
+        })}
+      </g>
+    );
+  };
+
+  renderLeak = (xScale: Scale, yScale: Scale) => {
+    if (!this.props.leakPeriodDate) {
+      return null;
+    }
+    const yScaleRange = yScale.range();
+    return (
+      <rect
+        x={xScale(this.props.leakPeriodDate)}
+        y={yScaleRange[yScaleRange.length - 1]}
+        width={xScale.range()[1] - xScale(this.props.leakPeriodDate)}
+        height={yScaleRange[0] - yScaleRange[yScaleRange.length - 1]}
+        fill="#fbf3d5"
+      />
+    );
+  };
+
+  renderLines = (xScale: Scale, yScale: Scale) => {
+    const line = d3Line().x(d => xScale(d.x)).y(d => yScale(d.y));
+    if (this.props.basisCurve) {
+      line.curve(curveBasis);
+    }
+    return (
+      <g>
+        {this.props.series.map((serie, idx) => (
+          <path
+            key={`${idx}-${serie.name}`}
+            className={classNames('line-chart-path', 'line-chart-path-' + idx)}
+            d={line(serie.data)}
+          />
+        ))}
+      </g>
+    );
+  };
+
+  renderEvents = (xScale: Scale, yScale: Scale) => {
+    const { events, eventSize } = this.props;
+    if (!events || !eventSize) {
+      return null;
+    }
+
+    const offset = eventSize / 2;
+    return (
+      <g>
+        {events.map((event, idx) => (
+          <path
+            d={this.getEventMarker(eventSize)}
+            className={classNames('line-chart-event', event.className)}
+            key={`${idx}-${event.date.getTime()}`}
+            transform={`translate(${xScale(event.date) - offset}, ${yScale.range()[0] - offset})`}
+          />
+        ))}
+      </g>
+    );
+  };
+
+  render() {
+    if (!this.props.width || !this.props.height) {
+      return <div />;
+    }
+
+    const { xScale, yScale } = this.getScales();
+    return (
+      <svg className="line-chart" width={this.props.width} height={this.props.height}>
+        <g transform={`translate(${this.props.padding[3]}, ${this.props.padding[0]})`}>
+          {this.renderLeak(xScale, yScale)}
+          {this.renderHorizontalGrid(xScale, yScale)}
+          {this.renderTicks(xScale, yScale)}
+          {this.renderLines(xScale, yScale)}
+          {this.renderEvents(xScale, yScale)}
+        </g>
+      </svg>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/components/common/ResizeHelper.js b/server/sonar-web/src/main/js/components/common/ResizeHelper.js
new file mode 100644 (file)
index 0000000..ec51c1c
--- /dev/null
@@ -0,0 +1,75 @@
+/*
+ * 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 ReactDOM from 'react-dom';
+
+type Props = {
+  children: React.Element<*>,
+  height?: number,
+  width?: number
+};
+
+type State = {
+  height?: number,
+  width?: number
+};
+
+export default class ResizeHelper extends React.PureComponent {
+  props: Props;
+  state: State;
+
+  constructor(props: Props) {
+    super(props);
+    this.state = { height: props.height, width: props.width };
+  }
+
+  componentDidMount() {
+    if (this.isResizable()) {
+      this.handleResize();
+      window.addEventListener('resize', this.handleResize);
+    }
+  }
+
+  componentWillUnmount() {
+    if (this.isResizable()) {
+      window.removeEventListener('resize', this.handleResize);
+    }
+  }
+
+  isResizable = () => {
+    return !this.props.width || !this.props.height;
+  };
+
+  handleResize = () => {
+    const domNode = ReactDOM.findDOMNode(this);
+    if (domNode && domNode.parentElement) {
+      const boundingClientRect = domNode.parentElement.getBoundingClientRect();
+      this.setState({ width: boundingClientRect.width, height: boundingClientRect.height });
+    }
+  };
+
+  render() {
+    return React.cloneElement(this.props.children, {
+      width: this.props.width || this.state.width,
+      height: this.props.height || this.state.height
+    });
+  }
+}
diff --git a/server/sonar-web/src/main/js/components/icons-components/ChartLegendIcon.js b/server/sonar-web/src/main/js/components/icons-components/ChartLegendIcon.js
new file mode 100644 (file)
index 0000000..7660213
--- /dev/null
@@ -0,0 +1,40 @@
+/*
+ * 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';
+
+type Props = { className?: string, size?: number };
+
+export default function ChartLegendIcon({ className, size = 16 }: Props) {
+  /* eslint-disable max-len */
+  return (
+    <svg
+      className={className}
+      xmlns="http://www.w3.org/2000/svg"
+      viewBox="0 0 16 16"
+      width={size}
+      height={size}>
+      <path
+        style={{ fill: 'currentColor' }}
+        d="M14.325 7.143v1.714q0 0.357-0.25 0.607t-0.607 0.25h-10.857q-0.357 0-0.607-0.25t-0.25-0.607v-1.714q0-0.357 0.25-0.607t0.607-0.25h10.857q0.357 0 0.607 0.25t0.25 0.607z"
+      />
+    </svg>
+  );
+}
index 911c8d90738366062a8b50d36cbd0309b11e5cbe..3c411450c52872590d0cdec408e2de8ec8d87c68 100644 (file)
  * Line Chart
  */
 
+@defaultSerieColor: @darkBlue;
+@serieColor1: @blue;
+@serieColor2: #26adff;
+
 .line-chart {
 }
 
 .line-chart-path {
   fill: none;
-  stroke: @blue;
+  stroke: @defaultSerieColor;
   stroke-width: 2px;
+
+  &.line-chart-path-1 {
+    stroke: @serieColor1
+  }
+
+  &.line-chart-path-2 {
+    stroke: @serieColor2;
+  }
+}
+
+.line-chart-legend {
+  color: @defaultSerieColor;
+
+  &.line-chart-legend-1 {
+    color: @serieColor1;
+  }
+
+  &.line-chart-legend-2 {
+    color: @serieColor2;
+  }
 }
 
 .line-chart-point {
   fill: #fff;
-  stroke: @darkBlue;
+  stroke: @defaultSerieColor;
   stroke-width: 2px;
 }
 
+.line-chart-event {
+  fill: #fff;
+  stroke: @defaultSerieColor;
+  stroke-width: 2px;
+
+  &.VERSION {
+    stroke: @green;
+  }
+
+  &.QUALITY_GATE {
+    stroke: @blue;
+  }
+
+  &.QUALITY_PROFILE {
+    stroke: @orange;
+  }
+
+  &.OTHER {
+    stroke: @purple;
+  }
+}
+
 .line-chart-backdrop {
 }
 
index f2f47860d44e636d8363f304b53c29ba6a3c90e8..675ef106a6dce85f88bc188a33e3c33b55108684 100644 (file)
@@ -591,7 +591,6 @@ comparison.page=Compare
 view_projects.page=Projects
 portfolios.page=Portfolios
 project_activity.page=Activity
-project_activity.page.description=The page shows the history of project analyses.
 
 
 #------------------------------------------------------------------------------
@@ -1271,8 +1270,8 @@ manual_rules.add_manual_rule=Add Manual Rule
 #
 #------------------------------------------------------------------------------
 
-project_activity.project_analyzed=Project Analyzed
 project_activity.add_version=Create Version
+project_activity.project_analyzed=Project Analyzed
 project_activity.remove_version=Remove Version
 project_activity.remove_version.question=Are you sure you want to delete this version?
 project_activity.change_version=Change Version
@@ -1282,6 +1281,9 @@ project_activity.remove_custom_event=Delete Event
 project_activity.remove_custom_event.question=Are you sure you want to delete this event?
 project_activity.delete_analysis=Delete Analysis
 project_activity.delete_analysis.question=Are you sure you want to delete this analysis from the project history?
+project_activity.filter_events=Filter events
+
+project_activity.graphs.overview=Overview
 
 project_history.col.year=Year
 project_history.col.month=Month