@@ -752,7 +752,7 @@ declare namespace T { | |||
export interface SourceLine { | |||
code?: string; | |||
conditions?: number; | |||
coverageStatus?: string; | |||
coverageStatus?: SourceLineCoverageStatus; | |||
coveredConditions?: number; | |||
duplicated?: boolean; | |||
isNew?: boolean; | |||
@@ -763,6 +763,8 @@ declare namespace T { | |||
scmRevision?: string; | |||
} | |||
export type SourceLineCoverageStatus = 'uncovered' | 'partially-covered' | 'covered'; | |||
export interface SourceViewerFile { | |||
canMarkAsFavorite?: boolean; | |||
fav?: boolean; |
@@ -1,184 +0,0 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2018 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 { groupBy } from 'lodash'; | |||
import * as PropTypes from 'prop-types'; | |||
import { getTests } from '../../../api/components'; | |||
import { DropdownOverlay } from '../../controls/Dropdown'; | |||
import TestStatusIcon from '../../icons-components/TestStatusIcon'; | |||
import { PopupPlacement } from '../../ui/popups'; | |||
import { WorkspaceContext } from '../../workspace/context'; | |||
import { | |||
isSameBranchLike, | |||
getBranchLikeQuery, | |||
isShortLivingBranch, | |||
isPullRequest | |||
} from '../../../helpers/branches'; | |||
import { translate } from '../../../helpers/l10n'; | |||
import { collapsePath } from '../../../helpers/path'; | |||
interface Props { | |||
branchLike: T.BranchLike | undefined; | |||
componentKey: string; | |||
line: T.SourceLine; | |||
onClose: () => void; | |||
} | |||
interface State { | |||
loading: boolean; | |||
testCases: T.TestCase[]; | |||
} | |||
export default class CoveragePopup extends React.PureComponent<Props, State> { | |||
context!: { workspace: WorkspaceContext }; | |||
mounted = false; | |||
static contextTypes = { | |||
workspace: PropTypes.object.isRequired | |||
}; | |||
state: State = { loading: true, testCases: [] }; | |||
componentDidMount() { | |||
this.mounted = true; | |||
this.fetchTests(); | |||
} | |||
componentDidUpdate(prevProps: Props) { | |||
if ( | |||
!isSameBranchLike(prevProps.branchLike, this.props.branchLike) || | |||
prevProps.componentKey !== this.props.componentKey || | |||
prevProps.line.line !== this.props.line.line | |||
) { | |||
this.fetchTests(); | |||
} | |||
} | |||
componentWillUnmount() { | |||
this.mounted = false; | |||
} | |||
shouldLink() { | |||
const { branchLike } = this.props; | |||
return !isShortLivingBranch(branchLike) && !isPullRequest(branchLike); | |||
} | |||
fetchTests = () => { | |||
this.setState({ loading: true }); | |||
getTests({ | |||
sourceFileKey: this.props.componentKey, | |||
sourceFileLineNumber: this.props.line.line, | |||
...getBranchLikeQuery(this.props.branchLike) | |||
}).then( | |||
testCases => { | |||
if (this.mounted) { | |||
this.setState({ loading: false, testCases }); | |||
} | |||
}, | |||
() => { | |||
if (this.mounted) { | |||
this.setState({ loading: false }); | |||
} | |||
} | |||
); | |||
}; | |||
handleTestClick = (event: React.MouseEvent<HTMLAnchorElement>) => { | |||
event.preventDefault(); | |||
event.currentTarget.blur(); | |||
const { key } = event.currentTarget.dataset; | |||
if (this.shouldLink() && key) { | |||
this.context.workspace.openComponent({ branchLike: this.props.branchLike, key }); | |||
} | |||
this.props.onClose(); | |||
}; | |||
renderFile(file: { key: string; longName: string }) { | |||
return this.shouldLink() ? ( | |||
<a data-key={file.key} href="#" onClick={this.handleTestClick} title={file.longName}> | |||
<span>{collapsePath(file.longName)}</span> | |||
</a> | |||
) : ( | |||
<span>{collapsePath(file.longName)}</span> | |||
); | |||
} | |||
render() { | |||
const { line } = this.props; | |||
const testCasesByFile = groupBy(this.state.testCases || [], 'fileKey'); | |||
const testFiles = Object.keys(testCasesByFile).map(fileKey => { | |||
const testSet = testCasesByFile[fileKey]; | |||
const test = testSet[0]; | |||
return { | |||
file: { key: test.fileKey, longName: test.fileName }, | |||
tests: testSet | |||
}; | |||
}); | |||
return ( | |||
<DropdownOverlay placement={PopupPlacement.RightTop}> | |||
<div className="source-viewer-bubble-popup abs-width-400"> | |||
<h6 className="spacer-bottom"> | |||
{translate('source_viewer.covered')} | |||
{!!line.conditions && ( | |||
<div> | |||
{'('} | |||
{line.coveredConditions || '0'} | |||
{' of '} | |||
{line.conditions} {translate('source_viewer.conditions')} | |||
{')'} | |||
</div> | |||
)} | |||
</h6> | |||
{this.state.loading ? ( | |||
<i className="spinner" /> | |||
) : ( | |||
<> | |||
{testFiles.length === 0 && | |||
translate('source_viewer.tooltip.no_information_about_tests')} | |||
{testFiles.map(testFile => ( | |||
<div className="spacer-top text-ellipsis" key={testFile.file.key}> | |||
{this.renderFile(testFile.file)} | |||
<ul> | |||
{testFile.tests.map(testCase => ( | |||
<li | |||
className="display-flex-center little-spacer-top" | |||
key={testCase.id} | |||
title={testCase.name}> | |||
<TestStatusIcon className="spacer-right" status={testCase.status} /> | |||
<div className="display-inline-block text-ellipsis">{testCase.name}</div> | |||
{testCase.status !== 'SKIPPED' && ( | |||
<span className="spacer-left note"> | |||
{testCase.durationInMs} | |||
ms | |||
</span> | |||
)} | |||
</li> | |||
))} | |||
</ul> | |||
</div> | |||
))} | |||
</> | |||
)} | |||
</div> | |||
</DropdownOverlay> | |||
); | |||
} | |||
} |
@@ -126,15 +126,7 @@ export default class Line extends React.PureComponent<Props> { | |||
previousLine={this.props.previousLine} | |||
/> | |||
{this.props.displayCoverage && ( | |||
<LineCoverage | |||
branchLike={this.props.branchLike} | |||
componentKey={this.props.componentKey} | |||
line={line} | |||
onPopupToggle={this.props.onLinePopupToggle} | |||
popupOpen={this.isPopupOpen('coverage')} | |||
/> | |||
)} | |||
{this.props.displayCoverage && <LineCoverage line={line} />} | |||
{this.props.displayDuplications && ( | |||
<LineDuplications line={line} onClick={this.props.loadDuplications} /> |
@@ -18,85 +18,49 @@ | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import * as React from 'react'; | |||
import CoveragePopup from './CoveragePopup'; | |||
import Tooltip from '../../controls/Tooltip'; | |||
import Toggler from '../../controls/Toggler'; | |||
import { translate } from '../../../helpers/l10n'; | |||
import { translate, translateWithParameters } from '../../../helpers/l10n'; | |||
interface Props { | |||
branchLike: T.BranchLike | undefined; | |||
componentKey: string; | |||
line: T.SourceLine; | |||
onPopupToggle: (x: { index?: number; line: number; name: string; open?: boolean }) => void; | |||
popupOpen: boolean; | |||
} | |||
export default class LineCoverage extends React.PureComponent<Props> { | |||
handleClick = (event: React.MouseEvent<HTMLElement>) => { | |||
event.preventDefault(); | |||
event.stopPropagation(); | |||
event.currentTarget.blur(); | |||
this.props.onPopupToggle({ line: this.props.line.line, name: 'coverage' }); | |||
}; | |||
handleTogglePopup = (open: boolean) => { | |||
this.props.onPopupToggle({ line: this.props.line.line, name: 'coverage', open }); | |||
}; | |||
closePopup = () => { | |||
this.props.onPopupToggle({ line: this.props.line.line, name: 'coverage', open: false }); | |||
}; | |||
render() { | |||
const { branchLike, componentKey, line, popupOpen } = this.props; | |||
const className = | |||
'source-meta source-line-coverage' + | |||
(line.coverageStatus != null ? ` source-line-${line.coverageStatus}` : ''); | |||
const hasPopup = | |||
line.coverageStatus === 'covered' || line.coverageStatus === 'partially-covered'; | |||
const bar = hasPopup ? ( | |||
<div className="source-line-bar" onClick={this.handleClick} role="button" tabIndex={0} /> | |||
) : ( | |||
<div className="source-line-bar" /> | |||
); | |||
const cell = line.coverageStatus ? ( | |||
<Tooltip | |||
overlay={popupOpen ? undefined : translate('source_viewer.tooltip', line.coverageStatus)} | |||
placement="right"> | |||
{bar} | |||
export default function LineCoverage({ line }: Props) { | |||
const className = | |||
'source-meta source-line-coverage' + | |||
(line.coverageStatus != null ? ` source-line-${line.coverageStatus}` : ''); | |||
return ( | |||
<td className={className} data-line-number={line.line}> | |||
<Tooltip overlay={getStatusTooltip(line)} placement="right"> | |||
<div className="source-line-bar" /> | |||
</Tooltip> | |||
) : ( | |||
bar | |||
); | |||
</td> | |||
); | |||
} | |||
if (hasPopup) { | |||
return ( | |||
<td className={className} data-line-number={line.line}> | |||
<Toggler | |||
onRequestClose={this.closePopup} | |||
open={popupOpen} | |||
overlay={ | |||
<CoveragePopup | |||
branchLike={branchLike} | |||
componentKey={componentKey} | |||
line={line} | |||
onClose={this.closePopup} | |||
/> | |||
}> | |||
{cell} | |||
</Toggler> | |||
</td> | |||
function getStatusTooltip(line: T.SourceLine) { | |||
if (line.coverageStatus === 'uncovered') { | |||
if (line.conditions) { | |||
return translateWithParameters('source_viewer.tooltip.uncovered.conditions', line.conditions); | |||
} else { | |||
return translate('source_viewer.tooltip.uncovered'); | |||
} | |||
} else if (line.coverageStatus === 'covered') { | |||
if (line.conditions) { | |||
return translateWithParameters('source_viewer.tooltip.covered.conditions', line.conditions); | |||
} else { | |||
return translate('source_viewer.tooltip.covered'); | |||
} | |||
} else if (line.coverageStatus === 'partially-covered') { | |||
if (line.conditions) { | |||
return translateWithParameters( | |||
'source_viewer.tooltip.partially-covered.conditions', | |||
line.coveredConditions || 0, | |||
line.conditions | |||
); | |||
} else { | |||
return translate('source_viewer.tooltip.partially-covered'); | |||
} | |||
return ( | |||
<td className={className} data-line-number={line.line}> | |||
{cell} | |||
</td> | |||
); | |||
} | |||
return undefined; | |||
} |
@@ -20,63 +20,21 @@ | |||
import * as React from 'react'; | |||
import { shallow } from 'enzyme'; | |||
import LineCoverage from '../LineCoverage'; | |||
import { click } from '../../../../helpers/testUtils'; | |||
it('render covered line', () => { | |||
const line: T.SourceLine = { line: 3, coverageStatus: 'covered' }; | |||
const wrapper = shallow( | |||
<LineCoverage | |||
branchLike={undefined} | |||
componentKey="foo" | |||
line={line} | |||
onPopupToggle={jest.fn()} | |||
popupOpen={false} | |||
/> | |||
); | |||
const wrapper = shallow(<LineCoverage line={line} />); | |||
expect(wrapper).toMatchSnapshot(); | |||
click(wrapper.find('[tabIndex]')); | |||
}); | |||
it('render uncovered line', () => { | |||
const line: T.SourceLine = { line: 3, coverageStatus: 'uncovered' }; | |||
const wrapper = shallow( | |||
<LineCoverage | |||
branchLike={undefined} | |||
componentKey="foo" | |||
line={line} | |||
onPopupToggle={jest.fn()} | |||
popupOpen={false} | |||
/> | |||
); | |||
const wrapper = shallow(<LineCoverage line={line} />); | |||
expect(wrapper).toMatchSnapshot(); | |||
}); | |||
it('render line with unknown coverage', () => { | |||
const line: T.SourceLine = { line: 3 }; | |||
const wrapper = shallow( | |||
<LineCoverage | |||
branchLike={undefined} | |||
componentKey="foo" | |||
line={line} | |||
onPopupToggle={jest.fn()} | |||
popupOpen={false} | |||
/> | |||
); | |||
const wrapper = shallow(<LineCoverage line={line} />); | |||
expect(wrapper).toMatchSnapshot(); | |||
}); | |||
it('should open coverage popup', () => { | |||
const line: T.SourceLine = { line: 3, coverageStatus: 'covered' }; | |||
const onPopupToggle = jest.fn(); | |||
const wrapper = shallow( | |||
<LineCoverage | |||
branchLike={undefined} | |||
componentKey="foo" | |||
line={line} | |||
onPopupToggle={onPopupToggle} | |||
popupOpen={false} | |||
/> | |||
); | |||
click(wrapper.find('[role="button"]')); | |||
expect(onPopupToggle).toBeCalledWith({ line: 3, name: 'coverage' }); | |||
}); |
@@ -5,34 +5,14 @@ exports[`render covered line 1`] = ` | |||
className="source-meta source-line-coverage source-line-covered" | |||
data-line-number={3} | |||
> | |||
<Toggler | |||
onRequestClose={[Function]} | |||
open={false} | |||
overlay={ | |||
<CoveragePopup | |||
componentKey="foo" | |||
line={ | |||
Object { | |||
"coverageStatus": "covered", | |||
"line": 3, | |||
} | |||
} | |||
onClose={[Function]} | |||
/> | |||
} | |||
<Tooltip | |||
overlay="source_viewer.tooltip.covered" | |||
placement="right" | |||
> | |||
<Tooltip | |||
overlay="source_viewer.tooltip.covered" | |||
placement="right" | |||
> | |||
<div | |||
className="source-line-bar" | |||
onClick={[Function]} | |||
role="button" | |||
tabIndex={0} | |||
/> | |||
</Tooltip> | |||
</Toggler> | |||
<div | |||
className="source-line-bar" | |||
/> | |||
</Tooltip> | |||
</td> | |||
`; | |||
@@ -41,9 +21,13 @@ exports[`render line with unknown coverage 1`] = ` | |||
className="source-meta source-line-coverage" | |||
data-line-number={3} | |||
> | |||
<div | |||
className="source-line-bar" | |||
/> | |||
<Tooltip | |||
placement="right" | |||
> | |||
<div | |||
className="source-line-bar" | |||
/> | |||
</Tooltip> | |||
</td> | |||
`; | |||
@@ -18,8 +18,8 @@ | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
export default function getCoverageStatus(s: T.SourceLine): string | undefined { | |||
let status: string | undefined; | |||
export default function getCoverageStatus(s: T.SourceLine): T.SourceLineCoverageStatus | undefined { | |||
let status: T.SourceLineCoverageStatus | undefined; | |||
if (s.lineHits != null && s.lineHits > 0) { | |||
status = 'partially-covered'; | |||
} |
@@ -2211,8 +2211,11 @@ source_viewer.conditions=conditions | |||
source_viewer.tooltip.duplicated_line=This line is duplicated. Click to see duplicated blocks. | |||
source_viewer.tooltip.duplicated_block=Duplicated block. Click for details. | |||
source_viewer.tooltip.covered=Fully covered by tests. | |||
source_viewer.tooltip.covered.conditions=Fully covered by tests ({0} conditions). | |||
source_viewer.tooltip.partially-covered=Partially covered by tests. | |||
source_viewer.tooltip.partially-covered.conditions=Partially covered by tests ({0} of {1} conditions). | |||
source_viewer.tooltip.uncovered=Not covered by tests. | |||
source_viewer.tooltip.uncovered.conditions=Not covered by tests ({0} conditions). | |||
source_viewer.tooltip.no_information_about_tests=There is no extra information about test files. | |||
source_viewer.load_more_code=Load More Code |