]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-7449 Selecting multiple issues with shift+click (#3127)
authorPascal Mugnier <pascal.mugnier@sonarsource.com>
Thu, 8 Mar 2018 08:30:47 +0000 (09:30 +0100)
committerGitHub <noreply@github.com>
Thu, 8 Mar 2018 08:30:47 +0000 (09:30 +0100)
server/sonar-web/src/main/js/apps/issues/components/App.js
server/sonar-web/src/main/js/apps/issues/components/IssuesList.js
server/sonar-web/src/main/js/apps/issues/components/ListItem.js
server/sonar-web/src/main/js/apps/issues/components/__tests__/App-test.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/issue/Issue.d.ts
server/sonar-web/src/main/js/components/issue/Issue.js
server/sonar-web/src/main/js/components/issue/IssueView.js

index edd359159add207405d98566d628e9f3a8b6efa3..02c1a0e1e47fe20afcc9ee6db0ad370975c22b3c 100644 (file)
@@ -21,7 +21,7 @@
 import React from 'react';
 import Helmet from 'react-helmet';
 import key from 'keymaster';
-import { keyBy, without } from 'lodash';
+import { keyBy, union, without } from 'lodash';
 import PropTypes from 'prop-types';
 import PageActions from './PageActions';
 import MyIssuesFilter from './MyIssuesFilter';
@@ -83,6 +83,7 @@ export type Props = {
 /*::
 export type State = {
   bulkChange: 'all' | 'selected' | null,
+  lastChecked: null | string,
   checked: Array<string>,
   facets: { [string]: Facet },
   issues: Array<Issue>,
@@ -122,6 +123,7 @@ export default class App extends React.PureComponent {
     super(props);
     this.state = {
       bulkChange: null,
+      lastChecked: null,
       checked: [],
       facets: {},
       issues: [],
@@ -652,12 +654,36 @@ export default class App extends React.PureComponent {
     });
   };
 
-  handleIssueCheck = (issue /*: string */) => {
-    this.setState(state => ({
-      checked: state.checked.includes(issue)
-        ? without(state.checked, issue)
-        : [...state.checked, issue]
-    }));
+  handleIssueCheck = (issue /*: string */, event /*: Event */) => {
+    // Selecting multiple issues with shift+click
+    const { lastChecked } = this.state;
+    if (event.shiftKey && lastChecked !== null) {
+      this.setState(state => {
+        const issueKeys = state.issues.map(issue => issue.key);
+        const currentIssueIndex = issueKeys.indexOf(issue);
+        const lastSelectedIndex = issueKeys.indexOf(lastChecked);
+        const shouldCheck = state.checked.includes(lastChecked);
+        let { checked } = state;
+        if (currentIssueIndex < 0) {
+          return null;
+        }
+        const start = Math.min(currentIssueIndex, lastSelectedIndex);
+        const end = Math.max(currentIssueIndex, lastSelectedIndex);
+        for (let i = start; i < end + 1; i++) {
+          checked = shouldCheck
+            ? union(checked, [state.issues[i].key])
+            : without(checked, state.issues[i].key);
+        }
+        return { checked };
+      });
+    } else {
+      this.setState(state => ({
+        lastChecked: issue,
+        checked: state.checked.includes(issue)
+          ? without(state.checked, issue)
+          : [...state.checked, issue]
+      }));
+    }
   };
 
   handleIssueChange = (issue /*: Issue */) => {
index 7689dd712a2a78e15adb1da83696034eae7741c7..30506292162f83b54320a6b49d9977da6edc884c 100644 (file)
@@ -31,7 +31,7 @@ type Props = {|
   issues: Array<Issue>,
   onFilterChange: (changes: {}) => void,
   onIssueChange: Issue => void,
-  onIssueCheck?: string => void,
+  onIssueCheck?: (issue: string, event: Event) => void,
   onIssueClick: string => void,
   onPopupToggle: (issue: string, popupName: string, open: ?boolean ) => void,
   openPopup: ?{ issue: string, name: string},
index 86301d6e2bdbeb7381ca313e5abaa0b14a4a2080..28c6ae6976091bb88bbe0b2479f4a3cad480f3c0 100644 (file)
@@ -31,7 +31,7 @@ type Props = {|
   component?: Component,
   issue: IssueType,
   onChange: IssueType => void,
-  onCheck?: string => void,
+  onCheck?: (issue: string, event: Event) => void,
   onClick: string => void,
   onFilterChange: (changes: {}) => void,
   onPopupToggle: (issue: string, popupName: string, open: ?boolean ) => void,
diff --git a/server/sonar-web/src/main/js/apps/issues/components/__tests__/App-test.js b/server/sonar-web/src/main/js/apps/issues/components/__tests__/App-test.js
new file mode 100644 (file)
index 0000000..7cf8713
--- /dev/null
@@ -0,0 +1,94 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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.
+ */
+// @flow
+import * as React from 'react';
+import { shallow, mount } from 'enzyme';
+import App from '../App';
+import { shallowWithIntl, waitAndUpdate } from '../../../../helpers/testUtils';
+
+const replace = jest.fn();
+const issues = [{ key: 'foo' }, { key: 'bar' }, { key: 'third' }, { key: 'fourth' }];
+const facets = [{ property: 'severities', values: [{ val: 'MINOR', count: 4 }] }];
+const paging = [{ pageIndex: 1, pageSize: 100, total: 4 }];
+
+const eventNoShiftKey = { shiftKey: false };
+const eventWithShiftKey = { shiftKey: true };
+
+const PROPS = {
+  branch: { isMain: true, name: 'master' },
+  currentUser: {
+    isLoggedIn: true,
+    avatar: 'foo',
+    email: 'forr@bar.com',
+    login: 'JohnDoe',
+    name: 'John Doe'
+  },
+  component: { key: 'foo', name: 'bar', organization: 'John', qualifier: 'Doe' },
+  location: { pathname: '/issues', query: {} },
+  fetchIssues: () => Promise.resolve({ facets, issues, paging }),
+  onBranchesChange: () => {},
+  onSonarCloud: false,
+  organization: { key: 'foo' }
+};
+
+it('should render a list of issue', async () => {
+  const wrapper = shallowWithIntl(<App {...PROPS} />, {
+    context: { router: { replace } }
+  });
+
+  await waitAndUpdate(wrapper);
+  expect(wrapper.state().issues.length).toBe(4);
+});
+
+it('should be able to check/uncheck a group of issues with the Shift key', async () => {
+  const wrapper = shallowWithIntl(<App {...PROPS} />, {
+    context: { router: { replace } }
+  });
+
+  await waitAndUpdate(wrapper);
+  expect(wrapper.state().issues.length).toBe(4);
+
+  wrapper.instance().handleIssueCheck('foo', eventNoShiftKey);
+  expect(wrapper.state().checked.length).toBe(1);
+
+  wrapper.instance().handleIssueCheck('fourth', eventWithShiftKey);
+  expect(wrapper.state().checked.length).toBe(4);
+
+  wrapper.instance().handleIssueCheck('third', eventNoShiftKey);
+  expect(wrapper.state().checked.length).toBe(3);
+
+  wrapper.instance().handleIssueCheck('foo', eventWithShiftKey);
+  expect(wrapper.state().checked.length).toBe(1);
+});
+
+it('should avoid non-existing keys', async () => {
+  const wrapper = shallowWithIntl(<App {...PROPS} />, {
+    context: { router: { replace } }
+  });
+
+  await waitAndUpdate(wrapper);
+  expect(wrapper.state().issues.length).toBe(4);
+
+  wrapper.instance().handleIssueCheck('foo', eventNoShiftKey);
+  expect(wrapper.state().checked.length).toBe(1);
+
+  wrapper.instance().handleIssueCheck('non-existing-key', eventWithShiftKey);
+  expect(wrapper.state().checked.length).toBe(1);
+});
index 5abf041951bca4382a394660176181e883b72973..6930914f170dc412b6e119eec29a566b04589ad7 100644 (file)
@@ -27,7 +27,7 @@ interface IssueProps {
   displayLocationsLink?: boolean;
   issue: IssueType;
   onChange: (issue: IssueType) => void;
-  onCheck?: (issueKey: string) => void;
+  onCheck?: (issueKey: string, event: Event) => void;
   onClick: (issueKey: string) => void;
   onFilter?: (property: string, issue: IssueType) => void;
   onPopupToggle: (issue: string, popupName: string, open?: boolean) => void;
index 0100ba16b5b044aecd07f0ed815deccaa42beb36..47bf09dab76d2e0b68b0cefd799bc46a3e6c4121 100644 (file)
@@ -35,7 +35,7 @@ type Props = {|
   displayLocationsLink?: boolean;
   issue: IssueType,
   onChange: IssueType => void,
-  onCheck?: string => void,
+  onCheck?: (issue: string, event: Event) => void,
   onClick: string => void,
   onFilter?: (property: string, issue: IssueType) => void,
   onPopupToggle: (issue: string, popupName: string, open: ?boolean) => void,
@@ -108,9 +108,9 @@ export default class Issue extends React.PureComponent {
       this.togglePopup('edit-tags');
       return false;
     });
-    key('space', 'issues', () => {
+    key('space', 'issues', (event /*: Event*/) => {
       if (this.props.onCheck) {
-        this.props.onCheck(this.props.issue.key);
+        this.props.onCheck(this.props.issue.key, event);
         return false;
       }
       return undefined;
index d6f3ab8335e577ee1a8f379f943be7031002049e..b7a658e77ba0f4fae1af1c4054425ff6cd286677 100644 (file)
@@ -37,7 +37,7 @@ type Props = {|
   issue: Issue,
   onAssign: string => void,
   onChange: Issue => void,
-  onCheck?: string => void,
+  onCheck?: (issue: string, event: Event) => void,
   onClick: string => void,
   onFail: Error => void,
   onFilter?: (property: string, issue: Issue) => void,
@@ -53,7 +53,7 @@ export default class IssueView extends React.PureComponent {
     event.preventDefault();
     event.stopPropagation();
     if (this.props.onCheck) {
-      this.props.onCheck(this.props.issue.key);
+      this.props.onCheck(this.props.issue.key, event);
     }
   };