From: Stas Vilchik Date: Mon, 11 Jan 2016 09:11:17 +0000 (+0100) Subject: SONAR-7178 Provide keyboard shortcuts to navigate between search results X-Git-Tag: 5.4-M4~7 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=0ef5d6d8e1f265622bb7671b67de8a661316105c;p=sonarqube.git SONAR-7178 Provide keyboard shortcuts to navigate between search results --- diff --git a/server/sonar-web/package.json b/server/sonar-web/package.json index ebaf78ddb3c..0a91cc377d4 100644 --- a/server/sonar-web/package.json +++ b/server/sonar-web/package.json @@ -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", diff --git a/server/sonar-web/src/main/js/apps/code/actions/index.js b/server/sonar-web/src/main/js/apps/code/actions/index.js index bc3515f64cb..3fa51d365aa 100644 --- a/server/sonar-web/src/main/js/apps/code/actions/index.js +++ b/server/sonar-web/src/main/js/apps/code/actions/index.js @@ -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)); + } + }; +} diff --git a/server/sonar-web/src/main/js/apps/code/components/Code.js b/server/sonar-web/src/main/js/apps/code/components/Code.js index f2c61b51ec7..0a78c6e6d63 100644 --- a/server/sonar-web/src/main/js/apps/code/components/Code.js +++ b/server/sonar-web/src/main/js/apps/code/components/Code.js @@ -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); diff --git a/server/sonar-web/src/main/js/apps/code/components/Component.js b/server/sonar-web/src/main/js/apps/code/components/Component.js index 205af9cef65..4636da3028b 100644 --- a/server/sonar-web/src/main/js/apps/code/components/Component.js +++ b/server/sonar-web/src/main/js/apps/code/components/Component.js @@ -17,7 +17,10 @@ * 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 = ; - break; - default: - componentAction = ; + +class Component extends React.Component { + componentDidMount () { + this.handleUpdate(); } - return ( - - - - {componentAction} - - - - - - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- { + 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 = ; + break; + default: + componentAction = ; + } + + return ( + + + + {componentAction} + + + + -
- - - ); -}; + previous={previous} + onBrowse={onBrowse}/> + + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + ); + } +} + +function mapStateToProps (state, ownProps) { + return { + selected: state.current.searchSelectedItem === ownProps.component + }; +} -export default Component; +export default connect(mapStateToProps)(Component); diff --git a/server/sonar-web/src/main/js/apps/code/components/Search.js b/server/sonar-web/src/main/js/apps/code/components/Search.js index 2a6c602926f..e29efa62600 100644 --- a/server/sonar-web/src/main/js/apps/code/components/Search.js +++ b/server/sonar-web/src/main/js/apps/code/components/Search.js @@ -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 { 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: diff --git a/server/sonar-web/src/main/less/init/tables.less b/server/sonar-web/src/main/less/init/tables.less index c34f2e032ca..a31ad328c28 100644 --- a/server/sonar-web/src/main/less/init/tables.less +++ b/server/sonar-web/src/main/less/init/tables.less @@ -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; diff --git a/server/sonar-web/tests/apps/code/store-test.js b/server/sonar-web/tests/apps/code/store-test.js index e8b36a58e4b..04b1e179835 100644 --- a/server/sonar-web/tests/apps/code/store-test.js +++ b/server/sonar-web/tests/apps/code/store-test.js @@ -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)