]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-8878 Add a sorting buttons on projects page facets (#1754)
authorGrégoire Aubert <gregaubert@users.noreply.github.com>
Wed, 8 Mar 2017 09:54:50 +0000 (10:54 +0100)
committerGitHub <noreply@github.com>
Wed, 8 Mar 2017 09:54:50 +0000 (10:54 +0100)
server/sonar-web/src/main/js/apps/projects/filters/CoverageFilter.js
server/sonar-web/src/main/js/apps/projects/filters/DuplicationsFilter.js
server/sonar-web/src/main/js/apps/projects/filters/Filter.js
server/sonar-web/src/main/js/apps/projects/filters/IssuesFilter.js
server/sonar-web/src/main/js/apps/projects/filters/SizeFilter.js
server/sonar-web/src/main/js/apps/projects/filters/SortingFilter.js [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/less/init/forms.less
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index ab7dd2fa33603671e92bf7603e43eb00919ff1f3..f13699a08072bba51adee240f4c5ae302683be12 100644 (file)
  */
 import React from 'react';
 import FilterContainer from './FilterContainer';
+import SortingFilter from './SortingFilter';
 import CoverageRating from '../../../components/ui/CoverageRating';
 import { getCoverageRatingLabel, getCoverageRatingAverageValue } from '../../../helpers/ratings';
 
 export default class CoverageFilter extends React.Component {
+  static propTypes = {
+    query: React.PropTypes.object.isRequired,
+    isFavorite: React.PropTypes.bool,
+    organization: React.PropTypes.object
+  }
+
+  property = 'coverage';
+
   renderOption = (option, selected) => {
     return (
         <span>
@@ -34,6 +43,17 @@ export default class CoverageFilter extends React.Component {
     );
   };
 
+  renderSort = () => {
+    return (
+        <SortingFilter
+          property={this.property}
+          query={this.props.query}
+          isFavorite={this.props.isFavorite}
+          organization={this.props.organization}
+          sortDesc="right"/>
+    );
+  }
+
   getFacetValueForOption = (facet, option) => {
     const map = ['80.0-*', '70.0-80.0', '50.0-70.0', '30.0-50.0', '*-30.0'];
     return facet[map[option - 1]];
@@ -42,10 +62,11 @@ export default class CoverageFilter extends React.Component {
   render () {
     return (
         <FilterContainer
-            property="coverage"
+            property={this.property}
             getOptions={() => [1, 2, 3, 4, 5]}
             renderName={() => 'Coverage'}
             renderOption={this.renderOption}
+            renderSort={this.renderSort}
             getFacetValueForOption={this.getFacetValueForOption}
             query={this.props.query}
             isFavorite={this.props.isFavorite}
index a7549c745895f374069cf18c55f8828ef0fb350c..818e80e6eca15d50d46e838f9007d0365502d0fa 100644 (file)
  */
 import React from 'react';
 import FilterContainer from './FilterContainer';
+import SortingFilter from './SortingFilter';
 import DuplicationsRating from '../../../components/ui/DuplicationsRating';
 import { getDuplicationsRatingLabel, getDuplicationsRatingAverageValue } from '../../../helpers/ratings';
 
 export default class DuplicationsFilter extends React.Component {
+  static propTypes = {
+    query: React.PropTypes.object.isRequired,
+    isFavorite: React.PropTypes.bool,
+    organization: React.PropTypes.object
+  }
+
+  property = 'duplications';
+
   renderOption = (option, selected) => {
     return (
         <span>
@@ -34,6 +43,16 @@ export default class DuplicationsFilter extends React.Component {
     );
   };
 
+  renderSort = () => {
+    return (
+        <SortingFilter
+          property={this.property}
+          query={this.props.query}
+          isFavorite={this.props.isFavorite}
+          organization={this.props.organization}/>
+    );
+  }
+
   getFacetValueForOption = (facet, option) => {
     const map = ['*-3.0', '3.0-5.0', '5.0-10.0', '10.0-20.0', '20.0-*'];
     return facet[map[option - 1]];
@@ -42,10 +61,11 @@ export default class DuplicationsFilter extends React.Component {
   render () {
     return (
         <FilterContainer
-            property="duplications"
+            property={this.property}
             getOptions={() => [1, 2, 3, 4, 5]}
             renderName={() => 'Duplications'}
             renderOption={this.renderOption}
+            renderSort={this.renderSort}
             getFacetValueForOption={this.getFacetValueForOption}
             query={this.props.query}
             isFavorite={this.props.isFavorite}
index d8d6c4562f05a71843b5b2c98df68c4fd765aad8..9f2bda8460c1468f3b19da320712d42dead64ed8 100644 (file)
@@ -35,6 +35,7 @@ export default class Filter extends React.Component {
     renderName: React.PropTypes.func.isRequired,
     renderOption: React.PropTypes.func.isRequired,
     renderFooter: React.PropTypes.func,
+    renderSort: React.PropTypes.func,
 
     getFacetValueForOption: React.PropTypes.func,
 
@@ -70,6 +71,7 @@ export default class Filter extends React.Component {
     return (
         <div className="search-navigator-facet-header projects-facet-header">
           {this.props.renderName()}
+          {this.props.renderSort && this.props.renderSort()}
         </div>
     );
   }
index 8d3bcc0c582b8424c4c6575e9719c5b87d2025ae..0250eafae0f75e7433cb3f7de43204b4fb163a39 100644 (file)
  */
 import React from 'react';
 import FilterContainer from './FilterContainer';
+import SortingFilter from './SortingFilter';
 import Rating from '../../../components/ui/Rating';
 
 export default class IssuesFilter extends React.Component {
+  static propTypes = {
+    property: React.PropTypes.string.isRequired,
+    name: React.PropTypes.string.isRequired,
+    query: React.PropTypes.object.isRequired,
+    isFavorite: React.PropTypes.bool,
+    organization: React.PropTypes.object
+  }
+
   renderOption = (option, selected) => {
     return (
         <span>
@@ -33,6 +42,16 @@ export default class IssuesFilter extends React.Component {
     );
   };
 
+  renderSort = () => {
+    return (
+        <SortingFilter
+          property={this.props.property}
+          query={this.props.query}
+          isFavorite={this.props.isFavorite}
+          organization={this.props.organization}/>
+    );
+  }
+
   getFacetValueForOption = (facet, option) => {
     return facet[option];
   };
@@ -44,6 +63,7 @@ export default class IssuesFilter extends React.Component {
             getOptions={() => [1, 2, 3, 4, 5]}
             renderName={() => this.props.name}
             renderOption={this.renderOption}
+            renderSort={this.renderSort}
             getFacetValueForOption={this.getFacetValueForOption}
             query={this.props.query}
             isFavorite={this.props.isFavorite}
index 5633034b737baf6814d2a51c9f2a709b1a2b653c..0bc240dd12d6c8536184b0185fe2a7db82f2c621 100644 (file)
  */
 import React from 'react';
 import FilterContainer from './FilterContainer';
+import SortingFilter from './SortingFilter';
 import SizeRating from '../../../components/ui/SizeRating';
+import { translate } from '../../../helpers/l10n';
 import { getSizeRatingLabel, getSizeRatingAverageValue } from '../../../helpers/ratings';
 
 export default class SizeFilter extends React.Component {
+  static propTypes = {
+    query: React.PropTypes.object.isRequired,
+    isFavorite: React.PropTypes.bool,
+    organization: React.PropTypes.object
+  }
+
+  property = 'size';
+
   renderOption = (option, selected) => {
     return (
         <span>
@@ -34,6 +44,18 @@ export default class SizeFilter extends React.Component {
     );
   };
 
+  renderSort = () => {
+    return (
+        <SortingFilter
+          property={this.property}
+          query={this.props.query}
+          isFavorite={this.props.isFavorite}
+          organization={this.props.organization}
+          leftText={translate('biggest')}
+          rightText={translate('smallest')}/>
+    );
+  }
+
   getFacetValueForOption = (facet, option) => {
     const map = ['*-1000.0', '1000.0-10000.0', '10000.0-100000.0', '100000.0-500000.0', '500000.0-*'];
     return facet[map[option - 1]];
@@ -42,10 +64,11 @@ export default class SizeFilter extends React.Component {
   render () {
     return (
         <FilterContainer
-            property="size"
+            property={this.property}
             getOptions={() => [1, 2, 3, 4, 5]}
             renderName={() => 'Size'}
             renderOption={this.renderOption}
+            renderSort={this.renderSort}
             getFacetValueForOption={this.getFacetValueForOption}
             query={this.props.query}
             isFavorite={this.props.isFavorite}
diff --git a/server/sonar-web/src/main/js/apps/projects/filters/SortingFilter.js b/server/sonar-web/src/main/js/apps/projects/filters/SortingFilter.js
new file mode 100644 (file)
index 0000000..842a359
--- /dev/null
@@ -0,0 +1,90 @@
+/*
+ * 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 { Link } from 'react-router';
+import { getFilterUrl } from './utils';
+import { translate } from '../../../helpers/l10n';
+
+export default class SortingFilter extends React.Component {
+  static propTypes = {
+    property: React.PropTypes.string.isRequired,
+    query: React.PropTypes.object.isRequired,
+    isFavorite: React.PropTypes.bool,
+    organization: React.PropTypes.object,
+    sortDesc: React.PropTypes.oneOf(['left', 'right']),
+    leftText: React.PropTypes.string,
+    rightText: React.PropTypes.string
+  }
+
+  static defaultProps = {
+    sortDesc: 'left',
+    leftText: translate('worst'),
+    rightText: translate('best')
+  };
+
+  isSortActive (side) {
+    const { sort } = this.props.query;
+    if (sort && sort[0] === '-') {
+      return sort.substr(1) === this.props.property && side === this.props.sortDesc;
+    } else {
+      return sort === this.props.property && side !== this.props.sortDesc;
+    }
+  }
+
+  getLinkClass (side) {
+    return classNames('button button-small button-grey', {
+      'button-active': this.isSortActive(side)
+    });
+  }
+
+  getLinkPath (side) {
+    if (this.isSortActive(side)) {
+      return getFilterUrl(this.props, { sort: null });
+    }
+    return getFilterUrl(this.props, {
+      sort: (this.props.sortDesc === side ? '-' : '') + this.props.property
+    });
+  }
+
+  blurLink (event) {
+    event.target.blur();
+  }
+
+  render () {
+    const { leftText, rightText } = this.props;
+
+    return (
+        <div className="projects-facet-sort">
+          <span>{translate('projects.sort_list')}</span>
+          <div className="spacer-left button-group">
+            <Link
+              onClick={this.blurLink}
+              className={this.getLinkClass('left')}
+              to={this.getLinkPath('left')}>{leftText}</Link>
+            <Link
+              onClick={this.blurLink}
+              className={this.getLinkClass('right')}
+              to={this.getLinkPath('right')}>{rightText}</Link>
+          </div>
+        </div>
+    );
+  }
+}
index b609227c95f1c7ec7ae9f2ab6e53dbd70a9ed07e..7cca5a9ec47c74c36c6774cd82b4b1af13a107da 100644 (file)
@@ -28,7 +28,7 @@ import { updateState } from './stateDuck';
 import { getProjectsAppState } from '../../../store/rootReducer';
 import { getMeasuresForProjects } from '../../../api/measures';
 import { receiveComponentsMeasures } from '../../../store/measures/actions';
-import { convertToFilter } from './utils';
+import { convertToQueryData } from './utils';
 import { receiveFavorites } from '../../../store/favorites/duck';
 import { getOrganizations } from '../../../api/organizations';
 import { receiveOrganizations } from '../../../store/organizations/duck';
@@ -143,14 +143,7 @@ const onReceiveMoreProjects = dispatch => response => {
 
 export const fetchProjects = (query, isFavorite, organization) => dispatch => {
   dispatch(updateState({ loading: true }));
-  const data = { ps: PAGE_SIZE, facets: FACETS.join() };
-  const filter = convertToFilter(query, isFavorite);
-  if (filter) {
-    data.filter = filter;
-  }
-  if (organization) {
-    data.organization = organization.key;
-  }
+  const data = convertToQueryData(query, isFavorite, organization, { ps: PAGE_SIZE, facets: FACETS.join() });
   return searchProjects(data).then(onReceiveProjects(dispatch), onFail(dispatch));
 };
 
@@ -158,13 +151,6 @@ export const fetchMoreProjects = (query, isFavorite, organization) => (dispatch,
   dispatch(updateState({ loading: true }));
   const state = getState();
   const { pageIndex } = getProjectsAppState(state);
-  const data = { ps: PAGE_SIZE, p: pageIndex + 1 };
-  const filter = convertToFilter(query, isFavorite);
-  if (filter) {
-    data.filter = filter;
-  }
-  if (organization) {
-    data.organization = organization.key;
-  }
+  const data = convertToQueryData(query, isFavorite, organization, { ps: PAGE_SIZE, p: pageIndex + 1 });
   return searchProjects(data).then(onReceiveMoreProjects(dispatch), onFail(dispatch));
 };
index e2cd838941b913e98a60e3ec26acf58ab8ef6faf..b9d15c5140cf9510d172752ff43a3acb696c1da4 100644 (file)
@@ -55,9 +55,40 @@ export const parseUrlQuery = urlQuery => ({
   'duplications': getAsNumericRating(urlQuery['duplications']),
   'size': getAsNumericRating(urlQuery['size']),
   'languages': getAsArray(urlQuery['languages'], getAsString),
-  'search': getAsString(urlQuery['search'])
+  'search': getAsString(urlQuery['search']),
+  'sort': getAsString(urlQuery['sort'])
 });
 
+export const mapMetricToProperty = metricKey => {
+  const map = {
+    'reliability_rating': 'reliability',
+    'security_rating': 'security',
+    'sqale_rating': 'maintainability',
+    'coverage': 'coverage',
+    'duplicated_lines_density': 'duplications',
+    'ncloc': 'size',
+    'alert_status': 'gate',
+    'languages': 'languages',
+    'query': 'search'
+  };
+  return map[metricKey];
+};
+
+export const mapPropertyToMetric = property => {
+  const map = {
+    'reliability': 'reliability_rating',
+    'security': 'security_rating',
+    'maintainability': 'sqale_rating',
+    'coverage': 'coverage',
+    'duplications': 'duplicated_lines_density',
+    'size': 'ncloc',
+    'gate': 'alert_status',
+    'languages': 'languages',
+    'search': 'query'
+  };
+  return map[property];
+};
+
 const convertIssuesRating = (metric, rating) => {
   if (rating > 1 && rating < 5) {
     return `${metric} >= ${rating}`;
@@ -69,15 +100,15 @@ const convertIssuesRating = (metric, rating) => {
 const convertCoverage = coverage => {
   switch (coverage) {
     case 1:
-      return 'coverage >= 80';
+      return mapPropertyToMetric('coverage') + ' >= 80';
     case 2:
-      return 'coverage < 80';
+      return mapPropertyToMetric('coverage') + ' < 80';
     case 3:
-      return 'coverage < 70';
+      return mapPropertyToMetric('coverage') + ' < 70';
     case 4:
-      return 'coverage < 50';
+      return mapPropertyToMetric('coverage') + ' < 50';
     case 5:
-      return 'coverage < 30';
+      return mapPropertyToMetric('coverage') + ' < 30';
     default:
       return '';
   }
@@ -86,15 +117,15 @@ const convertCoverage = coverage => {
 const convertDuplications = duplications => {
   switch (duplications) {
     case 1:
-      return 'duplicated_lines_density < 3';
+      return mapPropertyToMetric('duplications') + ' < 3';
     case 2:
-      return 'duplicated_lines_density >= 3';
+      return mapPropertyToMetric('duplications') + ' >= 3';
     case 3:
-      return 'duplicated_lines_density >= 5';
+      return mapPropertyToMetric('duplications') + ' >= 5';
     case 4:
-      return 'duplicated_lines_density >= 10';
+      return mapPropertyToMetric('duplications') + ' >= 10';
     case 5:
-      return 'duplicated_lines_density >= 20';
+      return mapPropertyToMetric('duplications') + ' >= 20';
     default:
       return '';
   }
@@ -103,21 +134,21 @@ const convertDuplications = duplications => {
 const convertSize = size => {
   switch (size) {
     case 1:
-      return 'ncloc < 1000';
+      return mapPropertyToMetric('size') + ' < 1000';
     case 2:
-      return 'ncloc >= 1000';
+      return mapPropertyToMetric('size') + ' >= 1000';
     case 3:
-      return 'ncloc >= 10000';
+      return mapPropertyToMetric('size') + ' >= 10000';
     case 4:
-      return 'ncloc >= 100000';
+      return mapPropertyToMetric('size') + ' >= 100000';
     case 5:
-      return 'ncloc >= 500000';
+      return mapPropertyToMetric('size') + ' >= 500000';
     default:
       return '';
   }
 };
 
-export const convertToFilter = (query, isFavorite) => {
+const convertToFilter = (query, isFavorite) => {
   const conditions = [];
 
   if (isFavorite) {
@@ -125,7 +156,7 @@ export const convertToFilter = (query, isFavorite) => {
   }
 
   if (query['gate'] != null) {
-    conditions.push('alert_status = ' + query['gate']);
+    conditions.push(mapPropertyToMetric('gate') + ' = ' + query['gate']);
   }
 
   if (query['coverage'] != null) {
@@ -140,44 +171,51 @@ export const convertToFilter = (query, isFavorite) => {
     conditions.push(convertSize(query['size']));
   }
 
-  if (query['reliability'] != null) {
-    conditions.push(convertIssuesRating('reliability_rating', query['reliability']));
-  }
-
-  if (query['security'] != null) {
-    conditions.push(convertIssuesRating('security_rating', query['security']));
-  }
-
-  if (query['maintainability'] != null) {
-    conditions.push(convertIssuesRating('sqale_rating', query['maintainability']));
-  }
+  ['reliability', 'security', 'maintainability'].forEach(property => {
+    if (query[property] != null) {
+      conditions.push(convertIssuesRating(mapPropertyToMetric(property), query[property]));
+    }
+  });
 
   const { languages } = query;
   if (languages != null) {
     if (!Array.isArray(languages) || languages.length < 2) {
-      conditions.push('languages = ' + languages);
+      conditions.push(mapPropertyToMetric('languages') + ' = ' + languages);
     } else {
-      conditions.push(`languages IN (${languages.join(', ')})`);
+      conditions.push(`${mapPropertyToMetric('languages')} IN (${languages.join(', ')})`);
     }
   }
 
   if (query['search'] != null) {
-    conditions.push(`query = "${query['search']}"`);
+    conditions.push(`${mapPropertyToMetric('search')} = "${query['search']}"`);
   }
 
   return conditions.join(' and ');
 };
 
-export const mapMetricToProperty = metricKey => {
-  const map = {
-    'reliability_rating': 'reliability',
-    'security_rating': 'security',
-    'sqale_rating': 'maintainability',
-    'coverage': 'coverage',
-    'duplicated_lines_density': 'duplications',
-    'ncloc': 'size',
-    'alert_status': 'gate',
-    'languages': 'languages'
-  };
-  return map[metricKey];
+export const convertToSorting = ({ sort }) => {
+  if (sort && sort[0] === '-') {
+    return { s: mapPropertyToMetric(sort.substr(1)), asc: false };
+  }
+  return { s: mapPropertyToMetric(sort) };
+};
+
+export const convertToQueryData = (query, isFavorite, organization, defaultData = {}) => {
+  const data = { ...defaultData };
+  const filter = convertToFilter(query, isFavorite);
+  const sort = convertToSorting(query);
+
+  if (filter) {
+    data.filter = filter;
+  }
+  if (sort.s) {
+    data.s = sort.s;
+  }
+  if (sort.hasOwnProperty('asc')) {
+    data.asc = sort.asc;
+  }
+  if (organization) {
+    data.organization = organization.key;
+  }
+  return data;
 };
index 0729984e8c7bee8389490c4a37f16efd636b2a94..176c2e5713b51c8189d1e0a929812779da26c493 100644 (file)
   transition: none;
 }
 
+.projects-facet-sort {
+  float: right;
+  font-weight: normal;
+  font-size: 11px;
+  color: #777;
+  text-transform: lowercase;
+}
+
+.projects-facet-sort .button-group {
+  margin-top: -3px;
+}
+
+.projects-facet-sort .button-small {
+  padding: 0 6px;
+  font-size: 11px;
+  font-weight: normal;
+}
+
 .projects-facets-header {
   margin-bottom: 10px;
   padding: 10px 0;
index 79bbe7cb4cb38038e7da31b5ccdcef47cf3cfe1a..bca41f0afcfbd1cccc9e7de8fa5d4510e999c8fa 100644 (file)
@@ -149,6 +149,23 @@ input[type="submit"].button-success {
   }
 }
 
+.button-grey,
+input[type="submit"].button-grey {
+  border-color: @middleGrey;
+  color: @secondFontColor;
+
+  &:hover, &:focus, &.active {
+    background: @middleGrey;
+    color: @white;
+  }
+
+  &.button-active {
+    background: @secondFontColor;
+    border-color: @secondFontColor;
+    color: @white;
+  }
+}
+
 .button-clean,
 .button-clean:hover,
 .button-clean:focus {
index 25332bd9dd5f978234ee35bc4eb5baae829c9c47..5e5ca82eddd9ff55bf1b443c588ecfd6bcff9187 100644 (file)
@@ -24,6 +24,8 @@ author=Author
 back=Back
 backup=Backup
 backup_verb=Back up
+best=Best
+biggest=Biggest
 blocker=Blocker
 bold=Bold
 branch=Branch
@@ -158,6 +160,7 @@ shared=Shared
 show_verb=Show
 x_of_y_shown={0} of {1} shown
 size=Size
+smallest=Smallest
 status=Status
 status_abbreviated=St.
 sub_project=Sub-project
@@ -194,6 +197,7 @@ view=View
 views=Views
 violations=Violations
 with=With
+worst=Worst
 
 
 
@@ -824,6 +828,7 @@ projects.no_favorite_projects.engagement=Discover and mark as favorites projects
 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
 
 
 #------------------------------------------------------------------------------