From: Revanshu Paliwal Date: Wed, 23 Feb 2022 13:56:36 +0000 (+0100) Subject: SONAR-16007: Move through locations using keyboard X-Git-Tag: 9.4.0.54424~158 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=c3602fdcd7063f15fedf56dea5f13756ae0aa7f1;p=sonarqube.git SONAR-16007: Move through locations using keyboard --- diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsApp.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsApp.tsx index 975dd3c674f..845dcf1e591 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsApp.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsApp.tsx @@ -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 { 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 { unregisterKeyboardEvents() { key.deleteScope(HOTSPOT_KEYMASTER_SCOPE); + window.removeEventListener('keydown', this.handleKeyDown); } constructFiltersFromProps( diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-test.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-test.tsx index ed73077f227..c530c789ff6 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-test.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-test.tsx @@ -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 = {}) {