]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-7178 Provide keyboard shortcuts to navigate between search results
authorStas Vilchik <vilchiks@gmail.com>
Mon, 11 Jan 2016 09:11:17 +0000 (10:11 +0100)
committerStas Vilchik <vilchiks@gmail.com>
Mon, 11 Jan 2016 11:40:33 +0000 (12:40 +0100)
server/sonar-web/package.json
server/sonar-web/src/main/js/apps/code/actions/index.js
server/sonar-web/src/main/js/apps/code/components/Code.js
server/sonar-web/src/main/js/apps/code/components/Component.js
server/sonar-web/src/main/js/apps/code/components/Search.js
server/sonar-web/src/main/js/apps/code/reducers/index.js
server/sonar-web/src/main/less/init/tables.less
server/sonar-web/tests/apps/code/store-test.js

index ebaf78ddb3cfebec99de6fc0621300b7cbe0c82d..0a91cc377d4117323ae10b9d9443b6fea749bcbb 100644 (file)
@@ -52,6 +52,7 @@
     "numeral": "1.5.3",
     "postcss-loader": "0.8.0",
     "react": "0.14.2",
+    "react-addons-perf": "0.14.2",
     "react-addons-test-utils": "0.14.2",
     "react-dom": "0.14.2",
     "react-redux": "4.0.1",
index bc3515f64cb3743042a9172574c16954e4c302a8..3fa51d365aa6cea0c7cdfefc0b5e20640803825e 100644 (file)
@@ -43,6 +43,8 @@ const METRICS_WITH_COVERAGE = [
 export const INIT = 'INIT';
 export const BROWSE = 'BROWSE';
 export const SEARCH = 'SEARCH';
+export const SELECT_NEXT = 'SELECT_NEXT';
+export const SELECT_PREV = 'SELECT_PREV';
 export const UPDATE_QUERY = 'UPDATE_QUERY';
 export const START_FETCHING = 'START_FETCHING';
 export const STOP_FETCHING = 'STOP_FETCHING';
@@ -80,6 +82,14 @@ export function updateQueryAction (query) {
   };
 }
 
+export function selectNext () {
+  return { type: SELECT_NEXT };
+}
+
+export function selectPrev () {
+  return { type: SELECT_PREV };
+}
+
 export function startFetching () {
   return { type: START_FETCHING };
 }
@@ -210,3 +220,13 @@ export function search (query, baseComponent) {
     debouncedSearch(query, baseComponent, dispatch);
   };
 }
+
+export function selectCurrent () {
+  return (dispatch, getState) => {
+    const { searchResults } = getState().current;
+    if (searchResults) {
+      const componentKey = getState().current.searchSelectedItem.key;
+      dispatch(browse(componentKey));
+    }
+  };
+}
index f2c61b51ec73ec02c3737c3e6378879d82a3891f..0a78c6e6d63dd6bb43b7de04098049e830bcf7b8 100644 (file)
@@ -65,7 +65,7 @@ class Code extends Component {
     const shouldShowSearchResults = !!searchResults;
     const shouldShowSourceViewer = !!sourceViewer;
     const shouldShowComponents = !shouldShowSearchResults && !shouldShowSourceViewer && components;
-    const shouldShowBreadcrumbs = !shouldShowSearchResults &&  Array.isArray(breadcrumbs) && breadcrumbs.length > 1;
+    const shouldShowBreadcrumbs = !shouldShowSearchResults && Array.isArray(breadcrumbs) && breadcrumbs.length > 1;
 
     const componentsClassName = classNames('spacer-top', { 'new-loading': fetching });
 
@@ -124,5 +124,15 @@ class Code extends Component {
 }
 
 export default connect(state => {
-  return Object.assign({ routing: state.routing }, state.current);
+  return {
+    routing: state.routing,
+    fetching: state.current.fetching,
+    baseComponent: state.current.baseComponent,
+    components: state.current.components,
+    breadcrumbs: state.current.breadcrumbs,
+    sourceViewer: state.current.sourceViewer,
+    coverageMetric: state.current.coverageMetric,
+    searchResults: state.current.searchResults,
+    errorMessage: state.current.errorMessage
+  };
 })(Code);
index 205af9cef65d362ae40ad465ec90ea70afd05ae9..4636da3028b287a4ad35ac3992e92ecf954b1192 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.
  */
+import classNames from 'classnames';
 import React from 'react';
+import ReactDOM from 'react-dom';
+import { connect } from 'react-redux';
 
 import ComponentName from './ComponentName';
 import ComponentMeasure from './ComponentMeasure';
@@ -25,74 +28,116 @@ import ComponentDetach from './ComponentDetach';
 import ComponentPin from './ComponentPin';
 
 
-const Component = ({ component, previous, coverageMetric, onBrowse }) => {
-  let componentAction = null;
+const TOP_OFFSET = 200;
+const BOTTOM_OFFSET = 10;
 
-  switch (component.qualifier) {
-    case 'FIL':
-    case 'UTS':
-      componentAction = <ComponentPin component={component}/>;
-      break;
-    default:
-      componentAction = <ComponentDetach component={component}/>;
+
+class Component extends React.Component {
+  componentDidMount () {
+    this.handleUpdate();
   }
 
-  return (
-      <tr>
-        <td className="thin nowrap">
-          <span className="spacer-right">
-            {componentAction}
-          </span>
-        </td>
-        <td className="code-name-cell">
-          <ComponentName
-              component={component}
-              previous={previous}
-              onBrowse={onBrowse}/>
-        </td>
-        <td className="thin nowrap text-right">
-          <div className="code-components-cell">
-            <ComponentMeasure
-                component={component}
-                metricKey="ncloc"
-                metricType="SHORT_INT"/>
-          </div>
-        </td>
-        <td className="thin nowrap text-right">
-          <div className="code-components-cell">
-            <ComponentMeasure
-                component={component}
-                metricKey="sqale_index"
-                metricType="SHORT_WORK_DUR"/>
-          </div>
-        </td>
-        <td className="thin nowrap text-right">
-          <div className="code-components-cell">
-            <ComponentMeasure
-                component={component}
-                metricKey="violations"
-                metricType="SHORT_INT"/>
-          </div>
-        </td>
-        <td className="thin nowrap text-right">
-          <div className="code-components-cell">
-            <ComponentMeasure
-                component={component}
-                metricKey={coverageMetric}
-                metricType="PERCENT"/>
-          </div>
-        </td>
-        <td className="thin nowrap text-right">
-          <div className="code-components-cell">
-            <ComponentMeasure
+  componentDidUpdate () {
+    this.handleUpdate();
+  }
+
+  handleUpdate () {
+    const { selected } = this.props;
+    if (selected) {
+      setTimeout(() => {
+        this.handleScroll();
+      }, 0);
+    }
+  }
+
+  handleScroll () {
+    const node = ReactDOM.findDOMNode(this);
+    const position = node.getBoundingClientRect();
+    const { top, bottom } = position;
+    if (bottom > window.innerHeight - BOTTOM_OFFSET) {
+      window.scrollTo(0, bottom - window.innerHeight + window.scrollY + BOTTOM_OFFSET);
+    } else if (top < TOP_OFFSET) {
+      window.scrollTo(0, top + window.scrollY - TOP_OFFSET);
+    }
+  }
+
+  render () {
+    const { component, selected, previous, coverageMetric, onBrowse } = this.props;
+
+    let componentAction = null;
+
+    switch (component.qualifier) {
+      case 'FIL':
+      case 'UTS':
+        componentAction = <ComponentPin component={component}/>;
+        break;
+      default:
+        componentAction = <ComponentDetach component={component}/>;
+    }
+
+    return (
+        <tr className={classNames({ 'selected': selected })}>
+          <td className="thin nowrap">
+            <span className="spacer-right">
+              {componentAction}
+            </span>
+          </td>
+          <td className="code-name-cell">
+            <ComponentName
                 component={component}
-                metricKey="duplicated_lines_density"
-                metricType="PERCENT"/>
-          </div>
-        </td>
-      </tr>
-  );
-};
+                previous={previous}
+                onBrowse={onBrowse}/>
+          </td>
+          <td className="thin nowrap text-right">
+            <div className="code-components-cell">
+              <ComponentMeasure
+                  component={component}
+                  metricKey="ncloc"
+                  metricType="SHORT_INT"/>
+            </div>
+          </td>
+          <td className="thin nowrap text-right">
+            <div className="code-components-cell">
+              <ComponentMeasure
+                  component={component}
+                  metricKey="sqale_index"
+                  metricType="SHORT_WORK_DUR"/>
+            </div>
+          </td>
+          <td className="thin nowrap text-right">
+            <div className="code-components-cell">
+              <ComponentMeasure
+                  component={component}
+                  metricKey="violations"
+                  metricType="SHORT_INT"/>
+            </div>
+          </td>
+          <td className="thin nowrap text-right">
+            <div className="code-components-cell">
+              <ComponentMeasure
+                  component={component}
+                  metricKey={coverageMetric}
+                  metricType="PERCENT"/>
+            </div>
+          </td>
+          <td className="thin nowrap text-right">
+            <div className="code-components-cell">
+              <ComponentMeasure
+                  component={component}
+                  metricKey="duplicated_lines_density"
+                  metricType="PERCENT"/>
+            </div>
+          </td>
+        </tr>
+    );
+  }
+}
+
 
+function mapStateToProps (state, ownProps) {
+  return {
+    selected: state.current.searchSelectedItem === ownProps.component
+  };
+}
 
-export default Component;
+export default connect(mapStateToProps)(Component);
index 2a6c602926fd428674769614997cd2a4f127b0a6..e29efa6260005013427a5b9a90ddddd871fa24d4 100644 (file)
@@ -20,7 +20,7 @@
 import React, { Component } from 'react';
 import { connect } from 'react-redux';
 
-import { search } from '../actions';
+import { search, selectCurrent, selectNext, selectPrev } from '../actions';
 
 
 class Search extends Component {
@@ -28,6 +28,26 @@ class Search extends Component {
     this.refs.input.focus();
   }
 
+  handleKeyDown (e) {
+    const { dispatch } = this.props;
+    switch (e.keyCode) {
+      case 13:
+        e.preventDefault();
+        dispatch(selectCurrent());
+        break;
+      case 38:
+        e.preventDefault();
+        dispatch(selectPrev());
+        break;
+      case 40:
+        e.preventDefault();
+        dispatch(selectNext());
+        break;
+      default:
+      // do nothing
+    }
+  }
+
   handleSearch (e) {
     e.preventDefault();
     const { dispatch, component } = this.props;
@@ -47,6 +67,7 @@ class Search extends Component {
           </button>
           <input
               ref="input"
+              onKeyDown={this.handleKeyDown.bind(this)}
               onChange={this.handleSearch.bind(this)}
               value={query}
               className="search-box-input"
index 2c8b31a1ebbb0f7b75d2562857b20a553351d620..0e697778d32f8f78ec7a7374c3b28f47d59fae85 100644 (file)
@@ -19,7 +19,8 @@
  */
 import _ from 'underscore';
 
-import { INIT, BROWSE, SEARCH, UPDATE_QUERY, START_FETCHING, STOP_FETCHING, RAISE_ERROR } from '../actions';
+import { INIT, BROWSE, SEARCH, UPDATE_QUERY, SELECT_NEXT, SELECT_PREV, START_FETCHING, STOP_FETCHING,
+    RAISE_ERROR } from '../actions';
 
 
 function hasSourceCode (component) {
@@ -68,6 +69,25 @@ function sortChildren (children) {
   return temp;
 }
 
+function getNext (element, list) {
+  if (list) {
+    const length = list.length;
+    const index = list.indexOf(element);
+    return index < length - 1 ? list[index + 1] : element;
+  } else {
+    return element;
+  }
+}
+
+function getPrev (element, list) {
+  if (list) {
+    const index = list.indexOf(element);
+    return index > 0 ? list[index - 1] : element;
+  } else {
+    return element;
+  }
+}
+
 
 export const initialState = {
   fetching: false,
@@ -77,6 +97,7 @@ export const initialState = {
   sourceViewer: null,
   searchResults: null,
   searchQuery: '',
+  searchSelectedItem: null,
   coverageMetric: null,
   baseBreadcrumbs: [],
   errorMessage: null
@@ -105,17 +126,29 @@ export function current (state = initialState, action) {
         sourceViewer,
         searchResults: null,
         searchQuery: '',
+        searchSelectedItem: null,
         errorMessage: null
       };
     case SEARCH:
       return {
         ...state,
         searchResults: action.components,
+        searchSelectedItem: _.first(action.components),
         sourceViewer: null,
         errorMessage: null
       };
     case UPDATE_QUERY:
       return { ...state, searchQuery: action.query };
+    case SELECT_NEXT:
+      return {
+        ...state,
+        searchSelectedItem: getNext(state.searchSelectedItem, state.searchResults)
+      };
+    case SELECT_PREV:
+      return {
+        ...state,
+        searchSelectedItem: getPrev(state.searchSelectedItem, state.searchResults)
+      };
     case START_FETCHING:
       return { ...state, fetching: true };
     case STOP_FETCHING:
index c34f2e032ca6cb2fb0e464929a49bc8b82d495fa..a31ad328c289b4887d855000f86029b9dff7b059 100644 (file)
@@ -76,6 +76,10 @@ table.data.zebra > tbody > tr:nth-child(odd) {
   background-color: #f5f5f5;
 }
 
+table.data.zebra > tbody > tr.selected {
+  background-color: @lightBlue !important;
+}
+
 .data thead tr.total {
   background-color: #EFEFEF;
   font-weight: normal;
index e8b36a58e4b4406cf63661a32feef727de7b4321..04b1e179835a24dec03aafab102879ae27ea18fa 100644 (file)
@@ -6,6 +6,8 @@ import {
     browseAction,
     searchAction,
     updateQueryAction,
+    selectNext,
+    selectPrev,
     startFetching,
     stopFetching,
     raiseError
@@ -13,6 +15,7 @@ import {
 
 
 const exampleComponent = { key: 'A' };
+const exampleComponent2 = { key: 'B' };
 const exampleComponents = [
   { key: 'B' },
   { key: 'C' }
@@ -228,6 +231,61 @@ describe('Code :: Store', () => {
               .to.equal('');
         });
       });
+      describe('searchSelectedItem', () => {
+        it('should be set to the first result', () => {
+          const results = [exampleComponent, exampleComponent2];
+          expect(current(initialState, searchAction(results)).searchSelectedItem)
+              .to.equal(exampleComponent);
+        });
+
+        it('should select next', () => {
+          const results = [exampleComponent, exampleComponent2];
+          const stateBefore = current(initialState, searchAction(results));
+          const stateAfter = current(stateBefore, selectNext());
+          expect(stateAfter.searchSelectedItem)
+              .to.equal(exampleComponent2);
+        });
+
+        it('should not select next', () => {
+          const results = [exampleComponent, exampleComponent2];
+          const stateBefore = Object.assign({}, current(initialState, searchAction(results)), {
+            searchSelectedItem: exampleComponent2
+          });
+          expect(current(stateBefore, selectNext()).searchSelectedItem)
+              .to.equal(exampleComponent2);
+        });
+
+        it('should select prev', () => {
+          const results = [exampleComponent, exampleComponent2];
+          const stateBefore = Object.assign({}, current(initialState, searchAction(results)), {
+            searchSelectedItem: exampleComponent2
+          });
+          expect(current(stateBefore, selectPrev()).searchSelectedItem)
+              .to.equal(exampleComponent);
+        });
+
+        it('should not select prev', () => {
+          const results = [exampleComponent, exampleComponent2];
+          const stateBefore = current(initialState, searchAction(results));
+          expect(current(stateBefore, selectPrev()).searchSelectedItem)
+              .to.equal(exampleComponent);
+        });
+
+        it('should ignore if no results', () => {
+          expect(current(initialState, selectNext()).searchSelectedItem)
+              .to.be.null;
+          expect(current(initialState, selectPrev()).searchSelectedItem)
+              .to.be.null;
+        });
+
+        it('should be reset on browse', () => {
+          const results = [exampleComponent, exampleComponent2];
+          const stateBefore = current(initialState, searchAction(results));
+          const stateAfter = current(stateBefore, browseAction(exampleComponent));
+          expect(stateAfter.searchSelectedItem)
+              .to.be.null;
+        });
+      });
       describe('errorMessage', () => {
         it('should be set', () => {
           expect(current(initialState, raiseError('error!')).errorMessage)