diff options
2 files changed, 102 insertions, 1 deletions
diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerTabs.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerTabs.tsx index bb4faab83bf..5593345a7ed 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerTabs.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerTabs.tsx @@ -17,6 +17,7 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +import key from 'keymaster'; import * as React from 'react'; import BoxedTabs from '../../../components/controls/BoxedTabs'; import { translate } from '../../../helpers/l10n'; @@ -47,6 +48,8 @@ export enum TabKeys { FixRecommendation = 'fix' } +const HOTSPOT_KEYMASTER_SCOPE = 'hotspots-list'; + export default class HotspotViewerTabs extends React.PureComponent<Props, State> { constructor(props: Props) { super(props); @@ -57,6 +60,10 @@ export default class HotspotViewerTabs extends React.PureComponent<Props, State> }; } + componentDidMount() { + this.registerKeyboardEvents(); + } + componentDidUpdate(prevProps: Props) { if (this.props.hotspot.key !== prevProps.hotspot.key) { const tabs = this.computeTabs(); @@ -75,6 +82,26 @@ export default class HotspotViewerTabs extends React.PureComponent<Props, State> } } + componentWillUnmount() { + this.unregisterKeyboardEvents(); + } + + registerKeyboardEvents() { + key.setScope(HOTSPOT_KEYMASTER_SCOPE); + key('left', HOTSPOT_KEYMASTER_SCOPE, () => { + this.selectNeighboringTab(-1); + return false; + }); + key('right', HOTSPOT_KEYMASTER_SCOPE, () => { + this.selectNeighboringTab(+1); + return false; + }); + } + + unregisterKeyboardEvents() { + key.deleteScope(HOTSPOT_KEYMASTER_SCOPE); + } + handleSelectTabs = (tabKey: TabKeys) => { const { tabs } = this.state; const currentTab = tabs.find(tab => tab.key === tabKey)!; @@ -112,6 +139,21 @@ export default class HotspotViewerTabs extends React.PureComponent<Props, State> ]; } + selectNeighboringTab(shift: number) { + this.setState(({ tabs, currentTab }) => { + const index = currentTab && tabs.findIndex(tab => tab.key === currentTab.key); + + if (index !== undefined && index > -1) { + const newIndex = Math.max(0, Math.min(tabs.length - 1, index + shift)); + return { + currentTab: tabs[newIndex] + }; + } + + return { currentTab }; + }); + } + render() { const { codeTabContent } = this.props; const { tabs, currentTab } = this.state; diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotViewerTabs-test.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotViewerTabs-test.tsx index 2ba96c577f8..ffbc475c3da 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotViewerTabs-test.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotViewerTabs-test.tsx @@ -17,13 +17,36 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { shallow } from 'enzyme'; +import { mount, shallow } from 'enzyme'; import * as React from 'react'; import BoxedTabs, { BoxedTabsProps } from '../../../../components/controls/BoxedTabs'; +import { KeyboardCodes } from '../../../../helpers/keycodes'; import { mockHotspot, mockHotspotRule } from '../../../../helpers/mocks/security-hotspots'; import { mockUser } from '../../../../helpers/testMocks'; +import { KEYCODE_MAP, keydown } from '../../../../helpers/testUtils'; import HotspotViewerTabs, { TabKeys } from '../HotspotViewerTabs'; +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; +}); + it('should render correctly', () => { const wrapper = shallowRender(); expect(wrapper).toMatchSnapshot('risk'); @@ -113,6 +136,42 @@ it('should select first tab when hotspot location is selected and is not undefin expect(wrapper.state().currentTab.key).toBe(TabKeys.VulnerabilityDescription); }); +describe('keyboard navigation', () => { + const tabList = [ + TabKeys.Code, + TabKeys.RiskDescription, + TabKeys.VulnerabilityDescription, + TabKeys.FixRecommendation + ]; + const wrapper = shallowRender(); + + it.each([ + ['selecting next', 0, 1, 1], + ['selecting previous', 1, -1, 0], + ['selecting previous, non-existent', 0, -1, 0], + ['selecting next, non-existent', 3, 1, 3] + ])('should work when %s', (_, start, shift, expected) => { + wrapper.setState({ currentTab: wrapper.state().tabs[start] }); + wrapper.instance().selectNeighboringTab(shift); + + expect(wrapper.state().currentTab.key).toBe(tabList[expected]); + }); +}); + +it('should navigate when up and down key are pressed', () => { + const wrapper = mount<HotspotViewerTabs>( + <HotspotViewerTabs codeTabContent={<div>CodeTabContent</div>} hotspot={mockHotspot()} /> + ); + + const selectNeighboringTab = jest.spyOn(wrapper.instance(), 'selectNeighboringTab'); + + keydown({ code: KeyboardCodes.LeftArrow }); + expect(selectNeighboringTab).toBeCalledWith(-1); + + keydown({ code: KeyboardCodes.RightArrow }); + expect(selectNeighboringTab).toBeCalledWith(1); +}); + function shallowRender(props?: Partial<HotspotViewerTabs['props']>) { return shallow<HotspotViewerTabs>( <HotspotViewerTabs |