@@ -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 */) => { |
@@ -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}, |
@@ -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, |
@@ -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); | |||
}); |
@@ -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; |
@@ -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; |
@@ -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); | |||
} | |||
}; | |||