]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-12754 Enable hotspot assignment
authorJeremy <jeremy.davis@sonarsource.com>
Thu, 19 Dec 2019 18:18:21 +0000 (19:18 +0100)
committerSonarTech <sonartech@sonarsource.com>
Mon, 13 Jan 2020 19:46:30 +0000 (20:46 +0100)
16 files changed:
server/sonar-web/src/main/js/api/security-hotspots.ts
server/sonar-web/src/main/js/app/styles/init/misc.css
server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotActionsForm.tsx
server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotActionsFormRenderer.tsx
server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotAssigneeSelect.css [new file with mode: 0644]
server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotAssigneeSelect.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotAssigneeSelectRenderer.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotActionsForm-test.tsx
server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotActionsFormRenderer-test.tsx
server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotAssigneeSelect-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotAssigneeSelectRenderer-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotActionsForm-test.tsx.snap
server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotActionsFormRenderer-test.tsx.snap
server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotAssigneeSelect-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotAssigneeSelectRenderer-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/types/security-hotspots.ts

index 316166e0f8dcf0c2d4f09b6f38bbf0a1c44f8558..cea427e8c121d8ea5190be988de346b109ec3609 100644 (file)
@@ -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(
index 9c8de00be8e9c2207f943afba7c379feccd803af..620e05f33e7a4558cb1ec1efec6fed8e3bad5a1a 100644 (file)
@@ -132,6 +132,10 @@ th.hide-overflow {
   margin-top: 4px !important;
 }
 
+.padded {
+  padding: var(--gridSize);
+}
+
 .big-padded {
   padding: calc(2 * var(--gridSize));
 }
index 97713831d4cb1b706730725660bbb1b16ec127be..b1c998847b59c6e9a5dd548bb3f29efa426f8880 100644 (file)
@@ -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}
       />
     );
index 666c8a809c23c2a9c0811bc37d9876ba17e9124e..8fb0824385d2eb14a545d9d6035abfcb13c9cbf3 100644 (file)
@@ -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 (file)
index 0000000..7a5c24e
--- /dev/null
@@ -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 (file)
index 0000000..e2d16a1
--- /dev/null
@@ -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 (file)
index 0000000..2df7734
--- /dev/null
@@ -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>
+      )}
+    </>
+  );
+}
index b4add87f98cf1c2755c7e1b13076c90b13319e29..a371d5efa12363cc4ba271b8ae59722446f300c2 100644 (file)
@@ -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');
index b7372b04035d265fe6230d790603c11d8587ec92..ff65d450a0fcc4988fef21747931a1eec260c00d 100644 (file)
@@ -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 (file)
index 0000000..1959330
--- /dev/null
@@ -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 (file)
index 0000000..c067c3d
--- /dev/null
@@ -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}
+    />
+  );
+}
index ef7582d543422ae3dc6706645f34f4b27cad5249..68ed8d32c27249f8ce2ffe400417c04268068d27 100644 (file)
@@ -3,6 +3,7 @@
 exports[`should render correctly 1`] = `
 <HotspotActionsFormRenderer
   hotspotKey="key"
+  onAssign={[Function]}
   onSelectOption={[Function]}
   onSubmit={[Function]}
   selectedOption="FIXED"
index 0c9633ff166f51e350c9e5b608ede21d4bfefe25..0d539f72b10f00c22a1c92f4f04407d3d4062dc3 100644 (file)
@@ -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 (file)
index 0000000..3d3a988
--- /dev/null
@@ -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 (file)
index 0000000..f86f59f
--- /dev/null
@@ -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>
+`;
index 13b8a12bb01d9bf6d4d7f60235ca51de3da00ab0..1a137246339df7d0357eae8a5170b5198b27b636 100644 (file)
@@ -99,7 +99,11 @@ export interface HotspotSearchResponse {
 }
 
 export interface HotspotSetStatusRequest {
-  hotspot: string;
   status: HotspotStatus;
   resolution?: HotspotResolution;
 }
+
+export interface HotspotAssignRequest {
+  assignee: string;
+  comment?: string;
+}