From: Pascal Mugnier Date: Thu, 8 Mar 2018 08:30:47 +0000 (+0100) Subject: SONAR-7449 Selecting multiple issues with shift+click (#3127) X-Git-Tag: 7.5~1576 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=d334ffbc6ab7bec1ad6a4c32df2007f6367ad03d;p=sonarqube.git SONAR-7449 Selecting multiple issues with shift+click (#3127) --- diff --git a/server/sonar-web/src/main/js/apps/issues/components/App.js b/server/sonar-web/src/main/js/apps/issues/components/App.js index edd359159ad..02c1a0e1e47 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/App.js +++ b/server/sonar-web/src/main/js/apps/issues/components/App.js @@ -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, facets: { [string]: Facet }, issues: Array, @@ -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 */) => { diff --git a/server/sonar-web/src/main/js/apps/issues/components/IssuesList.js b/server/sonar-web/src/main/js/apps/issues/components/IssuesList.js index 7689dd712a2..30506292162 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/IssuesList.js +++ b/server/sonar-web/src/main/js/apps/issues/components/IssuesList.js @@ -31,7 +31,7 @@ type Props = {| issues: Array, 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}, diff --git a/server/sonar-web/src/main/js/apps/issues/components/ListItem.js b/server/sonar-web/src/main/js/apps/issues/components/ListItem.js index 86301d6e2bd..28c6ae69760 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/ListItem.js +++ b/server/sonar-web/src/main/js/apps/issues/components/ListItem.js @@ -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 index 00000000000..7cf87139121 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/components/__tests__/App-test.js @@ -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(, { + 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(, { + 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(, { + 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); +}); diff --git a/server/sonar-web/src/main/js/components/issue/Issue.d.ts b/server/sonar-web/src/main/js/components/issue/Issue.d.ts index 5abf041951b..6930914f170 100644 --- a/server/sonar-web/src/main/js/components/issue/Issue.d.ts +++ b/server/sonar-web/src/main/js/components/issue/Issue.d.ts @@ -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; diff --git a/server/sonar-web/src/main/js/components/issue/Issue.js b/server/sonar-web/src/main/js/components/issue/Issue.js index 0100ba16b5b..47bf09dab76 100644 --- a/server/sonar-web/src/main/js/components/issue/Issue.js +++ b/server/sonar-web/src/main/js/components/issue/Issue.js @@ -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; diff --git a/server/sonar-web/src/main/js/components/issue/IssueView.js b/server/sonar-web/src/main/js/components/issue/IssueView.js index d6f3ab8335e..b7a658e77ba 100644 --- a/server/sonar-web/src/main/js/components/issue/IssueView.js +++ b/server/sonar-web/src/main/js/components/issue/IssueView.js @@ -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); } };