aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web
diff options
context:
space:
mode:
authorJeremy <jeremy.davis@sonarsource.com>2019-12-19 19:18:21 +0100
committerSonarTech <sonartech@sonarsource.com>2020-01-13 20:46:30 +0100
commit037ef05f53f198ec33bd7d5ecf4fa057d87a2a4d (patch)
tree53a20e9a5869ac0596cfd2f709b13563328e8e9b /server/sonar-web
parentce77e99565acad8ae1124ec7d4ead2d7fd9d07c6 (diff)
downloadsonarqube-037ef05f53f198ec33bd7d5ecf4fa057d87a2a4d.tar.gz
sonarqube-037ef05f53f198ec33bd7d5ecf4fa057d87a2a4d.zip
SONAR-12754 Enable hotspot assignment
Diffstat (limited to 'server/sonar-web')
-rw-r--r--server/sonar-web/src/main/js/api/security-hotspots.ts17
-rw-r--r--server/sonar-web/src/main/js/app/styles/init/misc.css4
-rw-r--r--server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotActionsForm.tsx30
-rw-r--r--server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotActionsFormRenderer.tsx9
-rw-r--r--server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotAssigneeSelect.css27
-rw-r--r--server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotAssigneeSelect.tsx158
-rw-r--r--server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotAssigneeSelectRenderer.tsx88
-rw-r--r--server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotActionsForm-test.tsx34
-rw-r--r--server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotActionsFormRenderer-test.tsx8
-rw-r--r--server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotAssigneeSelect-test.tsx97
-rw-r--r--server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotAssigneeSelectRenderer-test.tsx70
-rw-r--r--server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotActionsForm-test.tsx.snap1
-rw-r--r--server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotActionsFormRenderer-test.tsx.snap88
-rw-r--r--server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotAssigneeSelect-test.tsx.snap11
-rw-r--r--server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotAssigneeSelectRenderer-test.tsx.snap101
-rw-r--r--server/sonar-web/src/main/js/types/security-hotspots.ts6
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;
+}