@@ -0,0 +1,27 @@ | |||
/* | |||
* 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 throwGlobalError from '../app/utils/throwGlobalError'; | |||
import { getJSON } from '../helpers/request'; | |||
export function getProjectBadgesToken(project: string) { | |||
return getJSON('/api/project_badges/token', { project }) | |||
.then(({ token }) => token) | |||
.catch(throwGlobalError); | |||
} |
@@ -90,7 +90,6 @@ export class ProjectInformation extends React.PureComponent<Props, State> { | |||
isLoggedIn(currentUser) && component.qualifier === ComponentQualifier.Project; | |||
const canUseBadges = | |||
metrics !== undefined && | |||
component.visibility !== 'private' && | |||
(component.qualifier === ComponentQualifier.Application || | |||
component.qualifier === ComponentQualifier.Project); | |||
@@ -22,6 +22,8 @@ import * as React from 'react'; | |||
import { mockComponent } from '../../../../../../helpers/mocks/component'; | |||
import { mockCurrentUser, mockLoggedInUser, mockMetric } from '../../../../../../helpers/testMocks'; | |||
import { waitAndUpdate } from '../../../../../../helpers/testUtils'; | |||
import { ComponentQualifier } from '../../../../../../types/component'; | |||
import ProjectBadges from '../badges/ProjectBadges'; | |||
import { ProjectInformation } from '../ProjectInformation'; | |||
import { ProjectInformationPages } from '../ProjectInformationPages'; | |||
@@ -53,6 +55,17 @@ it('should handle page change', async () => { | |||
expect(wrapper.state().page).toBe(ProjectInformationPages.badges); | |||
}); | |||
it('should display badge', () => { | |||
const wrapper = shallowRender({ | |||
component: mockComponent({ qualifier: ComponentQualifier.Project }) | |||
}); | |||
expect(wrapper.find(ProjectBadges).type).toBeDefined(); | |||
wrapper.setProps({ component: mockComponent({ qualifier: ComponentQualifier.Application }) }); | |||
expect(wrapper.find(ProjectBadges).type).toBeDefined(); | |||
}); | |||
function shallowRender(props: Partial<ProjectInformation['props']> = {}) { | |||
return shallow<ProjectInformation>( | |||
<ProjectInformation |
@@ -203,7 +203,7 @@ exports[`should render correctly: private 1`] = ` | |||
<Fragment> | |||
<Memo(ProjectInformationRenderer) | |||
canConfigureNotifications={false} | |||
canUseBadges={false} | |||
canUseBadges={true} | |||
component={ | |||
Object { | |||
"breadcrumbs": Array [], | |||
@@ -230,5 +230,24 @@ exports[`should render correctly: private 1`] = ` | |||
onComponentChange={[MockFunction]} | |||
onPageChange={[Function]} | |||
/> | |||
<InfoDrawerPage | |||
displayed={false} | |||
onPageChange={[Function]} | |||
> | |||
<ProjectBadges | |||
metrics={ | |||
Object { | |||
"coverage": Object { | |||
"id": "coverage", | |||
"key": "coverage", | |||
"name": "Coverage", | |||
"type": "PERCENT", | |||
}, | |||
} | |||
} | |||
project="my-project" | |||
qualifier="TRK" | |||
/> | |||
</InfoDrawerPage> | |||
</Fragment> | |||
`; |
@@ -22,7 +22,7 @@ import * as React from 'react'; | |||
import { fetchWebApi } from '../../../../../../api/web-api'; | |||
import Select from '../../../../../../components/controls/Select'; | |||
import { getLocalizedMetricName, translate } from '../../../../../../helpers/l10n'; | |||
import { BadgeColors, BadgeFormats, BadgeOptions, BadgeType } from './utils'; | |||
import { BadgeFormats, BadgeOptions, BadgeType } from './utils'; | |||
interface Props { | |||
className?: string; | |||
@@ -90,10 +90,6 @@ export default class BadgeParams extends React.PureComponent<Props> { | |||
}); | |||
}; | |||
handleColorChange = ({ value }: { value: BadgeColors }) => { | |||
this.props.updateOptions({ color: value }); | |||
}; | |||
handleFormatChange = ({ value }: { value: BadgeFormats }) => { | |||
this.props.updateOptions({ format: value }); | |||
}; | |||
@@ -103,24 +99,7 @@ export default class BadgeParams extends React.PureComponent<Props> { | |||
}; | |||
renderBadgeType = (type: BadgeType, options: BadgeOptions) => { | |||
if (type === BadgeType.marketing) { | |||
return ( | |||
<> | |||
<label className="spacer-right" htmlFor="badge-color"> | |||
{translate('color')}: | |||
</label> | |||
<Select | |||
className="input-medium" | |||
clearable={false} | |||
name="badge-color" | |||
onChange={this.handleColorChange} | |||
options={this.getColorOptions()} | |||
searchable={false} | |||
value={options.color} | |||
/> | |||
</> | |||
); | |||
} else if (type === BadgeType.measure) { | |||
if (type === BadgeType.measure) { | |||
return ( | |||
<> | |||
<label className="spacer-right" htmlFor="badge-metric"> |
@@ -18,7 +18,9 @@ | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import * as React from 'react'; | |||
import { getProjectBadgesToken } from '../../../../../../api/project-badges'; | |||
import CodeSnippet from '../../../../../../components/common/CodeSnippet'; | |||
import { Alert } from '../../../../../../components/ui/Alert'; | |||
import { getBranchLikeQuery } from '../../../../../../helpers/branch-like'; | |||
import { translate } from '../../../../../../helpers/l10n'; | |||
import { BranchLike } from '../../../../../../types/branch-like'; | |||
@@ -36,16 +38,36 @@ interface Props { | |||
} | |||
interface State { | |||
token: string; | |||
selectedType: BadgeType; | |||
badgeOptions: BadgeOptions; | |||
} | |||
export default class ProjectBadges extends React.PureComponent<Props, State> { | |||
mounted = false; | |||
state: State = { | |||
token: '', | |||
selectedType: BadgeType.measure, | |||
badgeOptions: { color: 'white', metric: MetricKey.alert_status } | |||
badgeOptions: { metric: MetricKey.alert_status } | |||
}; | |||
componentDidMount() { | |||
this.mounted = true; | |||
this.fetchToken(); | |||
} | |||
componentWillUnmount() { | |||
this.mounted = false; | |||
} | |||
async fetchToken() { | |||
const { project } = this.props; | |||
const token = await getProjectBadgesToken(project); | |||
if (this.mounted) { | |||
this.setState({ token }); | |||
} | |||
} | |||
handleSelectBadge = (selectedType: BadgeType) => { | |||
this.setState({ selectedType }); | |||
}; | |||
@@ -56,7 +78,7 @@ export default class ProjectBadges extends React.PureComponent<Props, State> { | |||
render() { | |||
const { branchLike, project, qualifier } = this.props; | |||
const { selectedType, badgeOptions } = this.state; | |||
const { selectedType, badgeOptions, token } = this.state; | |||
const fullBadgeOptions = { project, ...badgeOptions, ...getBranchLikeQuery(branchLike) }; | |||
return ( | |||
@@ -67,7 +89,7 @@ export default class ProjectBadges extends React.PureComponent<Props, State> { | |||
onClick={this.handleSelectBadge} | |||
selected={BadgeType.measure === selectedType} | |||
type={BadgeType.measure} | |||
url={getBadgeUrl(BadgeType.measure, fullBadgeOptions)} | |||
url={getBadgeUrl(BadgeType.measure, fullBadgeOptions, token)} | |||
/> | |||
<p className="huge-spacer-bottom spacer-top"> | |||
{translate('overview.badges', BadgeType.measure, 'description', qualifier)} | |||
@@ -76,7 +98,7 @@ export default class ProjectBadges extends React.PureComponent<Props, State> { | |||
onClick={this.handleSelectBadge} | |||
selected={BadgeType.qualityGate === selectedType} | |||
type={BadgeType.qualityGate} | |||
url={getBadgeUrl(BadgeType.qualityGate, fullBadgeOptions)} | |||
url={getBadgeUrl(BadgeType.qualityGate, fullBadgeOptions, token)} | |||
/> | |||
<p className="huge-spacer-bottom spacer-top"> | |||
{translate('overview.badges', BadgeType.qualityGate, 'description', qualifier)} | |||
@@ -88,7 +110,11 @@ export default class ProjectBadges extends React.PureComponent<Props, State> { | |||
type={selectedType} | |||
updateOptions={this.handleUpdateOptions} | |||
/> | |||
<CodeSnippet isOneLine={true} snippet={getBadgeSnippet(selectedType, fullBadgeOptions)} /> | |||
<Alert variant="warning">{translate('overview.badges.leak_warning')}</Alert> | |||
<CodeSnippet | |||
isOneLine={true} | |||
snippet={getBadgeSnippet(selectedType, fullBadgeOptions, token)} | |||
/> | |||
</div> | |||
); | |||
} |
@@ -33,7 +33,7 @@ it('should return the badge type on click', () => { | |||
const onClick = jest.fn(); | |||
const wrapper = getWrapper({ onClick }); | |||
click(wrapper.find('Button')); | |||
expect(onClick).toHaveBeenCalledWith(BadgeType.marketing); | |||
expect(onClick).toHaveBeenCalledWith(BadgeType.qualityGate); | |||
}); | |||
function getWrapper(props = {}) { | |||
@@ -41,7 +41,7 @@ function getWrapper(props = {}) { | |||
<BadgeButton | |||
onClick={jest.fn()} | |||
selected={false} | |||
type={BadgeType.marketing} | |||
type={BadgeType.qualityGate} | |||
url="http://foo.bar" | |||
{...props} | |||
/> |
@@ -42,14 +42,6 @@ const METRICS = { | |||
coverage: { key: 'coverage', name: 'Coverage' } as T.Metric | |||
}; | |||
it('should display marketing badge params', () => { | |||
const updateOptions = jest.fn(); | |||
const wrapper = getWrapper({ updateOptions }); | |||
expect(wrapper).toMatchSnapshot(); | |||
(wrapper.instance() as BadgeParams).handleColorChange({ value: 'black' }); | |||
expect(updateOptions).toHaveBeenCalledWith({ color: 'black' }); | |||
}); | |||
it('should display measure badge params', () => { | |||
const updateOptions = jest.fn(); | |||
const wrapper = getWrapper({ updateOptions, type: BadgeType.measure }); | |||
@@ -70,8 +62,8 @@ function getWrapper(props = {}) { | |||
return shallow( | |||
<BadgeParams | |||
metrics={METRICS} | |||
options={{ color: 'white', metric: 'alert_status' }} | |||
type={BadgeType.marketing} | |||
options={{ metric: 'alert_status' }} | |||
type={BadgeType.measure} | |||
updateOptions={jest.fn()} | |||
{...props} | |||
/> |
@@ -21,6 +21,7 @@ import { shallow } from 'enzyme'; | |||
import * as React from 'react'; | |||
import { mockBranch } from '../../../../../../../helpers/mocks/branch-like'; | |||
import { mockMetric } from '../../../../../../../helpers/testMocks'; | |||
import { waitAndUpdate } from '../../../../../../../helpers/testUtils'; | |||
import { Location } from '../../../../../../../helpers/urls'; | |||
import { MetricKey } from '../../../../../../../types/metrics'; | |||
import ProjectBadges from '../ProjectBadges'; | |||
@@ -31,8 +32,14 @@ jest.mock('../../../../../../../helpers/urls', () => ({ | |||
getProjectUrl: () => ({ pathname: '/dashboard' } as Location) | |||
})); | |||
it('should display correctly', () => { | |||
expect(shallowRender()).toMatchSnapshot(); | |||
jest.mock('../../../../../../../api/project-badges', () => ({ | |||
getProjectBadgesToken: jest.fn().mockResolvedValue('foo') | |||
})); | |||
it('should display correctly', async () => { | |||
const wrapper = shallowRender(); | |||
await waitAndUpdate(wrapper); | |||
expect(wrapper).toMatchSnapshot(); | |||
}); | |||
function shallowRender(overrides = {}) { |
@@ -6,7 +6,7 @@ exports[`should display correctly 1`] = ` | |||
onClick={[Function]} | |||
> | |||
<img | |||
alt="overview.badges.marketing.alt" | |||
alt="overview.badges.quality_gate.alt" | |||
src="http://foo.bar" | |||
width="128px" | |||
/> | |||
@@ -19,7 +19,7 @@ exports[`should display correctly 2`] = ` | |||
onClick={[Function]} | |||
> | |||
<img | |||
alt="overview.badges.marketing.alt" | |||
alt="overview.badges.quality_gate.alt" | |||
src="http://foo.bar" | |||
width="128px" | |||
/> |
@@ -1,68 +1,5 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`should display marketing badge params 1`] = ` | |||
<div> | |||
<label | |||
className="spacer-right" | |||
htmlFor="badge-color" | |||
> | |||
color | |||
: | |||
</label> | |||
<Select | |||
className="input-medium" | |||
clearable={false} | |||
name="badge-color" | |||
onChange={[Function]} | |||
options={ | |||
Array [ | |||
Object { | |||
"label": "overview.badges.options.colors.white", | |||
"value": "white", | |||
}, | |||
Object { | |||
"label": "overview.badges.options.colors.black", | |||
"value": "black", | |||
}, | |||
Object { | |||
"label": "overview.badges.options.colors.orange", | |||
"value": "orange", | |||
}, | |||
] | |||
} | |||
searchable={false} | |||
value="white" | |||
/> | |||
<label | |||
className="spacer-right spacer-top" | |||
htmlFor="badge-format" | |||
> | |||
format | |||
: | |||
</label> | |||
<Select | |||
className="input-medium" | |||
clearable={false} | |||
name="badge-format" | |||
onChange={[Function]} | |||
options={ | |||
Array [ | |||
Object { | |||
"label": "overview.badges.options.formats.md", | |||
"value": "md", | |||
}, | |||
Object { | |||
"label": "overview.badges.options.formats.url", | |||
"value": "url", | |||
}, | |||
] | |||
} | |||
searchable={false} | |||
value="md" | |||
/> | |||
</div> | |||
`; | |||
exports[`should display measure badge params 1`] = ` | |||
<div> | |||
<label |
@@ -16,7 +16,7 @@ exports[`should display correctly 1`] = ` | |||
onClick={[Function]} | |||
selected={true} | |||
type="measure" | |||
url="host/api/project_badges/measure?branch=branch-6.7&project=foo&metric=alert_status" | |||
url="host/api/project_badges/measure?branch=branch-6.7&project=foo&metric=alert_status&token=foo" | |||
/> | |||
<p | |||
className="huge-spacer-bottom spacer-top" | |||
@@ -27,7 +27,7 @@ exports[`should display correctly 1`] = ` | |||
onClick={[Function]} | |||
selected={false} | |||
type="quality_gate" | |||
url="host/api/project_badges/quality_gate?branch=branch-6.7&project=foo" | |||
url="host/api/project_badges/quality_gate?branch=branch-6.7&project=foo&token=foo" | |||
/> | |||
<p | |||
className="huge-spacer-bottom spacer-top" | |||
@@ -54,16 +54,20 @@ exports[`should display correctly 1`] = ` | |||
} | |||
options={ | |||
Object { | |||
"color": "white", | |||
"metric": "alert_status", | |||
} | |||
} | |||
type="measure" | |||
updateOptions={[Function]} | |||
/> | |||
<Alert | |||
variant="warning" | |||
> | |||
overview.badges.leak_warning | |||
</Alert> | |||
<CodeSnippet | |||
isOneLine={true} | |||
snippet="[![alert_status](host/api/project_badges/measure?branch=branch-6.7&project=foo&metric=alert_status)](/dashboard)" | |||
snippet="[![alert_status](host/api/project_badges/measure?branch=branch-6.7&project=foo&metric=alert_status&token=foo)](/dashboard)" | |||
/> | |||
</div> | |||
`; |
@@ -29,44 +29,40 @@ jest.mock('../../../../../../../helpers/urls', () => ({ | |||
const options: BadgeOptions = { | |||
branch: 'master', | |||
color: 'white', | |||
metric: 'alert_status', | |||
project: 'foo' | |||
}; | |||
describe('#getBadgeUrl', () => { | |||
it('should generate correct marketing badge links', () => { | |||
expect(getBadgeUrl(BadgeType.marketing, options)).toBe( | |||
'host/images/project_badges/sonarcloud-white.svg' | |||
); | |||
expect(getBadgeUrl(BadgeType.marketing, { ...options, color: 'orange' })).toBe( | |||
'host/images/project_badges/sonarcloud-orange.svg' | |||
); | |||
}); | |||
it('should generate correct quality gate badge links', () => { | |||
expect(getBadgeUrl(BadgeType.qualityGate, options)).toBe( | |||
'host/api/project_badges/quality_gate?branch=master&project=foo' | |||
expect(getBadgeUrl(BadgeType.qualityGate, options, 'foo')).toBe( | |||
'host/api/project_badges/quality_gate?branch=master&project=foo&token=foo' | |||
); | |||
}); | |||
it('should generate correct measures badge links', () => { | |||
expect(getBadgeUrl(BadgeType.measure, options)).toBe( | |||
'host/api/project_badges/measure?branch=master&project=foo&metric=alert_status' | |||
expect(getBadgeUrl(BadgeType.measure, options, 'foo')).toBe( | |||
'host/api/project_badges/measure?branch=master&project=foo&metric=alert_status&token=foo' | |||
); | |||
}); | |||
it('should ignore undefined parameters', () => { | |||
expect(getBadgeUrl(BadgeType.measure, { color: 'white', metric: 'alert_status' })).toBe( | |||
'host/api/project_badges/measure?metric=alert_status' | |||
expect(getBadgeUrl(BadgeType.measure, { metric: 'alert_status' }, 'foo')).toBe( | |||
'host/api/project_badges/measure?metric=alert_status&token=foo' | |||
); | |||
}); | |||
it('should force metric parameters', () => { | |||
expect(getBadgeUrl(BadgeType.measure, {}, 'foo')).toBe( | |||
'host/api/project_badges/measure?metric=alert_status&token=foo' | |||
); | |||
}); | |||
}); | |||
describe('#getBadgeSnippet', () => { | |||
it('should generate a correct markdown image', () => { | |||
expect(getBadgeSnippet(BadgeType.marketing, { ...options, format: 'md' })).toBe( | |||
'[![SonarCloud](host/images/project_badges/sonarcloud-white.svg)](host/dashboard?id=foo&branch=master)' | |||
expect(getBadgeSnippet(BadgeType.measure, { ...options, format: 'md' }, 'foo')).toBe( | |||
'[![alert_status](host/api/project_badges/measure?branch=master&project=foo&metric=alert_status&token=foo)](host/dashboard?id=foo&branch=master)' | |||
); | |||
}); | |||
}); |
@@ -26,7 +26,6 @@ export type BadgeFormats = 'md' | 'url'; | |||
export interface BadgeOptions { | |||
branch?: string; | |||
color?: BadgeColors; | |||
format?: BadgeFormats; | |||
project?: string; | |||
metric?: string; | |||
@@ -35,12 +34,11 @@ export interface BadgeOptions { | |||
export enum BadgeType { | |||
measure = 'measure', | |||
qualityGate = 'quality_gate', | |||
marketing = 'marketing' | |||
qualityGate = 'quality_gate' | |||
} | |||
export function getBadgeSnippet(type: BadgeType, options: BadgeOptions) { | |||
const url = getBadgeUrl(type, options); | |||
export function getBadgeSnippet(type: BadgeType, options: BadgeOptions, token: string) { | |||
const url = getBadgeUrl(type, options, token); | |||
const { branch, format = 'md', metric = 'alert_status', project } = options; | |||
if (format === 'url') { | |||
@@ -50,9 +48,6 @@ export function getBadgeSnippet(type: BadgeType, options: BadgeOptions) { | |||
let projectUrl; | |||
switch (type) { | |||
case BadgeType.marketing: | |||
label = 'SonarCloud'; | |||
break; | |||
case BadgeType.measure: | |||
label = getLocalizedMetricName({ key: metric }); | |||
break; | |||
@@ -73,19 +68,18 @@ export function getBadgeSnippet(type: BadgeType, options: BadgeOptions) { | |||
export function getBadgeUrl( | |||
type: BadgeType, | |||
{ branch, project, color = 'white', metric = 'alert_status', pullRequest }: BadgeOptions | |||
{ branch, project, metric = 'alert_status', pullRequest }: BadgeOptions, | |||
token: string | |||
) { | |||
switch (type) { | |||
case BadgeType.marketing: | |||
return `${getHostUrl()}/images/project_badges/sonarcloud-${color}.svg`; | |||
case BadgeType.qualityGate: | |||
return `${getHostUrl()}/api/project_badges/quality_gate?${new URLSearchParams( | |||
omitNil({ branch, project, pullRequest }) | |||
omitNil({ branch, project, pullRequest, token }) | |||
).toString()}`; | |||
case BadgeType.measure: | |||
default: | |||
return `${getHostUrl()}/api/project_badges/measure?${new URLSearchParams( | |||
omitNil({ branch, project, metric, pullRequest }) | |||
omitNil({ branch, project, metric, pullRequest, token }) | |||
).toString()}`; | |||
} | |||
} |
@@ -42,7 +42,8 @@ export default function CodeSnippet(props: CodeSnippetProps) { | |||
return ( | |||
<div className={classNames('code-snippet spacer-top spacer-bottom display-flex-row', {})}> | |||
<pre className="flex-1" ref={snippetRef}> | |||
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */} | |||
<pre className="flex-1" ref={snippetRef} tabIndex={0}> | |||
{finalSnippet} | |||
</pre> | |||
{!noCopy && <ClipboardButton copyValue={finalSnippet} />} |
@@ -6,6 +6,7 @@ exports[`renders correctly: array snippet 1`] = ` | |||
> | |||
<pre | |||
className="flex-1" | |||
tabIndex={0} | |||
> | |||
foo \\ | |||
bar | |||
@@ -23,6 +24,7 @@ exports[`renders correctly: default 1`] = ` | |||
> | |||
<pre | |||
className="flex-1" | |||
tabIndex={0} | |||
> | |||
foo | |||
bar | |||
@@ -40,6 +42,7 @@ exports[`renders correctly: no copy 1`] = ` | |||
> | |||
<pre | |||
className="flex-1" | |||
tabIndex={0} | |||
> | |||
foo | |||
bar | |||
@@ -53,6 +56,7 @@ exports[`renders correctly: single line with array snippet 1`] = ` | |||
> | |||
<pre | |||
className="flex-1" | |||
tabIndex={0} | |||
> | |||
foo bar | |||
</pre> |
@@ -3169,7 +3169,7 @@ overview.badges.quality_gate.description=Displays the current quality gate statu | |||
overview.badges.quality_gate.description.APP=Displays the current quality gate status of your application. | |||
overview.badges.quality_gate.description.TRK=Displays the current quality gate status of your project. | |||
overview.badges.quality_gate.description.VW=Displays the current quality gate status of your portfolio. | |||
overview.badges.leak_warning=Project badges can expose your security rating and other measures. Only use project badges in trusted environments. | |||
#------------------------------------------------------------------------------ | |||
# |