Browse Source

SONAR-15138 Add report download and subscription buttons for projects and applications

tags/9.1.0.47736
Philippe Perrin 2 years ago
parent
commit
adc1fc3167
19 changed files with 1036 additions and 498 deletions
  1. 25
    17
      server/sonar-web/src/main/js/api/component-report.ts
  2. 5
    1
      server/sonar-web/src/main/js/apps/overview/branches/MeasuresPanel.tsx
  3. 363
    36
      server/sonar-web/src/main/js/apps/overview/branches/__tests__/__snapshots__/MeasuresPanel-test.tsx.snap
  4. 3
    2
      server/sonar-web/src/main/js/apps/portfolio/components/App.tsx
  5. 0
    112
      server/sonar-web/src/main/js/apps/portfolio/components/Report.tsx
  6. 0
    89
      server/sonar-web/src/main/js/apps/portfolio/components/Subscription.tsx
  7. 0
    55
      server/sonar-web/src/main/js/apps/portfolio/components/__tests__/Report-test.tsx
  8. 0
    108
      server/sonar-web/src/main/js/apps/portfolio/components/__tests__/Subscription-test.tsx
  9. 1
    1
      server/sonar-web/src/main/js/apps/portfolio/components/__tests__/__snapshots__/App-test.tsx.snap
  10. 0
    49
      server/sonar-web/src/main/js/apps/portfolio/components/__tests__/__snapshots__/Report-test.tsx.snap
  11. 0
    27
      server/sonar-web/src/main/js/apps/portfolio/components/__tests__/__snapshots__/Subscription-test.tsx.snap
  12. 140
    0
      server/sonar-web/src/main/js/components/controls/ComponentReportActions.tsx
  13. 100
    0
      server/sonar-web/src/main/js/components/controls/ComponentReportActionsRenderer.tsx
  14. 127
    0
      server/sonar-web/src/main/js/components/controls/__tests__/ComponentReportActions-test.tsx
  15. 56
    0
      server/sonar-web/src/main/js/components/controls/__tests__/ComponentReportActionsRenderer-test.tsx
  16. 136
    0
      server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/ComponentReportActionsRenderer-test.tsx.snap
  17. 36
    0
      server/sonar-web/src/main/js/helpers/mocks/component-report.ts
  18. 30
    0
      server/sonar-web/src/main/js/types/component-report.ts
  19. 14
    1
      sonar-core/src/main/resources/org/sonar/l10n/core.properties

server/sonar-web/src/main/js/api/report.ts → server/sonar-web/src/main/js/api/component-report.ts View File

@@ -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
);
}

+ 5
- 1
server/sonar-web/src/main/js/apps/overview/branches/MeasuresPanel.tsx View File

@@ -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">

+ 363
- 36
server/sonar-web/src/main/js/apps/overview/branches/__tests__/__snapshots__/MeasuresPanel-test.tsx.snap View File

@@ -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}

+ 3
- 2
server/sonar-web/src/main/js/apps/portfolio/components/App.tsx View File

@@ -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}

+ 0
- 112
server/sonar-web/src/main/js/apps/portfolio/components/Report.tsx View File

@@ -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>
);
}
}

+ 0
- 89
server/sonar-web/src/main/js/apps/portfolio/components/Subscription.tsx View File

@@ -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);

+ 0
- 55
server/sonar-web/src/main/js/apps/portfolio/components/__tests__/Report-test.tsx View File

@@ -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');
});

+ 0
- 108
server/sonar-web/src/main/js/apps/portfolio/components/__tests__/Subscription-test.tsx View File

@@ -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}
/>
);
}

+ 1
- 1
server/sonar-web/src/main/js/apps/portfolio/components/__tests__/__snapshots__/App-test.tsx.snap View File

@@ -7,7 +7,7 @@ exports[`renders 1`] = `
<div
className="page-actions"
>
<Report
<Connect(withCurrentUser(Connect(ComponentReportActions)))
component={
Object {
"description": "accurate description",

+ 0
- 49
server/sonar-web/src/main/js/apps/portfolio/components/__tests__/__snapshots__/Report-test.tsx.snap View File

@@ -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>
`;

+ 0
- 27
server/sonar-web/src/main/js/apps/portfolio/components/__tests__/__snapshots__/Subscription-test.tsx.snap View File

@@ -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>
`;

+ 140
- 0
server/sonar-web/src/main/js/components/controls/ComponentReportActions.tsx View File

@@ -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));

+ 100
- 0
server/sonar-web/src/main/js/components/controls/ComponentReportActionsRenderer.tsx View File

@@ -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()
);
}

+ 127
- 0
server/sonar-web/src/main/js/components/controls/__tests__/ComponentReportActions-test.tsx View File

@@ -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}
/>
);
}

+ 56
- 0
server/sonar-web/src/main/js/components/controls/__tests__/ComponentReportActionsRenderer-test.tsx View File

@@ -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}
/>
);
}

+ 136
- 0
server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/ComponentReportActionsRenderer-test.tsx.snap View File

@@ -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>
`;

+ 36
- 0
server/sonar-web/src/main/js/helpers/mocks/component-report.ts View File

@@ -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
};
}

+ 30
- 0
server/sonar-web/src/main/js/types/component-report.ts View File

@@ -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;
}

+ 14
- 1
sonar-core/src/main/resources/org/sonar/l10n/core.properties View File

@@ -3937,7 +3937,6 @@ portfolio.activity_link=Activity
portfolio.measures_link=Measures
portfolio.language_breakdown_link=Language breakdown
portfolio.breakdown=Portfolio breakdown
portfolio.pdf_report=Portfolio PDF Report
portfolio.number_of_projects=Number of projects
portfolio.number_of_lines=Number of lines of code

@@ -4168,3 +4167,17 @@ webhooks.url.bad_format=Bad format of URL.
webhooks.url.bad_protocol=URL must start with "http://" or "https://".
webhooks.url.description=Server endpoint that will receive the webhook payload, for example: "http://my_server/foo". If HTTP Basic authentication is used, HTTPS is recommended to avoid man in the middle attacks. Example: "https://myLogin:myPassword@my_server/foo"
webhooks.url.required=URL is required.


#------------------------------------------------------------------------------
#
# COMPONENT REPORT
#
#------------------------------------------------------------------------------
component_report.report={0} PDF report
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.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.

Loading…
Cancel
Save