]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-16007: Move through locations using keyboard
authorRevanshu Paliwal <revanshu.paliwal@sonarsource.com>
Wed, 23 Feb 2022 13:56:36 +0000 (14:56 +0100)
committersonartech <sonartech@sonarsource.com>
Fri, 25 Feb 2022 20:02:55 +0000 (20:02 +0000)
server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsApp.tsx
server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-test.tsx

index 975dd3c674f29f79df19213c42d705805d4319d7..845dcf1e591ee998ce56f681b0aa5fe78ff16cf7 100644 (file)
@@ -28,6 +28,7 @@ import { withCurrentUser } from '../../components/hoc/withCurrentUser';
 import { Router } from '../../components/hoc/withRouter';
 import { getLeakValue } from '../../components/measure/utils';
 import { getBranchLikeQuery, isPullRequest, isSameBranchLike } from '../../helpers/branch-like';
+import { KeyboardCodes, KeyboardKeys } from '../../helpers/keycodes';
 import { scrollToElement } from '../../helpers/scrolling';
 import { getStandards } from '../../helpers/security-standard';
 import { isLoggedIn } from '../../helpers/users';
@@ -44,7 +45,7 @@ import {
 import { Component, CurrentUser, Dict } from '../../types/types';
 import SecurityHotspotsAppRenderer from './SecurityHotspotsAppRenderer';
 import './styles.css';
-import { SECURITY_STANDARDS } from './utils';
+import { getLocations, SECURITY_STANDARDS } from './utils';
 
 const HOTSPOT_KEYMASTER_SCOPE = 'hotspots-list';
 const PAGE_SIZE = 500;
@@ -151,9 +152,57 @@ export class SecurityHotspotsApp extends React.PureComponent<Props, State> {
       this.selectNeighboringHotspot(+1);
       return false;
     });
+    window.addEventListener('keydown', this.handleKeyDown);
   }
 
+  handleKeyDown = (event: KeyboardEvent) => {
+    if (event.key === KeyboardKeys.Alt) {
+      event.preventDefault();
+    } else if (event.code === KeyboardCodes.DownArrow && event.altKey) {
+      event.preventDefault();
+      this.selectNextLocation();
+    } else if (event.code === KeyboardCodes.UpArrow && event.altKey) {
+      event.preventDefault();
+      this.selectPreviousLocation();
+    }
+  };
+
+  selectNextLocation = () => {
+    const { selectedHotspotLocationIndex, selectedHotspot } = this.state;
+    if (selectedHotspot === undefined) {
+      return;
+    }
+    const locations = getLocations(selectedHotspot.flows, undefined);
+    if (locations.length === 0) {
+      return;
+    }
+    const lastIndex = locations.length - 1;
+
+    let newIndex;
+    if (selectedHotspotLocationIndex === undefined) {
+      newIndex = 0;
+    } else if (selectedHotspotLocationIndex === lastIndex) {
+      newIndex = undefined;
+    } else {
+      newIndex = selectedHotspotLocationIndex + 1;
+    }
+    this.setState({ selectedHotspotLocationIndex: newIndex });
+  };
+
+  selectPreviousLocation = () => {
+    const { selectedHotspotLocationIndex } = this.state;
+
+    let newIndex;
+    if (selectedHotspotLocationIndex === 0) {
+      newIndex = undefined;
+    } else if (selectedHotspotLocationIndex !== undefined) {
+      newIndex = selectedHotspotLocationIndex - 1;
+    }
+    this.setState({ selectedHotspotLocationIndex: newIndex });
+  };
+
   selectNeighboringHotspot = (shift: number) => {
+    this.setState({ selectedHotspotLocationIndex: undefined });
     this.setState(({ hotspots, selectedHotspot }) => {
       const index = selectedHotspot && hotspots.findIndex(h => h.key === selectedHotspot.key);
 
@@ -170,6 +219,7 @@ export class SecurityHotspotsApp extends React.PureComponent<Props, State> {
 
   unregisterKeyboardEvents() {
     key.deleteScope(HOTSPOT_KEYMASTER_SCOPE);
+    window.removeEventListener('keydown', this.handleKeyDown);
   }
 
   constructFiltersFromProps(
index ed73077f227acd82e7b4f7c0c710a264ec54dc17..c530c789ff60ca4b009b755ca38c3708c70a6d8c 100644 (file)
@@ -28,11 +28,13 @@ import { mockRawHotspot, mockStandards } from '../../../helpers/mocks/security-h
 import { getStandards } from '../../../helpers/security-standard';
 import {
   mockCurrentUser,
+  mockEvent,
+  mockFlowLocation,
   mockLocation,
   mockLoggedInUser,
   mockRouter
 } from '../../../helpers/testMocks';
-import { waitAndUpdate } from '../../../helpers/testUtils';
+import { KEYCODE_MAP, waitAndUpdate } from '../../../helpers/testUtils';
 import { SecurityStandard } from '../../../types/security';
 import {
   HotspotResolution,
@@ -42,8 +44,29 @@ import {
 import { SecurityHotspotsApp } from '../SecurityHotspotsApp';
 import SecurityHotspotsAppRenderer from '../SecurityHotspotsAppRenderer';
 import { scrollToElement } from '../../../helpers/scrolling';
+import { KeyboardCodes } from '../../../helpers/keycodes';
 
-beforeEach(() => jest.clearAllMocks());
+const originalAddEventListener = window.addEventListener;
+const originalRemoveEventListener = window.removeEventListener;
+
+beforeEach(() => {
+  Object.defineProperty(window, 'addEventListener', {
+    value: jest.fn()
+  });
+  Object.defineProperty(window, 'removeEventListener', {
+    value: jest.fn()
+  });
+  jest.clearAllMocks();
+});
+
+afterEach(() => {
+  Object.defineProperty(window, 'addEventListener', {
+    value: originalAddEventListener
+  });
+  Object.defineProperty(window, 'removeEventListener', {
+    value: originalRemoveEventListener
+  });
+});
 
 jest.mock('../../../api/measures', () => ({
   getMeasures: jest.fn().mockResolvedValue([])
@@ -62,6 +85,27 @@ jest.mock('../../../helpers/scrolling', () => ({
   scrollToElement: jest.fn()
 }));
 
+jest.mock('keymaster', () => {
+  const key: any = (bindKey: string, _: string, callback: Function) => {
+    document.addEventListener('keydown', (event: KeyboardEvent) => {
+      const keymasterCode = event.code && KEYCODE_MAP[event.code as KeyboardCodes];
+      if (keymasterCode && bindKey.split(',').includes(keymasterCode)) {
+        return callback();
+      }
+      return true;
+    });
+  };
+  let scope = 'hotspots-list';
+
+  key.getScope = () => scope;
+  key.setScope = (newScope: string) => {
+    scope = newScope;
+  };
+  key.deleteScope = jest.fn();
+
+  return key;
+});
+
 const branch = mockBranch();
 
 it('should render correctly', () => {
@@ -426,6 +470,11 @@ describe('keyboard navigation', () => {
     mockRawHotspot({ key: 'k2' }),
     mockRawHotspot({ key: 'k3' })
   ];
+  const flowsData = {
+    flows: [{ locations: [mockFlowLocation(), mockFlowLocation(), mockFlowLocation()] }]
+  };
+  const hotspotsForLocation = mockRawHotspot(flowsData);
+
   (getSecurityHotspots as jest.Mock).mockResolvedValueOnce({ hotspots, paging: { total: 3 } });
 
   const wrapper = shallowRender();
@@ -444,6 +493,38 @@ describe('keyboard navigation', () => {
 
     expect(wrapper.state().selectedHotspot).toBe(hotspots[expected]);
   });
+
+  it.each([
+    ['selecting next locations when nothing is selected', undefined, 0],
+    ['selecting next locations', 0, 1],
+    ['selecting next locations, non-existent', 2, undefined]
+  ])('should work when %s', (_, start, expected) => {
+    wrapper.setState({ selectedHotspotLocationIndex: start, selectedHotspot: hotspotsForLocation });
+    wrapper.instance().handleKeyDown(mockEvent({ altKey: true, code: KeyboardCodes.DownArrow }));
+
+    expect(wrapper.state().selectedHotspotLocationIndex).toBe(expected);
+  });
+
+  it.each([
+    ['selecting previous locations when nothing is selected', undefined, undefined],
+    ['selecting previous locations', 1, 0],
+    ['selecting previous locations, non-existent', 0, undefined]
+  ])('should work when %s', (_, start, expected) => {
+    wrapper.setState({ selectedHotspotLocationIndex: start, selectedHotspot: hotspotsForLocation });
+    wrapper.instance().handleKeyDown(mockEvent({ altKey: true, code: KeyboardCodes.UpArrow }));
+
+    expect(wrapper.state().selectedHotspotLocationIndex).toBe(expected);
+  });
+
+  it('should not change location index when locations are empty', () => {
+    wrapper.setState({ selectedHotspotLocationIndex: undefined, selectedHotspot: hotspots[0] });
+
+    wrapper.instance().handleKeyDown(mockEvent({ altKey: true, code: KeyboardCodes.UpArrow }));
+    expect(wrapper.state().selectedHotspotLocationIndex).toBeUndefined();
+
+    wrapper.instance().handleKeyDown(mockEvent({ altKey: true, code: KeyboardCodes.DownArrow }));
+    expect(wrapper.state().selectedHotspotLocationIndex).toBeUndefined();
+  });
 });
 
 function shallowRender(props: Partial<SecurityHotspotsApp['props']> = {}) {