]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-9614 Add keyboard shortcuts on project measures page
authorGrégoire Aubert <gregoire.aubert@sonarsource.com>
Tue, 8 Aug 2017 14:29:03 +0000 (16:29 +0200)
committerGrégoire Aubert <gregoire.aubert@sonarsource.com>
Mon, 14 Aug 2017 09:44:44 +0000 (11:44 +0200)
server/sonar-web/src/main/js/apps/component-measures/components/App.js
server/sonar-web/src/main/js/apps/component-measures/components/Breadcrumbs.js
server/sonar-web/src/main/js/apps/component-measures/components/MeasureContent.js
server/sonar-web/src/main/js/apps/component-measures/components/MeasureViewSelect.js
server/sonar-web/src/main/js/apps/component-measures/components/__tests__/__snapshots__/MeasureViewSelect-test.js.snap
server/sonar-web/src/main/js/apps/component-measures/drilldown/ComponentCell.js
server/sonar-web/src/main/js/apps/component-measures/drilldown/ComponentsListRow.js
server/sonar-web/src/main/js/apps/component-measures/drilldown/FilesView.js
server/sonar-web/src/main/js/apps/component-measures/style.css

index 2a4e2ad6b624e195065f0b57b05313d9a4ae5dea..a71b9e69b736ecc967c1a3b48064411dbe84a432 100644 (file)
@@ -20,6 +20,7 @@
 // @flow
 import React from 'react';
 import Helmet from 'react-helmet';
+import key from 'keymaster';
 import MeasureContentContainer from './MeasureContentContainer';
 import MeasureOverviewContainer from './MeasureOverviewContainer';
 import Sidebar from '../sidebar/Sidebar';
@@ -71,7 +72,7 @@ export default class App extends React.PureComponent {
     this.mounted = true;
     this.props.fetchMetrics();
     this.fetchMeasures(this.props);
-
+    key.setScope('measures-files');
     const footer = document.getElementById('footer');
     if (footer) {
       footer.classList.add('search-navigator-footer');
@@ -89,6 +90,7 @@ export default class App extends React.PureComponent {
 
   componentWillUnmount() {
     this.mounted = false;
+    key.deleteScope('measures-files');
     const footer = document.getElementById('footer');
     if (footer) {
       footer.classList.remove('search-navigator-footer');
index a9b9c3ecbcfea4f46542175a58a6f3c0de36d6d6..6edb170b06e238b61a52f37a807b9de431572d72 100644 (file)
@@ -19,6 +19,7 @@
  */
 // @flow
 import React from 'react';
+import key from 'keymaster';
 import Breadcrumb from './Breadcrumb';
 import { getBreadcrumbs } from '../../../api/components';
 import type { Component } from '../types';
@@ -44,6 +45,7 @@ export default class Breadcrumbs extends React.PureComponent {
   componentDidMount() {
     this.mounted = true;
     this.fetchBreadcrumbs(this.props);
+    this.attachShortcuts();
   }
 
   componentWillReceiveProps(nextProps: Props) {
@@ -54,6 +56,21 @@ export default class Breadcrumbs extends React.PureComponent {
 
   componentWillUnmount() {
     this.mounted = false;
+    this.detachShortcuts();
+  }
+
+  attachShortcuts() {
+    key('left', 'measures-files', () => {
+      const { breadcrumbs } = this.state;
+      if (breadcrumbs.length > 1) {
+        this.props.handleSelect(breadcrumbs[breadcrumbs.length - 2].key);
+      }
+      return false;
+    });
+  }
+
+  detachShortcuts() {
+    key.unbind('left', 'measures-files');
   }
 
   fetchBreadcrumbs = ({ component, rootComponent }: Props) => {
index dbfd60547a013ce1dacfa6dfc436bae03494900c..66e9fe4775631f564319cff3f147018f8c38185d 100644 (file)
@@ -58,6 +58,7 @@ type State = {
   components: Array<ComponentEnhanced>,
   metric: ?Metric,
   paging?: Paging,
+  selected: ?string,
   view: ?string
 };
 
@@ -68,6 +69,7 @@ export default class MeasureContent extends React.PureComponent {
     components: [],
     metric: null,
     paging: null,
+    selected: null,
     view: null
   };
 
@@ -86,6 +88,13 @@ export default class MeasureContent extends React.PureComponent {
     this.mounted = false;
   }
 
+  getSelectedIndex = (): ?number => {
+    const index = this.state.components.findIndex(
+      component => component.key === this.state.selected
+    );
+    return index !== -1 ? index : null;
+  };
+
   getComponentRequestParams = (view: string, metric: Metric, options: Object = {}) => {
     const strategy = view === 'list' ? 'leaves' : 'children';
     const metricKeys = [metric.key];
@@ -127,6 +136,7 @@ export default class MeasureContent extends React.PureComponent {
               ),
               metric,
               paging: r.paging,
+              selected: r.components.length > 0 ? r.components[0].key : null,
               view
             });
           }
@@ -168,6 +178,8 @@ export default class MeasureContent extends React.PureComponent {
     );
   };
 
+  onSelectComponent = (component: string) => this.setState({ selected: component });
+
   renderContent() {
     const { component, leakPeriod } = this.props;
 
@@ -201,14 +213,18 @@ export default class MeasureContent extends React.PureComponent {
     }
 
     if (['list', 'tree'].includes(view)) {
+      const selectedIdx = this.getSelectedIndex();
       return (
         <FilesView
           components={this.state.components}
           fetchMore={this.fetchMoreComponents}
-          handleSelect={this.props.updateSelected}
+          handleOpen={this.props.updateSelected}
+          handleSelect={this.onSelectComponent}
           metric={metric}
           metrics={this.props.metrics}
           paging={this.state.paging}
+          selectedKey={selectedIdx != null ? this.state.selected : null}
+          selectedIdx={selectedIdx}
         />
       );
     }
@@ -253,7 +269,7 @@ export default class MeasureContent extends React.PureComponent {
                   view={view}
                 />}
               <PageActions
-                current={this.state.components.length}
+                current={this.getSelectedIndex() + 1}
                 loading={this.props.loading}
                 isFile={isFile}
                 paging={this.state.paging}
index 42b22ac221320fe47204df9f5a7a990bbc8d9541..ec33171709a8059ab3211fa3f3e8983b9cc6bccc 100644 (file)
@@ -82,6 +82,7 @@ export default class MeasureViewSelect extends React.PureComponent {
   render() {
     return (
       <Select
+        autoBlur={true}
         className={this.props.className}
         clearable={false}
         searchable={false}
index 7d51b2dd127ee8f45a05de038a24215000cbf604..7ab0276dde246f7cf4a74eb51ca924329e6afc4d 100644 (file)
@@ -4,6 +4,7 @@ exports[`should display correctly with treemap option 1`] = `
 <Select
   addLabelText="Add \\"{label}\\"?"
   arrowRenderer={[Function]}
+  autoBlur={true}
   autosize={true}
   backspaceRemoves={true}
   backspaceToRemoveMessage="Press backspace to remove {label}"
index 6b0850ec74e257cdc15a9f6f96ab44d971b1df0e..bb6cadb2b74bfc7b430138500d611951a4070d52 100644 (file)
@@ -19,7 +19,6 @@
  */
 // @flow
 import React from 'react';
-import classNames from 'classnames';
 import QualifierIcon from '../../../components/shared/QualifierIcon';
 import { splitPath } from '../../../helpers/path';
 import { getComponentUrl } from '../../../helpers/urls';
@@ -27,7 +26,6 @@ import type { Component } from '../types';
 
 type Props = {
   component: Component,
-  isSelected: boolean,
   onClick: string => void
 };
 
@@ -69,24 +67,20 @@ export default class ComponentCell extends React.PureComponent {
 
   render() {
     const { component } = this.props;
-    const linkClassName = classNames('link-no-underline', {
-      selected: this.props.isSelected
-    });
-
     return (
       <td className="measure-details-component-cell">
         <div className="text-ellipsis">
           {component.refId == null
             ? <a
                 id={'component-measures-component-link-' + component.key}
-                className={linkClassName}
+                className="link-no-underline"
                 href={getComponentUrl(component.key)}
                 onClick={this.handleClick}>
                 {this.renderInner()}
               </a>
             : <a
                 id={'component-measures-component-link-' + component.key}
-                className={linkClassName}
+                className="link-no-underline"
                 href={getComponentUrl(component.refKey || component.key)}>
                 <span className="big-spacer-right">
                   <i className="icon-detach" />
index 29613eb6b1e5a35dc39e89057053de7ea37ca0c8..9b813b15ba33b48cce8a90eaef2945dff8a27b1e 100644 (file)
@@ -19,6 +19,7 @@
  */
 // @flow
 import React from 'react';
+import classNames from 'classnames';
 import ComponentCell from './ComponentCell';
 import MeasureCell from './MeasureCell';
 import type { Component } from '../types';
@@ -38,9 +39,12 @@ export default function ComponentsListRow(props: Props) {
     const measure = component.measures.find(measure => measure.metric === metric.key);
     return { ...measure, metric };
   });
+  const rowClass = classNames('measure-details-component-row', {
+    selected: props.isSelected
+  });
   return (
-    <tr>
-      <ComponentCell component={component} isSelected={props.isSelected} onClick={props.onClick} />
+    <tr className={rowClass}>
+      <ComponentCell component={component} onClick={props.onClick} />
 
       <MeasureCell component={component} metric={props.metric} />
 
index a37c37af1d6394df38fdbd07a9cc520bc8804816..c6d5c826fb5b1e13d7a667b40d50f0548079ba13 100644 (file)
  */
 // @flow
 import React from 'react';
+import key from 'keymaster';
+import { throttle } from 'lodash';
 import ComponentsList from './ComponentsList';
 import ListFooter from '../../../components/controls/ListFooter';
+import { scrollToElement } from '../../../helpers/scrolling';
 import type { ComponentEnhanced, Paging } from '../types';
 import type { Metric } from '../../../store/metrics/actions';
 
@@ -28,26 +31,104 @@ type Props = {|
   components: Array<ComponentEnhanced>,
   fetchMore: () => void,
   handleSelect: string => void,
+  handleOpen: string => void,
   metric: Metric,
   metrics: { [string]: Metric },
-  paging: ?Paging
+  paging: ?Paging,
+  selectedKey: ?string,
+  selectedIdx: ?number
 |};
 
-export default function ListView(props: Props) {
-  return (
-    <div>
-      <ComponentsList
-        components={props.components}
-        metrics={props.metrics}
-        metric={props.metric}
-        onClick={props.handleSelect}
-      />
-      {props.paging &&
-        <ListFooter
-          count={props.components.length}
-          total={props.paging.total}
-          loadMore={props.fetchMore}
-        />}
-    </div>
-  );
+export default class ListView extends React.PureComponent {
+  listContainer: HTMLElement;
+  props: Props;
+
+  constructor(props: Props) {
+    super(props);
+    this.selectNext = throttle(this.selectNext, 100);
+    this.selectPrevious = throttle(this.selectPrevious, 100);
+  }
+
+  componentDidMount() {
+    this.attachShortcuts();
+  }
+
+  componentDidUpdate() {
+    if (this.listContainer && this.props.selectedIdx != null) {
+      const elem = this.listContainer.getElementsByClassName('selected')[0];
+      if (elem) {
+        scrollToElement(elem, { topOffset: 215, bottomOffset: 100 });
+      }
+    }
+  }
+
+  componentWillUnmount() {
+    this.detachShortcuts();
+  }
+
+  attachShortcuts() {
+    key('up', 'measures-files', () => {
+      this.selectPrevious();
+      return false;
+    });
+    key('down', 'measures-files', () => {
+      this.selectNext();
+      return false;
+    });
+    key('right', 'measures-files', () => {
+      this.openSelected();
+      return false;
+    });
+  }
+
+  detachShortcuts() {
+    ['up', 'down', 'right'].map(action => key.unbind(action, 'measures-files'));
+  }
+
+  openSelected = () => {
+    if (this.props.selectedKey != null) {
+      this.props.handleOpen(this.props.selectedKey);
+    }
+  };
+
+  selectPrevious = () => {
+    const { selectedIdx } = this.props;
+    if (selectedIdx != null && selectedIdx > 0) {
+      this.props.handleSelect(this.props.components[selectedIdx - 1].key);
+    } else {
+      this.props.handleSelect(this.props.components[this.props.components.length - 1].key);
+    }
+  };
+
+  selectNext = () => {
+    const { selectedIdx } = this.props;
+    if (selectedIdx != null && selectedIdx < this.props.components.length - 1) {
+      this.props.handleSelect(this.props.components[selectedIdx + 1].key);
+    } else {
+      this.props.handleSelect(this.props.components[0].key);
+    }
+  };
+
+  render() {
+    return (
+      <div
+        ref={elem => {
+          this.listContainer = elem;
+        }}>
+        <ComponentsList
+          components={this.props.components}
+          metrics={this.props.metrics}
+          metric={this.props.metric}
+          onClick={this.props.handleOpen}
+          selectedComponent={this.props.selectedKey}
+        />
+        {this.props.paging &&
+          <ListFooter
+            count={this.props.components.length}
+            total={this.props.paging.total}
+            loadMore={this.props.fetchMore}
+          />}
+      </div>
+    );
+  }
 }
index 5f8e064860b697ce9e8c841acf774ed082c73eab..2dba101338e407d1e23b5be1b3ad5d4737a9054a 100644 (file)
   margin-top: 4px;
 }
 
+.measure-details-component-row.selected {
+  background-color: #cae3f2 !important;
+}
+
 .measure-details-component-cell {
   max-width: 0;
 }