]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-13293 Unsubscribing from a portfolio report should be more straightforward
authorWouter Admiraal <wouter.admiraal@sonarsource.com>
Thu, 12 Aug 2021 14:37:24 +0000 (16:37 +0200)
committersonartech <sonartech@sonarsource.com>
Fri, 13 Aug 2021 20:03:54 +0000 (20:03 +0000)
server/sonar-web/src/main/js/apps/portfolio/components/App.tsx
server/sonar-web/src/main/js/apps/portfolio/components/UnsubscribeEmailModal.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/portfolio/components/__tests__/App-test.tsx
server/sonar-web/src/main/js/apps/portfolio/components/__tests__/UnsubscribeEmailModal-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/portfolio/components/__tests__/__snapshots__/App-test.tsx.snap
server/sonar-web/src/main/js/apps/portfolio/components/__tests__/__snapshots__/UnsubscribeEmailModal-test.tsx.snap [new file with mode: 0644]
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 6546a42ce8f91a59e34971ccb7a80e04bf50af51..197ead48a189d20e07e025c7d04601730e5a6eb6 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 { Location } from 'history';
 import * as React from 'react';
 import { connect } from 'react-redux';
+import { InjectedRouter } from 'react-router';
+import handleRequiredAuthentication from 'sonar-ui-common/helpers/handleRequiredAuthentication';
 import { translate } from 'sonar-ui-common/helpers/l10n';
 import { getChildren } from '../../../api/components';
 import { getMeasures } from '../../../api/measures';
 import MeasuresLink from '../../../components/common/MeasuresLink';
 import ComponentReportActions from '../../../components/controls/ComponentReportActions';
+import { withCurrentUser } from '../../../components/hoc/withCurrentUser';
 import Measure from '../../../components/measure/Measure';
+import { isLoggedIn } from '../../../helpers/users';
 import { fetchMetrics } from '../../../store/rootActions';
 import { getMetrics, Store } from '../../../store/rootReducer';
 import '../styles.css';
 import { SubComponent } from '../types';
 import { convertMeasures, PORTFOLIO_METRICS, SUB_COMPONENTS_METRICS } from '../utils';
 import MetricBox from './MetricBox';
+import UnsubscribeEmailModal from './UnsubscribeEmailModal';
 import WorstProjects from './WorstProjects';
 
 interface OwnProps {
   component: T.Component;
+  currentUser: T.CurrentUser;
+  location: Location;
+  router: InjectedRouter;
 }
 
 interface StateToProps {
@@ -52,14 +61,29 @@ interface State {
   measures?: T.Dict<string | undefined>;
   subComponents?: SubComponent[];
   totalSubComponents?: number;
+  showUnsubscribeModal: boolean;
 }
 
 export class App extends React.PureComponent<Props, State> {
   mounted = false;
-  state: State = { loading: true };
+
+  constructor(props: Props) {
+    super(props);
+
+    this.state = {
+      loading: true,
+      showUnsubscribeModal:
+        Boolean(props.location.query.unsubscribe) && isLoggedIn(props.currentUser)
+    };
+  }
 
   componentDidMount() {
     this.mounted = true;
+
+    if (Boolean(this.props.location.query.unsubscribe) && !isLoggedIn(this.props.currentUser)) {
+      handleRequiredAuthentication();
+    }
+
     this.props.fetchMetrics();
     this.fetchData();
   }
@@ -106,6 +130,12 @@ export class App extends React.PureComponent<Props, State> {
   isNotComputed = () =>
     this.state.measures && this.state.measures['reliability_rating'] === undefined;
 
+  handleCloseUnsubscribeEmailModal = () => {
+    const { location, router } = this.props;
+    this.setState({ showUnsubscribeModal: false });
+    router.replace({ ...location, query: { ...location.query, unsubscribe: undefined } });
+  };
+
   renderSpinner() {
     return (
       <div className="page page-limited">
@@ -142,7 +172,13 @@ export class App extends React.PureComponent<Props, State> {
 
   render() {
     const { component } = this.props;
-    const { loading, measures, subComponents, totalSubComponents } = this.state;
+    const {
+      loading,
+      measures,
+      subComponents,
+      totalSubComponents,
+      showUnsubscribeModal
+    } = this.state;
 
     if (loading) {
       return this.renderSpinner();
@@ -221,6 +257,13 @@ export class App extends React.PureComponent<Props, State> {
             total={totalSubComponents}
           />
         )}
+
+        {showUnsubscribeModal && (
+          <UnsubscribeEmailModal
+            component={component}
+            onClose={this.handleCloseUnsubscribeEmailModal}
+          />
+        )}
       </div>
     );
   }
@@ -232,4 +275,4 @@ const mapStateToProps = (state: Store): StateToProps => ({
   metrics: getMetrics(state)
 });
 
-export default connect(mapStateToProps, mapDispatchToProps)(App);
+export default connect(mapStateToProps, mapDispatchToProps)(withCurrentUser(App));
diff --git a/server/sonar-web/src/main/js/apps/portfolio/components/UnsubscribeEmailModal.tsx b/server/sonar-web/src/main/js/apps/portfolio/components/UnsubscribeEmailModal.tsx
new file mode 100644 (file)
index 0000000..b53479f
--- /dev/null
@@ -0,0 +1,101 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 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, ResetButtonLink, SubmitButton } from 'sonar-ui-common/components/controls/buttons';
+import SimpleModal from 'sonar-ui-common/components/controls/SimpleModal';
+import { Alert } from 'sonar-ui-common/components/ui/Alert';
+import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner';
+import { translate } from 'sonar-ui-common/helpers/l10n';
+import { unsubscribeFromEmailReport } from '../../../api/component-report';
+
+interface Props {
+  component: T.Component;
+  onClose: () => void;
+}
+
+interface State {
+  success?: boolean;
+}
+
+export default class UnsubscribeEmailModal extends React.PureComponent<Props, State> {
+  mounted = false;
+  state: State = {};
+
+  componentDidMount() {
+    this.mounted = true;
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  handleFormSubmit = async () => {
+    const { component } = this.props;
+
+    await unsubscribeFromEmailReport(component.key);
+
+    if (this.mounted) {
+      this.setState({ success: true });
+    }
+  };
+
+  render() {
+    const { success } = this.state;
+    const header = translate('component_report.unsubscribe');
+
+    return (
+      <SimpleModal
+        header={header}
+        onClose={this.props.onClose}
+        onSubmit={this.handleFormSubmit}
+        size="small">
+        {({ onCloseClick, onFormSubmit, submitting }) => (
+          <form onSubmit={onFormSubmit}>
+            <div className="modal-head">
+              <h2>{header}</h2>
+            </div>
+
+            <div className="modal-body">
+              {success ? (
+                <Alert variant="success">{translate('component_report.unsubscribe_success')}</Alert>
+              ) : (
+                <p>{translate('component_report.unsubscribe.description')}</p>
+              )}
+            </div>
+
+            <div className="modal-foot">
+              <DeferredSpinner className="spacer-right" loading={submitting} />
+              {success ? (
+                <Button onClick={onCloseClick}>{translate('close')}</Button>
+              ) : (
+                <>
+                  <SubmitButton disabled={submitting}>
+                    {translate('component_report.unsubscribe')}
+                  </SubmitButton>
+                  <ResetButtonLink onClick={onCloseClick}>{translate('cancel')}</ResetButtonLink>
+                </>
+              )}
+            </div>
+          </form>
+        )}
+      </SimpleModal>
+    );
+  }
+}
index 70aed21093acc5943a397d11715ee01ee713d747..061a517af28cf90a72a1c5ff027ad926641b37e1 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.
  */
-/* eslint-disable import/first */
+import { shallow } from 'enzyme';
+import * as React from 'react';
+import handleRequiredAuthentication from 'sonar-ui-common/helpers/handleRequiredAuthentication';
+import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
+import { getChildren } from '../../../../api/components';
+import { getMeasures } from '../../../../api/measures';
+import {
+  mockComponent,
+  mockCurrentUser,
+  mockLocation,
+  mockLoggedInUser,
+  mockRouter
+} from '../../../../helpers/testMocks';
+import { ComponentQualifier } from '../../../../types/component';
+import { App } from '../App';
+import UnsubscribeEmailModal from '../UnsubscribeEmailModal';
+
+jest.mock('sonar-ui-common/helpers/handleRequiredAuthentication', () => ({
+  default: jest.fn()
+}));
+
 jest.mock('../../../../api/measures', () => ({
-  getMeasures: jest.fn(() => Promise.resolve([]))
+  getMeasures: jest.fn().mockResolvedValue([])
 }));
 
 jest.mock('../../../../api/components', () => ({
-  getChildren: jest.fn(() => Promise.resolve({ components: [], paging: { total: 0 } }))
+  getChildren: jest.fn().mockResolvedValue({ components: [], paging: { total: 0 } })
 }));
 
-import { mount, shallow } from 'enzyme';
-import * as React from 'react';
-import { ComponentQualifier } from '../../../../types/component';
-import { App } from '../App';
+beforeEach(jest.clearAllMocks);
 
-const getMeasures = require('../../../../api/measures').getMeasures as jest.Mock<any>;
-const getChildren = require('../../../../api/components').getChildren as jest.Mock<any>;
+it('should render correctly', () => {
+  const wrapper = shallowRender({
+    component: mockComponent({
+      key: 'foo',
+      name: 'Foo',
+      qualifier: ComponentQualifier.Portfolio,
+      description: 'accurate description'
+    })
+  });
+  expect(wrapper).toMatchSnapshot('loading');
 
-const component = {
-  key: 'foo',
-  name: 'Foo',
-  qualifier: ComponentQualifier.Portfolio
-} as T.Component;
+  wrapper.setState({ loading: false, measures: { reliability_rating: '1' } });
+  expect(wrapper).toMatchSnapshot('portfolio is empty');
+
+  wrapper.setState({ measures: { ncloc: '173' } });
+  expect(wrapper).toMatchSnapshot('portfolio is not computed');
 
-it('renders', () => {
-  const wrapper = shallow(
-    <App
-      component={{ ...component, description: 'accurate description' }}
-      fetchMetrics={jest.fn()}
-      metrics={{}}
-    />
-  );
   wrapper.setState({
-    loading: false,
     measures: { ncloc: '173', reliability_rating: '1' },
     subComponents: [],
     totalSubComponents: 0
   });
-  expect(wrapper).toMatchSnapshot();
+  expect(wrapper).toMatchSnapshot('default');
 });
 
-it('renders when portfolio is empty', () => {
-  const wrapper = shallow(<App component={component} fetchMetrics={jest.fn()} metrics={{}} />);
-  wrapper.setState({ loading: false, measures: { reliability_rating: '1' } });
-  expect(wrapper).toMatchSnapshot();
+it('should require authentication if this is an unsubscription request and user is anonymous', () => {
+  shallowRender({ location: mockLocation({ query: { unsubscribe: '1' } }) });
+  expect(handleRequiredAuthentication).toBeCalled();
+});
+
+it('should show the unsubscribe modal if this is an unsubscription request and user is logged in', async () => {
+  (getMeasures as jest.Mock).mockResolvedValueOnce([
+    { metric: 'ncloc', value: '173' },
+    { metric: 'reliability_rating', value: '1' }
+  ]);
+  const wrapper = shallowRender({
+    location: mockLocation({ query: { unsubscribe: '1' } }),
+    currentUser: mockLoggedInUser()
+  });
+
+  await waitAndUpdate(wrapper);
+
+  expect(handleRequiredAuthentication).not.toBeCalled();
+  expect(wrapper.find(UnsubscribeEmailModal).exists()).toBe(true);
 });
 
-it('renders when portfolio is not computed', () => {
-  const wrapper = shallow(<App component={component} fetchMetrics={jest.fn()} metrics={{}} />);
-  wrapper.setState({ loading: false, measures: { ncloc: '173' } });
-  expect(wrapper).toMatchSnapshot();
+it('should update the location when unsubscribe modal is closed', () => {
+  const replace = jest.fn();
+  const wrapper = shallowRender({
+    location: mockLocation({ query: { unsubscribe: '1' } }),
+    currentUser: mockLoggedInUser(),
+    router: mockRouter({ replace })
+  });
+  wrapper.instance().handleCloseUnsubscribeEmailModal();
+  expect(replace).toBeCalledWith(expect.objectContaining({ query: { unsubscribe: undefined } }));
 });
 
 it('fetches measures and children components', () => {
-  getMeasures.mockClear();
-  getChildren.mockClear();
-  mount(<App component={component} fetchMetrics={jest.fn()} metrics={{}} />);
+  shallowRender();
+
   expect(getMeasures).toBeCalledWith({
     component: 'foo',
     metricKeys:
       'projects,ncloc,ncloc_language_distribution,releasability_rating,releasability_effort,sqale_rating,maintainability_rating_effort,reliability_rating,reliability_rating_effort,security_rating,security_rating_effort,security_review_rating,security_review_rating_effort,last_change_on_releasability_rating,last_change_on_maintainability_rating,last_change_on_security_rating,last_change_on_security_review_rating,last_change_on_reliability_rating'
   });
+
   expect(getChildren).toBeCalledWith(
     'foo',
     [
@@ -92,3 +128,21 @@ it('fetches measures and children components', () => {
     { ps: 20, s: 'qualifier' }
   );
 });
+
+function shallowRender(props: Partial<App['props']> = {}) {
+  return shallow<App>(
+    <App
+      component={mockComponent({
+        key: 'foo',
+        name: 'Foo',
+        qualifier: ComponentQualifier.Portfolio
+      })}
+      currentUser={mockCurrentUser()}
+      fetchMetrics={jest.fn()}
+      location={mockLocation()}
+      metrics={{}}
+      router={mockRouter()}
+      {...props}
+    />
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/portfolio/components/__tests__/UnsubscribeEmailModal-test.tsx b/server/sonar-web/src/main/js/apps/portfolio/components/__tests__/UnsubscribeEmailModal-test.tsx
new file mode 100644 (file)
index 0000000..7269733
--- /dev/null
@@ -0,0 +1,82 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 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, ShallowWrapper } from 'enzyme';
+import * as React from 'react';
+import SimpleModal from 'sonar-ui-common/components/controls/SimpleModal';
+import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
+import { unsubscribeFromEmailReport } from '../../../../api/component-report';
+import { mockComponent } from '../../../../helpers/testMocks';
+import { ComponentQualifier } from '../../../../types/component';
+import UnsubscribeEmailModal from '../UnsubscribeEmailModal';
+
+jest.mock('../../../../api/component-report', () => ({
+  unsubscribeFromEmailReport: jest.fn().mockResolvedValue(null)
+}));
+
+it('should render correctly', () => {
+  expect(shallowRender()).toMatchSnapshot('default');
+  expect(diveIntoSimpleModal(shallowRender())).toMatchSnapshot('modal content');
+  expect(diveIntoSimpleModal(shallowRender().setState({ success: true }))).toMatchSnapshot(
+    'modal content, success'
+  );
+});
+
+it('should correctly flag itself as (un)mounted', () => {
+  const wrapper = shallowRender();
+  const instance = wrapper.instance();
+  expect(instance.mounted).toBe(true);
+  wrapper.unmount();
+  expect(instance.mounted).toBe(false);
+});
+
+it('should correctly unsubscribe the user', async () => {
+  const component = mockComponent({ key: 'foo' });
+  const wrapper = shallowRender({ component });
+  submitSimpleModal(wrapper);
+  await waitAndUpdate(wrapper);
+
+  expect(unsubscribeFromEmailReport).toHaveBeenCalledWith('foo');
+  expect(wrapper.state().success).toBe(true);
+});
+
+function diveIntoSimpleModal(wrapper: ShallowWrapper) {
+  return wrapper
+    .find(SimpleModal)
+    .dive()
+    .children();
+}
+
+function submitSimpleModal(wrapper: ShallowWrapper) {
+  wrapper
+    .find(SimpleModal)
+    .props()
+    .onSubmit();
+}
+
+function shallowRender(props: Partial<UnsubscribeEmailModal['props']> = {}) {
+  return shallow<UnsubscribeEmailModal>(
+    <UnsubscribeEmailModal
+      component={mockComponent({ qualifier: ComponentQualifier.Portfolio })}
+      onClose={jest.fn()}
+      {...props}
+    />
+  );
+}
index a80e83b3132b61ed29ba49c8a4a50139d270ffe5..b898b84c8e17eb9092337ab8adad56754c3d5413 100644 (file)
@@ -1,6 +1,6 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`renders 1`] = `
+exports[`should render correctly: default 1`] = `
 <div
   className="page page-limited portfolio-overview"
 >
@@ -10,10 +10,25 @@ exports[`renders 1`] = `
     <Connect(withCurrentUser(Connect(ComponentReportActions)))
       component={
         Object {
+          "breadcrumbs": Array [],
           "description": "accurate description",
           "key": "foo",
           "name": "Foo",
           "qualifier": "VW",
+          "qualityGate": Object {
+            "isDefault": true,
+            "key": "30",
+            "name": "Sonar way",
+          },
+          "qualityProfiles": Array [
+            Object {
+              "deleted": false,
+              "key": "my-qp",
+              "language": "ts",
+              "name": "Sonar way",
+            },
+          ],
+          "tags": Array [],
         }
       }
     />
@@ -152,7 +167,21 @@ exports[`renders 1`] = `
 </div>
 `;
 
-exports[`renders when portfolio is empty 1`] = `
+exports[`should render correctly: loading 1`] = `
+<div
+  className="page page-limited"
+>
+  <div
+    className="text-center"
+  >
+    <i
+      className="spinner spacer"
+    />
+  </div>
+</div>
+`;
+
+exports[`should render correctly: portfolio is empty 1`] = `
 <div
   className="page page-limited"
 >
@@ -166,7 +195,7 @@ exports[`renders when portfolio is empty 1`] = `
 </div>
 `;
 
-exports[`renders when portfolio is not computed 1`] = `
+exports[`should render correctly: portfolio is not computed 1`] = `
 <div
   className="page page-limited"
 >
diff --git a/server/sonar-web/src/main/js/apps/portfolio/components/__tests__/__snapshots__/UnsubscribeEmailModal-test.tsx.snap b/server/sonar-web/src/main/js/apps/portfolio/components/__tests__/__snapshots__/UnsubscribeEmailModal-test.tsx.snap
new file mode 100644 (file)
index 0000000..d0a3d47
--- /dev/null
@@ -0,0 +1,87 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly: default 1`] = `
+<SimpleModal
+  header="component_report.unsubscribe"
+  onClose={[MockFunction]}
+  onSubmit={[Function]}
+  size="small"
+>
+  <Component />
+</SimpleModal>
+`;
+
+exports[`should render correctly: modal content 1`] = `
+<form
+  onSubmit={[Function]}
+>
+  <div
+    className="modal-head"
+  >
+    <h2>
+      component_report.unsubscribe
+    </h2>
+  </div>
+  <div
+    className="modal-body"
+  >
+    <p>
+      component_report.unsubscribe.description
+    </p>
+  </div>
+  <div
+    className="modal-foot"
+  >
+    <DeferredSpinner
+      className="spacer-right"
+      loading={false}
+    />
+    <SubmitButton
+      disabled={false}
+    >
+      component_report.unsubscribe
+    </SubmitButton>
+    <ResetButtonLink
+      onClick={[Function]}
+    >
+      cancel
+    </ResetButtonLink>
+  </div>
+</form>
+`;
+
+exports[`should render correctly: modal content, success 1`] = `
+<form
+  onSubmit={[Function]}
+>
+  <div
+    className="modal-head"
+  >
+    <h2>
+      component_report.unsubscribe
+    </h2>
+  </div>
+  <div
+    className="modal-body"
+  >
+    <Alert
+      variant="success"
+    >
+      component_report.unsubscribe_success
+    </Alert>
+  </div>
+  <div
+    className="modal-foot"
+  >
+    <DeferredSpinner
+      className="spacer-right"
+      loading={false}
+    />
+    <Button
+      onClick={[Function]}
+    >
+      close
+    </Button>
+  </div>
+</form>
+`;
index eb9027897b12048d1b95d3bde3a17da0986d82c0..674543e833222ce7626578a243f32519e3c62812 100644 (file)
@@ -4196,5 +4196,8 @@ component_report.download=Download {0} PDF report
 component_report.no_email_to_subscribe=Email subscription requires an email address.
 component_report.subscribe_x=Subscribe to {0} report 
 component_report.unsubscribe_x=Unsubscribe from {0} report
+component_report.unsubscribe=Unsubscribe from report
+component_report.unsubscribe.description=If you no longer wish to receive these reports via email, you can unsubscribe by clicking on the button below.
 component_report.subscribe_x_success=Subscription successful. You will receive a {0} report for this {1} by email.
-component_report.unsubscribe_x_success=Subscription successfully canceled. You won't receive a {0} report for this {1} by email.
\ No newline at end of file
+component_report.unsubscribe_x_success=Subscription successfully canceled. You won't receive a {0} report for this {1} by email.
+component_report.unsubscribe_success=Subscription successfully canceled. You won't receive these reports by email anymore.