From e28c7ce9bd7e42d2fde1dc02194f4676bf0ae7ee Mon Sep 17 00:00:00 2001 From: Wouter Admiraal Date: Thu, 12 Aug 2021 16:37:24 +0200 Subject: [PATCH] SONAR-13293 Unsubscribing from a portfolio report should be more straightforward --- .../main/js/apps/portfolio/components/App.tsx | 49 ++++++- .../components/UnsubscribeEmailModal.tsx | 101 ++++++++++++++ .../components/__tests__/App-test.tsx | 124 +++++++++++++----- .../__tests__/UnsubscribeEmailModal-test.tsx | 82 ++++++++++++ .../__tests__/__snapshots__/App-test.tsx.snap | 35 ++++- .../UnsubscribeEmailModal-test.tsx.snap | 87 ++++++++++++ .../resources/org/sonar/l10n/core.properties | 5 +- 7 files changed, 441 insertions(+), 42 deletions(-) create mode 100644 server/sonar-web/src/main/js/apps/portfolio/components/UnsubscribeEmailModal.tsx create mode 100644 server/sonar-web/src/main/js/apps/portfolio/components/__tests__/UnsubscribeEmailModal-test.tsx create mode 100644 server/sonar-web/src/main/js/apps/portfolio/components/__tests__/__snapshots__/UnsubscribeEmailModal-test.tsx.snap diff --git a/server/sonar-web/src/main/js/apps/portfolio/components/App.tsx b/server/sonar-web/src/main/js/apps/portfolio/components/App.tsx index 6546a42ce8f..197ead48a18 100644 --- a/server/sonar-web/src/main/js/apps/portfolio/components/App.tsx +++ b/server/sonar-web/src/main/js/apps/portfolio/components/App.tsx @@ -17,24 +17,33 @@ * 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; subComponents?: SubComponent[]; totalSubComponents?: number; + showUnsubscribeModal: boolean; } export class App extends React.PureComponent { 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 { 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 (
@@ -142,7 +172,13 @@ export class App extends React.PureComponent { 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 { total={totalSubComponents} /> )} + + {showUnsubscribeModal && ( + + )}
); } @@ -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 index 00000000000..b53479faa4b --- /dev/null +++ b/server/sonar-web/src/main/js/apps/portfolio/components/UnsubscribeEmailModal.tsx @@ -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 { + 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 ( + + {({ onCloseClick, onFormSubmit, submitting }) => ( +
+
+

{header}

+
+ +
+ {success ? ( + {translate('component_report.unsubscribe_success')} + ) : ( +

{translate('component_report.unsubscribe.description')}

+ )} +
+ +
+ + {success ? ( + + ) : ( + <> + + {translate('component_report.unsubscribe')} + + {translate('cancel')} + + )} +
+
+ )} +
+ ); + } +} diff --git a/server/sonar-web/src/main/js/apps/portfolio/components/__tests__/App-test.tsx b/server/sonar-web/src/main/js/apps/portfolio/components/__tests__/App-test.tsx index 70aed21093a..061a517af28 100644 --- a/server/sonar-web/src/main/js/apps/portfolio/components/__tests__/App-test.tsx +++ b/server/sonar-web/src/main/js/apps/portfolio/components/__tests__/App-test.tsx @@ -17,67 +17,103 @@ * 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; -const getChildren = require('../../../../api/components').getChildren as jest.Mock; +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( - - ); 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(); - 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(); - 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(); + 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 = {}) { + return shallow( + + ); +} 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 index 00000000000..7269733003c --- /dev/null +++ b/server/sonar-web/src/main/js/apps/portfolio/components/__tests__/UnsubscribeEmailModal-test.tsx @@ -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 = {}) { + return shallow( + + ); +} diff --git a/server/sonar-web/src/main/js/apps/portfolio/components/__tests__/__snapshots__/App-test.tsx.snap b/server/sonar-web/src/main/js/apps/portfolio/components/__tests__/__snapshots__/App-test.tsx.snap index a80e83b3132..b898b84c8e1 100644 --- a/server/sonar-web/src/main/js/apps/portfolio/components/__tests__/__snapshots__/App-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/portfolio/components/__tests__/__snapshots__/App-test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`renders 1`] = ` +exports[`should render correctly: default 1`] = `
@@ -10,10 +10,25 @@ exports[`renders 1`] = ` @@ -152,7 +167,21 @@ exports[`renders 1`] = `
`; -exports[`renders when portfolio is empty 1`] = ` +exports[`should render correctly: loading 1`] = ` +
+
+ +
+
+`; + +exports[`should render correctly: portfolio is empty 1`] = `
@@ -166,7 +195,7 @@ exports[`renders when portfolio is empty 1`] = `
`; -exports[`renders when portfolio is not computed 1`] = ` +exports[`should render correctly: portfolio is not computed 1`] = `
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 index 00000000000..d0a3d47e0e9 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/portfolio/components/__tests__/__snapshots__/UnsubscribeEmailModal-test.tsx.snap @@ -0,0 +1,87 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly: default 1`] = ` + + + +`; + +exports[`should render correctly: modal content 1`] = ` +
+
+

+ component_report.unsubscribe +

+
+
+

+ component_report.unsubscribe.description +

+
+
+ + + component_report.unsubscribe + + + cancel + +
+
+`; + +exports[`should render correctly: modal content, success 1`] = ` +
+
+

+ component_report.unsubscribe +

+
+
+ + component_report.unsubscribe_success + +
+
+ + +
+
+`; diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index eb9027897b1..674543e8332 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -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. -- 2.39.5