]> source.dussan.org Git - sonarqube.git/commitdiff
MMF-721 Visualizations on the Projects page (#1826)
authorStas Vilchik <stas-vilchik@users.noreply.github.com>
Thu, 23 Mar 2017 16:21:20 +0000 (17:21 +0100)
committerGitHub <noreply@github.com>
Thu, 23 Mar 2017 16:21:20 +0000 (17:21 +0100)
36 files changed:
server/sonar-web/.eslintrc
server/sonar-web/src/main/js/apps/component-measures/components/bubbleChart/BubbleChart.js
server/sonar-web/src/main/js/apps/component-measures/styles.css
server/sonar-web/src/main/js/apps/projects/components/AllProjects.js
server/sonar-web/src/main/js/apps/projects/components/AllProjectsContainer.js
server/sonar-web/src/main/js/apps/projects/components/FavoriteProjectsContainer.js
server/sonar-web/src/main/js/apps/projects/components/PageHeader.js
server/sonar-web/src/main/js/apps/projects/components/PageSidebar.js
server/sonar-web/src/main/js/apps/projects/components/ProjectCard.js
server/sonar-web/src/main/js/apps/projects/components/ViewSelect.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projects/components/__tests__/PageSidebar-test.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projects/components/__tests__/ViewSelect-test.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/PageSidebar-test.js.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/ViewSelect-test.js.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projects/store/actions.js
server/sonar-web/src/main/js/apps/projects/store/utils.js
server/sonar-web/src/main/js/apps/projects/styles.css
server/sonar-web/src/main/js/apps/projects/utils.js
server/sonar-web/src/main/js/apps/projects/visualizations/Bugs.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projects/visualizations/CodeSmells.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projects/visualizations/DuplicatedBlocks.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projects/visualizations/QualityModel.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projects/visualizations/SimpleBubbleChart.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projects/visualizations/UncoveredLines.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projects/visualizations/Visualizations.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projects/visualizations/VisualizationsContainer.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projects/visualizations/VisualizationsHeader.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projects/visualizations/Vulnerabilities.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/charts/BubbleChart.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/charts/__tests__/BubbleChart-test.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/charts/__tests__/__snapshots__/BubbleChart-test.js.snap [new file with mode: 0644]
server/sonar-web/src/main/js/components/charts/__tests__/bubble-chart-test.js [deleted file]
server/sonar-web/src/main/js/components/charts/bubble-chart.js [deleted file]
server/sonar-web/src/main/js/helpers/constants.js
server/sonar-web/src/main/less/components/react-select.less
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 63d587b6d24691f03da0133c735117a00572e2b8..04c8dcddb7a563148e1089d78eb90457c3d5a5b9 100644 (file)
     "jsx-a11y/tabindex-no-positive": 2,
 
     "react/jsx-boolean-value": [2, "always"],
-    "react/jsx-closing-bracket-location": 2,
     "react/jsx-curly-spacing": [2, "never"],
     "react/jsx-equals-spacing": [2, "never"],
     "react/jsx-key": 2,
index edf1b0def09cd6a3ac8f29ff4a829adc4e1895c5..dee878337fc5c45467676dc5ed9b44719d9c4ff2 100644 (file)
@@ -19,7 +19,7 @@
  */
 import React from 'react';
 import Spinner from './../Spinner';
-import { BubbleChart as OriginalBubbleChart } from '../../../../components/charts/bubble-chart';
+import OriginalBubbleChart from '../../../../components/charts/BubbleChart';
 import bubbles from '../../config/bubbles';
 import { getComponentLeaves } from '../../../../api/components';
 import { formatMeasure } from '../../../../helpers/measures';
index 5020b9b63acb6e51a2d4d78fe2e9d8cc0e233623..4263ca5d406695c7d9e8fc6c8c83a20772479a6a 100644 (file)
 .measure-details-bubble-chart-axis.x {
   left: 50%;
   bottom: 10px;
+  width: 500px;
+  margin-left: -250px;
+  text-align: center;
 }
 
 .measure-details-bubble-chart-axis.y {
 .measure-details-bubble-chart-axis.size {
   left: 50%;
   top: 10px;
+  width: 500px;
+  margin-left: -250px;
+  text-align: center;
 }
 
 .measure-details-treemap {
index 77061055d285fb51bbec9733f604c969d86fbbb0..96b4b5a4943054dd09478da3692044cfd5bdf175 100644 (file)
@@ -22,13 +22,17 @@ import PageHeaderContainer from './PageHeaderContainer';
 import ProjectsListContainer from './ProjectsListContainer';
 import ProjectsListFooterContainer from './ProjectsListFooterContainer';
 import PageSidebar from './PageSidebar';
+import VisualizationsContainer from '../visualizations/VisualizationsContainer';
 import { parseUrlQuery } from '../store/utils';
+import { getProjectUrl } from '../../../helpers/urls';
 
 export default class AllProjects extends React.Component {
   static propTypes = {
     isFavorite: React.PropTypes.bool.isRequired,
+    location: React.PropTypes.object.isRequired,
     fetchProjects: React.PropTypes.func.isRequired,
-    organization: React.PropTypes.object
+    organization: React.PropTypes.object,
+    router: React.PropTypes.object.isRequired
   };
 
   state = {
@@ -56,8 +60,41 @@ export default class AllProjects extends React.Component {
     this.props.fetchProjects(query, this.props.isFavorite, this.props.organization);
   }
 
+  handleViewChange = view => {
+    const query = {
+      ...this.props.location.query,
+      view: view === 'list' ? undefined : view
+    };
+    if (query.view !== 'visualizations') {
+      Object.assign(query, { visualization: undefined });
+    }
+    this.props.router.push({
+      pathname: this.props.location.pathname,
+      query
+    });
+  };
+
+  handleVisualizationChange = visualization => {
+    this.props.router.push({
+      pathname: this.props.location.pathname,
+      query: {
+        ...this.props.location.query,
+        view: 'visualizations',
+        visualization
+      }
+    });
+  };
+
+  handleProjectOpen = projectKey => {
+    this.props.router.push(getProjectUrl(projectKey));
+  };
+
   render() {
-    const isFiltered = Object.keys(this.state.query).some(key => this.state.query[key] != null);
+    const { query } = this.state;
+    const isFiltered = Object.keys(query).some(key => query[key] != null);
+
+    const view = query.view || 'list';
+    const visualization = query.visualization || 'quality';
 
     const top = this.props.organization ? 95 : 30;
 
@@ -66,24 +103,33 @@ export default class AllProjects extends React.Component {
         <aside className="page-sidebar-fixed page-sidebar-sticky projects-sidebar">
           <div className="page-sidebar-sticky-inner" style={{ top }}>
             <PageSidebar
-              query={this.state.query}
+              query={query}
               isFavorite={this.props.isFavorite}
               organization={this.props.organization}
             />
           </div>
         </aside>
         <div className="page-main">
-          <PageHeaderContainer />
-          <ProjectsListContainer
-            isFavorite={this.props.isFavorite}
-            isFiltered={isFiltered}
-            organization={this.props.organization}
-          />
-          <ProjectsListFooterContainer
-            query={this.state.query}
-            isFavorite={this.props.isFavorite}
-            organization={this.props.organization}
-          />
+          <PageHeaderContainer onViewChange={this.handleViewChange} view={view} />
+          {view === 'list' &&
+            <ProjectsListContainer
+              isFavorite={this.props.isFavorite}
+              isFiltered={isFiltered}
+              organization={this.props.organization}
+            />}
+          {view === 'list' &&
+            <ProjectsListFooterContainer
+              query={query}
+              isFavorite={this.props.isFavorite}
+              organization={this.props.organization}
+            />}
+          {view === 'visualizations' &&
+            <VisualizationsContainer
+              onProjectOpen={this.handleProjectOpen}
+              onVisualizationChange={this.handleVisualizationChange}
+              sort={query.sort}
+              visualization={visualization}
+            />}
         </div>
       </div>
     );
index 52398e9b34031369306624cf5d079a7d13d3ebcf..f2fa86a3f2ad6485c887bfa67c1e22d5ee0af22e 100644 (file)
@@ -18,7 +18,8 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import { connect } from 'react-redux';
+import { withRouter } from 'react-router';
 import AllProjects from './AllProjects';
 import { fetchProjects } from '../store/actions';
 
-export default connect(null, { fetchProjects })(AllProjects);
+export default connect(null, { fetchProjects })(withRouter(AllProjects));
index 8d242c75556f2a2e718284a7ea85c776552b832c..ffad9d0b8d50638894a3402184f917a1ccc8a6b5 100644 (file)
@@ -18,6 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import { connect } from 'react-redux';
+import { withRouter } from 'react-router';
 import AllProjects from './AllProjects';
 import { fetchProjects } from '../store/actions';
 import { getCurrentUser } from '../../../store/rootReducer';
@@ -27,4 +28,4 @@ const mapStateToProps = state => ({
   isFavorite: true
 });
 
-export default connect(mapStateToProps, { fetchProjects })(AllProjects);
+export default connect(mapStateToProps, { fetchProjects })(withRouter(AllProjects));
index 8b09599eb168bebf0ade0bd8fdb62eaaf0eb56d4..ee2cb6cf6d0fc68aaa62f73db46685ed9ad321a4 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+// @flow
 import React from 'react';
+import ViewSelect from './ViewSelect';
 import { translate } from '../../../helpers/l10n';
 
 export default class PageHeader extends React.Component {
-  static propTypes = {
-    loading: React.PropTypes.bool,
-    total: React.PropTypes.number
+  props: {
+    loading: boolean,
+    onViewChange: (string) => void,
+    total?: number,
+    view: string
   };
 
   render() {
-    const { loading } = this.props;
-
     return (
       <header className="page-header">
+        <ViewSelect onChange={this.props.onViewChange} view={this.props.view} />
+
         <div className="page-actions projects-page-actions">
-          {!!loading && <i className="spinner spacer-right" />}
+          {!!this.props.loading && <i className="spinner spacer-right" />}
 
           {this.props.total != null &&
             <span>
index bbae07c9429f871924e95d35081e0964d4f6e64d..ed924559a8554de3a4e9a4cff022db5afb7facd3 100644 (file)
@@ -40,12 +40,19 @@ export default class PageSidebar extends React.PureComponent {
   };
 
   render() {
-    const isFiltered = Object.keys(this.props.query).some(key => this.props.query[key] != null);
+    const { query } = this.props;
+
+    const isFiltered = Object.keys(query)
+      .filter(key => key !== 'view' && key !== 'visualization')
+      .some(key => query[key] != null);
 
     const basePathName = this.props.organization
       ? `/organizations/${this.props.organization.key}/projects`
       : '/projects';
     const pathname = basePathName + (this.props.isFavorite ? '/favorite' : '');
+    const linkQuery = query.view === 'visualizations'
+      ? { view: query.view, visualization: query.visualization }
+      : undefined;
 
     return (
       <div className="search-navigator-facets-list">
@@ -54,61 +61,61 @@ export default class PageSidebar extends React.PureComponent {
         <div className="projects-facets-header clearfix">
           {isFiltered &&
             <div className="projects-facets-reset">
-              <Link to={pathname} className="button button-red">
+              <Link to={{ pathname, query: linkQuery }} className="button button-red">
                 {translate('projects.clear_all_filters')}
               </Link>
             </div>}
 
           <h3>{translate('filters')}</h3>
           <SearchFilterContainer
-            query={this.props.query}
+            query={query}
             isFavorite={this.props.isFavorite}
             organization={this.props.organization}
           />
         </div>
 
         <QualityGateFilter
-          query={this.props.query}
+          query={query}
           isFavorite={this.props.isFavorite}
           organization={this.props.organization}
         />
         <ReliabilityFilter
-          query={this.props.query}
+          query={query}
           isFavorite={this.props.isFavorite}
           organization={this.props.organization}
         />
         <SecurityFilter
-          query={this.props.query}
+          query={query}
           isFavorite={this.props.isFavorite}
           organization={this.props.organization}
         />
         <MaintainabilityFilter
-          query={this.props.query}
+          query={query}
           isFavorite={this.props.isFavorite}
           organization={this.props.organization}
         />
         <CoverageFilter
-          query={this.props.query}
+          query={query}
           isFavorite={this.props.isFavorite}
           organization={this.props.organization}
         />
         <DuplicationsFilter
-          query={this.props.query}
+          query={query}
           isFavorite={this.props.isFavorite}
           organization={this.props.organization}
         />
         <SizeFilter
-          query={this.props.query}
+          query={query}
           isFavorite={this.props.isFavorite}
           organization={this.props.organization}
         />
         <LanguagesFilterContainer
-          query={this.props.query}
+          query={query}
           isFavorite={this.props.isFavorite}
           organization={this.props.organization}
         />
         <TagsFilterContainer
-          query={this.props.query}
+          query={query}
           isFavorite={this.props.isFavorite}
           organization={this.props.organization}
         />
index 537bdafbc63366a3658fcb9e30c68762b004852a..865ac9b200cf59313df411ffc9604c7694c80949 100644 (file)
@@ -50,7 +50,10 @@ export default class ProjectCard extends React.PureComponent {
       return null;
     }
 
-    const areProjectMeasuresLoaded = this.props.measures != null;
+    // check reliability_rating because only some measures can be loaded
+    // if coming from visualizations tab
+    const areProjectMeasuresLoaded = this.props.measures != null &&
+      this.props.measures['reliability_rating'] != null;
     const isProjectAnalyzed = project.analysisDate != null;
     const displayQualityGate = areProjectMeasuresLoaded && isProjectAnalyzed;
 
diff --git a/server/sonar-web/src/main/js/apps/projects/components/ViewSelect.js b/server/sonar-web/src/main/js/apps/projects/components/ViewSelect.js
new file mode 100644 (file)
index 0000000..c672a37
--- /dev/null
@@ -0,0 +1,50 @@
+/*
+ * 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 RadioToggle from '../../../components/controls/RadioToggle';
+import { translate } from '../../../helpers/l10n';
+
+export default class ViewSelect extends React.PureComponent {
+  props: {
+    onChange: (string) => void,
+    view: string
+  };
+
+  handleChange = (view: string) => {
+    this.props.onChange(view);
+  };
+
+  render() {
+    const options = ['list', 'visualizations'].map(option => ({
+      value: option,
+      label: translate('projects.view', option)
+    }));
+
+    return (
+      <RadioToggle
+        name="view"
+        onCheck={this.handleChange}
+        options={options}
+        value={this.props.view}
+      />
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/projects/components/__tests__/PageSidebar-test.js b/server/sonar-web/src/main/js/apps/projects/components/__tests__/PageSidebar-test.js
new file mode 100644 (file)
index 0000000..ff8d2e9
--- /dev/null
@@ -0,0 +1,33 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import React from 'react';
+import { shallow } from 'enzyme';
+import PageSidebar from '../PageSidebar';
+
+it('should handle `view` and `visualization`', () => {
+  const query = {
+    view: 'visualizations',
+    visualization: 'bugs'
+  };
+  const sidebar = shallow(<PageSidebar query={query} isFavorite={false} />);
+  expect(sidebar.find('.projects-facets-reset')).toMatchSnapshot();
+  sidebar.setProps({ query: { ...query, size: '3' } });
+  expect(sidebar.find('.projects-facets-reset')).toMatchSnapshot();
+});
diff --git a/server/sonar-web/src/main/js/apps/projects/components/__tests__/ViewSelect-test.js b/server/sonar-web/src/main/js/apps/projects/components/__tests__/ViewSelect-test.js
new file mode 100644 (file)
index 0000000..5d9c801
--- /dev/null
@@ -0,0 +1,26 @@
+/*
+ * 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 ViewSelect from '../ViewSelect';
+
+it('should render options', () => {
+  expect(shallow(<ViewSelect view="visualizations" />)).toMatchSnapshot();
+});
diff --git a/server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/PageSidebar-test.js.snap b/server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/PageSidebar-test.js.snap
new file mode 100644 (file)
index 0000000..211b0fb
--- /dev/null
@@ -0,0 +1,22 @@
+exports[`test should handle \`view\` and \`visualization\` 1`] = `undefined`;
+
+exports[`test should handle \`view\` and \`visualization\` 2`] = `
+<div
+  className="projects-facets-reset">
+  <Link
+    className="button button-red"
+    onlyActiveOnIndex={false}
+    style={Object {}}
+    to={
+      Object {
+        "pathname": "/projects",
+        "query": Object {
+          "view": "visualizations",
+          "visualization": "bugs",
+        },
+      }
+    }>
+    projects.clear_all_filters
+  </Link>
+</div>
+`;
diff --git a/server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/ViewSelect-test.js.snap b/server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/ViewSelect-test.js.snap
new file mode 100644 (file)
index 0000000..3e24cf0
--- /dev/null
@@ -0,0 +1,19 @@
+exports[`test should render options 1`] = `
+<RadioToggle
+  disabled={false}
+  name="view"
+  onCheck={[Function]}
+  options={
+    Array [
+      Object {
+        "label": "projects.view.list",
+        "value": "list",
+      },
+      Object {
+        "label": "projects.view.visualizations",
+        "value": "visualizations",
+      },
+    ]
+  }
+  value="visualizations" />
+`;
index dd1bb987851076fa126a15431699d4aa6b24ffe6..ec113c07fab04523ea66ae97b68a2f0e25de7531 100644 (file)
@@ -34,6 +34,7 @@ import { getOrganizations } from '../../../api/organizations';
 import { receiveOrganizations } from '../../../store/organizations/duck';
 
 const PAGE_SIZE = 50;
+const PAGE_SIZE_VISUALIZATIONS = 99;
 
 const METRICS = [
   'alert_status',
@@ -46,6 +47,16 @@ const METRICS = [
   'ncloc_language_distribution'
 ];
 
+const METRICS_BY_VISUALIZATION = {
+  quality: ['reliability_rating', 'security_rating', 'coverage', 'ncloc', 'sqale_index'],
+  // x, y, size, color
+  bugs: ['ncloc', 'reliability_remediation_effort', 'bugs', 'reliability_rating'],
+  vulnerabilities: ['ncloc', 'security_remediation_effort', 'vulnerabilities', 'security_rating'],
+  code_smells: ['ncloc', 'sqale_index', 'code_smells', 'sqale_rating'],
+  uncovered_lines: ['complexity', 'coverage', 'uncovered_lines'],
+  duplicated_blocks: ['ncloc', 'duplicated_lines', 'duplicated_blocks']
+};
+
 const FACETS = [
   'reliability_rating',
   'security_rating',
@@ -90,14 +101,23 @@ const onReceiveOrganizations = dispatch =>
     dispatch(receiveOrganizations(response.organizations));
   };
 
-const fetchProjectMeasures = projects =>
+const defineMetrics = query => {
+  if (query.view === 'visualizations') {
+    return METRICS_BY_VISUALIZATION[query.visualization || 'quality'];
+  } else {
+    return METRICS;
+  }
+};
+
+const fetchProjectMeasures = (projects, query) =>
   dispatch => {
     if (!projects.length) {
       return Promise.resolve();
     }
 
     const projectKeys = projects.map(project => project.key);
-    return getMeasuresForProjects(projectKeys, METRICS).then(
+    const metrics = defineMetrics(query);
+    return getMeasuresForProjects(projectKeys, metrics).then(
       onReceiveMeasures(dispatch, projectKeys),
       onFail(dispatch)
     );
@@ -124,13 +144,13 @@ const handleFavorites = (dispatch, projects) => {
   }
 };
 
-const onReceiveProjects = dispatch =>
+const onReceiveProjects = (dispatch, query) =>
   response => {
     dispatch(receiveComponents(response.components));
     dispatch(receiveProjects(response.components, response.facets));
     handleFavorites(dispatch, response.components);
     Promise.all([
-      dispatch(fetchProjectMeasures(response.components)),
+      dispatch(fetchProjectMeasures(response.components, query)),
       dispatch(fetchProjectOrganizations(response.components))
     ]).then(() => {
       dispatch(updateState({ loading: false }));
@@ -143,13 +163,13 @@ const onReceiveProjects = dispatch =>
     );
   };
 
-const onReceiveMoreProjects = dispatch =>
+const onReceiveMoreProjects = (dispatch, query) =>
   response => {
     dispatch(receiveComponents(response.components));
     dispatch(receiveMoreProjects(response.components));
     handleFavorites(dispatch, response.components);
     Promise.all([
-      dispatch(fetchProjectMeasures(response.components)),
+      dispatch(fetchProjectMeasures(response.components, query)),
       dispatch(fetchProjectOrganizations(response.components))
     ]).then(() => {
       dispatch(updateState({ loading: false }));
@@ -160,12 +180,13 @@ const onReceiveMoreProjects = dispatch =>
 export const fetchProjects = (query, isFavorite, organization) =>
   dispatch => {
     dispatch(updateState({ loading: true }));
+    const ps = query.view === 'visualizations' ? PAGE_SIZE_VISUALIZATIONS : PAGE_SIZE;
     const data = convertToQueryData(query, isFavorite, organization, {
-      ps: PAGE_SIZE,
+      ps,
       facets: FACETS.join(),
       f: 'analysisDate'
     });
-    return searchProjects(data).then(onReceiveProjects(dispatch), onFail(dispatch));
+    return searchProjects(data).then(onReceiveProjects(dispatch, query), onFail(dispatch));
   };
 
 export const fetchMoreProjects = (query, isFavorite, organization) =>
@@ -178,7 +199,7 @@ export const fetchMoreProjects = (query, isFavorite, organization) =>
       p: pageIndex + 1,
       f: 'analysisDate'
     });
-    return searchProjects(data).then(onReceiveMoreProjects(dispatch), onFail(dispatch));
+    return searchProjects(data).then(onReceiveMoreProjects(dispatch, query), onFail(dispatch));
   };
 
 export const setProjectTags = (project, tags) =>
index 63ee07a65632fadbdd867aac282194e1ac5d794e..18a20aad6cba45e230c35b36420a621b7759d44f 100644 (file)
@@ -17,6 +17,8 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+import { VISUALIZATIONS } from '../utils';
+
 const getAsNumericRating = value => {
   if (value === '' || value == null || isNaN(value)) {
     return null;
@@ -46,6 +48,12 @@ const getAsArray = (values, elementGetter) => {
   return values.split(',').map(elementGetter);
 };
 
+const getView = rawValue => rawValue === 'visualizations' ? rawValue : undefined;
+
+const getVisualization = value => {
+  return VISUALIZATIONS.includes(value) ? value : null;
+};
+
 export const parseUrlQuery = urlQuery => ({
   gate: getAsLevel(urlQuery['gate']),
   reliability: getAsNumericRating(urlQuery['reliability']),
@@ -57,7 +65,9 @@ export const parseUrlQuery = urlQuery => ({
   languages: getAsArray(urlQuery['languages'], getAsString),
   tags: getAsArray(urlQuery['tags'], getAsString),
   search: getAsString(urlQuery['search']),
-  sort: getAsString(urlQuery['sort'])
+  sort: getAsString(urlQuery['sort']),
+  view: getView(urlQuery['view']),
+  visualization: getVisualization(urlQuery['visualization'])
 });
 
 export const mapMetricToProperty = metricKey => {
index be23d753a3d8a924efcbeb2aa6045a33fdaf39a0..81b7af2c1cd79768560d4e2f161b7630aa3d84a4 100644 (file)
 .search-navigator-facet-highlight-under.active ~ .search-navigator-facet .projects-facet-bar-inner {
   background-color: #4b9fd5;
 }
+
+.projects-visualization {
+  position: relative;
+  height: 600px;
+  margin-top: 15px;
+  border-top: 1px solid #e6e6e6;
+}
+
+.projects-visualization .measure-details-bubble-chart-axis.y {
+  width: 300px;
+  left: 15px;
+  margin-top: 150px;
+  transform-origin: 0 0;
+  text-align: center;
+}
+
+.projects-visualizations-footer {
+  padding: 15px 0;
+  color: #777;
+  font-size: 12px;
+  text-align: center;
+}
\ No newline at end of file
index cf393674817980f72bf1c17da0bd83d476a2b682..bf3e0a14a26735c2b1f36a96f60527331ff75f8e 100644 (file)
@@ -18,6 +18,8 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 // @flow
+import { translate } from '../../helpers/l10n';
+
 const LOCALSTORAGE_KEY = 'sonarqube.projects.default';
 const LOCALSTORAGE_FAVORITE = 'favorite';
 const LOCALSTORAGE_ALL = 'all';
@@ -44,3 +46,16 @@ const save = (value: string) => {
 export const saveAll = () => save(LOCALSTORAGE_ALL);
 
 export const saveFavorite = () => save(LOCALSTORAGE_FAVORITE);
+
+export const VISUALIZATIONS = [
+  'quality',
+  'bugs',
+  'vulnerabilities',
+  'code_smells',
+  'uncovered_lines',
+  'duplicated_blocks'
+];
+
+export const localizeSorting = (sort?: string) => {
+  return translate('projects.sort', sort || 'name');
+};
diff --git a/server/sonar-web/src/main/js/apps/projects/visualizations/Bugs.js b/server/sonar-web/src/main/js/apps/projects/visualizations/Bugs.js
new file mode 100644 (file)
index 0000000..6a704cb
--- /dev/null
@@ -0,0 +1,36 @@
+/*
+ * 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 SimpleBubbleChart from './SimpleBubbleChart';
+
+export default class Bugs extends React.PureComponent {
+  render() {
+    return (
+      <SimpleBubbleChart
+        {...this.props}
+        xMetric={{ key: 'ncloc', type: 'SHORT_INT' }}
+        yMetric={{ key: 'reliability_remediation_effort', type: 'SHORT_WORK_DUR' }}
+        sizeMetric={{ key: 'bugs', type: 'SHORT_INT' }}
+        colorMetric="reliability_rating"
+      />
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/projects/visualizations/CodeSmells.js b/server/sonar-web/src/main/js/apps/projects/visualizations/CodeSmells.js
new file mode 100644 (file)
index 0000000..fb7682f
--- /dev/null
@@ -0,0 +1,36 @@
+/*
+ * 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 SimpleBubbleChart from './SimpleBubbleChart';
+
+export default class CodeSmells extends React.PureComponent {
+  render() {
+    return (
+      <SimpleBubbleChart
+        {...this.props}
+        xMetric={{ key: 'ncloc', type: 'SHORT_INT' }}
+        yMetric={{ key: 'sqale_index', type: 'SHORT_WORK_DUR' }}
+        sizeMetric={{ key: 'code_smells', type: 'SHORT_INT' }}
+        colorMetric="sqale_rating"
+      />
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/projects/visualizations/DuplicatedBlocks.js b/server/sonar-web/src/main/js/apps/projects/visualizations/DuplicatedBlocks.js
new file mode 100644 (file)
index 0000000..88e7454
--- /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.
+ */
+// @flow
+import React from 'react';
+import SimpleBubbleChart from './SimpleBubbleChart';
+
+export default class DuplicatedBlocks extends React.PureComponent {
+  render() {
+    return (
+      <SimpleBubbleChart
+        {...this.props}
+        xMetric={{ key: 'ncloc', type: 'SHORT_INT' }}
+        yMetric={{ key: 'duplicated_lines', type: 'SHORT_INT' }}
+        sizeMetric={{ key: 'duplicated_blocks', type: 'SHORT_INT' }}
+      />
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/projects/visualizations/QualityModel.js b/server/sonar-web/src/main/js/apps/projects/visualizations/QualityModel.js
new file mode 100644 (file)
index 0000000..a9c9392
--- /dev/null
@@ -0,0 +1,133 @@
+/*
+ * 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 BubbleChart from '../../../components/charts/BubbleChart';
+import { formatMeasure } from '../../../helpers/measures';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
+import { RATING_COLORS } from '../../../helpers/constants';
+
+type Project = {
+  key: string,
+  measures: { [string]: string },
+  name: string,
+  organization?: { name: string }
+};
+
+const X_METRIC = 'sqale_index';
+const X_METRIC_TYPE = 'SHORT_WORK_DUR';
+const Y_METRIC = 'coverage';
+const Y_METRIC_TYPE = 'PERCENT';
+const SIZE_METRIC = 'ncloc';
+const SIZE_METRIC_TYPE = 'SHORT_INT';
+const COLOR_METRIC_1 = 'reliability_rating';
+const COLOR_METRIC_2 = 'security_rating';
+const COLOR_METRIC_TYPE = 'RATING';
+
+export default class QualityModel extends React.PureComponent {
+  props: {
+    onProjectOpen: (?string) => void,
+    projects: Array<Project>
+  };
+
+  getMetricTooltip(metric: { key: string, type: string }, value: number) {
+    const name = translate('metric', metric.key, 'name');
+    return `<div>${name}: ${formatMeasure(value, metric.type)}</div>`;
+  }
+
+  getTooltip(project: Project, x: number, y: number, size: number, color1: number, color2: number) {
+    const fullProjectName = project.organization
+      ? `<div class="little-spacer-bottom">${project.organization.name} / <strong>${project.name}</strong></div>`
+      : `<div class="little-spacer-bottom"><strong>${project.name}</strong></div>`;
+    const inner = [
+      fullProjectName,
+      this.getMetricTooltip({ key: COLOR_METRIC_1, type: COLOR_METRIC_TYPE }, color1),
+      this.getMetricTooltip({ key: COLOR_METRIC_2, type: COLOR_METRIC_TYPE }, color2),
+      this.getMetricTooltip({ key: Y_METRIC, type: Y_METRIC_TYPE }, y),
+      this.getMetricTooltip({ key: X_METRIC, type: X_METRIC_TYPE }, x),
+      this.getMetricTooltip({ key: SIZE_METRIC, type: SIZE_METRIC_TYPE }, size)
+    ].join('');
+
+    return `<div class="text-left">${inner}</div>`;
+  }
+
+  render() {
+    const items = this.props.projects
+      .filter(
+        ({ measures }) =>
+          measures[X_METRIC] != null &&
+          measures[Y_METRIC] != null &&
+          measures[SIZE_METRIC] != null &&
+          measures[COLOR_METRIC_1] != null &&
+          measures[COLOR_METRIC_2] != null
+      )
+      .map(project => {
+        const x = Number(project.measures[X_METRIC]);
+        const y = Number(project.measures[Y_METRIC]);
+        const size = Number(project.measures[SIZE_METRIC]);
+        const color1 = Number(project.measures[COLOR_METRIC_1]);
+        const color2 = Number(project.measures[COLOR_METRIC_2]);
+        return {
+          x,
+          y,
+          size,
+          color: RATING_COLORS[Math.max(color1, color2) - 1],
+          key: project.key,
+          tooltip: this.getTooltip(project, x, y, size, color1, color2),
+          link: project.key
+        };
+      });
+
+    const formatXTick = tick => formatMeasure(tick, X_METRIC_TYPE);
+    const formatYTick = tick => formatMeasure(tick, Y_METRIC_TYPE);
+
+    return (
+      <div>
+        <BubbleChart
+          formatXTick={formatXTick}
+          formatYTick={formatYTick}
+          height={600}
+          items={items}
+          padding={[40, 20, 60, 100]}
+          onBubbleClick={this.props.onProjectOpen}
+          yDomain={[100, 0]}
+        />
+        <div className="measure-details-bubble-chart-axis x">
+          {translate('metric', X_METRIC, 'name')}
+        </div>
+        <div className="measure-details-bubble-chart-axis y">
+          {translate('metric', Y_METRIC, 'name')}
+        </div>
+        <div className="measure-details-bubble-chart-axis size">
+          <span className="spacer-right">
+            {translateWithParameters(
+              'component_measures.legend.color_x',
+              translate('projects.worse_of_reliablity_and_security')
+            )}
+          </span>
+          {translateWithParameters(
+            'component_measures.legend.size_x',
+            translate('metric', SIZE_METRIC, 'name')
+          )}
+        </div>
+      </div>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/projects/visualizations/SimpleBubbleChart.js b/server/sonar-web/src/main/js/apps/projects/visualizations/SimpleBubbleChart.js
new file mode 100644 (file)
index 0000000..a44fbf9
--- /dev/null
@@ -0,0 +1,125 @@
+/*
+ * 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 BubbleChart from '../../../components/charts/BubbleChart';
+import { formatMeasure } from '../../../helpers/measures';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
+import { RATING_COLORS } from '../../../helpers/constants';
+
+type Metric = { key: string, type: string };
+
+type Project = {
+  key: string,
+  measures: { [string]: string },
+  name: string,
+  organization?: { name: string }
+};
+
+export default class SimpleBubbleChart extends React.PureComponent {
+  props: {
+    onProjectOpen: (?string) => void,
+    projects: Array<Project>,
+    sizeMetric: Metric,
+    xMetric: Metric,
+    yDomain?: [number, number],
+    yMetric: Metric,
+    colorMetric?: string
+  };
+
+  getMetricTooltip(metric: Metric, value: number) {
+    const name = translate('metric', metric.key, 'name');
+    return `<div>${name}: ${formatMeasure(value, metric.type)}</div>`;
+  }
+
+  getTooltip(project: Project, x: number, y: number, size: number, color?: number) {
+    const fullProjectName = project.organization
+      ? `<div class="little-spacer-bottom">${project.organization.name} / <strong>${project.name}</strong></div>`
+      : `<div class="little-spacer-bottom"><strong>${project.name}</strong></div>`;
+
+    const inner = [
+      fullProjectName,
+      this.getMetricTooltip(this.props.xMetric, x),
+      this.getMetricTooltip(this.props.yMetric, y),
+      this.getMetricTooltip(this.props.sizeMetric, size)
+    ];
+
+    if (color) {
+      // $FlowFixMe if `color` is defined then `this.props.colorMetric` is defined too
+      this.getMetricTooltip({ key: this.props.colorMetric, type: 'RATING' }, color);
+    }
+
+    return `<div class="text-left">${inner.join('')}</div>`;
+  }
+
+  render() {
+    const { xMetric, yMetric, sizeMetric, colorMetric } = this.props;
+
+    const items = this.props.projects
+      .filter(project => project.measures[xMetric.key] != null)
+      .filter(project => project.measures[yMetric.key] != null)
+      .filter(project => project.measures[sizeMetric.key] != null)
+      .filter(project => colorMetric == null || project.measures[colorMetric] !== null)
+      .map(project => {
+        const x = Number(project.measures[xMetric.key]);
+        const y = Number(project.measures[yMetric.key]);
+        const size = Number(project.measures[sizeMetric.key]);
+        const color = colorMetric ? Number(project.measures[colorMetric]) : undefined;
+        return {
+          x,
+          y,
+          size,
+          color: color ? RATING_COLORS[color - 1] : undefined,
+          key: project.key,
+          tooltip: this.getTooltip(project, x, y, size, color),
+          link: project.key
+        };
+      });
+
+    const formatXTick = tick => formatMeasure(tick, xMetric.type);
+    const formatYTick = tick => formatMeasure(tick, yMetric.type);
+
+    return (
+      <div>
+        <BubbleChart
+          formatXTick={formatXTick}
+          formatYTick={formatYTick}
+          height={600}
+          items={items}
+          onBubbleClick={this.props.onProjectOpen}
+          padding={[40, 20, 60, 100]}
+          yDomain={this.props.yDomain}
+        />
+        <div className="measure-details-bubble-chart-axis x">
+          {translate('metric', xMetric.key, 'name')}
+        </div>
+        <div className="measure-details-bubble-chart-axis y">
+          {translate('metric', yMetric.key, 'name')}
+        </div>
+        <div className="measure-details-bubble-chart-axis size">
+          {translateWithParameters(
+            'component_measures.legend.size_x',
+            translate('metric', sizeMetric.key, 'name')
+          )}
+        </div>
+      </div>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/projects/visualizations/UncoveredLines.js b/server/sonar-web/src/main/js/apps/projects/visualizations/UncoveredLines.js
new file mode 100644 (file)
index 0000000..ae36d55
--- /dev/null
@@ -0,0 +1,36 @@
+/*
+ * 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 SimpleBubbleChart from './SimpleBubbleChart';
+
+export default class UncoveredLines extends React.PureComponent {
+  render() {
+    return (
+      <SimpleBubbleChart
+        {...this.props}
+        xMetric={{ key: 'complexity', type: 'SHORT_INT' }}
+        yMetric={{ key: 'coverage', type: 'PERCENT' }}
+        yDomain={[100, 0]}
+        sizeMetric={{ key: 'uncovered_lines', type: 'SHORT_INT' }}
+      />
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/projects/visualizations/Visualizations.js b/server/sonar-web/src/main/js/apps/projects/visualizations/Visualizations.js
new file mode 100644 (file)
index 0000000..5803e5b
--- /dev/null
@@ -0,0 +1,94 @@
+/*
+ * 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 VisualizationsHeader from './VisualizationsHeader';
+import QualityModel from './QualityModel';
+import Bugs from './Bugs';
+import Vulnerabilities from './Vulnerabilities';
+import CodeSmells from './CodeSmells';
+import UncoveredLines from './UncoveredLines';
+import DuplicatedBlocks from './DuplicatedBlocks';
+import { localizeSorting } from '../utils';
+import { translateWithParameters } from '../../../helpers/l10n';
+
+export default class Visualizations extends React.PureComponent {
+  props: {
+    onProjectOpen: (string) => void,
+    onVisualizationChange: (string) => void,
+    projects?: Array<*>,
+    sort?: string,
+    total?: number,
+    visualization: string
+  };
+
+  renderVisualization(projects: Array<*>) {
+    const visualizationToComponent = {
+      quality: QualityModel,
+      bugs: Bugs,
+      vulnerabilities: Vulnerabilities,
+      code_smells: CodeSmells,
+      uncovered_lines: UncoveredLines,
+      duplicated_blocks: DuplicatedBlocks
+    };
+    const Component = visualizationToComponent[this.props.visualization];
+
+    return Component
+      ? <Component onProjectOpen={this.props.onProjectOpen} projects={projects} />
+      : null;
+  }
+
+  renderFooter() {
+    const { projects, total, sort } = this.props;
+
+    if (projects == null || total == null || projects.length >= total) {
+      return null;
+    }
+
+    return (
+      <footer className="projects-visualizations-footer">
+        {translateWithParameters(
+          'projects.limited_set_of_projects',
+          projects.length,
+          localizeSorting(sort)
+        )}
+      </footer>
+    );
+  }
+
+  render() {
+    const { projects } = this.props;
+
+    return (
+      <div className="boxed-group projects-visualizations">
+        <VisualizationsHeader
+          onVisualizationChange={this.props.onVisualizationChange}
+          visualization={this.props.visualization}
+        />
+        <div className="projects-visualization">
+          <div>
+            {projects != null && this.renderVisualization(projects)}
+          </div>
+        </div>
+        {this.renderFooter()}
+      </div>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/projects/visualizations/VisualizationsContainer.js b/server/sonar-web/src/main/js/apps/projects/visualizations/VisualizationsContainer.js
new file mode 100644 (file)
index 0000000..5ee27e1
--- /dev/null
@@ -0,0 +1,44 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { connect } from 'react-redux';
+import Visualizations from './Visualizations';
+import {
+  getProjects,
+  getComponent,
+  getComponentMeasures,
+  getOrganizationByKey,
+  getProjectsAppState
+} from '../../../store/rootReducer';
+
+const mapStateToProps = state => {
+  const projectKeys = getProjects(state) || [];
+  const projects = projectKeys.map(key => {
+    const component = getComponent(state, key);
+    return {
+      ...component,
+      measures: getComponentMeasures(state, key) || {},
+      organization: getOrganizationByKey(state, component.organization)
+    };
+  });
+  const appState = getProjectsAppState(state);
+  return { projects, total: appState.total };
+};
+
+export default connect(mapStateToProps)(Visualizations);
diff --git a/server/sonar-web/src/main/js/apps/projects/visualizations/VisualizationsHeader.js b/server/sonar-web/src/main/js/apps/projects/visualizations/VisualizationsHeader.js
new file mode 100644 (file)
index 0000000..4d17b60
--- /dev/null
@@ -0,0 +1,57 @@
+/*
+ * 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 { translate } from '../../../helpers/l10n';
+import { VISUALIZATIONS } from '../utils';
+
+export default class VisualizationsHeader extends React.PureComponent {
+  props: {
+    onVisualizationChange: (string) => void,
+    visualization: string
+  };
+
+  handleChange = (option: { value: string }) => {
+    this.props.onVisualizationChange(option.value);
+  };
+
+  render() {
+    const options = VISUALIZATIONS.map(option => ({
+      value: option,
+      label: option === 'quality'
+        ? translate('projects.quality_model')
+        : translate('metric', option, 'name')
+    }));
+
+    return (
+      <header className="boxed-group-header">
+        <Select
+          className="input-medium"
+          clearable={false}
+          onChange={this.handleChange}
+          options={options}
+          searchable={false}
+          value={this.props.visualization}
+        />
+      </header>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/projects/visualizations/Vulnerabilities.js b/server/sonar-web/src/main/js/apps/projects/visualizations/Vulnerabilities.js
new file mode 100644 (file)
index 0000000..4451e58
--- /dev/null
@@ -0,0 +1,36 @@
+/*
+ * 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 SimpleBubbleChart from './SimpleBubbleChart';
+
+export default class Vulnerabilities extends React.PureComponent {
+  render() {
+    return (
+      <SimpleBubbleChart
+        {...this.props}
+        xMetric={{ key: 'ncloc', type: 'SHORT_INT' }}
+        yMetric={{ key: 'security_remediation_effort', type: 'SHORT_WORK_DUR' }}
+        sizeMetric={{ key: 'vulnerabilities', type: 'SHORT_INT' }}
+        colorMetric="security_rating"
+      />
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/components/charts/BubbleChart.js b/server/sonar-web/src/main/js/components/charts/BubbleChart.js
new file mode 100644 (file)
index 0000000..5ab12ae
--- /dev/null
@@ -0,0 +1,277 @@
+/*
+ * 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 d3 from 'd3';
+import sortBy from 'lodash/sortBy';
+import uniq from 'lodash/uniq';
+import { AutoSizer } from 'react-virtualized';
+import { TooltipsContainer } from '../mixins/tooltips-mixin';
+
+type Scale = {
+  (number): number,
+  range: () => [number, number],
+  ticks: (number) => Array<number>
+};
+
+const TICKS_COUNT = 5;
+
+class Bubble extends React.PureComponent {
+  props: {
+    color?: string,
+    link?: string,
+    onClick: (?string) => void,
+    r: number,
+    tooltip?: string,
+    x: number,
+    y: number
+  };
+
+  handleClick = () => {
+    if (this.props.onClick) {
+      this.props.onClick(this.props.link);
+    }
+  };
+
+  render() {
+    const tooltipAttrs = this.props.tooltip
+      ? {
+          'data-toggle': 'tooltip',
+          title: this.props.tooltip
+        }
+      : {};
+
+    const circle = (
+      <g>
+        <circle
+          {...tooltipAttrs}
+          onClick={this.handleClick}
+          className="bubble-chart-bubble"
+          r={this.props.r}
+          style={{
+            fill: this.props.color,
+            stroke: this.props.color
+          }}
+          transform={`translate(${this.props.x}, ${this.props.y})`}
+        />
+      </g>
+    );
+
+    return this.props.tooltip ? <TooltipsContainer>{circle}</TooltipsContainer> : circle;
+  }
+}
+
+export default class BubbleChart extends React.PureComponent {
+  props: {|
+    items: Array<{|
+      x: number,
+      y: number,
+      size: number,
+      color?: string,
+      key?: string,
+      link?: string,
+      tooltip?: string
+    |}>,
+    sizeRange?: [number, number],
+    displayXGrid: boolean,
+    displayXTicks: boolean,
+    displayYGrid: boolean,
+    displayYTicks: boolean,
+    height: number,
+    padding: [number, number, number, number],
+    formatXTick: (number) => string,
+    formatYTick: (number) => string,
+    onBubbleClick?: (?string) => void,
+    xDomain?: [number, number],
+    yDomain?: [number, number]
+  |};
+
+  static defaultProps = {
+    sizeRange: [5, 45],
+    displayXGrid: true,
+    displayYGrid: true,
+    displayXTicks: true,
+    displayYTicks: true,
+    padding: [10, 10, 10, 10],
+    formatXTick: d => d,
+    formatYTick: d => d
+  };
+
+  getXRange(xScale: Scale, sizeScale: Scale, availableWidth: number) {
+    const minX = d3.min(this.props.items, d => xScale(d.x) - sizeScale(d.size));
+    const maxX = d3.max(this.props.items, d => xScale(d.x) + sizeScale(d.size));
+    const dMinX = minX < 0 ? xScale.range()[0] - minX : xScale.range()[0];
+    const dMaxX = maxX > xScale.range()[1] ? maxX - xScale.range()[1] : 0;
+    return [dMinX, availableWidth - dMaxX];
+  }
+
+  getYRange(yScale: Scale, sizeScale: Scale, availableHeight: number) {
+    const minY = d3.min(this.props.items, d => yScale(d.y) - sizeScale(d.size));
+    const maxY = d3.max(this.props.items, d => yScale(d.y) + sizeScale(d.size));
+    const dMinY = minY < 0 ? yScale.range()[1] - minY : yScale.range()[1];
+    const dMaxY = maxY > yScale.range()[0] ? maxY - yScale.range()[0] : 0;
+    return [availableHeight - dMaxY, dMinY];
+  }
+
+  getTicks(scale: Scale, format: (number) => string) {
+    const ticks = scale.ticks(TICKS_COUNT).map(tick => format(tick));
+    const uniqueTicksCount = uniq(ticks).length;
+    const ticksCount = uniqueTicksCount < TICKS_COUNT ? uniqueTicksCount - 1 : TICKS_COUNT;
+    return scale.ticks(ticksCount);
+  }
+
+  renderXGrid(ticks: Array<number>, xScale: Scale, yScale: Scale) {
+    if (!this.props.displayXGrid) {
+      return null;
+    }
+
+    const lines = ticks.map((tick, index) => {
+      const x = xScale(tick);
+      const y1 = yScale.range()[0];
+      const y2 = yScale.range()[1];
+      return <line key={index} x1={x} x2={x} y1={y1} y2={y2} className="bubble-chart-grid" />;
+    });
+
+    return <g ref="xGrid">{lines}</g>;
+  }
+
+  renderYGrid(ticks: Array<number>, xScale: Scale, yScale: Scale) {
+    if (!this.props.displayYGrid) {
+      return null;
+    }
+
+    const lines = ticks.map((tick, index) => {
+      const y = yScale(tick);
+      const x1 = xScale.range()[0];
+      const x2 = xScale.range()[1];
+      return <line key={index} x1={x1} x2={x2} y1={y} y2={y} className="bubble-chart-grid" />;
+    });
+
+    return <g ref="yGrid">{lines}</g>;
+  }
+
+  renderXTicks(xTicks: Array<number>, xScale: Scale, yScale: Scale) {
+    if (!this.props.displayXTicks) {
+      return null;
+    }
+
+    const ticks = xTicks.map((tick, index) => {
+      const x = xScale(tick);
+      const y = yScale.range()[0];
+      const innerText = this.props.formatXTick(tick);
+      return (
+        <text key={index} className="bubble-chart-tick" x={x} y={y} dy="1.5em">
+          {innerText}
+        </text>
+      );
+    });
+
+    return <g>{ticks}</g>;
+  }
+
+  renderYTicks(yTicks: Array<number>, xScale: Scale, yScale: Scale) {
+    if (!this.props.displayYTicks) {
+      return null;
+    }
+
+    const ticks = yTicks.map((tick, index) => {
+      const x = xScale.range()[0];
+      const y = yScale(tick);
+      const innerText = this.props.formatYTick(tick);
+      return (
+        <text
+          key={index}
+          className="bubble-chart-tick bubble-chart-tick-y"
+          x={x}
+          y={y}
+          dx="-0.5em"
+          dy="0.3em">
+          {innerText}
+        </text>
+      );
+    });
+
+    return <g>{ticks}</g>;
+  }
+
+  renderChart(width: number) {
+    const availableWidth = width - this.props.padding[1] - this.props.padding[3];
+    const availableHeight = this.props.height - this.props.padding[0] - this.props.padding[2];
+
+    const xScale = d3.scale
+      .linear()
+      .domain(this.props.xDomain || [0, d3.max(this.props.items, d => d.x)])
+      .range([0, availableWidth])
+      .nice();
+    const yScale = d3.scale
+      .linear()
+      .domain(this.props.yDomain || [0, d3.max(this.props.items, d => d.y)])
+      .range([availableHeight, 0])
+      .nice();
+    const sizeScale = d3.scale
+      .linear()
+      .domain(this.props.sizeDomain || [0, d3.max(this.props.items, d => d.size)])
+      .range(this.props.sizeRange);
+
+    const xScaleOriginal = xScale.copy();
+    const yScaleOriginal = yScale.copy();
+
+    xScale.range(this.getXRange(xScale, sizeScale, availableWidth));
+    yScale.range(this.getYRange(yScale, sizeScale, availableHeight));
+
+    const bubbles = sortBy(this.props.items, b => -b.size).map((item, index) => {
+      return (
+        <Bubble
+          key={item.key || index}
+          link={item.link}
+          tooltip={item.tooltip}
+          x={xScale(item.x)}
+          y={yScale(item.y)}
+          r={sizeScale(item.size)}
+          color={item.color}
+          onClick={this.props.onBubbleClick}
+        />
+      );
+    });
+
+    const xTicks = this.getTicks(xScale, this.props.formatXTick);
+    const yTicks = this.getTicks(yScale, this.props.formatYTick);
+
+    return (
+      <svg className="bubble-chart" width={width} height={this.props.height}>
+        <g transform={`translate(${this.props.padding[3]}, ${this.props.padding[0]})`}>
+          {this.renderXGrid(xTicks, xScale, yScale)}
+          {this.renderXTicks(xTicks, xScale, yScaleOriginal)}
+          {this.renderYGrid(yTicks, xScale, yScale)}
+          {this.renderYTicks(yTicks, xScaleOriginal, yScale)}
+          {bubbles}
+        </g>
+      </svg>
+    );
+  }
+
+  render() {
+    return (
+      <AutoSizer disableHeight={true}>
+        {size => this.renderChart(size.width)}
+      </AutoSizer>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/components/charts/__tests__/BubbleChart-test.js b/server/sonar-web/src/main/js/components/charts/__tests__/BubbleChart-test.js
new file mode 100644 (file)
index 0000000..26be190
--- /dev/null
@@ -0,0 +1,28 @@
+/*
+ * 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 { mount } from 'enzyme';
+import BubbleChart from '../BubbleChart';
+
+it('should display bubbles', () => {
+  const items = [{ x: 1, y: 10, size: 7 }, { x: 2, y: 30, size: 5 }, { x: 3, y: 20, size: 2 }];
+  const chart = mount(<BubbleChart items={items} height={100} />);
+  expect(chart).toMatchSnapshot();
+});
diff --git a/server/sonar-web/src/main/js/components/charts/__tests__/__snapshots__/BubbleChart-test.js.snap b/server/sonar-web/src/main/js/components/charts/__tests__/__snapshots__/BubbleChart-test.js.snap
new file mode 100644 (file)
index 0000000..d851070
--- /dev/null
@@ -0,0 +1,311 @@
+exports[`test should display bubbles 1`] = `
+<BubbleChart
+  displayXGrid={true}
+  displayXTicks={true}
+  displayYGrid={true}
+  displayYTicks={true}
+  formatXTick={[Function]}
+  formatYTick={[Function]}
+  height={100}
+  items={
+    Array [
+      Object {
+        "size": 7,
+        "x": 1,
+        "y": 10,
+      },
+      Object {
+        "size": 5,
+        "x": 2,
+        "y": 30,
+      },
+      Object {
+        "size": 2,
+        "x": 3,
+        "y": 20,
+      },
+    ]
+  }
+  padding={
+    Array [
+      10,
+      10,
+      10,
+      10,
+    ]
+  }
+  sizeRange={
+    Array [
+      5,
+      45,
+    ]
+  }>
+  <AutoSizer
+    disableHeight={true}
+    onResize={[Function]}>
+    <div
+      style={
+        Object {
+          "overflow": "visible",
+          "width": 0,
+        }
+      }>
+      <svg
+        className="bubble-chart"
+        height={100}
+        width={0}>
+        <g
+          transform="translate(10, 10)">
+          <g>
+            <line
+              className="bubble-chart-grid"
+              x1={51.666666666666664}
+              x2={51.666666666666664}
+              y1={61.66666666666666}
+              y2={33.57142857142858} />
+            <line
+              className="bubble-chart-grid"
+              x1={30}
+              x2={30}
+              y1={61.66666666666666}
+              y2={33.57142857142858} />
+            <line
+              className="bubble-chart-grid"
+              x1={8.333333333333336}
+              x2={8.333333333333336}
+              y1={61.66666666666666}
+              y2={33.57142857142858} />
+            <line
+              className="bubble-chart-grid"
+              x1={-13.33333333333334}
+              x2={-13.33333333333334}
+              y1={61.66666666666666}
+              y2={33.57142857142858} />
+            <line
+              className="bubble-chart-grid"
+              x1={-35}
+              x2={-35}
+              y1={61.66666666666666}
+              y2={33.57142857142858} />
+            <line
+              className="bubble-chart-grid"
+              x1={-56.66666666666668}
+              x2={-56.66666666666668}
+              y1={61.66666666666666}
+              y2={33.57142857142858} />
+            <line
+              className="bubble-chart-grid"
+              x1={-78.33333333333334}
+              x2={-78.33333333333334}
+              y1={61.66666666666666}
+              y2={33.57142857142858} />
+          </g>
+          <g>
+            <text
+              className="bubble-chart-tick"
+              dy="1.5em"
+              x={51.666666666666664}
+              y={80} />
+            <text
+              className="bubble-chart-tick"
+              dy="1.5em"
+              x={30}
+              y={80}>
+              0.5
+            </text>
+            <text
+              className="bubble-chart-tick"
+              dy="1.5em"
+              x={8.333333333333336}
+              y={80}>
+              1
+            </text>
+            <text
+              className="bubble-chart-tick"
+              dy="1.5em"
+              x={-13.33333333333334}
+              y={80}>
+              1.5
+            </text>
+            <text
+              className="bubble-chart-tick"
+              dy="1.5em"
+              x={-35}
+              y={80}>
+              2
+            </text>
+            <text
+              className="bubble-chart-tick"
+              dy="1.5em"
+              x={-56.66666666666668}
+              y={80}>
+              2.5
+            </text>
+            <text
+              className="bubble-chart-tick"
+              dy="1.5em"
+              x={-78.33333333333334}
+              y={80}>
+              3
+            </text>
+          </g>
+          <g>
+            <line
+              className="bubble-chart-grid"
+              x1={51.666666666666664}
+              x2={-78.33333333333334}
+              y1={61.66666666666666}
+              y2={61.66666666666666} />
+            <line
+              className="bubble-chart-grid"
+              x1={51.666666666666664}
+              x2={-78.33333333333334}
+              y1={56.98412698412698}
+              y2={56.98412698412698} />
+            <line
+              className="bubble-chart-grid"
+              x1={51.666666666666664}
+              x2={-78.33333333333334}
+              y1={52.3015873015873}
+              y2={52.3015873015873} />
+            <line
+              className="bubble-chart-grid"
+              x1={51.666666666666664}
+              x2={-78.33333333333334}
+              y1={47.61904761904762}
+              y2={47.61904761904762} />
+            <line
+              className="bubble-chart-grid"
+              x1={51.666666666666664}
+              x2={-78.33333333333334}
+              y1={42.93650793650794}
+              y2={42.93650793650794} />
+            <line
+              className="bubble-chart-grid"
+              x1={51.666666666666664}
+              x2={-78.33333333333334}
+              y1={38.25396825396825}
+              y2={38.25396825396825} />
+            <line
+              className="bubble-chart-grid"
+              x1={51.666666666666664}
+              x2={-78.33333333333334}
+              y1={33.57142857142858}
+              y2={33.57142857142858} />
+          </g>
+          <g>
+            <text
+              className="bubble-chart-tick bubble-chart-tick-y"
+              dx="-0.5em"
+              dy="0.3em"
+              x={0}
+              y={61.66666666666666} />
+            <text
+              className="bubble-chart-tick bubble-chart-tick-y"
+              dx="-0.5em"
+              dy="0.3em"
+              x={0}
+              y={56.98412698412698}>
+              5
+            </text>
+            <text
+              className="bubble-chart-tick bubble-chart-tick-y"
+              dx="-0.5em"
+              dy="0.3em"
+              x={0}
+              y={52.3015873015873}>
+              10
+            </text>
+            <text
+              className="bubble-chart-tick bubble-chart-tick-y"
+              dx="-0.5em"
+              dy="0.3em"
+              x={0}
+              y={47.61904761904762}>
+              15
+            </text>
+            <text
+              className="bubble-chart-tick bubble-chart-tick-y"
+              dx="-0.5em"
+              dy="0.3em"
+              x={0}
+              y={42.93650793650794}>
+              20
+            </text>
+            <text
+              className="bubble-chart-tick bubble-chart-tick-y"
+              dx="-0.5em"
+              dy="0.3em"
+              x={0}
+              y={38.25396825396825}>
+              25
+            </text>
+            <text
+              className="bubble-chart-tick bubble-chart-tick-y"
+              dx="-0.5em"
+              dy="0.3em"
+              x={0}
+              y={33.57142857142858}>
+              30
+            </text>
+          </g>
+          <Bubble
+            r={45}
+            x={8.333333333333336}
+            y={52.3015873015873}>
+            <g>
+              <circle
+                className="bubble-chart-bubble"
+                onClick={[Function]}
+                r={45}
+                style={
+                  Object {
+                    "fill": undefined,
+                    "stroke": undefined,
+                  }
+                }
+                transform="translate(8.333333333333336, 52.3015873015873)" />
+            </g>
+          </Bubble>
+          <Bubble
+            r={33.57142857142858}
+            x={-35}
+            y={33.57142857142858}>
+            <g>
+              <circle
+                className="bubble-chart-bubble"
+                onClick={[Function]}
+                r={33.57142857142858}
+                style={
+                  Object {
+                    "fill": undefined,
+                    "stroke": undefined,
+                  }
+                }
+                transform="translate(-35, 33.57142857142858)" />
+            </g>
+          </Bubble>
+          <Bubble
+            r={16.428571428571427}
+            x={-78.33333333333334}
+            y={42.93650793650794}>
+            <g>
+              <circle
+                className="bubble-chart-bubble"
+                onClick={[Function]}
+                r={16.428571428571427}
+                style={
+                  Object {
+                    "fill": undefined,
+                    "stroke": undefined,
+                  }
+                }
+                transform="translate(-78.33333333333334, 42.93650793650794)" />
+            </g>
+          </Bubble>
+        </g>
+      </svg>
+    </div>
+  </AutoSizer>
+</BubbleChart>
+`;
diff --git a/server/sonar-web/src/main/js/components/charts/__tests__/bubble-chart-test.js b/server/sonar-web/src/main/js/components/charts/__tests__/bubble-chart-test.js
deleted file mode 100644 (file)
index c9d2dd1..0000000
+++ /dev/null
@@ -1,40 +0,0 @@
-/*
- * 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 { BubbleChart, Bubble } from '../bubble-chart';
-
-it('should display bubbles', () => {
-  const items = [{ x: 1, y: 10, size: 7 }, { x: 2, y: 30, size: 5 }, { x: 3, y: 20, size: 2 }];
-  const chart = shallow(<BubbleChart items={items} width={100} height={100} />);
-  expect(chart.find(Bubble).length).toBe(3);
-});
-
-it('should display grid', () => {
-  const items = [{ x: 1, y: 10, size: 7 }, { x: 2, y: 30, size: 5 }, { x: 3, y: 20, size: 2 }];
-  const chart = shallow(<BubbleChart items={items} width={100} height={100} />);
-  expect(chart.find('line').length).toBeGreaterThan(0);
-});
-
-it('should display ticks', () => {
-  const items = [{ x: 1, y: 10, size: 7 }, { x: 2, y: 30, size: 5 }, { x: 3, y: 20, size: 2 }];
-  const chart = shallow(<BubbleChart items={items} width={100} height={100} />);
-  expect(chart.find('.bubble-chart-tick').length).toBeGreaterThan(0);
-});
diff --git a/server/sonar-web/src/main/js/components/charts/bubble-chart.js b/server/sonar-web/src/main/js/components/charts/bubble-chart.js
deleted file mode 100644 (file)
index 0265423..0000000
+++ /dev/null
@@ -1,254 +0,0 @@
-/*
- * 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 d3 from 'd3';
-import React from 'react';
-import sortBy from 'lodash/sortBy';
-import uniq from 'lodash/uniq';
-import { ResizeMixin } from './../mixins/resize-mixin';
-import { TooltipsMixin } from './../mixins/tooltips-mixin';
-
-const TICKS_COUNT = 5;
-
-export const Bubble = React.createClass({
-  propTypes: {
-    x: React.PropTypes.number.isRequired,
-    y: React.PropTypes.number.isRequired,
-    r: React.PropTypes.number.isRequired,
-    tooltip: React.PropTypes.string,
-    link: React.PropTypes.any
-  },
-
-  handleClick() {
-    if (this.props.onClick) {
-      this.props.onClick(this.props.link);
-    }
-  },
-
-  render() {
-    let tooltipAttrs = {};
-    if (this.props.tooltip) {
-      tooltipAttrs = {
-        'data-toggle': 'tooltip',
-        title: this.props.tooltip
-      };
-    }
-    return (
-      <circle
-        onClick={this.handleClick}
-        className="bubble-chart-bubble"
-        r={this.props.r}
-        {...tooltipAttrs}
-        transform={`translate(${this.props.x}, ${this.props.y})`}
-      />
-    );
-  }
-});
-
-export const BubbleChart = React.createClass({
-  propTypes: {
-    items: React.PropTypes.arrayOf(React.PropTypes.object).isRequired,
-    sizeRange: React.PropTypes.arrayOf(React.PropTypes.number),
-    displayXGrid: React.PropTypes.bool,
-    displayXTicks: React.PropTypes.bool,
-    displayYGrid: React.PropTypes.bool,
-    displayYTicks: React.PropTypes.bool,
-    height: React.PropTypes.number,
-    padding: React.PropTypes.arrayOf(React.PropTypes.number),
-    formatXTick: React.PropTypes.func,
-    formatYTick: React.PropTypes.func,
-    onBubbleClick: React.PropTypes.func
-  },
-
-  mixins: [ResizeMixin, TooltipsMixin],
-
-  getDefaultProps() {
-    return {
-      sizeRange: [5, 45],
-      displayXGrid: true,
-      displayYGrid: true,
-      displayXTicks: true,
-      displayYTicks: true,
-      padding: [10, 10, 10, 10],
-      formatXTick: d => d,
-      formatYTick: d => d
-    };
-  },
-
-  getInitialState() {
-    return { width: this.props.width, height: this.props.height };
-  },
-
-  getXRange(xScale, sizeScale, availableWidth) {
-    const minX = d3.min(this.props.items, d => xScale(d.x) - sizeScale(d.size));
-    const maxX = d3.max(this.props.items, d => xScale(d.x) + sizeScale(d.size));
-    const dMinX = minX < 0 ? xScale.range()[0] - minX : xScale.range()[0];
-    const dMaxX = maxX > xScale.range()[1] ? maxX - xScale.range()[1] : 0;
-    return [dMinX, availableWidth - dMaxX];
-  },
-
-  getYRange(yScale, sizeScale, availableHeight) {
-    const minY = d3.min(this.props.items, d => yScale(d.y) - sizeScale(d.size));
-    const maxY = d3.max(this.props.items, d => yScale(d.y) + sizeScale(d.size));
-    const dMinY = minY < 0 ? yScale.range()[1] - minY : yScale.range()[1];
-    const dMaxY = maxY > yScale.range()[0] ? maxY - yScale.range()[0] : 0;
-    return [availableHeight - dMaxY, dMinY];
-  },
-
-  getTicks(scale, format) {
-    const ticks = scale.ticks(TICKS_COUNT).map(tick => format(tick));
-    const uniqueTicksCount = uniq(ticks).length;
-    const ticksCount = uniqueTicksCount < TICKS_COUNT ? uniqueTicksCount - 1 : TICKS_COUNT;
-    return scale.ticks(ticksCount);
-  },
-
-  renderXGrid(ticks, xScale, yScale) {
-    if (!this.props.displayXGrid) {
-      return null;
-    }
-
-    const lines = ticks.map((tick, index) => {
-      const x = xScale(tick);
-      const y1 = yScale.range()[0];
-      const y2 = yScale.range()[1];
-      return <line key={index} x1={x} x2={x} y1={y1} y2={y2} className="bubble-chart-grid" />;
-    });
-
-    return <g ref="xGrid">{lines}</g>;
-  },
-
-  renderYGrid(ticks, xScale, yScale) {
-    if (!this.props.displayYGrid) {
-      return null;
-    }
-
-    const lines = ticks.map((tick, index) => {
-      const y = yScale(tick);
-      const x1 = xScale.range()[0];
-      const x2 = xScale.range()[1];
-      return <line key={index} x1={x1} x2={x2} y1={y} y2={y} className="bubble-chart-grid" />;
-    });
-
-    return <g ref="yGrid">{lines}</g>;
-  },
-
-  renderXTicks(xTicks, xScale, yScale) {
-    if (!this.props.displayXTicks) {
-      return null;
-    }
-
-    const ticks = xTicks.map((tick, index) => {
-      const x = xScale(tick);
-      const y = yScale.range()[0];
-      const innerText = this.props.formatXTick(tick);
-      return (
-        <text key={index} className="bubble-chart-tick" x={x} y={y} dy="1.5em">
-          {innerText}
-        </text>
-      );
-    });
-
-    return <g>{ticks}</g>;
-  },
-
-  renderYTicks(yTicks, xScale, yScale) {
-    if (!this.props.displayYTicks) {
-      return null;
-    }
-
-    const ticks = yTicks.map((tick, index) => {
-      const x = xScale.range()[0];
-      const y = yScale(tick);
-      const innerText = this.props.formatYTick(tick);
-      return (
-        <text
-          key={index}
-          className="bubble-chart-tick bubble-chart-tick-y"
-          x={x}
-          y={y}
-          dx="-0.5em"
-          dy="0.3em"
-        >
-          {innerText}
-        </text>
-      );
-    });
-
-    return <g>{ticks}</g>;
-  },
-
-  render() {
-    if (!this.state.width || !this.state.height) {
-      return <div />;
-    }
-
-    const availableWidth = this.state.width - this.props.padding[1] - this.props.padding[3];
-    const availableHeight = this.state.height - this.props.padding[0] - this.props.padding[2];
-
-    const xScale = d3.scale
-      .linear()
-      .domain([0, d3.max(this.props.items, d => d.x)])
-      .range([0, availableWidth])
-      .nice();
-    const yScale = d3.scale
-      .linear()
-      .domain([0, d3.max(this.props.items, d => d.y)])
-      .range([availableHeight, 0])
-      .nice();
-    const sizeScale = d3.scale
-      .linear()
-      .domain([0, d3.max(this.props.items, d => d.size)])
-      .range(this.props.sizeRange);
-
-    const xScaleOriginal = xScale.copy();
-    const yScaleOriginal = yScale.copy();
-
-    xScale.range(this.getXRange(xScale, sizeScale, availableWidth));
-    yScale.range(this.getYRange(yScale, sizeScale, availableHeight));
-
-    const bubbles = sortBy(this.props.items, b => -b.size).map((item, index) => {
-      return (
-        <Bubble
-          key={index}
-          link={item.link}
-          tooltip={item.tooltip}
-          x={xScale(item.x)}
-          y={yScale(item.y)}
-          r={sizeScale(item.size)}
-          onClick={this.props.onBubbleClick}
-        />
-      );
-    });
-
-    const xTicks = this.getTicks(xScale, this.props.formatXTick);
-    const yTicks = this.getTicks(yScale, this.props.formatYTick);
-
-    return (
-      <svg className="bubble-chart" width={this.state.width} height={this.state.height}>
-        <g transform={`translate(${this.props.padding[3]}, ${this.props.padding[0]})`}>
-          {this.renderXGrid(xTicks, xScale, yScale)}
-          {this.renderXTicks(xTicks, xScale, yScaleOriginal)}
-          {this.renderYGrid(yTicks, xScale, yScale)}
-          {this.renderYTicks(yTicks, xScaleOriginal, yScale)}
-          {bubbles}
-        </g>
-      </svg>
-    );
-  }
-});
index 96422aad693778b7535f86ae8db5539b5609450c..8900b93777f24358148729bb8cf8374f2da6d7fb 100644 (file)
@@ -28,3 +28,5 @@ export const CHART_REVERSED_COLORS_RANGE_PERCENT = [
   '#b0d513',
   '#00aa00'
 ];
+
+export const RATING_COLORS = ['#00aa00', '#b0d513', '#eabe06', '#ed7d20', '#e00'];
index e7dc53d548d946f21c41b99f24c6cd2308a610fb..0f496e61f585f194d2e1c815a57273927d475245 100644 (file)
@@ -88,7 +88,6 @@
 .Select-placeholder,
 :not(.Select--multi) > .Select-control .Select-value {
   bottom: 0;
-  color: #aaa;
   left: 0;
   line-height: @formControlHeight - 1px;
   padding-left: 8px;
index 55121676b40c3accfd29cc06ac1b8e8c89be0892..d0e684f24c38a7c51c52a444049958151b4f1cd2 100644 (file)
@@ -833,6 +833,24 @@ projects.explore_projects=Explore Projects
 projects.not_analyzed=Project is not analyzed yet.
 projects.search=Search by project name or key
 projects.sort_list=Sort list by
+projects.view.list=List
+projects.view.visualizations=Visualizations
+projects.quality_model=Quality Model
+projects.worse_of_reliablity_and_security=Worse of Reliability and Security
+projects.limited_set_of_projects=Displayed project set limited to the top {0} projects based on current sort: {1}.
+projects.sort.name=by name
+projects.sort.reliability=by reliability (best first)
+projects.sort.-reliability=by reliability (worst first)
+projects.sort.security=by security (best first)
+projects.sort.-security=by security (worst first)
+projects.sort.maintainability=by maintainability (best first)
+projects.sort.-maintainability=by maintainability (worst first)
+projects.sort.coverage=by coverage (best first)
+projects.sort.-coverage=by coverage (worst first)
+projects.sort.duplications=by duplications (best first)
+projects.sort.-duplications=by duplications (worst first)
+projects.sort.size=by size (smallest first)
+projects.sort.-size=by size (biggest first)
 
 
 #------------------------------------------------------------------------------