]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-12719 Hotspot resolution form
authorJeremy Davis <jeremy.davis@sonarsource.com>
Wed, 11 Dec 2019 10:53:19 +0000 (11:53 +0100)
committerSonarTech <sonartech@sonarsource.com>
Mon, 13 Jan 2020 19:46:27 +0000 (20:46 +0100)
18 files changed:
server/sonar-web/src/main/js/api/security-hotspots.ts
server/sonar-web/src/main/js/apps/securityHotspots/SecurityHotspotsAppRenderer.tsx
server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotActions.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotActionsForm.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotActionsFormRenderer.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerRenderer.tsx
server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotActions-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotActionsForm-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotActionsFormRenderer-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotViewerRenderer-test.tsx
server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotActions-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotActionsForm-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotActionsFormRenderer-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewer-test.tsx.snap
server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerRenderer-test.tsx.snap
server/sonar-web/src/main/js/apps/securityHotspots/styles.css
server/sonar-web/src/main/js/types/security-hotspots.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index d9ed71e170ada0049024189b476ce567ce323b7c..9971cdd6c2e2265b4a3c386cbdce47de7df594dc 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { getJSON } from 'sonar-ui-common/helpers/request';
+import { getJSON, post } from 'sonar-ui-common/helpers/request';
 import throwGlobalError from '../app/utils/throwGlobalError';
-import { DetailedHotspot, HotspotSearchResponse } from '../types/security-hotspots';
+import {
+  DetailedHotspot,
+  HotspotSearchResponse,
+  HotspotSetStatusRequest
+} from '../types/security-hotspots';
+
+export function setSecurityHotspotStatus(data: HotspotSetStatusRequest): Promise<void> {
+  return post('/api/hotspots/change_status', data).catch(throwGlobalError);
+}
 
 export function getSecurityHotspots(data: {
   projectKey: string;
index 078a1d5b51b1e4735c42524276579e862d880d04..528951bfd7cbdde0c47fc76717192cf5052719a2 100644 (file)
@@ -42,6 +42,7 @@ export interface SecurityHotspotsAppRendererProps {
 
 export default function SecurityHotspotsAppRenderer(props: SecurityHotspotsAppRendererProps) {
   const { hotspots, loading, securityCategories, selectedHotspotKey } = props;
+
   return (
     <div id="security_hotspots">
       <FilterBar />
diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotActions.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotActions.tsx
new file mode 100644 (file)
index 0000000..9f40f72
--- /dev/null
@@ -0,0 +1,68 @@
+/*
+ * 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 React from 'react';
+import { Button } from 'sonar-ui-common/components/controls/buttons';
+import { DropdownOverlay } from 'sonar-ui-common/components/controls/Dropdown';
+import OutsideClickHandler from 'sonar-ui-common/components/controls/OutsideClickHandler';
+import DropdownIcon from 'sonar-ui-common/components/icons/DropdownIcon';
+import { PopupPlacement } from 'sonar-ui-common/components/ui/popups';
+import { translate } from 'sonar-ui-common/helpers/l10n';
+import HotspotActionsForm from './HotspotActionsForm';
+
+export interface HotspotActionsProps {
+  hotspotKey: string;
+}
+
+const ESCAPE_KEY = 'Escape';
+
+export default function HotspotActions(props: HotspotActionsProps) {
+  const [open, setOpen] = React.useState(false);
+
+  React.useEffect(() => {
+    const handleKeyDown = (event: KeyboardEvent) => {
+      if (event.key === ESCAPE_KEY) {
+        setOpen(false);
+      }
+    };
+
+    document.addEventListener('keydown', handleKeyDown, false);
+
+    return () => {
+      document.removeEventListener('keydown', handleKeyDown, false);
+    };
+  });
+
+  return (
+    <div className="dropdown">
+      <Button onClick={() => setOpen(!open)}>
+        {translate('hotspots.review_hotspot')}
+        <DropdownIcon className="little-spacer-left" />
+      </Button>
+
+      {open && (
+        <OutsideClickHandler onClickOutside={() => setOpen(false)}>
+          <DropdownOverlay placement={PopupPlacement.BottomRight}>
+            <HotspotActionsForm hotspotKey={props.hotspotKey} onSubmit={() => setOpen(false)} />
+          </DropdownOverlay>
+        </OutsideClickHandler>
+      )}
+    </div>
+  );
+}
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
new file mode 100644 (file)
index 0000000..e34a2ad
--- /dev/null
@@ -0,0 +1,89 @@
+/*
+ * 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 React from 'react';
+import { setSecurityHotspotStatus } from '../../../api/security-hotspots';
+import {
+  HotspotResolution,
+  HotspotSetStatusRequest,
+  HotspotStatus,
+  HotspotStatusOptions
+} from '../../../types/security-hotspots';
+import HotspotActionsFormRenderer from './HotspotActionsFormRenderer';
+
+interface Props {
+  hotspotKey: string;
+  onSubmit: () => void;
+}
+
+interface State {
+  selectedOption: HotspotStatusOptions;
+  submitting: boolean;
+}
+
+export default class HotspotActionsForm extends React.Component<Props, State> {
+  state: State = {
+    selectedOption: HotspotStatusOptions.FIXED,
+    submitting: false
+  };
+
+  handleSelectOption = (selectedOption: HotspotStatusOptions) => {
+    this.setState({ selectedOption });
+  };
+
+  handleSubmit = (event: React.SyntheticEvent<HTMLFormElement>) => {
+    event.preventDefault();
+
+    const { hotspotKey } = this.props;
+    const { selectedOption } = this.state;
+
+    const status =
+      selectedOption === HotspotStatusOptions.ADDITIONAL_REVIEW
+        ? HotspotStatus.TO_REVIEW
+        : HotspotStatus.REVIEWED;
+    const data: HotspotSetStatusRequest = { hotspot: hotspotKey, status };
+    if (selectedOption !== HotspotStatusOptions.ADDITIONAL_REVIEW) {
+      data.resolution = HotspotResolution[selectedOption];
+    }
+
+    this.setState({ submitting: true });
+    return setSecurityHotspotStatus(data)
+      .then(() => {
+        this.props.onSubmit();
+      })
+      .finally(() => {
+        this.setState({ submitting: false });
+      });
+  };
+
+  render() {
+    const { hotspotKey } = this.props;
+    const { selectedOption, submitting } = this.state;
+
+    return (
+      <HotspotActionsFormRenderer
+        hotspotKey={hotspotKey}
+        onSelectOption={this.handleSelectOption}
+        onSubmit={this.handleSubmit}
+        selectedOption={selectedOption}
+        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
new file mode 100644 (file)
index 0000000..666c8a8
--- /dev/null
@@ -0,0 +1,81 @@
+/*
+ * 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 React from 'react';
+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';
+
+export interface HotspotActionsFormRendererProps {
+  hotspotKey: string;
+  onSelectOption: (option: HotspotStatusOptions) => void;
+  onSubmit: (event: React.SyntheticEvent<HTMLFormElement>) => void;
+  selectedOption: HotspotStatusOptions;
+  submitting: boolean;
+}
+
+export default function HotspotActionsFormRenderer(props: HotspotActionsFormRendererProps) {
+  const { selectedOption, submitting } = props;
+
+  return (
+    <form className="abs-width-400" onSubmit={props.onSubmit}>
+      <h2>{translate('hotspots.form.title')}</h2>
+      <div className="display-flex-column big-spacer-bottom">
+        {renderOption({
+          option: HotspotStatusOptions.FIXED,
+          selectedOption,
+          onClick: props.onSelectOption
+        })}
+        {renderOption({
+          option: HotspotStatusOptions.SAFE,
+          selectedOption,
+          onClick: props.onSelectOption
+        })}
+        {renderOption({
+          option: HotspotStatusOptions.ADDITIONAL_REVIEW,
+          selectedOption,
+          onClick: props.onSelectOption
+        })}
+      </div>
+      <div className="text-right">
+        {submitting && <i className="spinner spacer-right" />}
+        <SubmitButton disabled={submitting}>{translate('hotspots.form.submit')}</SubmitButton>
+      </div>
+    </form>
+  );
+}
+
+function renderOption(params: {
+  option: HotspotStatusOptions;
+  onClick: (option: HotspotStatusOptions) => void;
+  selectedOption: HotspotStatusOptions;
+}) {
+  const { onClick, option, selectedOption } = params;
+  return (
+    <div className="big-spacer-top">
+      <Radio checked={selectedOption === option} onCheck={onClick} value={option}>
+        <h3>{translate('hotspots.status_option', option)}</h3>
+      </Radio>
+      <div className="radio-button-description">
+        {translate('hotspots.status_option', option, 'description')}
+      </div>
+    </div>
+  );
+}
index 9764ff4a34d879054e0eea93ba315f2208fa15d2..334b48c80ada087d1763b10eecfa2e89bc2a3c5f 100644 (file)
 import * as React from 'react';
 import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner';
 import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n';
+import { withCurrentUser } from '../../../components/hoc/withCurrentUser';
+import { isLoggedIn } from '../../../helpers/users';
 import { DetailedHotspot } from '../../../types/security-hotspots';
+import HotspotActions from './HotspotActions';
 import HotspotViewerTabs from './HotspotViewerTabs';
 
 export interface HotspotViewerRendererProps {
+  currentUser: T.CurrentUser;
   hotspot?: DetailedHotspot;
   loading: boolean;
   securityCategories: T.StandardSecurityCategories;
 }
 
-export default function HotspotViewerRenderer(props: HotspotViewerRendererProps) {
-  const { hotspot, loading, securityCategories } = props;
+export function HotspotViewerRenderer(props: HotspotViewerRendererProps) {
+  const { currentUser, hotspot, loading, securityCategories } = props;
 
   return (
     <DeferredSpinner loading={loading}>
       {hotspot && (
         <div className="big-padded">
           <div className="big-spacer-bottom">
-            <h1>{hotspot.message}</h1>
+            <div className="display-flex-space-between">
+              <h1>{hotspot.message}</h1>
+              {isLoggedIn(currentUser) && <HotspotActions hotspotKey={hotspot.key} />}
+            </div>
             <div className="text-muted">
               <span>{translate('hotspot.category')}</span>
               <span className="little-spacer-left">
@@ -67,3 +74,5 @@ export default function HotspotViewerRenderer(props: HotspotViewerRendererProps)
     </DeferredSpinner>
   );
 }
+
+export default withCurrentUser(HotspotViewerRenderer);
diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotActions-test.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotActions-test.tsx
new file mode 100644 (file)
index 0000000..7f6f238
--- /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 { Button } from 'sonar-ui-common/components/controls/buttons';
+import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
+import HotspotActions, { HotspotActionsProps } from '../HotspotActions';
+
+it('should render correctly', () => {
+  expect(shallowRender()).toMatchSnapshot();
+});
+
+it('should open when clicked', async () => {
+  const wrapper = shallowRender();
+
+  wrapper.find(Button).simulate('click');
+
+  await waitAndUpdate(wrapper);
+
+  expect(wrapper).toMatchSnapshot();
+});
+
+it('should register an eventlistener', () => {
+  let useEffectCleanup: void | (() => void | undefined) = () =>
+    fail('useEffect should clean after itself');
+  jest.spyOn(React, 'useEffect').mockImplementationOnce(f => {
+    useEffectCleanup = f() || useEffectCleanup;
+  });
+  let listenerCallback = (_event: { key: string }) =>
+    fail('Effect should have registered callback');
+  const addEventListener = jest.fn((_event, callback) => {
+    listenerCallback = callback;
+  });
+  jest.spyOn(document, 'addEventListener').mockImplementation(addEventListener);
+  const removeEventListener = jest.spyOn(document, 'removeEventListener');
+  const wrapper = shallowRender();
+
+  wrapper.find(Button).simulate('click');
+  expect(wrapper).toMatchSnapshot('Dropdown open');
+
+  listenerCallback({ key: 'whatever' });
+  expect(wrapper).toMatchSnapshot('Dropdown still open');
+
+  listenerCallback({ key: 'Escape' });
+  expect(wrapper).toMatchSnapshot('Dropdown closed');
+
+  useEffectCleanup();
+  expect(removeEventListener).toBeCalledWith('keydown', listenerCallback, false);
+});
+
+function shallowRender(props: Partial<HotspotActionsProps> = {}) {
+  return shallow(<HotspotActions hotspotKey="key" {...props} />);
+}
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
new file mode 100644 (file)
index 0000000..a19f3f2
--- /dev/null
@@ -0,0 +1,101 @@
+/*
+ * 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 { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
+import { setSecurityHotspotStatus } from '../../../../api/security-hotspots';
+import {
+  HotspotResolution,
+  HotspotStatus,
+  HotspotStatusOptions
+} from '../../../../types/security-hotspots';
+import HotspotActionsForm from '../HotspotActionsForm';
+
+jest.mock('../../../../api/security-hotspots', () => ({
+  setSecurityHotspotStatus: jest.fn().mockResolvedValue(undefined)
+}));
+
+it('should render correctly', () => {
+  expect(shallowRender()).toMatchSnapshot();
+});
+
+it('should handle option selection', () => {
+  const wrapper = shallowRender();
+  expect(wrapper.state().selectedOption).toBe(HotspotStatusOptions.FIXED);
+  wrapper.instance().handleSelectOption(HotspotStatusOptions.SAFE);
+  expect(wrapper.state().selectedOption).toBe(HotspotStatusOptions.SAFE);
+});
+
+it('should handle submit', async () => {
+  const onSubmit = jest.fn();
+  const wrapper = shallowRender({ onSubmit });
+  wrapper.setState({ selectedOption: HotspotStatusOptions.ADDITIONAL_REVIEW });
+  await waitAndUpdate(wrapper);
+
+  const preventDefault = jest.fn();
+  const promise = wrapper.instance().handleSubmit({ preventDefault } as any);
+  expect(preventDefault).toBeCalled();
+
+  expect(wrapper.state().submitting).toBe(true);
+  await promise;
+  expect(wrapper.state().submitting).toBe(false);
+  expect(setSecurityHotspotStatus).toBeCalledWith({
+    hotspot: 'key',
+    status: HotspotStatus.TO_REVIEW
+  });
+  expect(onSubmit).toBeCalled();
+
+  // SAFE
+  wrapper.setState({ selectedOption: HotspotStatusOptions.SAFE });
+  await waitAndUpdate(wrapper);
+  await wrapper.instance().handleSubmit({ preventDefault } as any);
+  expect(setSecurityHotspotStatus).toBeCalledWith({
+    hotspot: 'key',
+    status: HotspotStatus.REVIEWED,
+    resolution: HotspotResolution.SAFE
+  });
+
+  // FIXED
+  wrapper.setState({ selectedOption: HotspotStatusOptions.FIXED });
+  await waitAndUpdate(wrapper);
+  await wrapper.instance().handleSubmit({ preventDefault } as any);
+  expect(setSecurityHotspotStatus).toBeCalledWith({
+    hotspot: 'key',
+    status: HotspotStatus.REVIEWED,
+    resolution: HotspotResolution.FIXED
+  });
+});
+
+it('should handle submit failure', async () => {
+  const onSubmit = jest.fn();
+  (setSecurityHotspotStatus as jest.Mock).mockRejectedValueOnce('failure');
+  const wrapper = shallowRender({ onSubmit });
+  const promise = wrapper.instance().handleSubmit({ preventDefault: jest.fn() } as any);
+  expect(wrapper.state().submitting).toBe(true);
+  await promise.catch(() => {});
+  expect(wrapper.state().submitting).toBe(false);
+  expect(onSubmit).not.toBeCalled();
+});
+
+function shallowRender(props: Partial<HotspotActionsForm['props']> = {}) {
+  return shallow<HotspotActionsForm>(
+    <HotspotActionsForm hotspotKey="key" onSubmit={jest.fn()} {...props} />
+  );
+}
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
new file mode 100644 (file)
index 0000000..b7372b0
--- /dev/null
@@ -0,0 +1,47 @@
+/*
+ * 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 { HotspotStatusOptions } from '../../../../types/security-hotspots';
+import HotspotActionsForm from '../HotspotActionsForm';
+import HotspotActionsFormRenderer, {
+  HotspotActionsFormRendererProps
+} from '../HotspotActionsFormRenderer';
+
+it('should render correctly', () => {
+  expect(shallowRender()).toMatchSnapshot();
+  expect(shallowRender({ submitting: true })).toMatchSnapshot('Submitting');
+  expect(shallowRender({ selectedOption: HotspotStatusOptions.SAFE })).toMatchSnapshot(
+    'safe option selected'
+  );
+});
+
+function shallowRender(props: Partial<HotspotActionsFormRendererProps> = {}) {
+  return shallow<HotspotActionsForm>(
+    <HotspotActionsFormRenderer
+      hotspotKey="key"
+      onSelectOption={jest.fn()}
+      onSubmit={jest.fn()}
+      selectedOption={HotspotStatusOptions.FIXED}
+      submitting={false}
+      {...props}
+    />
+  );
+}
index 700d0cf0aea94c5b9b889a1468f8dddb10da9c38..70eaf778cd825307a9ce98cdac6c39ff17eaa5c5 100644 (file)
@@ -20,8 +20,8 @@
 import { shallow } from 'enzyme';
 import * as React from 'react';
 import { mockDetailledHotspot } from '../../../../helpers/mocks/security-hotspots';
-import { mockUser } from '../../../../helpers/testMocks';
-import HotspotViewerRenderer, { HotspotViewerRendererProps } from '../HotspotViewerRenderer';
+import { mockCurrentUser, mockLoggedInUser, mockUser } from '../../../../helpers/testMocks';
+import { HotspotViewerRenderer, HotspotViewerRendererProps } from '../HotspotViewerRenderer';
 
 it('should render correctly', () => {
   const wrapper = shallowRender();
@@ -30,11 +30,14 @@ it('should render correctly', () => {
   expect(
     shallowRender({ hotspot: mockDetailledHotspot({ assignee: mockUser({ active: false }) }) })
   ).toMatchSnapshot('deleted assignee');
+  expect(shallowRender()).toMatchSnapshot('anonymous user');
+  expect(shallowRender({ currentUser: mockLoggedInUser() })).toMatchSnapshot('user logged in');
 });
 
 function shallowRender(props?: Partial<HotspotViewerRendererProps>) {
   return shallow(
     <HotspotViewerRenderer
+      currentUser={mockCurrentUser()}
       hotspot={mockDetailledHotspot()}
       loading={false}
       securityCategories={{ 'sql-injection': { title: 'SQL injection' } }}
diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotActions-test.tsx.snap b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotActions-test.tsx.snap
new file mode 100644 (file)
index 0000000..efa3d92
--- /dev/null
@@ -0,0 +1,112 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should open when clicked 1`] = `
+<div
+  className="dropdown"
+>
+  <Button
+    onClick={[Function]}
+  >
+    hotspots.review_hotspot
+    <DropdownIcon
+      className="little-spacer-left"
+    />
+  </Button>
+  <OutsideClickHandler
+    onClickOutside={[Function]}
+  >
+    <DropdownOverlay
+      placement="bottom-right"
+    >
+      <HotspotActionsForm
+        hotspotKey="key"
+        onSubmit={[Function]}
+      />
+    </DropdownOverlay>
+  </OutsideClickHandler>
+</div>
+`;
+
+exports[`should register an eventlistener: Dropdown closed 1`] = `
+<div
+  className="dropdown"
+>
+  <Button
+    onClick={[Function]}
+  >
+    hotspots.review_hotspot
+    <DropdownIcon
+      className="little-spacer-left"
+    />
+  </Button>
+</div>
+`;
+
+exports[`should register an eventlistener: Dropdown open 1`] = `
+<div
+  className="dropdown"
+>
+  <Button
+    onClick={[Function]}
+  >
+    hotspots.review_hotspot
+    <DropdownIcon
+      className="little-spacer-left"
+    />
+  </Button>
+  <OutsideClickHandler
+    onClickOutside={[Function]}
+  >
+    <DropdownOverlay
+      placement="bottom-right"
+    >
+      <HotspotActionsForm
+        hotspotKey="key"
+        onSubmit={[Function]}
+      />
+    </DropdownOverlay>
+  </OutsideClickHandler>
+</div>
+`;
+
+exports[`should register an eventlistener: Dropdown still open 1`] = `
+<div
+  className="dropdown"
+>
+  <Button
+    onClick={[Function]}
+  >
+    hotspots.review_hotspot
+    <DropdownIcon
+      className="little-spacer-left"
+    />
+  </Button>
+  <OutsideClickHandler
+    onClickOutside={[Function]}
+  >
+    <DropdownOverlay
+      placement="bottom-right"
+    >
+      <HotspotActionsForm
+        hotspotKey="key"
+        onSubmit={[Function]}
+      />
+    </DropdownOverlay>
+  </OutsideClickHandler>
+</div>
+`;
+
+exports[`should render correctly 1`] = `
+<div
+  className="dropdown"
+>
+  <Button
+    onClick={[Function]}
+  >
+    hotspots.review_hotspot
+    <DropdownIcon
+      className="little-spacer-left"
+    />
+  </Button>
+</div>
+`;
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
new file mode 100644 (file)
index 0000000..ef7582d
--- /dev/null
@@ -0,0 +1,11 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<HotspotActionsFormRenderer
+  hotspotKey="key"
+  onSelectOption={[Function]}
+  onSubmit={[Function]}
+  selectedOption="FIXED"
+  submitting={false}
+/>
+`;
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
new file mode 100644 (file)
index 0000000..0c9633f
--- /dev/null
@@ -0,0 +1,238 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 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={true}
+        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={false}
+        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="text-right"
+  >
+    <SubmitButton
+      disabled={false}
+    >
+      hotspots.form.submit
+    </SubmitButton>
+  </div>
+</form>
+`;
+
+exports[`should render correctly: Submitting 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={true}
+        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={false}
+        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="text-right"
+  >
+    <i
+      className="spinner spacer-right"
+    />
+    <SubmitButton
+      disabled={true}
+    >
+      hotspots.form.submit
+    </SubmitButton>
+  </div>
+</form>
+`;
+
+exports[`should render correctly: safe option 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={true}
+        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={false}
+        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="text-right"
+  >
+    <SubmitButton
+      disabled={false}
+    >
+      hotspots.form.submit
+    </SubmitButton>
+  </div>
+</form>
+`;
index edce37a1791d27a41abb062d427a0a5e87fe761b..39de7c9e177aff8bd7cbdbf54a5edcb62e5c3a24 100644 (file)
@@ -1,7 +1,7 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
 exports[`should render correctly 1`] = `
-<HotspotViewerRenderer
+<Connect(withCurrentUser(HotspotViewerRenderer))
   loading={true}
   securityCategories={
     Object {
@@ -14,7 +14,7 @@ exports[`should render correctly 1`] = `
 `;
 
 exports[`should render correctly 2`] = `
-<HotspotViewerRenderer
+<Connect(withCurrentUser(HotspotViewerRenderer))
   hotspot={
     Object {
       "id": "I am a detailled hotspot",
index dc2f58110098e10f9240c4a550620fa48e48a00f..4f8cbe67c8826ab82611d78ba03a7f0bb581de2e 100644 (file)
@@ -11,9 +11,152 @@ exports[`should render correctly 1`] = `
     <div
       className="big-spacer-bottom"
     >
-      <h1>
-        '3' is a magic number.
-      </h1>
+      <div
+        className="display-flex-space-between"
+      >
+        <h1>
+          '3' is a magic number.
+        </h1>
+      </div>
+      <div
+        className="text-muted"
+      >
+        <span>
+          hotspot.category
+        </span>
+        <span
+          className="little-spacer-left"
+        >
+          SQL injection
+        </span>
+      </div>
+    </div>
+    <div
+      className="huge-spacer-bottom"
+    >
+      <span>
+        hotspot.status
+      </span>
+      <span
+        className="badge little-spacer-left"
+      >
+        issue.status.RESOLVED
+      </span>
+      <span
+        className="huge-spacer-left"
+      >
+        hotspot.assigned_to
+      </span>
+      <strong
+        className="little-spacer-left"
+      >
+        John Doe
+      </strong>
+    </div>
+    <HotspotViewerTabs
+      hotspot={
+        Object {
+          "assignee": Object {
+            "active": true,
+            "local": true,
+            "login": "john.doe",
+            "name": "John Doe",
+          },
+          "author": Object {
+            "active": true,
+            "local": true,
+            "login": "john.doe",
+            "name": "John Doe",
+          },
+          "component": Object {
+            "breadcrumbs": Array [],
+            "key": "my-project",
+            "name": "MyProject",
+            "organization": "foo",
+            "qualifier": "FIL",
+            "qualityGate": Object {
+              "isDefault": true,
+              "key": "30",
+              "name": "Sonar way",
+            },
+            "qualityProfiles": Array [
+              Object {
+                "deleted": false,
+                "key": "my-qp",
+                "language": "ts",
+                "name": "Sonar way",
+              },
+            ],
+            "tags": Array [],
+          },
+          "creationDate": "2013-05-13T17:55:41+0200",
+          "key": "01fc972e-2a3c-433e-bcae-0bd7f88f5123",
+          "line": 142,
+          "message": "'3' is a magic number.",
+          "project": Object {
+            "breadcrumbs": Array [],
+            "key": "my-project",
+            "name": "MyProject",
+            "organization": "foo",
+            "qualifier": "TRK",
+            "qualityGate": Object {
+              "isDefault": true,
+              "key": "30",
+              "name": "Sonar way",
+            },
+            "qualityProfiles": Array [
+              Object {
+                "deleted": false,
+                "key": "my-qp",
+                "language": "ts",
+                "name": "Sonar way",
+              },
+            ],
+            "tags": Array [],
+          },
+          "resolution": "FALSE-POSITIVE",
+          "rule": Object {
+            "fixRecommendations": "<p>This a <strong>strong</strong> message about fixing !</p>",
+            "key": "squid:S2077",
+            "name": "That rule",
+            "riskDescription": "<p>This a <strong>strong</strong> message about risk !</p>",
+            "securityCategory": "sql-injection",
+            "vulnerabilityDescription": "<p>This a <strong>strong</strong> message about vulnerability !</p>",
+            "vulnerabilityProbability": "HIGH",
+          },
+          "status": "RESOLVED",
+          "textRange": Object {
+            "endLine": 142,
+            "endOffset": 83,
+            "startLine": 142,
+            "startOffset": 26,
+          },
+          "updateDate": "2013-05-13T17:55:42+0200",
+        }
+      }
+    />
+  </div>
+</DeferredSpinner>
+`;
+
+exports[`should render correctly: anonymous user 1`] = `
+<DeferredSpinner
+  loading={false}
+  timeout={100}
+>
+  <div
+    className="big-padded"
+  >
+    <div
+      className="big-spacer-bottom"
+    >
+      <div
+        className="display-flex-space-between"
+      >
+        <h1>
+          '3' is a magic number.
+        </h1>
+      </div>
       <div
         className="text-muted"
       >
@@ -146,9 +289,13 @@ exports[`should render correctly: deleted assignee 1`] = `
     <div
       className="big-spacer-bottom"
     >
-      <h1>
-        '3' is a magic number.
-      </h1>
+      <div
+        className="display-flex-space-between"
+      >
+        <h1>
+          '3' is a magic number.
+        </h1>
+      </div>
       <div
         className="text-muted"
       >
@@ -276,3 +423,145 @@ exports[`should render correctly: no hotspot 1`] = `
   timeout={100}
 />
 `;
+
+exports[`should render correctly: user logged in 1`] = `
+<DeferredSpinner
+  loading={false}
+  timeout={100}
+>
+  <div
+    className="big-padded"
+  >
+    <div
+      className="big-spacer-bottom"
+    >
+      <div
+        className="display-flex-space-between"
+      >
+        <h1>
+          '3' is a magic number.
+        </h1>
+        <HotspotActions
+          hotspotKey="01fc972e-2a3c-433e-bcae-0bd7f88f5123"
+        />
+      </div>
+      <div
+        className="text-muted"
+      >
+        <span>
+          hotspot.category
+        </span>
+        <span
+          className="little-spacer-left"
+        >
+          SQL injection
+        </span>
+      </div>
+    </div>
+    <div
+      className="huge-spacer-bottom"
+    >
+      <span>
+        hotspot.status
+      </span>
+      <span
+        className="badge little-spacer-left"
+      >
+        issue.status.RESOLVED
+      </span>
+      <span
+        className="huge-spacer-left"
+      >
+        hotspot.assigned_to
+      </span>
+      <strong
+        className="little-spacer-left"
+      >
+        John Doe
+      </strong>
+    </div>
+    <HotspotViewerTabs
+      hotspot={
+        Object {
+          "assignee": Object {
+            "active": true,
+            "local": true,
+            "login": "john.doe",
+            "name": "John Doe",
+          },
+          "author": Object {
+            "active": true,
+            "local": true,
+            "login": "john.doe",
+            "name": "John Doe",
+          },
+          "component": Object {
+            "breadcrumbs": Array [],
+            "key": "my-project",
+            "name": "MyProject",
+            "organization": "foo",
+            "qualifier": "FIL",
+            "qualityGate": Object {
+              "isDefault": true,
+              "key": "30",
+              "name": "Sonar way",
+            },
+            "qualityProfiles": Array [
+              Object {
+                "deleted": false,
+                "key": "my-qp",
+                "language": "ts",
+                "name": "Sonar way",
+              },
+            ],
+            "tags": Array [],
+          },
+          "creationDate": "2013-05-13T17:55:41+0200",
+          "key": "01fc972e-2a3c-433e-bcae-0bd7f88f5123",
+          "line": 142,
+          "message": "'3' is a magic number.",
+          "project": Object {
+            "breadcrumbs": Array [],
+            "key": "my-project",
+            "name": "MyProject",
+            "organization": "foo",
+            "qualifier": "TRK",
+            "qualityGate": Object {
+              "isDefault": true,
+              "key": "30",
+              "name": "Sonar way",
+            },
+            "qualityProfiles": Array [
+              Object {
+                "deleted": false,
+                "key": "my-qp",
+                "language": "ts",
+                "name": "Sonar way",
+              },
+            ],
+            "tags": Array [],
+          },
+          "resolution": "FALSE-POSITIVE",
+          "rule": Object {
+            "fixRecommendations": "<p>This a <strong>strong</strong> message about fixing !</p>",
+            "key": "squid:S2077",
+            "name": "That rule",
+            "riskDescription": "<p>This a <strong>strong</strong> message about risk !</p>",
+            "securityCategory": "sql-injection",
+            "vulnerabilityDescription": "<p>This a <strong>strong</strong> message about vulnerability !</p>",
+            "vulnerabilityProbability": "HIGH",
+          },
+          "status": "RESOLVED",
+          "textRange": Object {
+            "endLine": 142,
+            "endOffset": 83,
+            "startLine": 142,
+            "startOffset": 26,
+          },
+          "updateDate": "2013-05-13T17:55:42+0200",
+        }
+      }
+    />
+  </div>
+</DeferredSpinner>
+`;
index 187f9e9a3402be27b103af8b78e71ba0220a4253..43cfe55e449512991fe33f3ec47661553bc1e796 100644 (file)
   overflow-y: auto;
   background-color: white;
 }
+
+/*
+* Align description with label by offsetting by width of radio + margin
+*/
+#security_hotspots .radio-button-description {
+  margin-left: 23px;
+}
index 76f816e19f93c0679249dbe2c75222b611b18f75..cabf1267559f842e0fcfcd8b05911274d8653238 100644 (file)
@@ -23,6 +23,22 @@ export enum RiskExposure {
   HIGH = 'HIGH'
 }
 
+export enum HotspotStatus {
+  TO_REVIEW = 'TO_REVIEW',
+  REVIEWED = 'REVIEWED'
+}
+
+export enum HotspotResolution {
+  FIXED = 'FIXED',
+  SAFE = 'SAFE'
+}
+
+export enum HotspotStatusOptions {
+  FIXED = 'FIXED',
+  SAFE = 'SAFE',
+  ADDITIONAL_REVIEW = 'ADDITIONAL_REVIEW'
+}
+
 export interface RawHotspot {
   assignee?: string;
   author?: string;
@@ -72,3 +88,9 @@ export interface HotspotSearchResponse {
   hotspots: RawHotspot[];
   paging: T.Paging;
 }
+
+export interface HotspotSetStatusRequest {
+  hotspot: string;
+  status: HotspotStatus;
+  resolution?: HotspotResolution;
+}
index c565392d3ab971eefb8604d7d931fd4e2dd9721a..4d815040df95db04238567f32838d5a1ac444737 100644 (file)
@@ -658,6 +658,21 @@ hotspot.assigned_to=Assigned to:
 hotspot.tabs.risk_description=What's the risk?
 hotspot.tabs.vulnerability_description=Are you vulnerable?
 hotspot.tabs.fix_recommendations=How can you fix it?
+hotspots.review_hotspot=Review Hotspot
+
+hotspots.form.title=Mark Security Hotspot as:
+
+hotspots.form.assign_to=Assign to:
+hotspots.form.select_user=Select a user...
+hotspots.form.comment=Comment
+hotspots.form.submit=Apply changes
+
+hotspots.status_option.FIXED=Fixed
+hotspots.status_option.FIXED.description=The code has been modified to follow recommended secure coding practices.
+hotspots.status_option.SAFE=Safe
+hotspots.status_option.SAFE.description=The code is not at risk and doesn't need to be modified.
+hotspots.status_option.ADDITIONAL_REVIEW=Needs additional review
+hotspots.status_option.ADDITIONAL_REVIEW.description=Someone else needs to review this Security Hotspot.
 
 #------------------------------------------------------------------------------
 #