]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-14111 Allow user to select IDE when several ones are detected
authorJean-Baptiste Lievremont <jeanbaptiste.lievremont@sonarsource.com>
Thu, 12 Nov 2020 14:43:50 +0000 (15:43 +0100)
committersonartech <sonartech@sonarsource.com>
Thu, 26 Nov 2020 20:06:29 +0000 (20:06 +0000)
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotOpenInIdeButton.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotOpenInIdeOverlay.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotOpenInIdeButton-test.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotOpenInIdeOverlay-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/__snapshots__/HotspotOpenInIdeButton-test.tsx.snap
server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/__snapshots__/HotspotOpenInIdeOverlay-test.tsx.snap [new file with mode: 0644]

index 2a35407be6e4daf709276ed6656268b1e5eb30c6..53bfbdb08d7babad99f27c0a64e41b1f129b0f54 100644 (file)
 
 import * as React from 'react';
 import { Button } from 'sonar-ui-common/components/controls/buttons';
+import { DropdownOverlay } from 'sonar-ui-common/components/controls/Dropdown';
+import Toggler from 'sonar-ui-common/components/controls/Toggler';
 import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner';
 import { translate } from 'sonar-ui-common/helpers/l10n';
 import addGlobalErrorMessage from '../../../app/utils/addGlobalErrorMessage';
 import addGlobalSuccessMessage from '../../../app/utils/addGlobalSuccessMessage';
 import { openHotspot, probeSonarLintServers } from '../../../helpers/sonarlint';
+import { Ide } from '../../../types/sonarlint';
+import { HotspotOpenInIdeOverlay } from './HotspotOpenInIdeOverlay';
 
 interface Props {
   projectKey: string;
@@ -32,43 +36,75 @@ interface Props {
 }
 
 interface State {
-  inDiscovery: boolean;
+  loading: boolean;
+  ides: Array<Ide>;
 }
 
 export default class HotspotOpenInIdeButton extends React.PureComponent<Props, State> {
+  mounted = false;
+
   state = {
-    inDiscovery: false
+    loading: false,
+    ides: []
   };
 
-  handleOnClick = () => {
+  componentDidMount() {
+    this.mounted = true;
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  handleOnClick = async () => {
+    this.setState({ loading: true, ides: [] });
+    const ides = await probeSonarLintServers();
+    if (ides.length === 0) {
+      if (this.mounted) {
+        this.setState({ loading: false });
+      }
+      this.showError();
+    } else if (ides.length === 1) {
+      this.openHotspot(ides[0]);
+    } else if (this.mounted) {
+      this.setState({ loading: false, ides });
+    }
+  };
+
+  openHotspot = (ide: Ide) => {
+    this.setState({ loading: true, ides: [] });
     const { projectKey, hotspotKey } = this.props;
-    this.setState({ inDiscovery: true });
-    return probeSonarLintServers()
-      .then(ides => {
-        if (ides.length > 0) {
-          const calledPort = ides[0].port;
-          return openHotspot(calledPort, projectKey, hotspotKey);
-        } else {
-          return Promise.reject();
-        }
-      })
-      .then(() => {
-        addGlobalSuccessMessage(translate('hotspots.open_in_ide.success'));
-      })
-      .catch(() => {
-        addGlobalErrorMessage(translate('hotspots.open_in_ide.failure'));
-      })
-      .finally(() => {
-        this.setState({ inDiscovery: false });
-      });
+    return openHotspot(ide.port, projectKey, hotspotKey)
+      .then(this.showSuccess)
+      .catch(this.showError)
+      .finally(this.cleanState);
+  };
+
+  showError = () => addGlobalErrorMessage(translate('hotspots.open_in_ide.failure'));
+
+  showSuccess = () => addGlobalSuccessMessage(translate('hotspots.open_in_ide.success'));
+
+  cleanState = () => {
+    if (this.mounted) {
+      this.setState({ loading: false, ides: [] });
+    }
   };
 
   render() {
     return (
-      <Button onClick={this.handleOnClick}>
-        {translate('hotspots.open_in_ide.open')}
-        <DeferredSpinner loading={this.state.inDiscovery} className="spacer-left" />
-      </Button>
+      <Toggler
+        open={this.state.ides.length > 1}
+        onRequestClose={this.cleanState}
+        overlay={
+          <DropdownOverlay>
+            <HotspotOpenInIdeOverlay ides={this.state.ides} onIdeSelected={this.openHotspot} />
+          </DropdownOverlay>
+        }>
+        <Button onClick={this.handleOnClick}>
+          {translate('hotspots.open_in_ide.open')}
+          <DeferredSpinner loading={this.state.loading} className="spacer-left" />
+        </Button>
+      </Toggler>
     );
   }
 }
diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotOpenInIdeOverlay.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotOpenInIdeOverlay.tsx
new file mode 100644 (file)
index 0000000..1df109c
--- /dev/null
@@ -0,0 +1,41 @@
+/*
+ * 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 { Ide } from '../../../types/sonarlint';
+
+export const HotspotOpenInIdeOverlay = ({
+  ides,
+  onIdeSelected
+}: {
+  ides: Array<Ide>;
+  onIdeSelected: (ide: Ide) => Promise<void>;
+}) =>
+  ides.length > 1 ? (
+    <ul className="menu">
+      {ides.map(ide => (
+        <li key={ide.port}>
+          <a href="#" onClick={() => onIdeSelected(ide)}>
+            {ide.ideName} - {ide.description}
+          </a>
+        </li>
+      ))}
+    </ul>
+  ) : null;
index 025668b120e942a43139d57768af5dd90d19c08d..8410c0d7977d17c9b40a25a4d9395d616584147b 100644 (file)
@@ -25,21 +25,58 @@ import HotspotOpenInIdeButton from '../HotspotOpenInIdeButton';
 
 jest.mock('../../../../helpers/sonarlint');
 
-it('should render correctly', async () => {
-  const projectKey = 'my-project:key';
-  const hotspotKey = 'AXWsgE9RpggAQesHYfwm';
+describe('HotspotOpenInIdeButton', () => {
+  beforeEach(jest.resetAllMocks);
 
-  const wrapper = shallow(
-    <HotspotOpenInIdeButton projectKey={projectKey} hotspotKey={hotspotKey} />
-  );
-  expect(wrapper).toMatchSnapshot();
+  it('should render correctly', async () => {
+    const projectKey = 'my-project:key';
+    const hotspotKey = 'AXWsgE9RpggAQesHYfwm';
+    const port = 42001;
 
-  (sonarlint.probeSonarLintServers as jest.Mock).mockResolvedValue([
-    { port: 42001, ideName: 'BlueJ IDE', description: 'Hello World' }
-  ]);
+    const wrapper = shallow(
+      <HotspotOpenInIdeButton projectKey={projectKey} hotspotKey={hotspotKey} />
+    );
+    expect(wrapper).toMatchSnapshot();
 
-  wrapper.find(Button).simulate('click');
+    (sonarlint.probeSonarLintServers as jest.Mock).mockResolvedValue([
+      { port, ideName: 'BlueJ IDE', description: 'Hello World' }
+    ]);
+    (sonarlint.openHotspot as jest.Mock).mockResolvedValue(null);
 
-  await new Promise(setImmediate);
-  expect(sonarlint.openHotspot).toBeCalledWith(42001, projectKey, hotspotKey);
+    wrapper.find(Button).simulate('click');
+
+    await new Promise(setImmediate);
+    expect(sonarlint.openHotspot).toBeCalledWith(port, projectKey, hotspotKey);
+  });
+
+  it('should gracefully handle zero IDE detected', async () => {
+    const wrapper = shallow(<HotspotOpenInIdeButton projectKey="polop" hotspotKey="palap" />);
+    (sonarlint.probeSonarLintServers as jest.Mock).mockResolvedValue([]);
+    wrapper.find(Button).simulate('click');
+
+    await new Promise(setImmediate);
+    expect(sonarlint.openHotspot).not.toHaveBeenCalled();
+  });
+
+  it('should handle several IDE', async () => {
+    const projectKey = 'my-project:key';
+    const hotspotKey = 'AXWsgE9RpggAQesHYfwm';
+    const port1 = 42000;
+    const port2 = 42001;
+
+    const wrapper = shallow(
+      <HotspotOpenInIdeButton projectKey={projectKey} hotspotKey={hotspotKey} />
+    );
+    expect(wrapper).toMatchSnapshot();
+
+    (sonarlint.probeSonarLintServers as jest.Mock).mockResolvedValue([
+      { port: port1, ideName: 'BlueJ IDE', description: 'Hello World' },
+      { port: port2, ideName: 'Arduino IDE', description: 'Blink' }
+    ]);
+
+    wrapper.find(Button).simulate('click');
+
+    await new Promise(setImmediate);
+    expect(wrapper).toMatchSnapshot('dropdown open');
+  });
 });
diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotOpenInIdeOverlay-test.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotOpenInIdeOverlay-test.tsx
new file mode 100644 (file)
index 0000000..d131f21
--- /dev/null
@@ -0,0 +1,53 @@
+/*
+ * 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 { HotspotOpenInIdeOverlay } from '../HotspotOpenInIdeOverlay';
+
+it('should render nothing with fewer than 2 IDE', () => {
+  const onIdeSelected = jest.fn();
+  expect(
+    shallow(<HotspotOpenInIdeOverlay ides={[]} onIdeSelected={onIdeSelected} />).type()
+  ).toBeNull();
+  expect(
+    shallow(
+      <HotspotOpenInIdeOverlay
+        ides={[{ port: 0, ideName: 'Polop', description: 'Plouf' }]}
+        onIdeSelected={onIdeSelected}
+      />
+    ).type()
+  ).toBeNull();
+});
+
+it('should render menu and select the right IDE', () => {
+  const onIdeSelected = jest.fn();
+  const ide1 = { port: 0, ideName: 'Polop', description: 'Plouf' };
+  const ide2 = { port: 1, ideName: 'Foo', description: 'Bar' };
+  const wrapper = shallow(
+    <HotspotOpenInIdeOverlay ides={[ide1, ide2]} onIdeSelected={onIdeSelected} />
+  );
+  expect(wrapper).toMatchSnapshot();
+
+  wrapper
+    .find('a')
+    .last()
+    .simulate('click');
+  expect(onIdeSelected).toHaveBeenCalledWith(ide2);
+});
index 396feb43591eec5bba5fbade9b67a83ed77c70af..c8e8a39851d71e513e19da59ca84cb13c5a5d8da 100644 (file)
@@ -1,13 +1,89 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`should render correctly 1`] = `
-<Button
-  onClick={[Function]}
+exports[`HotspotOpenInIdeButton should handle several IDE 1`] = `
+<Toggler
+  onRequestClose={[Function]}
+  open={false}
+  overlay={
+    <DropdownOverlay>
+      <Unknown
+        ides={Array []}
+        onIdeSelected={[Function]}
+      />
+    </DropdownOverlay>
+  }
 >
-  hotspots.open_in_ide.open
-  <DeferredSpinner
-    className="spacer-left"
-    loading={false}
-  />
-</Button>
+  <Button
+    onClick={[Function]}
+  >
+    hotspots.open_in_ide.open
+    <DeferredSpinner
+      className="spacer-left"
+      loading={false}
+    />
+  </Button>
+</Toggler>
 `;
+
+exports[`HotspotOpenInIdeButton should handle several IDE: dropdown open 1`] = `
+<Toggler
+  onRequestClose={[Function]}
+  open={true}
+  overlay={
+    <DropdownOverlay>
+      <Unknown
+        ides={
+          Array [
+            Object {
+              "description": "Hello World",
+              "ideName": "BlueJ IDE",
+              "port": 42000,
+            },
+            Object {
+              "description": "Blink",
+              "ideName": "Arduino IDE",
+              "port": 42001,
+            },
+          ]
+        }
+        onIdeSelected={[Function]}
+      />
+    </DropdownOverlay>
+  }
+>
+  <Button
+    onClick={[Function]}
+  >
+    hotspots.open_in_ide.open
+    <DeferredSpinner
+      className="spacer-left"
+      loading={false}
+    />
+  </Button>
+</Toggler>
+`;
+
+exports[`HotspotOpenInIdeButton should render correctly 1`] = `
+<Toggler
+  onRequestClose={[Function]}
+  open={false}
+  overlay={
+    <DropdownOverlay>
+      <Unknown
+        ides={Array []}
+        onIdeSelected={[Function]}
+      />
+    </DropdownOverlay>
+  }
+>
+  <Button
+    onClick={[Function]}
+  >
+    hotspots.open_in_ide.open
+    <DeferredSpinner
+      className="spacer-left"
+      loading={false}
+    />
+  </Button>
+</Toggler>
+`;
\ No newline at end of file
diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/__snapshots__/HotspotOpenInIdeOverlay-test.tsx.snap b/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/__snapshots__/HotspotOpenInIdeOverlay-test.tsx.snap
new file mode 100644 (file)
index 0000000..7c4ffa6
--- /dev/null
@@ -0,0 +1,32 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render menu and select the right IDE 1`] = `
+<ul
+  className="menu"
+>
+  <li
+    key="0"
+  >
+    <a
+      href="#"
+      onClick={[Function]}
+    >
+      Polop
+       - 
+      Plouf
+    </a>
+  </li>
+  <li
+    key="1"
+  >
+    <a
+      href="#"
+      onClick={[Function]}
+    >
+      Foo
+       - 
+      Bar
+    </a>
+  </li>
+</ul>
+`;