diff options
author | Jeremy <jeremy.davis@sonarsource.com> | 2019-12-19 19:18:21 +0100 |
---|---|---|
committer | SonarTech <sonartech@sonarsource.com> | 2020-01-13 20:46:30 +0100 |
commit | 037ef05f53f198ec33bd7d5ecf4fa057d87a2a4d (patch) | |
tree | 53a20e9a5869ac0596cfd2f709b13563328e8e9b /server/sonar-web | |
parent | ce77e99565acad8ae1124ec7d4ead2d7fd9d07c6 (diff) | |
download | sonarqube-037ef05f53f198ec33bd7d5ecf4fa057d87a2a4d.tar.gz sonarqube-037ef05f53f198ec33bd7d5ecf4fa057d87a2a4d.zip |
SONAR-12754 Enable hotspot assignment
Diffstat (limited to 'server/sonar-web')
16 files changed, 735 insertions, 14 deletions
diff --git a/server/sonar-web/src/main/js/api/security-hotspots.ts b/server/sonar-web/src/main/js/api/security-hotspots.ts index 316166e0f8d..cea427e8c12 100644 --- a/server/sonar-web/src/main/js/api/security-hotspots.ts +++ b/server/sonar-web/src/main/js/api/security-hotspots.ts @@ -22,12 +22,25 @@ import throwGlobalError from '../app/utils/throwGlobalError'; import { BranchParameters } from '../types/branch-like'; import { DetailedHotspot, + HotspotAssignRequest, HotspotSearchResponse, HotspotSetStatusRequest } from '../types/security-hotspots'; -export function setSecurityHotspotStatus(data: HotspotSetStatusRequest): Promise<void> { - return post('/api/hotspots/change_status', data).catch(throwGlobalError); +export function assignSecurityHotspot( + hotspotKey: string, + data: HotspotAssignRequest +): Promise<void> { + return post('/api/hotspots/assign', { hotspot: hotspotKey, ...data }).catch(throwGlobalError); +} + +export function setSecurityHotspotStatus( + hotspotKey: string, + data: HotspotSetStatusRequest +): Promise<void> { + return post('/api/hotspots/change_status', { hotspot: hotspotKey, ...data }).catch( + throwGlobalError + ); } export function getSecurityHotspots( diff --git a/server/sonar-web/src/main/js/app/styles/init/misc.css b/server/sonar-web/src/main/js/app/styles/init/misc.css index 9c8de00be8e..620e05f33e7 100644 --- a/server/sonar-web/src/main/js/app/styles/init/misc.css +++ b/server/sonar-web/src/main/js/app/styles/init/misc.css @@ -132,6 +132,10 @@ th.hide-overflow { margin-top: 4px !important; } +.padded { + padding: var(--gridSize); +} + .big-padded { padding: calc(2 * var(--gridSize)); } diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotActionsForm.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotActionsForm.tsx index 97713831d4c..b1c998847b5 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotActionsForm.tsx +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotActionsForm.tsx @@ -18,7 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import { setSecurityHotspotStatus } from '../../../api/security-hotspots'; +import { assignSecurityHotspot, setSecurityHotspotStatus } from '../../../api/security-hotspots'; import { HotspotResolution, HotspotSetStatusRequest, @@ -34,6 +34,7 @@ interface Props { } interface State { + selectedUser?: T.UserActive; selectedOption: HotspotStatusOptions; submitting: boolean; } @@ -48,6 +49,10 @@ export default class HotspotActionsForm extends React.Component<Props, State> { this.setState({ selectedOption }); }; + handleAssign = (selectedUser: T.UserActive) => { + this.setState({ selectedUser }); + }; + handleSubmit = (event: React.SyntheticEvent<HTMLFormElement>) => { event.preventDefault(); @@ -58,13 +63,20 @@ export default class HotspotActionsForm extends React.Component<Props, State> { selectedOption === HotspotStatusOptions.ADDITIONAL_REVIEW ? HotspotStatus.TO_REVIEW : HotspotStatus.REVIEWED; - const data: HotspotSetStatusRequest = { hotspot: hotspotKey, status }; + const data: HotspotSetStatusRequest = { status }; if (selectedOption !== HotspotStatusOptions.ADDITIONAL_REVIEW) { data.resolution = HotspotResolution[selectedOption]; } this.setState({ submitting: true }); - return setSecurityHotspotStatus(data) + return setSecurityHotspotStatus(hotspotKey, data) + .then(() => { + const { selectedUser } = this.state; + if (selectedOption === HotspotStatusOptions.ADDITIONAL_REVIEW && selectedUser) { + return this.assignHotspot(selectedUser); + } + return null; + }) .then(() => { this.props.onSubmit({ status, resolution: data.resolution }); }) @@ -73,16 +85,26 @@ export default class HotspotActionsForm extends React.Component<Props, State> { }); }; + assignHotspot = (assignee: T.UserActive) => { + const { hotspotKey } = this.props; + + return assignSecurityHotspot(hotspotKey, { + assignee: assignee.login + }); + }; + render() { const { hotspotKey } = this.props; - const { selectedOption, submitting } = this.state; + const { selectedOption, selectedUser, submitting } = this.state; return ( <HotspotActionsFormRenderer hotspotKey={hotspotKey} + onAssign={this.handleAssign} onSelectOption={this.handleSelectOption} onSubmit={this.handleSubmit} selectedOption={selectedOption} + selectedUser={selectedUser} submitting={submitting} /> ); diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotActionsFormRenderer.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotActionsFormRenderer.tsx index 666c8a809c2..8fb0824385d 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotActionsFormRenderer.tsx +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotActionsFormRenderer.tsx @@ -22,12 +22,15 @@ import { SubmitButton } from 'sonar-ui-common/components/controls/buttons'; import Radio from 'sonar-ui-common/components/controls/Radio'; import { translate } from 'sonar-ui-common/helpers/l10n'; import { HotspotStatusOptions } from '../../../types/security-hotspots'; +import HotspotAssigneeSelect from './HotspotAssigneeSelect'; export interface HotspotActionsFormRendererProps { hotspotKey: string; + onAssign: (user: T.UserActive) => void; onSelectOption: (option: HotspotStatusOptions) => void; onSubmit: (event: React.SyntheticEvent<HTMLFormElement>) => void; selectedOption: HotspotStatusOptions; + selectedUser?: T.UserActive; submitting: boolean; } @@ -54,6 +57,12 @@ export default function HotspotActionsFormRenderer(props: HotspotActionsFormRend onClick: props.onSelectOption })} </div> + {selectedOption === HotspotStatusOptions.ADDITIONAL_REVIEW && ( + <div className="form-field huge-spacer-left"> + <label>{translate('hotspots.form.assign_to')}</label> + <HotspotAssigneeSelect onSelect={props.onAssign} /> + </div> + )} <div className="text-right"> {submitting && <i className="spinner spacer-right" />} <SubmitButton disabled={submitting}>{translate('hotspots.form.submit')}</SubmitButton> diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotAssigneeSelect.css b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotAssigneeSelect.css new file mode 100644 index 00000000000..7a5c24ebf0e --- /dev/null +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotAssigneeSelect.css @@ -0,0 +1,27 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 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. + */ +.hotspot-assignee-search-results li { + cursor: pointer; +} + +.hotspot-assignee-search-results li:hover, +.hotspot-assignee-search-results li.active { + background-color: var(--barBackgroundColor); +} diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotAssigneeSelect.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotAssigneeSelect.tsx new file mode 100644 index 00000000000..e2d16a1edf8 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotAssigneeSelect.tsx @@ -0,0 +1,158 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 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. + */ +import { debounce } from 'lodash'; +import * as React from 'react'; +import { KeyCodes } from 'sonar-ui-common/helpers/keycodes'; +import { searchUsers } from '../../../api/users'; +import { isUserActive } from '../../../helpers/users'; +import HotspotAssigneeSelectRenderer from './HotspotAssigneeSelectRenderer'; + +interface Props { + onSelect: (user: T.UserActive) => void; +} + +interface State { + highlighted?: T.UserActive; + loading: boolean; + open: boolean; + query?: string; + suggestedUsers?: T.UserActive[]; +} + +export default class HotspotAssigneeSelect extends React.PureComponent<Props, State> { + state: State; + + constructor(props: Props) { + super(props); + this.state = { + loading: false, + open: false + }; + this.handleSearch = debounce(this.handleSearch, 250); + } + + getCurrentIndex = () => { + const { highlighted, suggestedUsers } = this.state; + return highlighted && suggestedUsers + ? suggestedUsers.findIndex(suggestion => suggestion.login === highlighted.login) + : -1; + }; + + handleSearch = (query: string) => { + if (query.length < 2) { + this.setState({ open: false, query }); + return Promise.resolve([]); + } + + this.setState({ loading: true, query }); + return searchUsers({ q: query }) + .then(this.handleSearchResult, () => {}) + .catch(() => this.setState({ loading: false })); + }; + + handleSearchResult = ({ users }: { users: T.UserBase[] }) => { + const activeUsers = users.filter(isUserActive); + this.setState(({ highlighted }) => { + if (activeUsers.length === 0) { + highlighted = undefined; + } else { + const findHighlited = activeUsers.find(u => highlighted && u.login === highlighted.login); + highlighted = findHighlited || activeUsers[0]; + } + + return { + highlighted, + loading: false, + open: true, + suggestedUsers: activeUsers + }; + }); + }; + + handleKeyDown = (event: React.KeyboardEvent) => { + switch (event.keyCode) { + case KeyCodes.Enter: + event.preventDefault(); + this.handleSelectHighlighted(); + break; + case KeyCodes.UpArrow: + event.preventDefault(); + this.handleHighlightPrevious(); + break; + case KeyCodes.DownArrow: + event.preventDefault(); + this.handleHighlightNext(); + break; + } + }; + + highlightIndex = (index: number) => { + const { suggestedUsers } = this.state; + if (suggestedUsers && suggestedUsers.length > 0) { + if (index < 0) { + index = suggestedUsers.length - 1; + } else if (index >= suggestedUsers.length) { + index = 0; + } + this.setState({ + highlighted: suggestedUsers[index] + }); + } + }; + + handleHighlightPrevious = () => { + this.highlightIndex(this.getCurrentIndex() - 1); + }; + + handleHighlightNext = () => { + this.highlightIndex(this.getCurrentIndex() + 1); + }; + + handleSelectHighlighted = () => { + const { highlighted } = this.state; + if (highlighted !== undefined) { + this.handleSelect(highlighted); + } + }; + + handleSelect = (selectedUser: T.UserActive) => { + this.setState({ + open: false, + query: selectedUser.name + }); + this.props.onSelect(selectedUser); + }; + + render() { + const { highlighted, loading, open, query, suggestedUsers } = this.state; + return ( + <HotspotAssigneeSelectRenderer + highlighted={highlighted} + loading={loading} + onKeyDown={this.handleKeyDown} + onSearch={this.handleSearch} + onSelect={this.handleSelect} + open={open} + query={query} + suggestedUsers={suggestedUsers} + /> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotAssigneeSelectRenderer.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotAssigneeSelectRenderer.tsx new file mode 100644 index 00000000000..2df77348d12 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotAssigneeSelectRenderer.tsx @@ -0,0 +1,88 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 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. + */ +import * as classNames from 'classnames'; +import * as React from 'react'; +import { DropdownOverlay } from 'sonar-ui-common/components/controls/Dropdown'; +import SearchBox from 'sonar-ui-common/components/controls/SearchBox'; +import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner'; +import { PopupPlacement } from 'sonar-ui-common/components/ui/popups'; +import { translate } from 'sonar-ui-common/helpers/l10n'; +import Avatar from '../../../components/ui/Avatar'; +import './HotspotAssigneeSelect.css'; + +export interface HotspotAssigneeSelectRendererProps { + highlighted?: T.UserActive; + loading: boolean; + onKeyDown: (event: React.KeyboardEvent) => void; + onSearch: (query: string) => void; + onSelect: (user: T.UserActive) => void; + open: boolean; + query?: string; + suggestedUsers?: T.UserActive[]; +} + +export default function HotspotAssigneeSelectRenderer(props: HotspotAssigneeSelectRendererProps) { + const { highlighted, loading, open, query, suggestedUsers } = props; + return ( + <> + <SearchBox + autoFocus={true} + onChange={props.onSearch} + onKeyDown={props.onKeyDown} + placeholder={translate('hotspots.form.select_user')} + value={query} + /> + + {loading && <DeferredSpinner />} + + {!loading && open && ( + <div className="position-relative"> + <DropdownOverlay + className="abs-width-400" + noPadding={true} + placement={PopupPlacement.BottomLeft}> + {suggestedUsers && suggestedUsers.length > 0 ? ( + <ul className="hotspot-assignee-search-results"> + {suggestedUsers.map(suggestion => ( + <li + className={classNames('padded', { + active: highlighted && highlighted.login === suggestion.login + })} + key={suggestion.login} + onClick={() => props.onSelect(suggestion)}> + <Avatar + className="spacer-right" + hash={suggestion.avatar} + name={suggestion.name} + size={16} + /> + {suggestion.name} + </li> + ))} + </ul> + ) : ( + <div className="padded">{translate('no_results')}</div> + )} + </DropdownOverlay> + </div> + )} + </> + ); +} diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotActionsForm-test.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotActionsForm-test.tsx index b4add87f98c..a371d5efa12 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotActionsForm-test.tsx +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotActionsForm-test.tsx @@ -20,7 +20,8 @@ import { shallow } from 'enzyme'; import * as React from 'react'; import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; -import { setSecurityHotspotStatus } from '../../../../api/security-hotspots'; +import { assignSecurityHotspot, setSecurityHotspotStatus } from '../../../../api/security-hotspots'; +import { mockLoggedInUser } from '../../../../helpers/testMocks'; import { HotspotResolution, HotspotStatus, @@ -29,6 +30,7 @@ import { import HotspotActionsForm from '../HotspotActionsForm'; jest.mock('../../../../api/security-hotspots', () => ({ + assignSecurityHotspot: jest.fn().mockResolvedValue(undefined), setSecurityHotspotStatus: jest.fn().mockResolvedValue(undefined) })); @@ -55,8 +57,7 @@ it('should handle submit', async () => { expect(wrapper.state().submitting).toBe(true); await promise; - expect(setSecurityHotspotStatus).toBeCalledWith({ - hotspot: 'key', + expect(setSecurityHotspotStatus).toBeCalledWith('key', { status: HotspotStatus.TO_REVIEW }); expect(onSubmit).toBeCalled(); @@ -65,8 +66,7 @@ it('should handle submit', async () => { wrapper.setState({ selectedOption: HotspotStatusOptions.SAFE }); await waitAndUpdate(wrapper); await wrapper.instance().handleSubmit({ preventDefault } as any); - expect(setSecurityHotspotStatus).toBeCalledWith({ - hotspot: 'key', + expect(setSecurityHotspotStatus).toBeCalledWith('key', { status: HotspotStatus.REVIEWED, resolution: HotspotResolution.SAFE }); @@ -75,13 +75,33 @@ it('should handle submit', async () => { wrapper.setState({ selectedOption: HotspotStatusOptions.FIXED }); await waitAndUpdate(wrapper); await wrapper.instance().handleSubmit({ preventDefault } as any); - expect(setSecurityHotspotStatus).toBeCalledWith({ - hotspot: 'key', + expect(setSecurityHotspotStatus).toBeCalledWith('key', { status: HotspotStatus.REVIEWED, resolution: HotspotResolution.FIXED }); }); +it('should handle assignment', async () => { + const onSubmit = jest.fn(); + const wrapper = shallowRender({ onSubmit }); + wrapper.setState({ selectedOption: HotspotStatusOptions.ADDITIONAL_REVIEW }); + wrapper.instance().handleAssign(mockLoggedInUser({ login: 'userLogin' })); + await waitAndUpdate(wrapper); + + const promise = wrapper.instance().handleSubmit({ preventDefault: jest.fn() } as any); + + expect(wrapper.state().submitting).toBe(true); + await promise; + + expect(setSecurityHotspotStatus).toBeCalledWith('key', { + status: HotspotStatus.TO_REVIEW + }); + expect(assignSecurityHotspot).toBeCalledWith('key', { + assignee: 'userLogin' + }); + expect(onSubmit).toBeCalled(); +}); + it('should handle submit failure', async () => { const onSubmit = jest.fn(); (setSecurityHotspotStatus as jest.Mock).mockRejectedValueOnce('failure'); diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotActionsFormRenderer-test.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotActionsFormRenderer-test.tsx index b7372b04035..ff65d450a0f 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotActionsFormRenderer-test.tsx +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotActionsFormRenderer-test.tsx @@ -19,6 +19,7 @@ */ import { shallow } from 'enzyme'; import * as React from 'react'; +import { mockLoggedInUser } from '../../../../helpers/testMocks'; import { HotspotStatusOptions } from '../../../../types/security-hotspots'; import HotspotActionsForm from '../HotspotActionsForm'; import HotspotActionsFormRenderer, { @@ -31,12 +32,19 @@ it('should render correctly', () => { expect(shallowRender({ selectedOption: HotspotStatusOptions.SAFE })).toMatchSnapshot( 'safe option selected' ); + expect( + shallowRender({ + selectedOption: HotspotStatusOptions.ADDITIONAL_REVIEW, + selectedUser: mockLoggedInUser() + }) + ).toMatchSnapshot('user selected'); }); function shallowRender(props: Partial<HotspotActionsFormRendererProps> = {}) { return shallow<HotspotActionsForm>( <HotspotActionsFormRenderer hotspotKey="key" + onAssign={jest.fn()} onSelectOption={jest.fn()} onSubmit={jest.fn()} selectedOption={HotspotStatusOptions.FIXED} diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotAssigneeSelect-test.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotAssigneeSelect-test.tsx new file mode 100644 index 00000000000..19593304c9f --- /dev/null +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotAssigneeSelect-test.tsx @@ -0,0 +1,97 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 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. + */ +import { shallow } from 'enzyme'; +import * as React from 'react'; +import { KeyCodes } from 'sonar-ui-common/helpers/keycodes'; +import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; +import { searchUsers } from '../../../../api/users'; +import { mockUser } from '../../../../helpers/testMocks'; +import HotspotAssigneeSelect from '../HotspotAssigneeSelect'; + +jest.mock('../../../../api/users', () => ({ + searchUsers: jest.fn().mockResolvedValue([]) +})); + +it('should render correctly', () => { + expect(shallowRender()).toMatchSnapshot(); +}); + +it('should handle keydown', () => { + const mockEvent = (keyCode: number) => ({ preventDefault: jest.fn(), keyCode }); + const suggestedUsers = [ + mockUser({ login: '1' }) as T.UserActive, + mockUser({ login: '2' }) as T.UserActive, + mockUser({ login: '3' }) as T.UserActive + ]; + + const onSelect = jest.fn(); + const wrapper = shallowRender({ onSelect }); + + wrapper.instance().handleKeyDown(mockEvent(KeyCodes.UpArrow) as any); + expect(wrapper.state().highlighted).toBeUndefined(); + + wrapper.setState({ suggestedUsers }); + + // press down to highlight the first + wrapper.instance().handleKeyDown(mockEvent(KeyCodes.DownArrow) as any); + expect(wrapper.state().highlighted).toBe(suggestedUsers[0]); + + // press up to loop around to last + wrapper.instance().handleKeyDown(mockEvent(KeyCodes.UpArrow) as any); + expect(wrapper.state().highlighted).toBe(suggestedUsers[2]); + + // press down to loop around to first + wrapper.instance().handleKeyDown(mockEvent(KeyCodes.DownArrow) as any); + expect(wrapper.state().highlighted).toBe(suggestedUsers[0]); + + // press down highlight the next + wrapper.instance().handleKeyDown(mockEvent(KeyCodes.DownArrow) as any); + expect(wrapper.state().highlighted).toBe(suggestedUsers[1]); + + // press enter to select the highlighted user + wrapper.instance().handleKeyDown(mockEvent(KeyCodes.Enter) as any); + expect(onSelect).toBeCalledWith(suggestedUsers[1]); +}); + +it('should handle search', async () => { + const users = [mockUser({ login: '1' }), mockUser({ login: '2' }), mockUser({ login: '3' })]; + (searchUsers as jest.Mock).mockResolvedValueOnce({ users }); + + const wrapper = shallowRender(); + wrapper.instance().handleSearch('j'); + + expect(searchUsers).not.toBeCalled(); + expect(wrapper.state().open).toBe(false); + + wrapper.instance().handleSearch('jo'); + expect(wrapper.state().loading).toBe(true); + expect(searchUsers).toBeCalledWith({ q: 'jo' }); + + await waitAndUpdate(wrapper); + + expect(wrapper.state().highlighted).toBe(users[0]); + expect(wrapper.state().loading).toBe(false); + expect(wrapper.state().open).toBe(true); + expect(wrapper.state().suggestedUsers).toHaveLength(3); +}); + +function shallowRender(props?: Partial<HotspotAssigneeSelect['props']>) { + return shallow<HotspotAssigneeSelect>(<HotspotAssigneeSelect onSelect={jest.fn()} {...props} />); +} diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotAssigneeSelectRenderer-test.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotAssigneeSelectRenderer-test.tsx new file mode 100644 index 00000000000..c067c3dcfa0 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotAssigneeSelectRenderer-test.tsx @@ -0,0 +1,70 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 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. + */ +import { shallow } from 'enzyme'; +import * as React from 'react'; +import { mockUser } from '../../../../helpers/testMocks'; +import HotspotAssigneeSelectRenderer, { + HotspotAssigneeSelectRendererProps +} from '../HotspotAssigneeSelectRenderer'; + +it('should render correctly', () => { + expect(shallowRender()).toMatchSnapshot(); + expect(shallowRender({ loading: true })).toMatchSnapshot('loading'); + expect(shallowRender({ open: true })).toMatchSnapshot('open'); + + const highlightedUser = mockUser({ login: 'highlighted' }) as T.UserActive; + expect( + shallowRender({ + highlighted: highlightedUser, + open: true, + suggestedUsers: [mockUser() as T.UserActive, highlightedUser] + }) + ).toMatchSnapshot('open with results'); +}); + +it('should call onSelect when clicked', () => { + const user = mockUser() as T.UserActive; + const onSelect = jest.fn(); + const wrapper = shallowRender({ + open: true, + onSelect, + suggestedUsers: [user] + }); + + wrapper + .find('li') + .at(0) + .simulate('click'); + + expect(onSelect).toBeCalledWith(user); +}); + +function shallowRender(props?: Partial<HotspotAssigneeSelectRendererProps>) { + return shallow( + <HotspotAssigneeSelectRenderer + loading={false} + onKeyDown={jest.fn()} + onSearch={jest.fn()} + onSelect={jest.fn()} + open={false} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotActionsForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotActionsForm-test.tsx.snap index ef7582d5434..68ed8d32c27 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotActionsForm-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotActionsForm-test.tsx.snap @@ -3,6 +3,7 @@ exports[`should render correctly 1`] = ` <HotspotActionsFormRenderer hotspotKey="key" + onAssign={[Function]} onSelectOption={[Function]} onSubmit={[Function]} selectedOption="FIXED" diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotActionsFormRenderer-test.tsx.snap b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotActionsFormRenderer-test.tsx.snap index 0c9633ff166..0d539f72b10 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotActionsFormRenderer-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotActionsFormRenderer-test.tsx.snap @@ -236,3 +236,91 @@ exports[`should render correctly: safe option selected 1`] = ` </div> </form> `; + +exports[`should render correctly: user selected 1`] = ` +<form + className="abs-width-400" + onSubmit={[MockFunction]} +> + <h2> + hotspots.form.title + </h2> + <div + className="display-flex-column big-spacer-bottom" + > + <div + className="big-spacer-top" + > + <Radio + checked={false} + onCheck={[MockFunction]} + value="FIXED" + > + <h3> + hotspots.status_option.FIXED + </h3> + </Radio> + <div + className="radio-button-description" + > + hotspots.status_option.FIXED.description + </div> + </div> + <div + className="big-spacer-top" + > + <Radio + checked={false} + onCheck={[MockFunction]} + value="SAFE" + > + <h3> + hotspots.status_option.SAFE + </h3> + </Radio> + <div + className="radio-button-description" + > + hotspots.status_option.SAFE.description + </div> + </div> + <div + className="big-spacer-top" + > + <Radio + checked={true} + onCheck={[MockFunction]} + value="ADDITIONAL_REVIEW" + > + <h3> + hotspots.status_option.ADDITIONAL_REVIEW + </h3> + </Radio> + <div + className="radio-button-description" + > + hotspots.status_option.ADDITIONAL_REVIEW.description + </div> + </div> + </div> + <div + className="form-field huge-spacer-left" + > + <label> + hotspots.form.assign_to + </label> + <HotspotAssigneeSelect + onSelect={[MockFunction]} + /> + </div> + <div + className="text-right" + > + <SubmitButton + disabled={false} + > + hotspots.form.submit + </SubmitButton> + </div> +</form> +`; diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotAssigneeSelect-test.tsx.snap b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotAssigneeSelect-test.tsx.snap new file mode 100644 index 00000000000..3d3a988ea50 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotAssigneeSelect-test.tsx.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` +<HotspotAssigneeSelectRenderer + loading={false} + onKeyDown={[Function]} + onSearch={[Function]} + onSelect={[Function]} + open={false} +/> +`; diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotAssigneeSelectRenderer-test.tsx.snap b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotAssigneeSelectRenderer-test.tsx.snap new file mode 100644 index 00000000000..f86f59f3bdc --- /dev/null +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotAssigneeSelectRenderer-test.tsx.snap @@ -0,0 +1,101 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` +<Fragment> + <SearchBox + autoFocus={true} + onChange={[MockFunction]} + onKeyDown={[MockFunction]} + placeholder="hotspots.form.select_user" + /> +</Fragment> +`; + +exports[`should render correctly: loading 1`] = ` +<Fragment> + <SearchBox + autoFocus={true} + onChange={[MockFunction]} + onKeyDown={[MockFunction]} + placeholder="hotspots.form.select_user" + /> + <DeferredSpinner + timeout={100} + /> +</Fragment> +`; + +exports[`should render correctly: open 1`] = ` +<Fragment> + <SearchBox + autoFocus={true} + onChange={[MockFunction]} + onKeyDown={[MockFunction]} + placeholder="hotspots.form.select_user" + /> + <div + className="position-relative" + > + <DropdownOverlay + className="abs-width-400" + noPadding={true} + placement="bottom-left" + > + <div + className="padded" + > + no_results + </div> + </DropdownOverlay> + </div> +</Fragment> +`; + +exports[`should render correctly: open with results 1`] = ` +<Fragment> + <SearchBox + autoFocus={true} + onChange={[MockFunction]} + onKeyDown={[MockFunction]} + placeholder="hotspots.form.select_user" + /> + <div + className="position-relative" + > + <DropdownOverlay + className="abs-width-400" + noPadding={true} + placement="bottom-left" + > + <ul + className="hotspot-assignee-search-results" + > + <li + className="padded" + key="john.doe" + onClick={[Function]} + > + <Connect(Avatar) + className="spacer-right" + name="John Doe" + size={16} + /> + John Doe + </li> + <li + className="padded active" + key="highlighted" + onClick={[Function]} + > + <Connect(Avatar) + className="spacer-right" + name="John Doe" + size={16} + /> + John Doe + </li> + </ul> + </DropdownOverlay> + </div> +</Fragment> +`; diff --git a/server/sonar-web/src/main/js/types/security-hotspots.ts b/server/sonar-web/src/main/js/types/security-hotspots.ts index 13b8a12bb01..1a137246339 100644 --- a/server/sonar-web/src/main/js/types/security-hotspots.ts +++ b/server/sonar-web/src/main/js/types/security-hotspots.ts @@ -99,7 +99,11 @@ export interface HotspotSearchResponse { } export interface HotspotSetStatusRequest { - hotspot: string; status: HotspotStatus; resolution?: HotspotResolution; } + +export interface HotspotAssignRequest { + assignee: string; + comment?: string; +} |