@@ -136,7 +136,6 @@ | |||
"jsx-a11y/tabindex-no-positive": 2, | |||
"react/jsx-boolean-value": [2, "always"], | |||
"react/jsx-closing-bracket-location": 2, | |||
"react/jsx-curly-spacing": [2, "never"], | |||
"react/jsx-equals-spacing": [2, "never"], | |||
"react/jsx-key": 2, |
@@ -19,7 +19,7 @@ | |||
*/ | |||
import React from 'react'; | |||
import Spinner from './../Spinner'; | |||
import { BubbleChart as OriginalBubbleChart } from '../../../../components/charts/bubble-chart'; | |||
import OriginalBubbleChart from '../../../../components/charts/BubbleChart'; | |||
import bubbles from '../../config/bubbles'; | |||
import { getComponentLeaves } from '../../../../api/components'; | |||
import { formatMeasure } from '../../../../helpers/measures'; |
@@ -320,6 +320,9 @@ | |||
.measure-details-bubble-chart-axis.x { | |||
left: 50%; | |||
bottom: 10px; | |||
width: 500px; | |||
margin-left: -250px; | |||
text-align: center; | |||
} | |||
.measure-details-bubble-chart-axis.y { | |||
@@ -331,6 +334,9 @@ | |||
.measure-details-bubble-chart-axis.size { | |||
left: 50%; | |||
top: 10px; | |||
width: 500px; | |||
margin-left: -250px; | |||
text-align: center; | |||
} | |||
.measure-details-treemap { |
@@ -22,13 +22,17 @@ import PageHeaderContainer from './PageHeaderContainer'; | |||
import ProjectsListContainer from './ProjectsListContainer'; | |||
import ProjectsListFooterContainer from './ProjectsListFooterContainer'; | |||
import PageSidebar from './PageSidebar'; | |||
import VisualizationsContainer from '../visualizations/VisualizationsContainer'; | |||
import { parseUrlQuery } from '../store/utils'; | |||
import { getProjectUrl } from '../../../helpers/urls'; | |||
export default class AllProjects extends React.Component { | |||
static propTypes = { | |||
isFavorite: React.PropTypes.bool.isRequired, | |||
location: React.PropTypes.object.isRequired, | |||
fetchProjects: React.PropTypes.func.isRequired, | |||
organization: React.PropTypes.object | |||
organization: React.PropTypes.object, | |||
router: React.PropTypes.object.isRequired | |||
}; | |||
state = { | |||
@@ -56,8 +60,41 @@ export default class AllProjects extends React.Component { | |||
this.props.fetchProjects(query, this.props.isFavorite, this.props.organization); | |||
} | |||
handleViewChange = view => { | |||
const query = { | |||
...this.props.location.query, | |||
view: view === 'list' ? undefined : view | |||
}; | |||
if (query.view !== 'visualizations') { | |||
Object.assign(query, { visualization: undefined }); | |||
} | |||
this.props.router.push({ | |||
pathname: this.props.location.pathname, | |||
query | |||
}); | |||
}; | |||
handleVisualizationChange = visualization => { | |||
this.props.router.push({ | |||
pathname: this.props.location.pathname, | |||
query: { | |||
...this.props.location.query, | |||
view: 'visualizations', | |||
visualization | |||
} | |||
}); | |||
}; | |||
handleProjectOpen = projectKey => { | |||
this.props.router.push(getProjectUrl(projectKey)); | |||
}; | |||
render() { | |||
const isFiltered = Object.keys(this.state.query).some(key => this.state.query[key] != null); | |||
const { query } = this.state; | |||
const isFiltered = Object.keys(query).some(key => query[key] != null); | |||
const view = query.view || 'list'; | |||
const visualization = query.visualization || 'quality'; | |||
const top = this.props.organization ? 95 : 30; | |||
@@ -66,24 +103,33 @@ export default class AllProjects extends React.Component { | |||
<aside className="page-sidebar-fixed page-sidebar-sticky projects-sidebar"> | |||
<div className="page-sidebar-sticky-inner" style={{ top }}> | |||
<PageSidebar | |||
query={this.state.query} | |||
query={query} | |||
isFavorite={this.props.isFavorite} | |||
organization={this.props.organization} | |||
/> | |||
</div> | |||
</aside> | |||
<div className="page-main"> | |||
<PageHeaderContainer /> | |||
<ProjectsListContainer | |||
isFavorite={this.props.isFavorite} | |||
isFiltered={isFiltered} | |||
organization={this.props.organization} | |||
/> | |||
<ProjectsListFooterContainer | |||
query={this.state.query} | |||
isFavorite={this.props.isFavorite} | |||
organization={this.props.organization} | |||
/> | |||
<PageHeaderContainer onViewChange={this.handleViewChange} view={view} /> | |||
{view === 'list' && | |||
<ProjectsListContainer | |||
isFavorite={this.props.isFavorite} | |||
isFiltered={isFiltered} | |||
organization={this.props.organization} | |||
/>} | |||
{view === 'list' && | |||
<ProjectsListFooterContainer | |||
query={query} | |||
isFavorite={this.props.isFavorite} | |||
organization={this.props.organization} | |||
/>} | |||
{view === 'visualizations' && | |||
<VisualizationsContainer | |||
onProjectOpen={this.handleProjectOpen} | |||
onVisualizationChange={this.handleVisualizationChange} | |||
sort={query.sort} | |||
visualization={visualization} | |||
/>} | |||
</div> | |||
</div> | |||
); |
@@ -18,7 +18,8 @@ | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { connect } from 'react-redux'; | |||
import { withRouter } from 'react-router'; | |||
import AllProjects from './AllProjects'; | |||
import { fetchProjects } from '../store/actions'; | |||
export default connect(null, { fetchProjects })(AllProjects); | |||
export default connect(null, { fetchProjects })(withRouter(AllProjects)); |
@@ -18,6 +18,7 @@ | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { connect } from 'react-redux'; | |||
import { withRouter } from 'react-router'; | |||
import AllProjects from './AllProjects'; | |||
import { fetchProjects } from '../store/actions'; | |||
import { getCurrentUser } from '../../../store/rootReducer'; | |||
@@ -27,4 +28,4 @@ const mapStateToProps = state => ({ | |||
isFavorite: true | |||
}); | |||
export default connect(mapStateToProps, { fetchProjects })(AllProjects); | |||
export default connect(mapStateToProps, { fetchProjects })(withRouter(AllProjects)); |
@@ -17,22 +17,26 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
// @flow | |||
import React from 'react'; | |||
import ViewSelect from './ViewSelect'; | |||
import { translate } from '../../../helpers/l10n'; | |||
export default class PageHeader extends React.Component { | |||
static propTypes = { | |||
loading: React.PropTypes.bool, | |||
total: React.PropTypes.number | |||
props: { | |||
loading: boolean, | |||
onViewChange: (string) => void, | |||
total?: number, | |||
view: string | |||
}; | |||
render() { | |||
const { loading } = this.props; | |||
return ( | |||
<header className="page-header"> | |||
<ViewSelect onChange={this.props.onViewChange} view={this.props.view} /> | |||
<div className="page-actions projects-page-actions"> | |||
{!!loading && <i className="spinner spacer-right" />} | |||
{!!this.props.loading && <i className="spinner spacer-right" />} | |||
{this.props.total != null && | |||
<span> |
@@ -40,12 +40,19 @@ export default class PageSidebar extends React.PureComponent { | |||
}; | |||
render() { | |||
const isFiltered = Object.keys(this.props.query).some(key => this.props.query[key] != null); | |||
const { query } = this.props; | |||
const isFiltered = Object.keys(query) | |||
.filter(key => key !== 'view' && key !== 'visualization') | |||
.some(key => query[key] != null); | |||
const basePathName = this.props.organization | |||
? `/organizations/${this.props.organization.key}/projects` | |||
: '/projects'; | |||
const pathname = basePathName + (this.props.isFavorite ? '/favorite' : ''); | |||
const linkQuery = query.view === 'visualizations' | |||
? { view: query.view, visualization: query.visualization } | |||
: undefined; | |||
return ( | |||
<div className="search-navigator-facets-list"> | |||
@@ -54,61 +61,61 @@ export default class PageSidebar extends React.PureComponent { | |||
<div className="projects-facets-header clearfix"> | |||
{isFiltered && | |||
<div className="projects-facets-reset"> | |||
<Link to={pathname} className="button button-red"> | |||
<Link to={{ pathname, query: linkQuery }} className="button button-red"> | |||
{translate('projects.clear_all_filters')} | |||
</Link> | |||
</div>} | |||
<h3>{translate('filters')}</h3> | |||
<SearchFilterContainer | |||
query={this.props.query} | |||
query={query} | |||
isFavorite={this.props.isFavorite} | |||
organization={this.props.organization} | |||
/> | |||
</div> | |||
<QualityGateFilter | |||
query={this.props.query} | |||
query={query} | |||
isFavorite={this.props.isFavorite} | |||
organization={this.props.organization} | |||
/> | |||
<ReliabilityFilter | |||
query={this.props.query} | |||
query={query} | |||
isFavorite={this.props.isFavorite} | |||
organization={this.props.organization} | |||
/> | |||
<SecurityFilter | |||
query={this.props.query} | |||
query={query} | |||
isFavorite={this.props.isFavorite} | |||
organization={this.props.organization} | |||
/> | |||
<MaintainabilityFilter | |||
query={this.props.query} | |||
query={query} | |||
isFavorite={this.props.isFavorite} | |||
organization={this.props.organization} | |||
/> | |||
<CoverageFilter | |||
query={this.props.query} | |||
query={query} | |||
isFavorite={this.props.isFavorite} | |||
organization={this.props.organization} | |||
/> | |||
<DuplicationsFilter | |||
query={this.props.query} | |||
query={query} | |||
isFavorite={this.props.isFavorite} | |||
organization={this.props.organization} | |||
/> | |||
<SizeFilter | |||
query={this.props.query} | |||
query={query} | |||
isFavorite={this.props.isFavorite} | |||
organization={this.props.organization} | |||
/> | |||
<LanguagesFilterContainer | |||
query={this.props.query} | |||
query={query} | |||
isFavorite={this.props.isFavorite} | |||
organization={this.props.organization} | |||
/> | |||
<TagsFilterContainer | |||
query={this.props.query} | |||
query={query} | |||
isFavorite={this.props.isFavorite} | |||
organization={this.props.organization} | |||
/> |
@@ -50,7 +50,10 @@ export default class ProjectCard extends React.PureComponent { | |||
return null; | |||
} | |||
const areProjectMeasuresLoaded = this.props.measures != null; | |||
// check reliability_rating because only some measures can be loaded | |||
// if coming from visualizations tab | |||
const areProjectMeasuresLoaded = this.props.measures != null && | |||
this.props.measures['reliability_rating'] != null; | |||
const isProjectAnalyzed = project.analysisDate != null; | |||
const displayQualityGate = areProjectMeasuresLoaded && isProjectAnalyzed; | |||
@@ -0,0 +1,50 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2017 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. | |||
*/ | |||
// @flow | |||
import React from 'react'; | |||
import RadioToggle from '../../../components/controls/RadioToggle'; | |||
import { translate } from '../../../helpers/l10n'; | |||
export default class ViewSelect extends React.PureComponent { | |||
props: { | |||
onChange: (string) => void, | |||
view: string | |||
}; | |||
handleChange = (view: string) => { | |||
this.props.onChange(view); | |||
}; | |||
render() { | |||
const options = ['list', 'visualizations'].map(option => ({ | |||
value: option, | |||
label: translate('projects.view', option) | |||
})); | |||
return ( | |||
<RadioToggle | |||
name="view" | |||
onCheck={this.handleChange} | |||
options={options} | |||
value={this.props.view} | |||
/> | |||
); | |||
} | |||
} |
@@ -0,0 +1,33 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2017 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 React from 'react'; | |||
import { shallow } from 'enzyme'; | |||
import PageSidebar from '../PageSidebar'; | |||
it('should handle `view` and `visualization`', () => { | |||
const query = { | |||
view: 'visualizations', | |||
visualization: 'bugs' | |||
}; | |||
const sidebar = shallow(<PageSidebar query={query} isFavorite={false} />); | |||
expect(sidebar.find('.projects-facets-reset')).toMatchSnapshot(); | |||
sidebar.setProps({ query: { ...query, size: '3' } }); | |||
expect(sidebar.find('.projects-facets-reset')).toMatchSnapshot(); | |||
}); |
@@ -0,0 +1,26 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2017 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 React from 'react'; | |||
import { shallow } from 'enzyme'; | |||
import ViewSelect from '../ViewSelect'; | |||
it('should render options', () => { | |||
expect(shallow(<ViewSelect view="visualizations" />)).toMatchSnapshot(); | |||
}); |
@@ -0,0 +1,22 @@ | |||
exports[`test should handle \`view\` and \`visualization\` 1`] = `undefined`; | |||
exports[`test should handle \`view\` and \`visualization\` 2`] = ` | |||
<div | |||
className="projects-facets-reset"> | |||
<Link | |||
className="button button-red" | |||
onlyActiveOnIndex={false} | |||
style={Object {}} | |||
to={ | |||
Object { | |||
"pathname": "/projects", | |||
"query": Object { | |||
"view": "visualizations", | |||
"visualization": "bugs", | |||
}, | |||
} | |||
}> | |||
projects.clear_all_filters | |||
</Link> | |||
</div> | |||
`; |
@@ -0,0 +1,19 @@ | |||
exports[`test should render options 1`] = ` | |||
<RadioToggle | |||
disabled={false} | |||
name="view" | |||
onCheck={[Function]} | |||
options={ | |||
Array [ | |||
Object { | |||
"label": "projects.view.list", | |||
"value": "list", | |||
}, | |||
Object { | |||
"label": "projects.view.visualizations", | |||
"value": "visualizations", | |||
}, | |||
] | |||
} | |||
value="visualizations" /> | |||
`; |
@@ -34,6 +34,7 @@ import { getOrganizations } from '../../../api/organizations'; | |||
import { receiveOrganizations } from '../../../store/organizations/duck'; | |||
const PAGE_SIZE = 50; | |||
const PAGE_SIZE_VISUALIZATIONS = 99; | |||
const METRICS = [ | |||
'alert_status', | |||
@@ -46,6 +47,16 @@ const METRICS = [ | |||
'ncloc_language_distribution' | |||
]; | |||
const METRICS_BY_VISUALIZATION = { | |||
quality: ['reliability_rating', 'security_rating', 'coverage', 'ncloc', 'sqale_index'], | |||
// x, y, size, color | |||
bugs: ['ncloc', 'reliability_remediation_effort', 'bugs', 'reliability_rating'], | |||
vulnerabilities: ['ncloc', 'security_remediation_effort', 'vulnerabilities', 'security_rating'], | |||
code_smells: ['ncloc', 'sqale_index', 'code_smells', 'sqale_rating'], | |||
uncovered_lines: ['complexity', 'coverage', 'uncovered_lines'], | |||
duplicated_blocks: ['ncloc', 'duplicated_lines', 'duplicated_blocks'] | |||
}; | |||
const FACETS = [ | |||
'reliability_rating', | |||
'security_rating', | |||
@@ -90,14 +101,23 @@ const onReceiveOrganizations = dispatch => | |||
dispatch(receiveOrganizations(response.organizations)); | |||
}; | |||
const fetchProjectMeasures = projects => | |||
const defineMetrics = query => { | |||
if (query.view === 'visualizations') { | |||
return METRICS_BY_VISUALIZATION[query.visualization || 'quality']; | |||
} else { | |||
return METRICS; | |||
} | |||
}; | |||
const fetchProjectMeasures = (projects, query) => | |||
dispatch => { | |||
if (!projects.length) { | |||
return Promise.resolve(); | |||
} | |||
const projectKeys = projects.map(project => project.key); | |||
return getMeasuresForProjects(projectKeys, METRICS).then( | |||
const metrics = defineMetrics(query); | |||
return getMeasuresForProjects(projectKeys, metrics).then( | |||
onReceiveMeasures(dispatch, projectKeys), | |||
onFail(dispatch) | |||
); | |||
@@ -124,13 +144,13 @@ const handleFavorites = (dispatch, projects) => { | |||
} | |||
}; | |||
const onReceiveProjects = dispatch => | |||
const onReceiveProjects = (dispatch, query) => | |||
response => { | |||
dispatch(receiveComponents(response.components)); | |||
dispatch(receiveProjects(response.components, response.facets)); | |||
handleFavorites(dispatch, response.components); | |||
Promise.all([ | |||
dispatch(fetchProjectMeasures(response.components)), | |||
dispatch(fetchProjectMeasures(response.components, query)), | |||
dispatch(fetchProjectOrganizations(response.components)) | |||
]).then(() => { | |||
dispatch(updateState({ loading: false })); | |||
@@ -143,13 +163,13 @@ const onReceiveProjects = dispatch => | |||
); | |||
}; | |||
const onReceiveMoreProjects = dispatch => | |||
const onReceiveMoreProjects = (dispatch, query) => | |||
response => { | |||
dispatch(receiveComponents(response.components)); | |||
dispatch(receiveMoreProjects(response.components)); | |||
handleFavorites(dispatch, response.components); | |||
Promise.all([ | |||
dispatch(fetchProjectMeasures(response.components)), | |||
dispatch(fetchProjectMeasures(response.components, query)), | |||
dispatch(fetchProjectOrganizations(response.components)) | |||
]).then(() => { | |||
dispatch(updateState({ loading: false })); | |||
@@ -160,12 +180,13 @@ const onReceiveMoreProjects = dispatch => | |||
export const fetchProjects = (query, isFavorite, organization) => | |||
dispatch => { | |||
dispatch(updateState({ loading: true })); | |||
const ps = query.view === 'visualizations' ? PAGE_SIZE_VISUALIZATIONS : PAGE_SIZE; | |||
const data = convertToQueryData(query, isFavorite, organization, { | |||
ps: PAGE_SIZE, | |||
ps, | |||
facets: FACETS.join(), | |||
f: 'analysisDate' | |||
}); | |||
return searchProjects(data).then(onReceiveProjects(dispatch), onFail(dispatch)); | |||
return searchProjects(data).then(onReceiveProjects(dispatch, query), onFail(dispatch)); | |||
}; | |||
export const fetchMoreProjects = (query, isFavorite, organization) => | |||
@@ -178,7 +199,7 @@ export const fetchMoreProjects = (query, isFavorite, organization) => | |||
p: pageIndex + 1, | |||
f: 'analysisDate' | |||
}); | |||
return searchProjects(data).then(onReceiveMoreProjects(dispatch), onFail(dispatch)); | |||
return searchProjects(data).then(onReceiveMoreProjects(dispatch, query), onFail(dispatch)); | |||
}; | |||
export const setProjectTags = (project, tags) => |
@@ -17,6 +17,8 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { VISUALIZATIONS } from '../utils'; | |||
const getAsNumericRating = value => { | |||
if (value === '' || value == null || isNaN(value)) { | |||
return null; | |||
@@ -46,6 +48,12 @@ const getAsArray = (values, elementGetter) => { | |||
return values.split(',').map(elementGetter); | |||
}; | |||
const getView = rawValue => rawValue === 'visualizations' ? rawValue : undefined; | |||
const getVisualization = value => { | |||
return VISUALIZATIONS.includes(value) ? value : null; | |||
}; | |||
export const parseUrlQuery = urlQuery => ({ | |||
gate: getAsLevel(urlQuery['gate']), | |||
reliability: getAsNumericRating(urlQuery['reliability']), | |||
@@ -57,7 +65,9 @@ export const parseUrlQuery = urlQuery => ({ | |||
languages: getAsArray(urlQuery['languages'], getAsString), | |||
tags: getAsArray(urlQuery['tags'], getAsString), | |||
search: getAsString(urlQuery['search']), | |||
sort: getAsString(urlQuery['sort']) | |||
sort: getAsString(urlQuery['sort']), | |||
view: getView(urlQuery['view']), | |||
visualization: getVisualization(urlQuery['visualization']) | |||
}); | |||
export const mapMetricToProperty = metricKey => { |
@@ -181,3 +181,25 @@ | |||
.search-navigator-facet-highlight-under.active ~ .search-navigator-facet .projects-facet-bar-inner { | |||
background-color: #4b9fd5; | |||
} | |||
.projects-visualization { | |||
position: relative; | |||
height: 600px; | |||
margin-top: 15px; | |||
border-top: 1px solid #e6e6e6; | |||
} | |||
.projects-visualization .measure-details-bubble-chart-axis.y { | |||
width: 300px; | |||
left: 15px; | |||
margin-top: 150px; | |||
transform-origin: 0 0; | |||
text-align: center; | |||
} | |||
.projects-visualizations-footer { | |||
padding: 15px 0; | |||
color: #777; | |||
font-size: 12px; | |||
text-align: center; | |||
} |
@@ -18,6 +18,8 @@ | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
// @flow | |||
import { translate } from '../../helpers/l10n'; | |||
const LOCALSTORAGE_KEY = 'sonarqube.projects.default'; | |||
const LOCALSTORAGE_FAVORITE = 'favorite'; | |||
const LOCALSTORAGE_ALL = 'all'; | |||
@@ -44,3 +46,16 @@ const save = (value: string) => { | |||
export const saveAll = () => save(LOCALSTORAGE_ALL); | |||
export const saveFavorite = () => save(LOCALSTORAGE_FAVORITE); | |||
export const VISUALIZATIONS = [ | |||
'quality', | |||
'bugs', | |||
'vulnerabilities', | |||
'code_smells', | |||
'uncovered_lines', | |||
'duplicated_blocks' | |||
]; | |||
export const localizeSorting = (sort?: string) => { | |||
return translate('projects.sort', sort || 'name'); | |||
}; |
@@ -0,0 +1,36 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2017 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. | |||
*/ | |||
// @flow | |||
import React from 'react'; | |||
import SimpleBubbleChart from './SimpleBubbleChart'; | |||
export default class Bugs extends React.PureComponent { | |||
render() { | |||
return ( | |||
<SimpleBubbleChart | |||
{...this.props} | |||
xMetric={{ key: 'ncloc', type: 'SHORT_INT' }} | |||
yMetric={{ key: 'reliability_remediation_effort', type: 'SHORT_WORK_DUR' }} | |||
sizeMetric={{ key: 'bugs', type: 'SHORT_INT' }} | |||
colorMetric="reliability_rating" | |||
/> | |||
); | |||
} | |||
} |
@@ -0,0 +1,36 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2017 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. | |||
*/ | |||
// @flow | |||
import React from 'react'; | |||
import SimpleBubbleChart from './SimpleBubbleChart'; | |||
export default class CodeSmells extends React.PureComponent { | |||
render() { | |||
return ( | |||
<SimpleBubbleChart | |||
{...this.props} | |||
xMetric={{ key: 'ncloc', type: 'SHORT_INT' }} | |||
yMetric={{ key: 'sqale_index', type: 'SHORT_WORK_DUR' }} | |||
sizeMetric={{ key: 'code_smells', type: 'SHORT_INT' }} | |||
colorMetric="sqale_rating" | |||
/> | |||
); | |||
} | |||
} |
@@ -0,0 +1,35 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2017 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. | |||
*/ | |||
// @flow | |||
import React from 'react'; | |||
import SimpleBubbleChart from './SimpleBubbleChart'; | |||
export default class DuplicatedBlocks extends React.PureComponent { | |||
render() { | |||
return ( | |||
<SimpleBubbleChart | |||
{...this.props} | |||
xMetric={{ key: 'ncloc', type: 'SHORT_INT' }} | |||
yMetric={{ key: 'duplicated_lines', type: 'SHORT_INT' }} | |||
sizeMetric={{ key: 'duplicated_blocks', type: 'SHORT_INT' }} | |||
/> | |||
); | |||
} | |||
} |
@@ -0,0 +1,133 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2017 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. | |||
*/ | |||
// @flow | |||
import React from 'react'; | |||
import BubbleChart from '../../../components/charts/BubbleChart'; | |||
import { formatMeasure } from '../../../helpers/measures'; | |||
import { translate, translateWithParameters } from '../../../helpers/l10n'; | |||
import { RATING_COLORS } from '../../../helpers/constants'; | |||
type Project = { | |||
key: string, | |||
measures: { [string]: string }, | |||
name: string, | |||
organization?: { name: string } | |||
}; | |||
const X_METRIC = 'sqale_index'; | |||
const X_METRIC_TYPE = 'SHORT_WORK_DUR'; | |||
const Y_METRIC = 'coverage'; | |||
const Y_METRIC_TYPE = 'PERCENT'; | |||
const SIZE_METRIC = 'ncloc'; | |||
const SIZE_METRIC_TYPE = 'SHORT_INT'; | |||
const COLOR_METRIC_1 = 'reliability_rating'; | |||
const COLOR_METRIC_2 = 'security_rating'; | |||
const COLOR_METRIC_TYPE = 'RATING'; | |||
export default class QualityModel extends React.PureComponent { | |||
props: { | |||
onProjectOpen: (?string) => void, | |||
projects: Array<Project> | |||
}; | |||
getMetricTooltip(metric: { key: string, type: string }, value: number) { | |||
const name = translate('metric', metric.key, 'name'); | |||
return `<div>${name}: ${formatMeasure(value, metric.type)}</div>`; | |||
} | |||
getTooltip(project: Project, x: number, y: number, size: number, color1: number, color2: number) { | |||
const fullProjectName = project.organization | |||
? `<div class="little-spacer-bottom">${project.organization.name} / <strong>${project.name}</strong></div>` | |||
: `<div class="little-spacer-bottom"><strong>${project.name}</strong></div>`; | |||
const inner = [ | |||
fullProjectName, | |||
this.getMetricTooltip({ key: COLOR_METRIC_1, type: COLOR_METRIC_TYPE }, color1), | |||
this.getMetricTooltip({ key: COLOR_METRIC_2, type: COLOR_METRIC_TYPE }, color2), | |||
this.getMetricTooltip({ key: Y_METRIC, type: Y_METRIC_TYPE }, y), | |||
this.getMetricTooltip({ key: X_METRIC, type: X_METRIC_TYPE }, x), | |||
this.getMetricTooltip({ key: SIZE_METRIC, type: SIZE_METRIC_TYPE }, size) | |||
].join(''); | |||
return `<div class="text-left">${inner}</div>`; | |||
} | |||
render() { | |||
const items = this.props.projects | |||
.filter( | |||
({ measures }) => | |||
measures[X_METRIC] != null && | |||
measures[Y_METRIC] != null && | |||
measures[SIZE_METRIC] != null && | |||
measures[COLOR_METRIC_1] != null && | |||
measures[COLOR_METRIC_2] != null | |||
) | |||
.map(project => { | |||
const x = Number(project.measures[X_METRIC]); | |||
const y = Number(project.measures[Y_METRIC]); | |||
const size = Number(project.measures[SIZE_METRIC]); | |||
const color1 = Number(project.measures[COLOR_METRIC_1]); | |||
const color2 = Number(project.measures[COLOR_METRIC_2]); | |||
return { | |||
x, | |||
y, | |||
size, | |||
color: RATING_COLORS[Math.max(color1, color2) - 1], | |||
key: project.key, | |||
tooltip: this.getTooltip(project, x, y, size, color1, color2), | |||
link: project.key | |||
}; | |||
}); | |||
const formatXTick = tick => formatMeasure(tick, X_METRIC_TYPE); | |||
const formatYTick = tick => formatMeasure(tick, Y_METRIC_TYPE); | |||
return ( | |||
<div> | |||
<BubbleChart | |||
formatXTick={formatXTick} | |||
formatYTick={formatYTick} | |||
height={600} | |||
items={items} | |||
padding={[40, 20, 60, 100]} | |||
onBubbleClick={this.props.onProjectOpen} | |||
yDomain={[100, 0]} | |||
/> | |||
<div className="measure-details-bubble-chart-axis x"> | |||
{translate('metric', X_METRIC, 'name')} | |||
</div> | |||
<div className="measure-details-bubble-chart-axis y"> | |||
{translate('metric', Y_METRIC, 'name')} | |||
</div> | |||
<div className="measure-details-bubble-chart-axis size"> | |||
<span className="spacer-right"> | |||
{translateWithParameters( | |||
'component_measures.legend.color_x', | |||
translate('projects.worse_of_reliablity_and_security') | |||
)} | |||
</span> | |||
{translateWithParameters( | |||
'component_measures.legend.size_x', | |||
translate('metric', SIZE_METRIC, 'name') | |||
)} | |||
</div> | |||
</div> | |||
); | |||
} | |||
} |
@@ -0,0 +1,125 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2017 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. | |||
*/ | |||
// @flow | |||
import React from 'react'; | |||
import BubbleChart from '../../../components/charts/BubbleChart'; | |||
import { formatMeasure } from '../../../helpers/measures'; | |||
import { translate, translateWithParameters } from '../../../helpers/l10n'; | |||
import { RATING_COLORS } from '../../../helpers/constants'; | |||
type Metric = { key: string, type: string }; | |||
type Project = { | |||
key: string, | |||
measures: { [string]: string }, | |||
name: string, | |||
organization?: { name: string } | |||
}; | |||
export default class SimpleBubbleChart extends React.PureComponent { | |||
props: { | |||
onProjectOpen: (?string) => void, | |||
projects: Array<Project>, | |||
sizeMetric: Metric, | |||
xMetric: Metric, | |||
yDomain?: [number, number], | |||
yMetric: Metric, | |||
colorMetric?: string | |||
}; | |||
getMetricTooltip(metric: Metric, value: number) { | |||
const name = translate('metric', metric.key, 'name'); | |||
return `<div>${name}: ${formatMeasure(value, metric.type)}</div>`; | |||
} | |||
getTooltip(project: Project, x: number, y: number, size: number, color?: number) { | |||
const fullProjectName = project.organization | |||
? `<div class="little-spacer-bottom">${project.organization.name} / <strong>${project.name}</strong></div>` | |||
: `<div class="little-spacer-bottom"><strong>${project.name}</strong></div>`; | |||
const inner = [ | |||
fullProjectName, | |||
this.getMetricTooltip(this.props.xMetric, x), | |||
this.getMetricTooltip(this.props.yMetric, y), | |||
this.getMetricTooltip(this.props.sizeMetric, size) | |||
]; | |||
if (color) { | |||
// $FlowFixMe if `color` is defined then `this.props.colorMetric` is defined too | |||
this.getMetricTooltip({ key: this.props.colorMetric, type: 'RATING' }, color); | |||
} | |||
return `<div class="text-left">${inner.join('')}</div>`; | |||
} | |||
render() { | |||
const { xMetric, yMetric, sizeMetric, colorMetric } = this.props; | |||
const items = this.props.projects | |||
.filter(project => project.measures[xMetric.key] != null) | |||
.filter(project => project.measures[yMetric.key] != null) | |||
.filter(project => project.measures[sizeMetric.key] != null) | |||
.filter(project => colorMetric == null || project.measures[colorMetric] !== null) | |||
.map(project => { | |||
const x = Number(project.measures[xMetric.key]); | |||
const y = Number(project.measures[yMetric.key]); | |||
const size = Number(project.measures[sizeMetric.key]); | |||
const color = colorMetric ? Number(project.measures[colorMetric]) : undefined; | |||
return { | |||
x, | |||
y, | |||
size, | |||
color: color ? RATING_COLORS[color - 1] : undefined, | |||
key: project.key, | |||
tooltip: this.getTooltip(project, x, y, size, color), | |||
link: project.key | |||
}; | |||
}); | |||
const formatXTick = tick => formatMeasure(tick, xMetric.type); | |||
const formatYTick = tick => formatMeasure(tick, yMetric.type); | |||
return ( | |||
<div> | |||
<BubbleChart | |||
formatXTick={formatXTick} | |||
formatYTick={formatYTick} | |||
height={600} | |||
items={items} | |||
onBubbleClick={this.props.onProjectOpen} | |||
padding={[40, 20, 60, 100]} | |||
yDomain={this.props.yDomain} | |||
/> | |||
<div className="measure-details-bubble-chart-axis x"> | |||
{translate('metric', xMetric.key, 'name')} | |||
</div> | |||
<div className="measure-details-bubble-chart-axis y"> | |||
{translate('metric', yMetric.key, 'name')} | |||
</div> | |||
<div className="measure-details-bubble-chart-axis size"> | |||
{translateWithParameters( | |||
'component_measures.legend.size_x', | |||
translate('metric', sizeMetric.key, 'name') | |||
)} | |||
</div> | |||
</div> | |||
); | |||
} | |||
} |
@@ -0,0 +1,36 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2017 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. | |||
*/ | |||
// @flow | |||
import React from 'react'; | |||
import SimpleBubbleChart from './SimpleBubbleChart'; | |||
export default class UncoveredLines extends React.PureComponent { | |||
render() { | |||
return ( | |||
<SimpleBubbleChart | |||
{...this.props} | |||
xMetric={{ key: 'complexity', type: 'SHORT_INT' }} | |||
yMetric={{ key: 'coverage', type: 'PERCENT' }} | |||
yDomain={[100, 0]} | |||
sizeMetric={{ key: 'uncovered_lines', type: 'SHORT_INT' }} | |||
/> | |||
); | |||
} | |||
} |
@@ -0,0 +1,94 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2017 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. | |||
*/ | |||
// @flow | |||
import React from 'react'; | |||
import VisualizationsHeader from './VisualizationsHeader'; | |||
import QualityModel from './QualityModel'; | |||
import Bugs from './Bugs'; | |||
import Vulnerabilities from './Vulnerabilities'; | |||
import CodeSmells from './CodeSmells'; | |||
import UncoveredLines from './UncoveredLines'; | |||
import DuplicatedBlocks from './DuplicatedBlocks'; | |||
import { localizeSorting } from '../utils'; | |||
import { translateWithParameters } from '../../../helpers/l10n'; | |||
export default class Visualizations extends React.PureComponent { | |||
props: { | |||
onProjectOpen: (string) => void, | |||
onVisualizationChange: (string) => void, | |||
projects?: Array<*>, | |||
sort?: string, | |||
total?: number, | |||
visualization: string | |||
}; | |||
renderVisualization(projects: Array<*>) { | |||
const visualizationToComponent = { | |||
quality: QualityModel, | |||
bugs: Bugs, | |||
vulnerabilities: Vulnerabilities, | |||
code_smells: CodeSmells, | |||
uncovered_lines: UncoveredLines, | |||
duplicated_blocks: DuplicatedBlocks | |||
}; | |||
const Component = visualizationToComponent[this.props.visualization]; | |||
return Component | |||
? <Component onProjectOpen={this.props.onProjectOpen} projects={projects} /> | |||
: null; | |||
} | |||
renderFooter() { | |||
const { projects, total, sort } = this.props; | |||
if (projects == null || total == null || projects.length >= total) { | |||
return null; | |||
} | |||
return ( | |||
<footer className="projects-visualizations-footer"> | |||
{translateWithParameters( | |||
'projects.limited_set_of_projects', | |||
projects.length, | |||
localizeSorting(sort) | |||
)} | |||
</footer> | |||
); | |||
} | |||
render() { | |||
const { projects } = this.props; | |||
return ( | |||
<div className="boxed-group projects-visualizations"> | |||
<VisualizationsHeader | |||
onVisualizationChange={this.props.onVisualizationChange} | |||
visualization={this.props.visualization} | |||
/> | |||
<div className="projects-visualization"> | |||
<div> | |||
{projects != null && this.renderVisualization(projects)} | |||
</div> | |||
</div> | |||
{this.renderFooter()} | |||
</div> | |||
); | |||
} | |||
} |
@@ -0,0 +1,44 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2017 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 { connect } from 'react-redux'; | |||
import Visualizations from './Visualizations'; | |||
import { | |||
getProjects, | |||
getComponent, | |||
getComponentMeasures, | |||
getOrganizationByKey, | |||
getProjectsAppState | |||
} from '../../../store/rootReducer'; | |||
const mapStateToProps = state => { | |||
const projectKeys = getProjects(state) || []; | |||
const projects = projectKeys.map(key => { | |||
const component = getComponent(state, key); | |||
return { | |||
...component, | |||
measures: getComponentMeasures(state, key) || {}, | |||
organization: getOrganizationByKey(state, component.organization) | |||
}; | |||
}); | |||
const appState = getProjectsAppState(state); | |||
return { projects, total: appState.total }; | |||
}; | |||
export default connect(mapStateToProps)(Visualizations); |
@@ -0,0 +1,57 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2017 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. | |||
*/ | |||
// @flow | |||
import React from 'react'; | |||
import Select from 'react-select'; | |||
import { translate } from '../../../helpers/l10n'; | |||
import { VISUALIZATIONS } from '../utils'; | |||
export default class VisualizationsHeader extends React.PureComponent { | |||
props: { | |||
onVisualizationChange: (string) => void, | |||
visualization: string | |||
}; | |||
handleChange = (option: { value: string }) => { | |||
this.props.onVisualizationChange(option.value); | |||
}; | |||
render() { | |||
const options = VISUALIZATIONS.map(option => ({ | |||
value: option, | |||
label: option === 'quality' | |||
? translate('projects.quality_model') | |||
: translate('metric', option, 'name') | |||
})); | |||
return ( | |||
<header className="boxed-group-header"> | |||
<Select | |||
className="input-medium" | |||
clearable={false} | |||
onChange={this.handleChange} | |||
options={options} | |||
searchable={false} | |||
value={this.props.visualization} | |||
/> | |||
</header> | |||
); | |||
} | |||
} |
@@ -0,0 +1,36 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2017 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. | |||
*/ | |||
// @flow | |||
import React from 'react'; | |||
import SimpleBubbleChart from './SimpleBubbleChart'; | |||
export default class Vulnerabilities extends React.PureComponent { | |||
render() { | |||
return ( | |||
<SimpleBubbleChart | |||
{...this.props} | |||
xMetric={{ key: 'ncloc', type: 'SHORT_INT' }} | |||
yMetric={{ key: 'security_remediation_effort', type: 'SHORT_WORK_DUR' }} | |||
sizeMetric={{ key: 'vulnerabilities', type: 'SHORT_INT' }} | |||
colorMetric="security_rating" | |||
/> | |||
); | |||
} | |||
} |
@@ -17,108 +17,127 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import d3 from 'd3'; | |||
// @flow | |||
import React from 'react'; | |||
import d3 from 'd3'; | |||
import sortBy from 'lodash/sortBy'; | |||
import uniq from 'lodash/uniq'; | |||
import { ResizeMixin } from './../mixins/resize-mixin'; | |||
import { TooltipsMixin } from './../mixins/tooltips-mixin'; | |||
import { AutoSizer } from 'react-virtualized'; | |||
import { TooltipsContainer } from '../mixins/tooltips-mixin'; | |||
type Scale = { | |||
(number): number, | |||
range: () => [number, number], | |||
ticks: (number) => Array<number> | |||
}; | |||
const TICKS_COUNT = 5; | |||
export const Bubble = React.createClass({ | |||
propTypes: { | |||
x: React.PropTypes.number.isRequired, | |||
y: React.PropTypes.number.isRequired, | |||
r: React.PropTypes.number.isRequired, | |||
tooltip: React.PropTypes.string, | |||
link: React.PropTypes.any | |||
}, | |||
class Bubble extends React.PureComponent { | |||
props: { | |||
color?: string, | |||
link?: string, | |||
onClick: (?string) => void, | |||
r: number, | |||
tooltip?: string, | |||
x: number, | |||
y: number | |||
}; | |||
handleClick() { | |||
handleClick = () => { | |||
if (this.props.onClick) { | |||
this.props.onClick(this.props.link); | |||
} | |||
}, | |||
}; | |||
render() { | |||
let tooltipAttrs = {}; | |||
if (this.props.tooltip) { | |||
tooltipAttrs = { | |||
'data-toggle': 'tooltip', | |||
title: this.props.tooltip | |||
}; | |||
} | |||
return ( | |||
<circle | |||
onClick={this.handleClick} | |||
className="bubble-chart-bubble" | |||
r={this.props.r} | |||
{...tooltipAttrs} | |||
transform={`translate(${this.props.x}, ${this.props.y})`} | |||
/> | |||
const tooltipAttrs = this.props.tooltip | |||
? { | |||
'data-toggle': 'tooltip', | |||
title: this.props.tooltip | |||
} | |||
: {}; | |||
const circle = ( | |||
<g> | |||
<circle | |||
{...tooltipAttrs} | |||
onClick={this.handleClick} | |||
className="bubble-chart-bubble" | |||
r={this.props.r} | |||
style={{ | |||
fill: this.props.color, | |||
stroke: this.props.color | |||
}} | |||
transform={`translate(${this.props.x}, ${this.props.y})`} | |||
/> | |||
</g> | |||
); | |||
return this.props.tooltip ? <TooltipsContainer>{circle}</TooltipsContainer> : circle; | |||
} | |||
}); | |||
export const BubbleChart = React.createClass({ | |||
propTypes: { | |||
items: React.PropTypes.arrayOf(React.PropTypes.object).isRequired, | |||
sizeRange: React.PropTypes.arrayOf(React.PropTypes.number), | |||
displayXGrid: React.PropTypes.bool, | |||
displayXTicks: React.PropTypes.bool, | |||
displayYGrid: React.PropTypes.bool, | |||
displayYTicks: React.PropTypes.bool, | |||
height: React.PropTypes.number, | |||
padding: React.PropTypes.arrayOf(React.PropTypes.number), | |||
formatXTick: React.PropTypes.func, | |||
formatYTick: React.PropTypes.func, | |||
onBubbleClick: React.PropTypes.func | |||
}, | |||
mixins: [ResizeMixin, TooltipsMixin], | |||
getDefaultProps() { | |||
return { | |||
sizeRange: [5, 45], | |||
displayXGrid: true, | |||
displayYGrid: true, | |||
displayXTicks: true, | |||
displayYTicks: true, | |||
padding: [10, 10, 10, 10], | |||
formatXTick: d => d, | |||
formatYTick: d => d | |||
}; | |||
}, | |||
getInitialState() { | |||
return { width: this.props.width, height: this.props.height }; | |||
}, | |||
getXRange(xScale, sizeScale, availableWidth) { | |||
} | |||
export default class BubbleChart extends React.PureComponent { | |||
props: {| | |||
items: Array<{| | |||
x: number, | |||
y: number, | |||
size: number, | |||
color?: string, | |||
key?: string, | |||
link?: string, | |||
tooltip?: string | |||
|}>, | |||
sizeRange?: [number, number], | |||
displayXGrid: boolean, | |||
displayXTicks: boolean, | |||
displayYGrid: boolean, | |||
displayYTicks: boolean, | |||
height: number, | |||
padding: [number, number, number, number], | |||
formatXTick: (number) => string, | |||
formatYTick: (number) => string, | |||
onBubbleClick?: (?string) => void, | |||
xDomain?: [number, number], | |||
yDomain?: [number, number] | |||
|}; | |||
static defaultProps = { | |||
sizeRange: [5, 45], | |||
displayXGrid: true, | |||
displayYGrid: true, | |||
displayXTicks: true, | |||
displayYTicks: true, | |||
padding: [10, 10, 10, 10], | |||
formatXTick: d => d, | |||
formatYTick: d => d | |||
}; | |||
getXRange(xScale: Scale, sizeScale: Scale, availableWidth: number) { | |||
const minX = d3.min(this.props.items, d => xScale(d.x) - sizeScale(d.size)); | |||
const maxX = d3.max(this.props.items, d => xScale(d.x) + sizeScale(d.size)); | |||
const dMinX = minX < 0 ? xScale.range()[0] - minX : xScale.range()[0]; | |||
const dMaxX = maxX > xScale.range()[1] ? maxX - xScale.range()[1] : 0; | |||
return [dMinX, availableWidth - dMaxX]; | |||
}, | |||
} | |||
getYRange(yScale, sizeScale, availableHeight) { | |||
getYRange(yScale: Scale, sizeScale: Scale, availableHeight: number) { | |||
const minY = d3.min(this.props.items, d => yScale(d.y) - sizeScale(d.size)); | |||
const maxY = d3.max(this.props.items, d => yScale(d.y) + sizeScale(d.size)); | |||
const dMinY = minY < 0 ? yScale.range()[1] - minY : yScale.range()[1]; | |||
const dMaxY = maxY > yScale.range()[0] ? maxY - yScale.range()[0] : 0; | |||
return [availableHeight - dMaxY, dMinY]; | |||
}, | |||
} | |||
getTicks(scale, format) { | |||
getTicks(scale: Scale, format: (number) => string) { | |||
const ticks = scale.ticks(TICKS_COUNT).map(tick => format(tick)); | |||
const uniqueTicksCount = uniq(ticks).length; | |||
const ticksCount = uniqueTicksCount < TICKS_COUNT ? uniqueTicksCount - 1 : TICKS_COUNT; | |||
return scale.ticks(ticksCount); | |||
}, | |||
} | |||
renderXGrid(ticks, xScale, yScale) { | |||
renderXGrid(ticks: Array<number>, xScale: Scale, yScale: Scale) { | |||
if (!this.props.displayXGrid) { | |||
return null; | |||
} | |||
@@ -131,9 +150,9 @@ export const BubbleChart = React.createClass({ | |||
}); | |||
return <g ref="xGrid">{lines}</g>; | |||
}, | |||
} | |||
renderYGrid(ticks, xScale, yScale) { | |||
renderYGrid(ticks: Array<number>, xScale: Scale, yScale: Scale) { | |||
if (!this.props.displayYGrid) { | |||
return null; | |||
} | |||
@@ -146,9 +165,9 @@ export const BubbleChart = React.createClass({ | |||
}); | |||
return <g ref="yGrid">{lines}</g>; | |||
}, | |||
} | |||
renderXTicks(xTicks, xScale, yScale) { | |||
renderXTicks(xTicks: Array<number>, xScale: Scale, yScale: Scale) { | |||
if (!this.props.displayXTicks) { | |||
return null; | |||
} | |||
@@ -165,9 +184,9 @@ export const BubbleChart = React.createClass({ | |||
}); | |||
return <g>{ticks}</g>; | |||
}, | |||
} | |||
renderYTicks(yTicks, xScale, yScale) { | |||
renderYTicks(yTicks: Array<number>, xScale: Scale, yScale: Scale) { | |||
if (!this.props.displayYTicks) { | |||
return null; | |||
} | |||
@@ -183,37 +202,32 @@ export const BubbleChart = React.createClass({ | |||
x={x} | |||
y={y} | |||
dx="-0.5em" | |||
dy="0.3em" | |||
> | |||
dy="0.3em"> | |||
{innerText} | |||
</text> | |||
); | |||
}); | |||
return <g>{ticks}</g>; | |||
}, | |||
render() { | |||
if (!this.state.width || !this.state.height) { | |||
return <div />; | |||
} | |||
} | |||
const availableWidth = this.state.width - this.props.padding[1] - this.props.padding[3]; | |||
const availableHeight = this.state.height - this.props.padding[0] - this.props.padding[2]; | |||
renderChart(width: number) { | |||
const availableWidth = width - this.props.padding[1] - this.props.padding[3]; | |||
const availableHeight = this.props.height - this.props.padding[0] - this.props.padding[2]; | |||
const xScale = d3.scale | |||
.linear() | |||
.domain([0, d3.max(this.props.items, d => d.x)]) | |||
.domain(this.props.xDomain || [0, d3.max(this.props.items, d => d.x)]) | |||
.range([0, availableWidth]) | |||
.nice(); | |||
const yScale = d3.scale | |||
.linear() | |||
.domain([0, d3.max(this.props.items, d => d.y)]) | |||
.domain(this.props.yDomain || [0, d3.max(this.props.items, d => d.y)]) | |||
.range([availableHeight, 0]) | |||
.nice(); | |||
const sizeScale = d3.scale | |||
.linear() | |||
.domain([0, d3.max(this.props.items, d => d.size)]) | |||
.domain(this.props.sizeDomain || [0, d3.max(this.props.items, d => d.size)]) | |||
.range(this.props.sizeRange); | |||
const xScaleOriginal = xScale.copy(); | |||
@@ -225,12 +239,13 @@ export const BubbleChart = React.createClass({ | |||
const bubbles = sortBy(this.props.items, b => -b.size).map((item, index) => { | |||
return ( | |||
<Bubble | |||
key={index} | |||
key={item.key || index} | |||
link={item.link} | |||
tooltip={item.tooltip} | |||
x={xScale(item.x)} | |||
y={yScale(item.y)} | |||
r={sizeScale(item.size)} | |||
color={item.color} | |||
onClick={this.props.onBubbleClick} | |||
/> | |||
); | |||
@@ -240,7 +255,7 @@ export const BubbleChart = React.createClass({ | |||
const yTicks = this.getTicks(yScale, this.props.formatYTick); | |||
return ( | |||
<svg className="bubble-chart" width={this.state.width} height={this.state.height}> | |||
<svg className="bubble-chart" width={width} height={this.props.height}> | |||
<g transform={`translate(${this.props.padding[3]}, ${this.props.padding[0]})`}> | |||
{this.renderXGrid(xTicks, xScale, yScale)} | |||
{this.renderXTicks(xTicks, xScale, yScaleOriginal)} | |||
@@ -251,4 +266,12 @@ export const BubbleChart = React.createClass({ | |||
</svg> | |||
); | |||
} | |||
}); | |||
render() { | |||
return ( | |||
<AutoSizer disableHeight={true}> | |||
{size => this.renderChart(size.width)} | |||
</AutoSizer> | |||
); | |||
} | |||
} |
@@ -18,23 +18,11 @@ | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import React from 'react'; | |||
import { shallow } from 'enzyme'; | |||
import { BubbleChart, Bubble } from '../bubble-chart'; | |||
import { mount } from 'enzyme'; | |||
import BubbleChart from '../BubbleChart'; | |||
it('should display bubbles', () => { | |||
const items = [{ x: 1, y: 10, size: 7 }, { x: 2, y: 30, size: 5 }, { x: 3, y: 20, size: 2 }]; | |||
const chart = shallow(<BubbleChart items={items} width={100} height={100} />); | |||
expect(chart.find(Bubble).length).toBe(3); | |||
}); | |||
it('should display grid', () => { | |||
const items = [{ x: 1, y: 10, size: 7 }, { x: 2, y: 30, size: 5 }, { x: 3, y: 20, size: 2 }]; | |||
const chart = shallow(<BubbleChart items={items} width={100} height={100} />); | |||
expect(chart.find('line').length).toBeGreaterThan(0); | |||
}); | |||
it('should display ticks', () => { | |||
const items = [{ x: 1, y: 10, size: 7 }, { x: 2, y: 30, size: 5 }, { x: 3, y: 20, size: 2 }]; | |||
const chart = shallow(<BubbleChart items={items} width={100} height={100} />); | |||
expect(chart.find('.bubble-chart-tick').length).toBeGreaterThan(0); | |||
const chart = mount(<BubbleChart items={items} height={100} />); | |||
expect(chart).toMatchSnapshot(); | |||
}); |
@@ -0,0 +1,311 @@ | |||
exports[`test should display bubbles 1`] = ` | |||
<BubbleChart | |||
displayXGrid={true} | |||
displayXTicks={true} | |||
displayYGrid={true} | |||
displayYTicks={true} | |||
formatXTick={[Function]} | |||
formatYTick={[Function]} | |||
height={100} | |||
items={ | |||
Array [ | |||
Object { | |||
"size": 7, | |||
"x": 1, | |||
"y": 10, | |||
}, | |||
Object { | |||
"size": 5, | |||
"x": 2, | |||
"y": 30, | |||
}, | |||
Object { | |||
"size": 2, | |||
"x": 3, | |||
"y": 20, | |||
}, | |||
] | |||
} | |||
padding={ | |||
Array [ | |||
10, | |||
10, | |||
10, | |||
10, | |||
] | |||
} | |||
sizeRange={ | |||
Array [ | |||
5, | |||
45, | |||
] | |||
}> | |||
<AutoSizer | |||
disableHeight={true} | |||
onResize={[Function]}> | |||
<div | |||
style={ | |||
Object { | |||
"overflow": "visible", | |||
"width": 0, | |||
} | |||
}> | |||
<svg | |||
className="bubble-chart" | |||
height={100} | |||
width={0}> | |||
<g | |||
transform="translate(10, 10)"> | |||
<g> | |||
<line | |||
className="bubble-chart-grid" | |||
x1={51.666666666666664} | |||
x2={51.666666666666664} | |||
y1={61.66666666666666} | |||
y2={33.57142857142858} /> | |||
<line | |||
className="bubble-chart-grid" | |||
x1={30} | |||
x2={30} | |||
y1={61.66666666666666} | |||
y2={33.57142857142858} /> | |||
<line | |||
className="bubble-chart-grid" | |||
x1={8.333333333333336} | |||
x2={8.333333333333336} | |||
y1={61.66666666666666} | |||
y2={33.57142857142858} /> | |||
<line | |||
className="bubble-chart-grid" | |||
x1={-13.33333333333334} | |||
x2={-13.33333333333334} | |||
y1={61.66666666666666} | |||
y2={33.57142857142858} /> | |||
<line | |||
className="bubble-chart-grid" | |||
x1={-35} | |||
x2={-35} | |||
y1={61.66666666666666} | |||
y2={33.57142857142858} /> | |||
<line | |||
className="bubble-chart-grid" | |||
x1={-56.66666666666668} | |||
x2={-56.66666666666668} | |||
y1={61.66666666666666} | |||
y2={33.57142857142858} /> | |||
<line | |||
className="bubble-chart-grid" | |||
x1={-78.33333333333334} | |||
x2={-78.33333333333334} | |||
y1={61.66666666666666} | |||
y2={33.57142857142858} /> | |||
</g> | |||
<g> | |||
<text | |||
className="bubble-chart-tick" | |||
dy="1.5em" | |||
x={51.666666666666664} | |||
y={80} /> | |||
<text | |||
className="bubble-chart-tick" | |||
dy="1.5em" | |||
x={30} | |||
y={80}> | |||
0.5 | |||
</text> | |||
<text | |||
className="bubble-chart-tick" | |||
dy="1.5em" | |||
x={8.333333333333336} | |||
y={80}> | |||
1 | |||
</text> | |||
<text | |||
className="bubble-chart-tick" | |||
dy="1.5em" | |||
x={-13.33333333333334} | |||
y={80}> | |||
1.5 | |||
</text> | |||
<text | |||
className="bubble-chart-tick" | |||
dy="1.5em" | |||
x={-35} | |||
y={80}> | |||
2 | |||
</text> | |||
<text | |||
className="bubble-chart-tick" | |||
dy="1.5em" | |||
x={-56.66666666666668} | |||
y={80}> | |||
2.5 | |||
</text> | |||
<text | |||
className="bubble-chart-tick" | |||
dy="1.5em" | |||
x={-78.33333333333334} | |||
y={80}> | |||
3 | |||
</text> | |||
</g> | |||
<g> | |||
<line | |||
className="bubble-chart-grid" | |||
x1={51.666666666666664} | |||
x2={-78.33333333333334} | |||
y1={61.66666666666666} | |||
y2={61.66666666666666} /> | |||
<line | |||
className="bubble-chart-grid" | |||
x1={51.666666666666664} | |||
x2={-78.33333333333334} | |||
y1={56.98412698412698} | |||
y2={56.98412698412698} /> | |||
<line | |||
className="bubble-chart-grid" | |||
x1={51.666666666666664} | |||
x2={-78.33333333333334} | |||
y1={52.3015873015873} | |||
y2={52.3015873015873} /> | |||
<line | |||
className="bubble-chart-grid" | |||
x1={51.666666666666664} | |||
x2={-78.33333333333334} | |||
y1={47.61904761904762} | |||
y2={47.61904761904762} /> | |||
<line | |||
className="bubble-chart-grid" | |||
x1={51.666666666666664} | |||
x2={-78.33333333333334} | |||
y1={42.93650793650794} | |||
y2={42.93650793650794} /> | |||
<line | |||
className="bubble-chart-grid" | |||
x1={51.666666666666664} | |||
x2={-78.33333333333334} | |||
y1={38.25396825396825} | |||
y2={38.25396825396825} /> | |||
<line | |||
className="bubble-chart-grid" | |||
x1={51.666666666666664} | |||
x2={-78.33333333333334} | |||
y1={33.57142857142858} | |||
y2={33.57142857142858} /> | |||
</g> | |||
<g> | |||
<text | |||
className="bubble-chart-tick bubble-chart-tick-y" | |||
dx="-0.5em" | |||
dy="0.3em" | |||
x={0} | |||
y={61.66666666666666} /> | |||
<text | |||
className="bubble-chart-tick bubble-chart-tick-y" | |||
dx="-0.5em" | |||
dy="0.3em" | |||
x={0} | |||
y={56.98412698412698}> | |||
5 | |||
</text> | |||
<text | |||
className="bubble-chart-tick bubble-chart-tick-y" | |||
dx="-0.5em" | |||
dy="0.3em" | |||
x={0} | |||
y={52.3015873015873}> | |||
10 | |||
</text> | |||
<text | |||
className="bubble-chart-tick bubble-chart-tick-y" | |||
dx="-0.5em" | |||
dy="0.3em" | |||
x={0} | |||
y={47.61904761904762}> | |||
15 | |||
</text> | |||
<text | |||
className="bubble-chart-tick bubble-chart-tick-y" | |||
dx="-0.5em" | |||
dy="0.3em" | |||
x={0} | |||
y={42.93650793650794}> | |||
20 | |||
</text> | |||
<text | |||
className="bubble-chart-tick bubble-chart-tick-y" | |||
dx="-0.5em" | |||
dy="0.3em" | |||
x={0} | |||
y={38.25396825396825}> | |||
25 | |||
</text> | |||
<text | |||
className="bubble-chart-tick bubble-chart-tick-y" | |||
dx="-0.5em" | |||
dy="0.3em" | |||
x={0} | |||
y={33.57142857142858}> | |||
30 | |||
</text> | |||
</g> | |||
<Bubble | |||
r={45} | |||
x={8.333333333333336} | |||
y={52.3015873015873}> | |||
<g> | |||
<circle | |||
className="bubble-chart-bubble" | |||
onClick={[Function]} | |||
r={45} | |||
style={ | |||
Object { | |||
"fill": undefined, | |||
"stroke": undefined, | |||
} | |||
} | |||
transform="translate(8.333333333333336, 52.3015873015873)" /> | |||
</g> | |||
</Bubble> | |||
<Bubble | |||
r={33.57142857142858} | |||
x={-35} | |||
y={33.57142857142858}> | |||
<g> | |||
<circle | |||
className="bubble-chart-bubble" | |||
onClick={[Function]} | |||
r={33.57142857142858} | |||
style={ | |||
Object { | |||
"fill": undefined, | |||
"stroke": undefined, | |||
} | |||
} | |||
transform="translate(-35, 33.57142857142858)" /> | |||
</g> | |||
</Bubble> | |||
<Bubble | |||
r={16.428571428571427} | |||
x={-78.33333333333334} | |||
y={42.93650793650794}> | |||
<g> | |||
<circle | |||
className="bubble-chart-bubble" | |||
onClick={[Function]} | |||
r={16.428571428571427} | |||
style={ | |||
Object { | |||
"fill": undefined, | |||
"stroke": undefined, | |||
} | |||
} | |||
transform="translate(-78.33333333333334, 42.93650793650794)" /> | |||
</g> | |||
</Bubble> | |||
</g> | |||
</svg> | |||
</div> | |||
</AutoSizer> | |||
</BubbleChart> | |||
`; |
@@ -28,3 +28,5 @@ export const CHART_REVERSED_COLORS_RANGE_PERCENT = [ | |||
'#b0d513', | |||
'#00aa00' | |||
]; | |||
export const RATING_COLORS = ['#00aa00', '#b0d513', '#eabe06', '#ed7d20', '#e00']; |
@@ -88,7 +88,6 @@ | |||
.Select-placeholder, | |||
:not(.Select--multi) > .Select-control .Select-value { | |||
bottom: 0; | |||
color: #aaa; | |||
left: 0; | |||
line-height: @formControlHeight - 1px; | |||
padding-left: 8px; |
@@ -833,6 +833,24 @@ projects.explore_projects=Explore Projects | |||
projects.not_analyzed=Project is not analyzed yet. | |||
projects.search=Search by project name or key | |||
projects.sort_list=Sort list by | |||
projects.view.list=List | |||
projects.view.visualizations=Visualizations | |||
projects.quality_model=Quality Model | |||
projects.worse_of_reliablity_and_security=Worse of Reliability and Security | |||
projects.limited_set_of_projects=Displayed project set limited to the top {0} projects based on current sort: {1}. | |||
projects.sort.name=by name | |||
projects.sort.reliability=by reliability (best first) | |||
projects.sort.-reliability=by reliability (worst first) | |||
projects.sort.security=by security (best first) | |||
projects.sort.-security=by security (worst first) | |||
projects.sort.maintainability=by maintainability (best first) | |||
projects.sort.-maintainability=by maintainability (worst first) | |||
projects.sort.coverage=by coverage (best first) | |||
projects.sort.-coverage=by coverage (worst first) | |||
projects.sort.duplications=by duplications (best first) | |||
projects.sort.-duplications=by duplications (worst first) | |||
projects.sort.size=by size (smallest first) | |||
projects.sort.-size=by size (biggest first) | |||
#------------------------------------------------------------------------------ |