aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web/src/main
diff options
context:
space:
mode:
authorPhilippe Perrin <philippe.perrin@sonarsource.com>2021-07-28 10:49:06 +0200
committersonartech <sonartech@sonarsource.com>2021-08-09 20:03:18 +0000
commitadc1fc316700608671a18bcdcba001eb45c0645b (patch)
treef400f41707d23b58ffb298b1317d7063480e7a16 /server/sonar-web/src/main
parent90198d576c83d7e63cfe9bdfd2bb1db8eed9fad6 (diff)
downloadsonarqube-adc1fc316700608671a18bcdcba001eb45c0645b.tar.gz
sonarqube-adc1fc316700608671a18bcdcba001eb45c0645b.zip
SONAR-15138 Add report download and subscription buttons for projects and applications
Diffstat (limited to 'server/sonar-web/src/main')
-rw-r--r--server/sonar-web/src/main/js/api/component-report.ts (renamed from server/sonar-web/src/main/js/api/report.ts)42
-rw-r--r--server/sonar-web/src/main/js/apps/overview/branches/MeasuresPanel.tsx6
-rw-r--r--server/sonar-web/src/main/js/apps/overview/branches/__tests__/__snapshots__/MeasuresPanel-test.tsx.snap399
-rw-r--r--server/sonar-web/src/main/js/apps/portfolio/components/App.tsx5
-rw-r--r--server/sonar-web/src/main/js/apps/portfolio/components/Report.tsx112
-rw-r--r--server/sonar-web/src/main/js/apps/portfolio/components/Subscription.tsx89
-rw-r--r--server/sonar-web/src/main/js/apps/portfolio/components/__tests__/Report-test.tsx55
-rw-r--r--server/sonar-web/src/main/js/apps/portfolio/components/__tests__/Subscription-test.tsx108
-rw-r--r--server/sonar-web/src/main/js/apps/portfolio/components/__tests__/__snapshots__/App-test.tsx.snap2
-rw-r--r--server/sonar-web/src/main/js/apps/portfolio/components/__tests__/__snapshots__/Report-test.tsx.snap49
-rw-r--r--server/sonar-web/src/main/js/apps/portfolio/components/__tests__/__snapshots__/Subscription-test.tsx.snap27
-rw-r--r--server/sonar-web/src/main/js/components/controls/ComponentReportActions.tsx140
-rw-r--r--server/sonar-web/src/main/js/components/controls/ComponentReportActionsRenderer.tsx100
-rw-r--r--server/sonar-web/src/main/js/components/controls/__tests__/ComponentReportActions-test.tsx127
-rw-r--r--server/sonar-web/src/main/js/components/controls/__tests__/ComponentReportActionsRenderer-test.tsx56
-rw-r--r--server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/ComponentReportActionsRenderer-test.tsx.snap136
-rw-r--r--server/sonar-web/src/main/js/helpers/mocks/component-report.ts36
-rw-r--r--server/sonar-web/src/main/js/types/component-report.ts30
18 files changed, 1022 insertions, 497 deletions
diff --git a/server/sonar-web/src/main/js/api/report.ts b/server/sonar-web/src/main/js/api/component-report.ts
index 513f6d16a0d..08f6e7563cc 100644
--- a/server/sonar-web/src/main/js/api/report.ts
+++ b/server/sonar-web/src/main/js/api/component-report.ts
@@ -20,35 +20,43 @@
import { getJSON, post } from 'sonar-ui-common/helpers/request';
import throwGlobalError from '../app/utils/throwGlobalError';
import { getBaseUrl } from '../helpers/system';
+import { ComponentReportStatus } from '../types/component-report';
-export interface ReportStatus {
- canDownload?: boolean;
- canSubscribe: boolean;
- componentFrequency?: string;
- globalFrequency: string;
- subscribed?: boolean;
-}
-
-export function getReportStatus(component: string): Promise<ReportStatus> {
- return getJSON('/api/governance_reports/status', { componentKey: component }).catch(
+export function getReportStatus(
+ componentKey: string,
+ branchKey?: string
+): Promise<ComponentReportStatus> {
+ return getJSON('/api/governance_reports/status', { componentKey, branchKey }).catch(
throwGlobalError
);
}
-export function getReportUrl(component: string): string {
- return `${getBaseUrl()}/api/governance_reports/download?componentKey=${encodeURIComponent(
- component
+export function getReportUrl(componentKey: string, branchKey?: string): string {
+ let url = `${getBaseUrl()}/api/governance_reports/download?componentKey=${encodeURIComponent(
+ componentKey
)}`;
+
+ if (branchKey) {
+ url += `&branchKey=${branchKey}`;
+ }
+
+ return url;
}
-export function subscribe(component: string): Promise<void | Response> {
- return post('/api/governance_reports/subscribe', { componentKey: component }).catch(
+export function subscribeToEmailReport(
+ componentKey: string,
+ branchKey?: string
+): Promise<void | Response> {
+ return post('/api/governance_reports/subscribe', { componentKey, branchKey }).catch(
throwGlobalError
);
}
-export function unsubscribe(component: string): Promise<void | Response> {
- return post('/api/governance_reports/unsubscribe', { componentKey: component }).catch(
+export function unsubscribeFromEmailReport(
+ componentKey: string,
+ branchKey?: string
+): Promise<void | Response> {
+ return post('/api/governance_reports/unsubscribe', { componentKey, branchKey }).catch(
throwGlobalError
);
}
diff --git a/server/sonar-web/src/main/js/apps/overview/branches/MeasuresPanel.tsx b/server/sonar-web/src/main/js/apps/overview/branches/MeasuresPanel.tsx
index 5089095546f..2e1cbae10d9 100644
--- a/server/sonar-web/src/main/js/apps/overview/branches/MeasuresPanel.tsx
+++ b/server/sonar-web/src/main/js/apps/overview/branches/MeasuresPanel.tsx
@@ -23,6 +23,7 @@ import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner';
import { translate } from 'sonar-ui-common/helpers/l10n';
import { isDiffMetric } from 'sonar-ui-common/helpers/measures';
import { rawSizes } from '../../../app/theme';
+import ComponentReportActions from '../../../components/controls/ComponentReportActions';
import { findMeasure } from '../../../helpers/measures';
import { ApplicationPeriod } from '../../../types/application';
import { Branch } from '../../../types/branch-like';
@@ -95,7 +96,10 @@ export function MeasuresPanel(props: MeasuresPanelProps) {
return (
<div className="overview-panel" data-test="overview__measures-panel">
- <h2 className="overview-panel-title">{translate('overview.measures')}</h2>
+ <div className="display-flex-space-between display-flex-start">
+ <h2 className="overview-panel-title">{translate('overview.measures')}</h2>
+ <ComponentReportActions component={component} branch={branch} />
+ </div>
{loading ? (
<div className="overview-panel-content overview-panel-big-padded">
diff --git a/server/sonar-web/src/main/js/apps/overview/branches/__tests__/__snapshots__/MeasuresPanel-test.tsx.snap b/server/sonar-web/src/main/js/apps/overview/branches/__tests__/__snapshots__/MeasuresPanel-test.tsx.snap
index 6870020f8aa..0ccb4ef60f0 100644
--- a/server/sonar-web/src/main/js/apps/overview/branches/__tests__/__snapshots__/MeasuresPanel-test.tsx.snap
+++ b/server/sonar-web/src/main/js/apps/overview/branches/__tests__/__snapshots__/MeasuresPanel-test.tsx.snap
@@ -5,11 +5,47 @@ exports[`should render correctly for applications 1`] = `
className="overview-panel"
data-test="overview__measures-panel"
>
- <h2
- className="overview-panel-title"
+ <div
+ className="display-flex-space-between display-flex-start"
>
- overview.measures
- </h2>
+ <h2
+ className="overview-panel-title"
+ >
+ overview.measures
+ </h2>
+ <Connect(withCurrentUser(Connect(ComponentReportActions)))
+ branch={
+ Object {
+ "analysisDate": "2018-01-01",
+ "excludedFromPurge": true,
+ "isMain": true,
+ "name": "master",
+ }
+ }
+ component={
+ Object {
+ "breadcrumbs": Array [],
+ "key": "my-project",
+ "name": "MyProject",
+ "qualifier": "APP",
+ "qualityGate": Object {
+ "isDefault": true,
+ "key": "30",
+ "name": "Sonar way",
+ },
+ "qualityProfiles": Array [
+ Object {
+ "deleted": false,
+ "key": "my-qp",
+ "language": "ts",
+ "name": "Sonar way",
+ },
+ ],
+ "tags": Array [],
+ }
+ }
+ />
+ </div>
<BoxedTabs
onSelect={[Function]}
selected={0}
@@ -691,11 +727,47 @@ exports[`should render correctly for applications 2`] = `
className="overview-panel"
data-test="overview__measures-panel"
>
- <h2
- className="overview-panel-title"
+ <div
+ className="display-flex-space-between display-flex-start"
>
- overview.measures
- </h2>
+ <h2
+ className="overview-panel-title"
+ >
+ overview.measures
+ </h2>
+ <Connect(withCurrentUser(Connect(ComponentReportActions)))
+ branch={
+ Object {
+ "analysisDate": "2018-01-01",
+ "excludedFromPurge": true,
+ "isMain": true,
+ "name": "master",
+ }
+ }
+ component={
+ Object {
+ "breadcrumbs": Array [],
+ "key": "my-project",
+ "name": "MyProject",
+ "qualifier": "APP",
+ "qualityGate": Object {
+ "isDefault": true,
+ "key": "30",
+ "name": "Sonar way",
+ },
+ "qualityProfiles": Array [
+ Object {
+ "deleted": false,
+ "key": "my-qp",
+ "language": "ts",
+ "name": "Sonar way",
+ },
+ ],
+ "tags": Array [],
+ }
+ }
+ />
+ </div>
<BoxedTabs
onSelect={[Function]}
selected={1}
@@ -1587,11 +1659,47 @@ exports[`should render correctly for projects 1`] = `
className="overview-panel"
data-test="overview__measures-panel"
>
- <h2
- className="overview-panel-title"
+ <div
+ className="display-flex-space-between display-flex-start"
>
- overview.measures
- </h2>
+ <h2
+ className="overview-panel-title"
+ >
+ overview.measures
+ </h2>
+ <Connect(withCurrentUser(Connect(ComponentReportActions)))
+ branch={
+ Object {
+ "analysisDate": "2018-01-01",
+ "excludedFromPurge": true,
+ "isMain": true,
+ "name": "master",
+ }
+ }
+ component={
+ Object {
+ "breadcrumbs": Array [],
+ "key": "my-project",
+ "name": "MyProject",
+ "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 [],
+ }
+ }
+ />
+ </div>
<BoxedTabs
onSelect={[Function]}
selected={0}
@@ -2273,11 +2381,47 @@ exports[`should render correctly for projects 2`] = `
className="overview-panel"
data-test="overview__measures-panel"
>
- <h2
- className="overview-panel-title"
+ <div
+ className="display-flex-space-between display-flex-start"
>
- overview.measures
- </h2>
+ <h2
+ className="overview-panel-title"
+ >
+ overview.measures
+ </h2>
+ <Connect(withCurrentUser(Connect(ComponentReportActions)))
+ branch={
+ Object {
+ "analysisDate": "2018-01-01",
+ "excludedFromPurge": true,
+ "isMain": true,
+ "name": "master",
+ }
+ }
+ component={
+ Object {
+ "breadcrumbs": Array [],
+ "key": "my-project",
+ "name": "MyProject",
+ "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 [],
+ }
+ }
+ />
+ </div>
<BoxedTabs
onSelect={[Function]}
selected={1}
@@ -3169,11 +3313,47 @@ exports[`should render correctly if branch is misconfigured: hide settings 1`] =
className="overview-panel"
data-test="overview__measures-panel"
>
- <h2
- className="overview-panel-title"
+ <div
+ className="display-flex-space-between display-flex-start"
>
- overview.measures
- </h2>
+ <h2
+ className="overview-panel-title"
+ >
+ overview.measures
+ </h2>
+ <Connect(withCurrentUser(Connect(ComponentReportActions)))
+ branch={
+ Object {
+ "analysisDate": "2018-01-01",
+ "excludedFromPurge": true,
+ "isMain": false,
+ "name": "own-reference",
+ }
+ }
+ component={
+ Object {
+ "breadcrumbs": Array [],
+ "key": "my-project",
+ "name": "MyProject",
+ "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 [],
+ }
+ }
+ />
+ </div>
<BoxedTabs
onSelect={[Function]}
selected={0}
@@ -3274,11 +3454,50 @@ exports[`should render correctly if branch is misconfigured: show settings 1`] =
className="overview-panel"
data-test="overview__measures-panel"
>
- <h2
- className="overview-panel-title"
+ <div
+ className="display-flex-space-between display-flex-start"
>
- overview.measures
- </h2>
+ <h2
+ className="overview-panel-title"
+ >
+ overview.measures
+ </h2>
+ <Connect(withCurrentUser(Connect(ComponentReportActions)))
+ branch={
+ Object {
+ "analysisDate": "2018-01-01",
+ "excludedFromPurge": true,
+ "isMain": false,
+ "name": "own-reference",
+ }
+ }
+ component={
+ Object {
+ "breadcrumbs": Array [],
+ "configuration": Object {
+ "showSettings": true,
+ },
+ "key": "my-project",
+ "name": "MyProject",
+ "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 [],
+ }
+ }
+ />
+ </div>
<BoxedTabs
onSelect={[Function]}
selected={0}
@@ -3382,11 +3601,47 @@ exports[`should render correctly if the data is still loading 1`] = `
className="overview-panel"
data-test="overview__measures-panel"
>
- <h2
- className="overview-panel-title"
+ <div
+ className="display-flex-space-between display-flex-start"
>
- overview.measures
- </h2>
+ <h2
+ className="overview-panel-title"
+ >
+ overview.measures
+ </h2>
+ <Connect(withCurrentUser(Connect(ComponentReportActions)))
+ branch={
+ Object {
+ "analysisDate": "2018-01-01",
+ "excludedFromPurge": true,
+ "isMain": true,
+ "name": "master",
+ }
+ }
+ component={
+ Object {
+ "breadcrumbs": Array [],
+ "key": "my-project",
+ "name": "MyProject",
+ "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 [],
+ }
+ }
+ />
+ </div>
<div
className="overview-panel-content overview-panel-big-padded"
>
@@ -3402,11 +3657,47 @@ exports[`should render correctly if there is no coverage 1`] = `
className="overview-panel"
data-test="overview__measures-panel"
>
- <h2
- className="overview-panel-title"
+ <div
+ className="display-flex-space-between display-flex-start"
>
- overview.measures
- </h2>
+ <h2
+ className="overview-panel-title"
+ >
+ overview.measures
+ </h2>
+ <Connect(withCurrentUser(Connect(ComponentReportActions)))
+ branch={
+ Object {
+ "analysisDate": "2018-01-01",
+ "excludedFromPurge": true,
+ "isMain": true,
+ "name": "master",
+ }
+ }
+ component={
+ Object {
+ "breadcrumbs": Array [],
+ "key": "my-project",
+ "name": "MyProject",
+ "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 [],
+ }
+ }
+ />
+ </div>
<BoxedTabs
onSelect={[Function]}
selected={0}
@@ -3820,11 +4111,47 @@ exports[`should render correctly if there is no new code measures 1`] = `
className="overview-panel"
data-test="overview__measures-panel"
>
- <h2
- className="overview-panel-title"
+ <div
+ className="display-flex-space-between display-flex-start"
>
- overview.measures
- </h2>
+ <h2
+ className="overview-panel-title"
+ >
+ overview.measures
+ </h2>
+ <Connect(withCurrentUser(Connect(ComponentReportActions)))
+ branch={
+ Object {
+ "analysisDate": "2018-01-01",
+ "excludedFromPurge": true,
+ "isMain": true,
+ "name": "master",
+ }
+ }
+ component={
+ Object {
+ "breadcrumbs": Array [],
+ "key": "my-project",
+ "name": "MyProject",
+ "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 [],
+ }
+ }
+ />
+ </div>
<BoxedTabs
onSelect={[Function]}
selected={0}
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 e7c170aa7a7..6546a42ce8f 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
@@ -23,6 +23,7 @@ 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 Measure from '../../../components/measure/Measure';
import { fetchMetrics } from '../../../store/rootActions';
import { getMetrics, Store } from '../../../store/rootReducer';
@@ -30,7 +31,6 @@ import '../styles.css';
import { SubComponent } from '../types';
import { convertMeasures, PORTFOLIO_METRICS, SUB_COMPONENTS_METRICS } from '../utils';
import MetricBox from './MetricBox';
-import Report from './Report';
import WorstProjects from './WorstProjects';
interface OwnProps {
@@ -159,8 +159,9 @@ export class App extends React.PureComponent<Props, State> {
return (
<div className="page page-limited portfolio-overview">
<div className="page-actions">
- <Report component={component} />
+ <ComponentReportActions component={component} />
</div>
+
{component.description && (
<div className="portfolio-description display-inline-block big-spacer-bottom">
{component.description}
diff --git a/server/sonar-web/src/main/js/apps/portfolio/components/Report.tsx b/server/sonar-web/src/main/js/apps/portfolio/components/Report.tsx
deleted file mode 100644
index 699b6ba73f9..00000000000
--- a/server/sonar-web/src/main/js/apps/portfolio/components/Report.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-/*
- * 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 } from 'sonar-ui-common/components/controls/buttons';
-import Dropdown from 'sonar-ui-common/components/controls/Dropdown';
-import DropdownIcon from 'sonar-ui-common/components/icons/DropdownIcon';
-import { translate } from 'sonar-ui-common/helpers/l10n';
-import { getReportStatus, getReportUrl, ReportStatus } from '../../../api/report';
-import Subscription from './Subscription';
-
-interface Props {
- component: { key: string; name: string };
-}
-
-interface State {
- loading: boolean;
- status?: ReportStatus;
-}
-
-export default class Report extends React.PureComponent<Props, State> {
- mounted = false;
- state: State = { loading: true };
-
- componentDidMount() {
- this.mounted = true;
- this.loadStatus();
- }
-
- componentWillUnmount() {
- this.mounted = false;
- }
-
- loadStatus = () => {
- getReportStatus(this.props.component.key).then(
- status => {
- if (this.mounted) {
- this.setState({ status, loading: false });
- }
- },
- () => {
- if (this.mounted) {
- this.setState({ loading: false });
- }
- }
- );
- };
-
- render() {
- const { component } = this.props;
- const { status, loading } = this.state;
-
- if (loading || !status) {
- return null;
- }
-
- return status.canSubscribe ? (
- <Dropdown
- overlay={
- <ul className="menu">
- <li>
- <a
- download={component.name + ' - Executive Report.pdf'}
- href={getReportUrl(component.key)}
- target="_blank"
- rel="noopener noreferrer">
- {translate('report.print')}
- </a>
- </li>
- <li>
- <Subscription
- component={component.key}
- onSubscribe={this.loadStatus}
- status={status}
- />
- </li>
- </ul>
- }
- tagName="li">
- <Button className="dropdown-toggle">
- {translate('portfolio.pdf_report')}
- <DropdownIcon className="spacer-left icon-half-transparent" />
- </Button>
- </Dropdown>
- ) : (
- <a
- className="button"
- download={component.name + ' - Executive Report.pdf'}
- href={getReportUrl(component.key)}
- target="_blank"
- rel="noopener noreferrer">
- {translate('report.print')}
- </a>
- );
- }
-}
diff --git a/server/sonar-web/src/main/js/apps/portfolio/components/Subscription.tsx b/server/sonar-web/src/main/js/apps/portfolio/components/Subscription.tsx
deleted file mode 100644
index 0ed99c67a64..00000000000
--- a/server/sonar-web/src/main/js/apps/portfolio/components/Subscription.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-/*
- * 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 { connect } from 'react-redux';
-import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n';
-import { ReportStatus, subscribe, unsubscribe } from '../../../api/report';
-import addGlobalSuccessMessage from '../../../app/utils/addGlobalSuccessMessage';
-import throwGlobalError from '../../../app/utils/throwGlobalError';
-import { isLoggedIn } from '../../../helpers/users';
-import { getCurrentUser, Store } from '../../../store/rootReducer';
-
-interface Props {
- component: string;
- currentUser: T.CurrentUser;
- onSubscribe: () => void;
- status: ReportStatus;
-}
-
-export class Subscription extends React.PureComponent<Props> {
- handleSubscription = (subscribed: boolean) => {
- addGlobalSuccessMessage(
- subscribed
- ? translateWithParameters('report.subscribe_x_success', this.getFrequencyText())
- : translateWithParameters('report.unsubscribe_x_success', this.getFrequencyText())
- );
- this.props.onSubscribe();
- };
-
- handleSubscribe = () => {
- subscribe(this.props.component)
- .then(() => this.handleSubscription(true))
- .catch(throwGlobalError);
- };
-
- handleUnsubscribe = () => {
- unsubscribe(this.props.component)
- .then(() => this.handleSubscription(false))
- .catch(throwGlobalError);
- };
-
- getFrequencyText = () => {
- const effectiveFrequency =
- this.props.status.componentFrequency || this.props.status.globalFrequency;
- return translate('report.frequency', effectiveFrequency);
- };
-
- render() {
- const hasEmail = isLoggedIn(this.props.currentUser) && !!this.props.currentUser.email;
-
- const { status } = this.props;
-
- if (!hasEmail) {
- return <span className="text-muted-2">{translate('report.no_email_to_subscribe')}</span>;
- }
-
- return status.subscribed ? (
- <a href="#" onClick={this.handleUnsubscribe}>
- {translateWithParameters('report.unsubscribe_x', this.getFrequencyText())}
- </a>
- ) : (
- <a href="#" onClick={this.handleSubscribe}>
- {translateWithParameters('report.subscribe_x', this.getFrequencyText())}
- </a>
- );
- }
-}
-
-const mapStateToProps = (state: Store) => ({
- currentUser: getCurrentUser(state)
-});
-
-export default connect(mapStateToProps)(Subscription);
diff --git a/server/sonar-web/src/main/js/apps/portfolio/components/__tests__/Report-test.tsx b/server/sonar-web/src/main/js/apps/portfolio/components/__tests__/Report-test.tsx
deleted file mode 100644
index 26638a78f2c..00000000000
--- a/server/sonar-web/src/main/js/apps/portfolio/components/__tests__/Report-test.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-/*
- * 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.
- */
-/* eslint-disable import/first */
-jest.mock('../../../../api/report', () => {
- const report = jest.requireActual('../../../../api/report');
- report.getReportStatus = jest.fn(() => Promise.resolve({}));
- return report;
-});
-
-import { mount, shallow } from 'enzyme';
-import * as React from 'react';
-import Report from '../Report';
-
-const getReportStatus = require('../../../../api/report').getReportStatus as jest.Mock<any>;
-
-const component = { key: 'foo', name: 'Foo' };
-
-it('renders', () => {
- const wrapper = shallow(<Report component={component} />);
- expect(wrapper).toMatchSnapshot();
- wrapper.setState({
- loading: false,
- status: {
- canDownload: true,
- canSubscribe: true,
- componentFrequency: 'montly',
- globalFrequency: 'weekly',
- subscribed: true
- }
- });
- expect(wrapper).toMatchSnapshot();
-});
-
-it('fetches status', () => {
- getReportStatus.mockClear();
- mount(<Report component={component} />);
- expect(getReportStatus).toBeCalledWith('foo');
-});
diff --git a/server/sonar-web/src/main/js/apps/portfolio/components/__tests__/Subscription-test.tsx b/server/sonar-web/src/main/js/apps/portfolio/components/__tests__/Subscription-test.tsx
deleted file mode 100644
index 045226a7e85..00000000000
--- a/server/sonar-web/src/main/js/apps/portfolio/components/__tests__/Subscription-test.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-/*
- * 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.
- */
-/* eslint-disable import/first */
-jest.mock('../../../../api/report', () => {
- const report = jest.requireActual('../../../../api/report');
- report.subscribe = jest.fn(() => Promise.resolve());
- report.unsubscribe = jest.fn(() => Promise.resolve());
- return report;
-});
-
-import { mount, shallow } from 'enzyme';
-import * as React from 'react';
-import { click, waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
-import { ReportStatus } from '../../../../api/report';
-import { Subscription } from '../Subscription';
-
-const subscribe = require('../../../../api/report').subscribe as jest.Mock<any>;
-const unsubscribe = require('../../../../api/report').unsubscribe as jest.Mock<any>;
-
-beforeEach(() => {
- subscribe.mockClear();
- unsubscribe.mockClear();
-});
-
-it('renders when subscribed', () => {
- expect(shallowRender()).toMatchSnapshot();
-});
-
-it('renders when not subscribed', () => {
- expect(shallowRender({}, { subscribed: false })).toMatchSnapshot();
-});
-
-it('renders when no email', () => {
- expect(shallowRender({ currentUser: { isLoggedIn: false } })).toMatchSnapshot();
-});
-
-it('changes subscription', async () => {
- const status = {
- canDownload: true,
- canSubscribe: true,
- componentFrequency: 'montly',
- globalFrequency: 'weekly',
- subscribed: true
- };
-
- const currentUser = { isLoggedIn: true, email: 'foo@example.com' };
-
- const wrapper = mount(
- <Subscription
- component="foo"
- currentUser={currentUser}
- onSubscribe={jest.fn()}
- status={status}
- />
- );
-
- click(wrapper.find('a'));
- expect(unsubscribe).toBeCalledWith('foo');
-
- wrapper.setProps({ status: { ...status, subscribed: false } });
- await waitAndUpdate(wrapper);
-
- click(wrapper.find('a'));
- expect(subscribe).toBeCalledWith('foo');
-});
-
-function shallowRender(
- props: Partial<Subscription['props']> = {},
- statusOverrides: Partial<ReportStatus> = {}
-) {
- const status = {
- canDownload: true,
- canSubscribe: true,
- componentFrequency: 'montly',
- globalFrequency: 'weekly',
- subscribed: true,
- ...statusOverrides
- };
-
- const currentUser = { isLoggedIn: true, email: 'foo@example.com' };
-
- return shallow<Subscription>(
- <Subscription
- component="foo"
- currentUser={currentUser}
- onSubscribe={jest.fn()}
- status={status}
- {...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 fb87db2a7d0..a80e83b3132 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
@@ -7,7 +7,7 @@ exports[`renders 1`] = `
<div
className="page-actions"
>
- <Report
+ <Connect(withCurrentUser(Connect(ComponentReportActions)))
component={
Object {
"description": "accurate description",
diff --git a/server/sonar-web/src/main/js/apps/portfolio/components/__tests__/__snapshots__/Report-test.tsx.snap b/server/sonar-web/src/main/js/apps/portfolio/components/__tests__/__snapshots__/Report-test.tsx.snap
deleted file mode 100644
index 3852cde2841..00000000000
--- a/server/sonar-web/src/main/js/apps/portfolio/components/__tests__/__snapshots__/Report-test.tsx.snap
+++ /dev/null
@@ -1,49 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`renders 1`] = `""`;
-
-exports[`renders 2`] = `
-<Dropdown
- overlay={
- <ul
- className="menu"
- >
- <li>
- <a
- download="Foo - Executive Report.pdf"
- href="/api/governance_reports/download?componentKey=foo"
- rel="noopener noreferrer"
- target="_blank"
- >
- report.print
- </a>
- </li>
- <li>
- <Connect(Subscription)
- component="foo"
- onSubscribe={[Function]}
- status={
- Object {
- "canDownload": true,
- "canSubscribe": true,
- "componentFrequency": "montly",
- "globalFrequency": "weekly",
- "subscribed": true,
- }
- }
- />
- </li>
- </ul>
- }
- tagName="li"
->
- <Button
- className="dropdown-toggle"
- >
- portfolio.pdf_report
- <DropdownIcon
- className="spacer-left icon-half-transparent"
- />
- </Button>
-</Dropdown>
-`;
diff --git a/server/sonar-web/src/main/js/apps/portfolio/components/__tests__/__snapshots__/Subscription-test.tsx.snap b/server/sonar-web/src/main/js/apps/portfolio/components/__tests__/__snapshots__/Subscription-test.tsx.snap
deleted file mode 100644
index a3df93ebe6e..00000000000
--- a/server/sonar-web/src/main/js/apps/portfolio/components/__tests__/__snapshots__/Subscription-test.tsx.snap
+++ /dev/null
@@ -1,27 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`renders when no email 1`] = `
-<span
- className="text-muted-2"
->
- report.no_email_to_subscribe
-</span>
-`;
-
-exports[`renders when not subscribed 1`] = `
-<a
- href="#"
- onClick={[Function]}
->
- report.subscribe_x.report.frequency.montly
-</a>
-`;
-
-exports[`renders when subscribed 1`] = `
-<a
- href="#"
- onClick={[Function]}
->
- report.unsubscribe_x.report.frequency.montly
-</a>
-`;
diff --git a/server/sonar-web/src/main/js/components/controls/ComponentReportActions.tsx b/server/sonar-web/src/main/js/components/controls/ComponentReportActions.tsx
new file mode 100644
index 00000000000..84e7be44554
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/controls/ComponentReportActions.tsx
@@ -0,0 +1,140 @@
+/*
+ * 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 { connect } from 'react-redux';
+import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n';
+import {
+ getReportStatus,
+ subscribeToEmailReport,
+ unsubscribeFromEmailReport
+} from '../../api/component-report';
+import addGlobalSuccessMessage from '../../app/utils/addGlobalSuccessMessage';
+import { isLoggedIn } from '../../helpers/users';
+import { Store } from '../../store/rootReducer';
+import { Branch } from '../../types/branch-like';
+import { ComponentQualifier } from '../../types/component';
+import { ComponentReportStatus } from '../../types/component-report';
+import { withCurrentUser } from '../hoc/withCurrentUser';
+import ComponentReportActionsRenderer from './ComponentReportActionsRenderer';
+
+interface Props {
+ appState: Pick<T.AppState, 'qualifiers'>;
+ component: T.Component;
+ branch?: Branch;
+ currentUser: T.CurrentUser;
+}
+
+interface State {
+ loadingStatus?: boolean;
+ status?: ComponentReportStatus;
+}
+
+export class ComponentReportActions extends React.PureComponent<Props, State> {
+ mounted = false;
+ state: State = {};
+
+ componentDidMount() {
+ this.mounted = true;
+ const governanceEnabled = this.props.appState.qualifiers.includes(ComponentQualifier.Portfolio);
+ if (governanceEnabled) {
+ this.loadReportStatus();
+ }
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ loadReportStatus = async () => {
+ const { component, branch } = this.props;
+
+ const status = await getReportStatus(component.key, branch?.name).catch(() => undefined);
+
+ if (this.mounted) {
+ this.setState({ status, loadingStatus: false });
+ }
+ };
+
+ handleSubscription = (subscribed: boolean) => {
+ const { component } = this.props;
+ const { status } = this.state;
+
+ const translationKey = subscribed
+ ? 'component_report.subscribe_x_success'
+ : 'component_report.unsubscribe_x_success';
+ const frequencyTranslation = translate(
+ 'report.frequency',
+ status?.componentFrequency || status?.globalFrequency || ''
+ ).toLowerCase();
+ const qualifierTranslation = translate('qualifier', component.qualifier).toLowerCase();
+
+ addGlobalSuccessMessage(
+ translateWithParameters(translationKey, frequencyTranslation, qualifierTranslation)
+ );
+
+ this.loadReportStatus();
+ };
+
+ handleSubscribe = async () => {
+ const { component, branch } = this.props;
+
+ await subscribeToEmailReport(component.key, branch?.name);
+
+ this.handleSubscription(true);
+ };
+
+ handleUnsubscribe = async () => {
+ const { component, branch } = this.props;
+
+ await unsubscribeFromEmailReport(component.key, branch?.name);
+
+ this.handleSubscription(false);
+ };
+
+ render() {
+ const { currentUser, component, branch } = this.props;
+ const { status, loadingStatus } = this.state;
+
+ if (loadingStatus || !status || (branch && !branch.excludedFromPurge)) {
+ return null;
+ }
+
+ const currentUserHasEmail = isLoggedIn(currentUser) && !!currentUser.email;
+
+ return (
+ <ComponentReportActionsRenderer
+ branch={branch}
+ component={component}
+ frequency={status.componentFrequency || status.globalFrequency}
+ subscribed={status.subscribed}
+ canSubscribe={status.canSubscribe}
+ currentUserHasEmail={currentUserHasEmail}
+ handleSubscription={this.handleSubscribe}
+ handleUnsubscription={this.handleUnsubscribe}
+ />
+ );
+ }
+}
+
+const mapStateToProps = (state: Store) => ({
+ appState: state.appState
+});
+
+export default withCurrentUser(connect(mapStateToProps)(ComponentReportActions));
diff --git a/server/sonar-web/src/main/js/components/controls/ComponentReportActionsRenderer.tsx b/server/sonar-web/src/main/js/components/controls/ComponentReportActionsRenderer.tsx
new file mode 100644
index 00000000000..b08035d5325
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/controls/ComponentReportActionsRenderer.tsx
@@ -0,0 +1,100 @@
+/*
+ * 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 } from 'sonar-ui-common/components/controls/buttons';
+import Dropdown from 'sonar-ui-common/components/controls/Dropdown';
+import DropdownIcon from 'sonar-ui-common/components/icons/DropdownIcon';
+import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n';
+import { getReportUrl } from '../../api/component-report';
+import { Branch } from '../../types/branch-like';
+import { isPortfolioLike } from '../../types/component';
+
+export interface ComponentReportActionsRendererProps {
+ component: T.Component;
+ branch?: Branch;
+ frequency: string;
+ subscribed: boolean;
+ canSubscribe: boolean;
+ currentUserHasEmail: boolean;
+ handleSubscription: () => void;
+ handleUnsubscription: () => void;
+}
+
+export default function ComponentReportActionsRenderer(props: ComponentReportActionsRendererProps) {
+ const { branch, component, frequency, subscribed, canSubscribe, currentUserHasEmail } = props;
+
+ const renderDownloadButton = (simple = false) => {
+ return (
+ <a
+ download={[component.name, branch?.name, 'PDF Report.pdf'].filter(s => !!s).join(' - ')}
+ href={getReportUrl(component.key, branch?.name)}
+ target="_blank"
+ rel="noopener noreferrer">
+ {simple
+ ? translate('download_verb')
+ : translateWithParameters(
+ 'component_report.download',
+ translate('qualifier', component.qualifier).toLowerCase()
+ )}
+ </a>
+ );
+ };
+
+ const renderSubscriptionButton = () => {
+ if (!currentUserHasEmail) {
+ return (
+ <span className="text-muted-2">{translate('component_report.no_email_to_subscribe')}</span>
+ );
+ }
+
+ const translationKey = subscribed
+ ? 'component_report.unsubscribe_x'
+ : 'component_report.subscribe_x';
+ const onClickHandler = subscribed ? props.handleUnsubscription : props.handleSubscription;
+ const frequencyTranslation = translate('report.frequency', frequency).toLowerCase();
+
+ return (
+ <a href="#" onClick={onClickHandler}>
+ {translateWithParameters(translationKey, frequencyTranslation)}
+ </a>
+ );
+ };
+
+ return canSubscribe && isPortfolioLike(component.qualifier) ? (
+ <Dropdown
+ overlay={
+ <ul className="menu">
+ <li>{renderDownloadButton(true)}</li>
+ <li>{renderSubscriptionButton()}</li>
+ </ul>
+ }>
+ <Button className="dropdown-toggle">
+ {translateWithParameters(
+ 'component_report.report',
+ translate('qualifier', component.qualifier)
+ )}
+ <DropdownIcon className="spacer-left icon-half-transparent" />
+ </Button>
+ </Dropdown>
+ ) : (
+ renderDownloadButton()
+ );
+}
diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/ComponentReportActions-test.tsx b/server/sonar-web/src/main/js/components/controls/__tests__/ComponentReportActions-test.tsx
new file mode 100644
index 00000000000..844b4d78cb9
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/controls/__tests__/ComponentReportActions-test.tsx
@@ -0,0 +1,127 @@
+/*
+ * 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 } from 'enzyme';
+import * as React from 'react';
+import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
+import {
+ getReportStatus,
+ subscribeToEmailReport,
+ unsubscribeFromEmailReport
+} from '../../../api/component-report';
+import addGlobalSuccessMessage from '../../../app/utils/addGlobalSuccessMessage';
+import { mockBranch } from '../../../helpers/mocks/branch-like';
+import { mockComponentReportStatus } from '../../../helpers/mocks/component-report';
+import { mockComponent, mockCurrentUser } from '../../../helpers/testMocks';
+import { ComponentQualifier } from '../../../types/component';
+import { ComponentReportActions } from '../ComponentReportActions';
+
+jest.mock('../../../api/component-report', () => ({
+ ...jest.requireActual('../../../api/component-report'),
+ getReportStatus: jest
+ .fn()
+ .mockResolvedValue(
+ jest.requireActual('../../../helpers/mocks/component-report').mockComponentReportStatus()
+ ),
+ subscribeToEmailReport: jest.fn().mockResolvedValue(undefined),
+ unsubscribeFromEmailReport: jest.fn().mockResolvedValue(undefined)
+}));
+
+jest.mock('../../../helpers/system', () => ({
+ ...jest.requireActual('../../../helpers/system'),
+ getBaseUrl: jest.fn().mockReturnValue('baseUrl')
+}));
+
+jest.mock('../../../app/utils/addGlobalSuccessMessage', () => ({ default: jest.fn() }));
+
+beforeEach(jest.clearAllMocks);
+
+it('should not render anything', async () => {
+ // loading
+ expect(shallowRender().type()).toBeNull();
+
+ // No status
+ (getReportStatus as jest.Mock).mockResolvedValueOnce(undefined);
+ const w1 = shallowRender();
+ await waitAndUpdate(w1);
+ expect(w1.type()).toBeNull();
+
+ // Branch purgeable
+ const w2 = shallowRender({ branch: mockBranch({ excludedFromPurge: false }) });
+ await waitAndUpdate(w2);
+ expect(w2.type()).toBeNull();
+
+ // no governance
+ const w3 = shallowRender({ appState: { qualifiers: [] } });
+ await waitAndUpdate(w3);
+ expect(w3.type()).toBeNull();
+});
+
+it('should call for status properly', async () => {
+ const component = mockComponent();
+ const branch = mockBranch();
+
+ const wrapper = shallowRender({ component, branch });
+
+ await waitAndUpdate(wrapper);
+
+ expect(getReportStatus).toHaveBeenCalledWith(component.key, branch.name);
+});
+
+it('should handle subscription', async () => {
+ const component = mockComponent();
+ const branch = mockBranch();
+ const wrapper = shallowRender({ component, branch });
+
+ await wrapper.instance().handleSubscribe();
+
+ expect(subscribeToEmailReport).toHaveBeenCalledWith(component.key, branch.name);
+ expect(addGlobalSuccessMessage).toHaveBeenCalledWith(
+ 'component_report.subscribe_x_success.report.frequency..qualifier.trk'
+ );
+});
+
+it('should handle unsubscription', async () => {
+ const component = mockComponent();
+ const branch = mockBranch();
+ const wrapper = shallowRender({ component, branch });
+
+ await waitAndUpdate(wrapper);
+
+ wrapper.setState({ status: mockComponentReportStatus({ componentFrequency: 'compfreq' }) });
+
+ await wrapper.instance().handleUnsubscribe();
+
+ expect(unsubscribeFromEmailReport).toHaveBeenCalledWith(component.key, branch.name);
+ expect(addGlobalSuccessMessage).toHaveBeenCalledWith(
+ 'component_report.unsubscribe_x_success.report.frequency.compfreq.qualifier.trk'
+ );
+});
+
+function shallowRender(props: Partial<ComponentReportActions['props']> = {}) {
+ return shallow<ComponentReportActions>(
+ <ComponentReportActions
+ appState={{ qualifiers: [ComponentQualifier.Portfolio] }}
+ component={mockComponent()}
+ currentUser={mockCurrentUser()}
+ {...props}
+ />
+ );
+}
diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/ComponentReportActionsRenderer-test.tsx b/server/sonar-web/src/main/js/components/controls/__tests__/ComponentReportActionsRenderer-test.tsx
new file mode 100644
index 00000000000..76144927f72
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/controls/__tests__/ComponentReportActionsRenderer-test.tsx
@@ -0,0 +1,56 @@
+/*
+ * 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 } from 'enzyme';
+import * as React from 'react';
+import { mockComponent } from '../../../helpers/testMocks';
+import { ComponentQualifier } from '../../../types/component';
+import ComponentReportActionsRenderer, {
+ ComponentReportActionsRendererProps
+} from '../ComponentReportActionsRenderer';
+
+it('should render correctly', () => {
+ expect(shallowRender({ canSubscribe: false })).toMatchSnapshot('cannot subscribe');
+ expect(shallowRender({ canSubscribe: true, subscribed: false })).toMatchSnapshot(
+ 'can subscribe, not subscribed'
+ );
+ expect(shallowRender({ canSubscribe: true, subscribed: true })).toMatchSnapshot(
+ 'can subscribe, subscribed'
+ );
+ expect(shallowRender({ canSubscribe: true, currentUserHasEmail: false })).toMatchSnapshot(
+ 'current user without email'
+ );
+ expect(shallowRender({ component: mockComponent() })).toMatchSnapshot('not a portfolio');
+});
+
+function shallowRender(props: Partial<ComponentReportActionsRendererProps> = {}) {
+ return shallow<ComponentReportActionsRendererProps>(
+ <ComponentReportActionsRenderer
+ component={mockComponent({ qualifier: ComponentQualifier.Portfolio })}
+ canSubscribe={true}
+ subscribed={false}
+ currentUserHasEmail={true}
+ frequency="weekly"
+ handleSubscription={jest.fn()}
+ handleUnsubscription={jest.fn()}
+ {...props}
+ />
+ );
+}
diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/ComponentReportActionsRenderer-test.tsx.snap b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/ComponentReportActionsRenderer-test.tsx.snap
new file mode 100644
index 00000000000..df37dbbcc68
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/ComponentReportActionsRenderer-test.tsx.snap
@@ -0,0 +1,136 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly: can subscribe, not subscribed 1`] = `
+<Dropdown
+ overlay={
+ <ul
+ className="menu"
+ >
+ <li>
+ <a
+ download="MyProject - PDF Report.pdf"
+ href="/api/governance_reports/download?componentKey=my-project"
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+ download_verb
+ </a>
+ </li>
+ <li>
+ <a
+ href="#"
+ onClick={[MockFunction]}
+ >
+ component_report.subscribe_x.report.frequency.weekly
+ </a>
+ </li>
+ </ul>
+ }
+>
+ <Button
+ className="dropdown-toggle"
+ >
+ component_report.report.qualifier.VW
+ <DropdownIcon
+ className="spacer-left icon-half-transparent"
+ />
+ </Button>
+</Dropdown>
+`;
+
+exports[`should render correctly: can subscribe, subscribed 1`] = `
+<Dropdown
+ overlay={
+ <ul
+ className="menu"
+ >
+ <li>
+ <a
+ download="MyProject - PDF Report.pdf"
+ href="/api/governance_reports/download?componentKey=my-project"
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+ download_verb
+ </a>
+ </li>
+ <li>
+ <a
+ href="#"
+ onClick={[MockFunction]}
+ >
+ component_report.unsubscribe_x.report.frequency.weekly
+ </a>
+ </li>
+ </ul>
+ }
+>
+ <Button
+ className="dropdown-toggle"
+ >
+ component_report.report.qualifier.VW
+ <DropdownIcon
+ className="spacer-left icon-half-transparent"
+ />
+ </Button>
+</Dropdown>
+`;
+
+exports[`should render correctly: cannot subscribe 1`] = `
+<a
+ download="MyProject - PDF Report.pdf"
+ href="/api/governance_reports/download?componentKey=my-project"
+ rel="noopener noreferrer"
+ target="_blank"
+>
+ component_report.download.qualifier.vw
+</a>
+`;
+
+exports[`should render correctly: current user without email 1`] = `
+<Dropdown
+ overlay={
+ <ul
+ className="menu"
+ >
+ <li>
+ <a
+ download="MyProject - PDF Report.pdf"
+ href="/api/governance_reports/download?componentKey=my-project"
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+ download_verb
+ </a>
+ </li>
+ <li>
+ <span
+ className="text-muted-2"
+ >
+ component_report.no_email_to_subscribe
+ </span>
+ </li>
+ </ul>
+ }
+>
+ <Button
+ className="dropdown-toggle"
+ >
+ component_report.report.qualifier.VW
+ <DropdownIcon
+ className="spacer-left icon-half-transparent"
+ />
+ </Button>
+</Dropdown>
+`;
+
+exports[`should render correctly: not a portfolio 1`] = `
+<a
+ download="MyProject - PDF Report.pdf"
+ href="/api/governance_reports/download?componentKey=my-project"
+ rel="noopener noreferrer"
+ target="_blank"
+>
+ component_report.download.qualifier.trk
+</a>
+`;
diff --git a/server/sonar-web/src/main/js/helpers/mocks/component-report.ts b/server/sonar-web/src/main/js/helpers/mocks/component-report.ts
new file mode 100644
index 00000000000..0d1d987d084
--- /dev/null
+++ b/server/sonar-web/src/main/js/helpers/mocks/component-report.ts
@@ -0,0 +1,36 @@
+/*
+ * 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 { ComponentReportStatus } from '../../types/component-report';
+
+export function mockComponentReportStatus(
+ props?: Partial<ComponentReportStatus>
+): ComponentReportStatus {
+ return {
+ canAdmin: true,
+ canDownload: true,
+ canSubscribe: true,
+ componentRecipients: [],
+ globalFrequency: '',
+ globalRecipients: [],
+ subscribed: false,
+ ...props
+ };
+}
diff --git a/server/sonar-web/src/main/js/types/component-report.ts b/server/sonar-web/src/main/js/types/component-report.ts
new file mode 100644
index 00000000000..24024a4f370
--- /dev/null
+++ b/server/sonar-web/src/main/js/types/component-report.ts
@@ -0,0 +1,30 @@
+/*
+ * 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.
+ */
+
+export interface ComponentReportStatus {
+ canAdmin: boolean;
+ canDownload: boolean;
+ canSubscribe: boolean;
+ componentFrequency?: string;
+ componentRecipients: Array<string>;
+ globalFrequency: string;
+ globalRecipients: Array<string>;
+ subscribed: boolean;
+}