]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-16337 Handle shortcuts and inputs when keydown
authorGuillaume Peoc'h <guillaume.peoch@sonarsource.com>
Mon, 30 May 2022 09:17:09 +0000 (11:17 +0200)
committersonartech <sonartech@sonarsource.com>
Tue, 31 May 2022 20:02:50 +0000 (20:02 +0000)
16 files changed:
server/sonar-web/src/main/js/app/components/search/Search.tsx
server/sonar-web/src/main/js/app/components/search/__tests__/Search-test.tsx
server/sonar-web/src/main/js/apps/coding-rules/components/App.tsx
server/sonar-web/src/main/js/apps/component-measures/drilldown/FilesView.tsx
server/sonar-web/src/main/js/apps/component-measures/drilldown/__tests__/FilesView-test.tsx
server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx
server/sonar-web/src/main/js/apps/issues/components/__tests__/IssuesApp-test.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerTabs.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotViewerTabs-test.tsx
server/sonar-web/src/main/js/components/common/MultiSelect.tsx
server/sonar-web/src/main/js/components/hoc/__tests__/withKeyboardNavigation-test.tsx
server/sonar-web/src/main/js/components/hoc/withKeyboardNavigation.tsx
server/sonar-web/src/main/js/components/issue/Issue.tsx
server/sonar-web/src/main/js/components/issue/__tests__/issue-test.tsx
server/sonar-web/src/main/js/helpers/keyboardEventHelpers.ts [new file with mode: 0644]
server/sonar-web/src/main/js/helpers/testUtils.ts

index 47549c7d890411912f58d6f52e674188ce6e9e86..03b0ea8d70cd433bb007806e4e6a52e6c3a2a367 100644 (file)
@@ -28,6 +28,7 @@ import SearchBox from '../../../components/controls/SearchBox';
 import ClockIcon from '../../../components/icons/ClockIcon';
 import { lazyLoadComponent } from '../../../components/lazyLoadComponent';
 import DeferredSpinner from '../../../components/ui/DeferredSpinner';
+import { isInput, isShortcut } from '../../../helpers/keyboardEventHelpers';
 import { KeyboardKeys } from '../../../helpers/keycodes';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { scrollToElement } from '../../../helpers/scrolling';
@@ -272,9 +273,10 @@ export class Search extends React.PureComponent<WithRouterProps, State> {
   };
 
   handleSKeyDown = (event: KeyboardEvent) => {
-    const { tagName } = event.target as HTMLElement;
-    const isInput = tagName === 'INPUT' || tagName === 'SELECT' || tagName === 'TEXTAREA';
-    if (event.key === KeyboardKeys.KeyS && !isInput) {
+    if (isInput(event) || isShortcut(event)) {
+      return true;
+    }
+    if (event.key === KeyboardKeys.KeyS) {
       event.preventDefault();
       this.focusInput();
       this.openSearch();
index 39d4e1d3895c98b1a7bae427b5b81101233a42d9..8b10c19f072bedec8d08d2fea1675d3d37670b38 100644 (file)
@@ -104,6 +104,8 @@ it('shows warning about short input', () => {
 it('should open the results when pressing key S and close it when pressing Escape', () => {
   const router = mockRouter();
   const form = shallowRender({ router });
+  keydown({ key: KeyboardKeys.KeyS, ctrlKey: true });
+  expect(form.state().open).toBe(false);
   keydown({ key: KeyboardKeys.KeyS });
   expect(form.state().open).toBe(true);
   elementKeydown(form.find('SearchBox'), KeyboardKeys.Escape);
index 210d99ccf96443cbea2f5f61af2aef80c75bca8f..94a9b44e2ea3ddf68bc468b30db619c3b53a2f04 100644 (file)
@@ -32,6 +32,7 @@ import SearchBox from '../../../components/controls/SearchBox';
 import Suggestions from '../../../components/embed-docs-modal/Suggestions';
 import BackIcon from '../../../components/icons/BackIcon';
 import '../../../components/search-navigator.css';
+import { isInput, isShortcut } from '../../../helpers/keyboardEventHelpers';
 import { KeyboardKeys } from '../../../helpers/keycodes';
 import { translate } from '../../../helpers/l10n';
 import {
@@ -152,10 +153,8 @@ export class App extends React.PureComponent<Props, State> {
   };
 
   handleKeyPress = (event: KeyboardEvent) => {
-    const { tagName } = event.target as HTMLElement;
-    const isInput = tagName === 'INPUT' || tagName === 'SELECT' || tagName === 'TEXTAREA';
-    if (isInput) {
-      return false;
+    if (isInput(event) || isShortcut(event)) {
+      return true;
     }
     switch (event.key) {
       case KeyboardKeys.LeftArrow:
index 74c2a9195ab01a50ecd191311d094c40b40b87c5..a9a0eadb745de2bda5d33a1eb0fbaab827e016c3 100644 (file)
@@ -22,6 +22,7 @@ import * as React from 'react';
 import { Button } from '../../../components/controls/buttons';
 import ListFooter from '../../../components/controls/ListFooter';
 import { Alert } from '../../../components/ui/Alert';
+import { isInput, isShortcut } from '../../../helpers/keyboardEventHelpers';
 import { KeyboardCodes } from '../../../helpers/keycodes';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { formatMeasure, isDiffMetric, isPeriodBestValue } from '../../../helpers/measures';
@@ -92,6 +93,9 @@ export default class FilesView extends React.PureComponent<Props, State> {
   }
 
   handleKeyDown = (event: KeyboardEvent) => {
+    if (isInput(event) || isShortcut(event)) {
+      return true;
+    }
     if (event.code === KeyboardCodes.UpArrow) {
       event.preventDefault();
       this.selectPrevious();
index 53eb6307322eb06ef59c10515e7be53103bf902d..5d4b67545485e190b7d97bbc52b21733eff33351 100644 (file)
@@ -92,6 +92,9 @@ it('should correctly bind key events for file navigation', () => {
   keydown({ code: KeyboardCodes.UpArrow });
   expect(handleSelect).toBeCalledWith(FILES[2]);
 
+  keydown({ code: KeyboardCodes.RightArrow, ctrlKey: true });
+  expect(handleOpen).not.toBeCalled();
+
   keydown({ code: KeyboardCodes.RightArrow });
   expect(handleOpen).toBeCalled();
 });
index 8d14844683918396d52159d00e45b1434920b183..911b422183f259dacc9c65b0b8665a6045f61a8c 100644 (file)
@@ -45,6 +45,7 @@ import {
 } from '../../../helpers/branch-like';
 import handleRequiredAuthentication from '../../../helpers/handleRequiredAuthentication';
 import { parseIssueFromResponse } from '../../../helpers/issues';
+import { isInput, isShortcut } from '../../../helpers/keyboardEventHelpers';
 import { KeyboardCodes, KeyboardKeys } from '../../../helpers/keycodes';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
 import {
@@ -263,6 +264,10 @@ export default class App extends React.PureComponent<Props, State> {
       return;
     }
 
+    if (isInput(event) || isShortcut(event)) {
+      return true;
+    }
+
     if (event.key === KeyboardKeys.Alt) {
       event.preventDefault();
       this.setState(actions.enableLocationsNavigator);
index 689eec932519324bec3c428f79be98f329579668..952aeb5d9b787b396a1c49e5d3fd673f215163da 100644 (file)
@@ -241,6 +241,8 @@ it('should correctly bind key events for issue navigation', async () => {
   keydown({ code: KeyboardCodes.DownArrow });
   expect(wrapper.state('selected')).toBe(ISSUES[3].key);
 
+  keydown({ code: KeyboardCodes.RightArrow, ctrlKey: true });
+  expect(push).not.toBeCalled();
   keydown({ code: KeyboardCodes.RightArrow });
   expect(push).toBeCalledTimes(1);
 
index 4adab7cdcb545ca0eb864f928e5c2d823b0333bd..996ea790cd4d32769105b8edd9bed9b709a4dcfe 100644 (file)
@@ -20,6 +20,7 @@
 import classNames from 'classnames';
 import * as React from 'react';
 import BoxedTabs from '../../../components/controls/BoxedTabs';
+import { isInput, isShortcut } from '../../../helpers/keyboardEventHelpers';
 import { KeyboardCodes } from '../../../helpers/keycodes';
 import { translate } from '../../../helpers/l10n';
 import { sanitizeString } from '../../../helpers/sanitize';
@@ -86,6 +87,9 @@ export default class HotspotViewerTabs extends React.PureComponent<Props, State>
   }
 
   handleKeyboardNavigation = (event: KeyboardEvent) => {
+    if (isInput(event) || isShortcut(event)) {
+      return true;
+    }
     if (event.code === KeyboardCodes.LeftArrow) {
       event.preventDefault();
       this.selectNeighboringTab(-1);
index 50fbf5f43922b06ee72ee234a524fe23542ee31e..db529dd16451ea349cb1d0ea9b292b3077c1cd5c 100644 (file)
@@ -157,6 +157,19 @@ describe('keyboard navigation', () => {
   });
 });
 
+it("shouldn't navigate when ctrl or command are pressed with up and down", () => {
+  const wrapper = mount<HotspotViewerTabs>(
+    <HotspotViewerTabs codeTabContent={<div>CodeTabContent</div>} hotspot={mockHotspot()} />
+  );
+
+  wrapper.setState({ currentTab: wrapper.state().tabs[0] });
+  wrapper
+    .instance()
+    .handleKeyboardNavigation(mockEvent({ code: KeyboardCodes.LeftArrow, metaKey: true }));
+
+  expect(wrapper.state().currentTab.key).toBe(TabKeys.Code);
+});
+
 it('should navigate when up and down key are pressed', () => {
   const wrapper = mount<HotspotViewerTabs>(
     <HotspotViewerTabs codeTabContent={<div>CodeTabContent</div>} hotspot={mockHotspot()} />
index 695c062d3db28797b9433e6069c0233744cbe603..b042c9b196db276bfc61f13f1749c3924f6469a4 100644 (file)
@@ -21,6 +21,7 @@ import classNames from 'classnames';
 import { difference } from 'lodash';
 import * as React from 'react';
 import SearchBox from '../../components/controls/SearchBox';
+import { isShortcut } from '../../helpers/keyboardEventHelpers';
 import { KeyboardCodes } from '../../helpers/keycodes';
 import { translateWithParameters } from '../../helpers/l10n';
 import MultiSelectOption from './MultiSelectOption';
@@ -139,6 +140,9 @@ export default class MultiSelect extends React.PureComponent<PropsWithDefault, S
   };
 
   handleKeyboard = (event: KeyboardEvent) => {
+    if (isShortcut(event)) {
+      return true;
+    }
     switch (event.code) {
       case KeyboardCodes.DownArrow:
         event.stopPropagation();
index b4197c05a62b82f1a42638dbe28e393c468aa255..4ee53ee75b2a82f8dcb2acdf1d4a10422424082b 100644 (file)
@@ -79,12 +79,16 @@ it('should correctly bind key events for component navigation', () => {
   keydown({ code: KeyboardCodes.DownArrow });
   expect(onHighlight).toBeCalledWith(COMPONENTS[0]);
 
+  keydown({ code: KeyboardCodes.RightArrow, metaKey: true });
+  expect(onSelect).not.toBeCalled();
   keydown({ code: KeyboardCodes.RightArrow });
   expect(onSelect).toBeCalledWith(COMPONENTS[0]);
 
   keydown({ code: KeyboardCodes.Enter });
   expect(onSelect).toBeCalledWith(COMPONENTS[0]);
 
+  keydown({ code: KeyboardCodes.LeftArrow, metaKey: true });
+  expect(onGoToParent).not.toBeCalled();
   keydown({ code: KeyboardCodes.LeftArrow });
   expect(onGoToParent).toBeCalled();
 });
index b92a470199e7a60588cbd981cc1d5e284e14f97e..4232c27bc5701c69979bfdb702db3bcf09590160 100644 (file)
@@ -20,6 +20,7 @@
 import * as React from 'react';
 import PageActions from '../../components/ui/PageActions';
 import { getComponentMeasureUniqueKey } from '../../helpers/component';
+import { isInput, isShortcut } from '../../helpers/keyboardEventHelpers';
 import { KeyboardCodes } from '../../helpers/keycodes';
 import { ComponentMeasure } from '../../types/types';
 import { getWrappedDisplayName } from './utils';
@@ -50,6 +51,9 @@ export default function withKeyboardNavigation<P>(
     }
 
     handleKeyDown = (event: KeyboardEvent) => {
+      if (isInput(event) || isShortcut(event)) {
+        return true;
+      }
       if (event.code === KeyboardCodes.UpArrow) {
         event.preventDefault();
         return this.skipIfFile(this.handleHighlightPrevious);
index 76de0d88fa3f7d21938c854334e58074bf7c7ae1..b91d7065d7313f415a38f5ce1ecdede1470ebb74 100644 (file)
@@ -19,6 +19,7 @@
  */
 import * as React from 'react';
 import { setIssueAssignee } from '../../api/issues';
+import { isInput, isShortcut } from '../../helpers/keyboardEventHelpers';
 import { KeyboardKeys } from '../../helpers/keycodes';
 import { BranchLike } from '../../types/branch-like';
 import { Issue as TypeIssue } from '../../types/types';
@@ -68,10 +69,8 @@ export default class Issue extends React.PureComponent<Props> {
   }
 
   handleKeyDown = (event: KeyboardEvent) => {
-    const { tagName } = event.target as HTMLElement;
-    const isInput = tagName === 'INPUT' || tagName === 'SELECT' || tagName === 'TEXTAREA';
-    if (isInput) {
-      return false;
+    if (isInput(event) || isShortcut(event)) {
+      return true;
     } else if (event.key === KeyboardKeys.KeyF) {
       event.preventDefault();
       return this.togglePopup('transition');
index 85bfbe69d0027a6ada115f2be672fd24f1ea3641..91a7b49da56c0624c482fcb27c10e8ac0615a724 100644 (file)
@@ -50,6 +50,9 @@ it('should call the proper function with the proper props when pressing shortcut
   });
 
   shallowRender({ onPopupToggle, issue, onCheck });
+  keydown({ key: KeyboardKeys.KeyF, metaKey: true });
+  expect(onPopupToggle).not.toBeCalledWith(issue.key, 'transition', undefined);
+
   keydown({ key: KeyboardKeys.KeyF });
   expect(onPopupToggle).toBeCalledWith(issue.key, 'transition', undefined);
 
@@ -63,6 +66,9 @@ it('should call the proper function with the proper props when pressing shortcut
   keydown({ key: KeyboardKeys.KeyI });
   expect(onPopupToggle).toBeCalledWith(issue.key, 'set-severity', undefined);
 
+  keydown({ key: KeyboardKeys.KeyC, metaKey: true });
+  expect(onPopupToggle).not.toBeCalledWith(issue.key, 'comment', undefined);
+
   keydown({ key: KeyboardKeys.KeyC });
   expect(onPopupToggle).toBeCalledWith(issue.key, 'comment', undefined);
   keydown({ key: KeyboardKeys.Escape });
diff --git a/server/sonar-web/src/main/js/helpers/keyboardEventHelpers.ts b/server/sonar-web/src/main/js/helpers/keyboardEventHelpers.ts
new file mode 100644 (file)
index 0000000..fc156ad
--- /dev/null
@@ -0,0 +1,28 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+export function isShortcut(event: KeyboardEvent): boolean {
+  return event.ctrlKey || event.metaKey;
+}
+
+export function isInput(event: KeyboardEvent): boolean {
+  const { tagName } = event.target as HTMLElement;
+  return ['INPUT', 'SELECT', 'TEXTAREA'].includes(tagName);
+}
index 1b1abba7dbe11798cc329f6b94d67968543d9fdb..72228cf16e026e2e6cecda2ed1800ac1f6f68b2e 100644 (file)
@@ -82,7 +82,12 @@ export const KEYCODE_MAP: { [code in KeyboardCodes]?: string } = {
   [KeyboardCodes.DownArrow]: 'down'
 };
 
-export function keydown(args: { code?: KeyboardCodes; key?: KeyboardKeys }): void {
+export function keydown(args: {
+  code?: KeyboardCodes;
+  key?: KeyboardKeys;
+  metaKey?: boolean;
+  ctrlKey?: boolean;
+}): void {
   const event = new KeyboardEvent('keydown', args as KeyboardEventInit);
   document.dispatchEvent(event);
 }