aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web/src
diff options
context:
space:
mode:
authorWouter Admiraal <wouter.admiraal@sonarsource.com>2021-08-12 16:37:24 +0200
committersonartech <sonartech@sonarsource.com>2021-08-13 20:03:54 +0000
commite28c7ce9bd7e42d2fde1dc02194f4676bf0ae7ee (patch)
treea6776cc5d9ba8aab107f5a461e0ea21c56081a33 /server/sonar-web/src
parent5e2206bf21a879ac7050f5e5e59afe40611e8101 (diff)
downloadsonarqube-e28c7ce9bd7e42d2fde1dc02194f4676bf0ae7ee.tar.gz
sonarqube-e28c7ce9bd7e42d2fde1dc02194f4676bf0ae7ee.zip
SONAR-13293 Unsubscribing from a portfolio report should be more straightforward
Diffstat (limited to 'server/sonar-web/src')
-rw-r--r--server/sonar-web/src/main/js/apps/portfolio/components/App.tsx49
-rw-r--r--server/sonar-web/src/main/js/apps/portfolio/components/UnsubscribeEmailModal.tsx101
-rw-r--r--server/sonar-web/src/main/js/apps/portfolio/components/__tests__/App-test.tsx124
-rw-r--r--server/sonar-web/src/main/js/apps/portfolio/components/__tests__/UnsubscribeEmailModal-test.tsx82
-rw-r--r--server/sonar-web/src/main/js/apps/portfolio/components/__tests__/__snapshots__/App-test.tsx.snap35
-rw-r--r--server/sonar-web/src/main/js/apps/portfolio/components/__tests__/__snapshots__/UnsubscribeEmailModal-test.tsx.snap87
6 files changed, 437 insertions, 41 deletions
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<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
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<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>
+ );
+ }
+}
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<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
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<UnsubscribeEmailModal['props']> = {}) {
+ return shallow<UnsubscribeEmailModal>(
+ <UnsubscribeEmailModal
+ component={mockComponent({ qualifier: ComponentQualifier.Portfolio })}
+ onClose={jest.fn()}
+ {...props}
+ />
+ );
+}
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`] = `
<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
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`] = `
+<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>
+`;