@@ -18,7 +18,7 @@ | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { AnalysisEvent } from '../../types/types'; | |||
import { State } from './components/ProjectActivityAppContainer'; | |||
import { State } from './components/ProjectActivityApp'; | |||
export function addCustomEvent(analysis: string, event: AnalysisEvent) { | |||
return (state: State) => ({ |
@@ -18,94 +18,399 @@ | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import * as React from 'react'; | |||
import { Helmet } from 'react-helmet-async'; | |||
import A11ySkipTarget from '../../../components/a11y/A11ySkipTarget'; | |||
import Suggestions from '../../../components/embed-docs-modal/Suggestions'; | |||
import { useSearchParams } from 'react-router-dom'; | |||
import { getAllMetrics } from '../../../api/metrics'; | |||
import { | |||
changeEvent, | |||
createEvent, | |||
deleteAnalysis, | |||
deleteEvent, | |||
getProjectActivity, | |||
ProjectActivityStatuses | |||
} from '../../../api/projectActivity'; | |||
import { getAllTimeMachineData } from '../../../api/time-machine'; | |||
import withComponentContext from '../../../app/components/componentContext/withComponentContext'; | |||
import { | |||
DEFAULT_GRAPH, | |||
getActivityGraph, | |||
getHistoryMetrics, | |||
isCustomGraph | |||
} from '../../../components/activity-graph/utils'; | |||
import { Location, Router, withRouter } from '../../../components/hoc/withRouter'; | |||
import { getBranchLikeQuery } from '../../../helpers/branch-like'; | |||
import { parseDate } from '../../../helpers/dates'; | |||
import { translate } from '../../../helpers/l10n'; | |||
import { MeasureHistory } from '../../../types/project-activity'; | |||
import { Component, Metric, ParsedAnalysis } from '../../../types/types'; | |||
import { Query } from '../utils'; | |||
import './projectActivity.css'; | |||
import ProjectActivityAnalysesList from './ProjectActivityAnalysesList'; | |||
import ProjectActivityGraphs from './ProjectActivityGraphs'; | |||
import ProjectActivityPageFilters from './ProjectActivityPageFilters'; | |||
import { serializeStringArray } from '../../../helpers/query'; | |||
import { BranchLike } from '../../../types/branch-like'; | |||
import { MetricKey } from '../../../types/metrics'; | |||
import { GraphType, MeasureHistory } from '../../../types/project-activity'; | |||
import { Component, Metric, Paging, ParsedAnalysis, RawQuery } from '../../../types/types'; | |||
import * as actions from '../actions'; | |||
import { | |||
customMetricsChanged, | |||
parseQuery, | |||
Query, | |||
serializeQuery, | |||
serializeUrlQuery | |||
} from '../utils'; | |||
import ProjectActivityAppRenderer from './ProjectActivityAppRenderer'; | |||
interface Props { | |||
addCustomEvent: (analysis: string, name: string, category?: string) => Promise<void>; | |||
addVersion: (analysis: string, version: string) => Promise<void>; | |||
branchLike?: BranchLike; | |||
component: Component; | |||
location: Location; | |||
router: Router; | |||
} | |||
export interface State { | |||
analyses: ParsedAnalysis[]; | |||
analysesLoading: boolean; | |||
changeEvent: (event: string, name: string) => Promise<void>; | |||
deleteAnalysis: (analysis: string) => Promise<void>; | |||
deleteEvent: (analysis: string, event: string) => Promise<void>; | |||
graphLoading: boolean; | |||
initializing: boolean; | |||
project: Pick<Component, 'configuration' | 'key' | 'leakPeriodDate' | 'qualifier'>; | |||
initialized: boolean; | |||
metrics: Metric[]; | |||
measuresHistory: MeasureHistory[]; | |||
query: Query; | |||
updateQuery: (changes: Partial<Query>) => void; | |||
} | |||
export default function ProjectActivityApp(props: Props) { | |||
const { analyses, measuresHistory, query } = props; | |||
const { configuration } = props.project; | |||
const canAdmin = | |||
(props.project.qualifier === 'TRK' || props.project.qualifier === 'APP') && | |||
(configuration ? configuration.showHistory : false); | |||
const canDeleteAnalyses = configuration ? configuration.showHistory : false; | |||
return ( | |||
<div className="page page-limited" id="project-activity"> | |||
<Suggestions suggestions="project_activity" /> | |||
<Helmet defer={false} title={translate('project_activity.page')} /> | |||
<A11ySkipTarget anchor="activity_main" /> | |||
<ProjectActivityPageFilters | |||
category={query.category} | |||
from={query.from} | |||
project={props.project} | |||
to={query.to} | |||
updateQuery={props.updateQuery} | |||
export const PROJECT_ACTIVITY_GRAPH = 'sonar_project_activity.graph'; | |||
const ACTIVITY_PAGE_SIZE_FIRST_BATCH = 100; | |||
const ACTIVITY_PAGE_SIZE = 500; | |||
export class ProjectActivityApp extends React.PureComponent<Props, State> { | |||
mounted = false; | |||
constructor(props: Props) { | |||
super(props); | |||
this.state = { | |||
analyses: [], | |||
analysesLoading: false, | |||
graphLoading: true, | |||
initialized: false, | |||
measuresHistory: [], | |||
metrics: [], | |||
query: parseQuery(props.location.query) | |||
}; | |||
} | |||
componentDidMount() { | |||
this.mounted = true; | |||
this.firstLoadData(this.state.query, this.props.component); | |||
} | |||
componentDidUpdate(prevProps: Props) { | |||
if (prevProps.location.query !== this.props.location.query) { | |||
const query = parseQuery(this.props.location.query); | |||
if (query.graph !== this.state.query.graph || customMetricsChanged(this.state.query, query)) { | |||
if (this.state.initialized) { | |||
this.updateGraphData(query.graph || DEFAULT_GRAPH, query.customMetrics); | |||
} else { | |||
this.firstLoadData(query, this.props.component); | |||
} | |||
} | |||
this.setState({ query }); | |||
} | |||
} | |||
componentWillUnmount() { | |||
this.mounted = false; | |||
} | |||
addCustomEvent = (analysisKey: string, name: string, category?: string) => { | |||
return createEvent(analysisKey, name, category).then(({ analysis, ...event }) => { | |||
if (this.mounted) { | |||
this.setState(actions.addCustomEvent(analysis, event)); | |||
} | |||
}); | |||
}; | |||
addVersion = (analysis: string, version: string) => { | |||
return this.addCustomEvent(analysis, version, 'VERSION'); | |||
}; | |||
changeEvent = (eventKey: string, name: string) => { | |||
return changeEvent(eventKey, name).then(({ analysis, ...event }) => { | |||
if (this.mounted) { | |||
this.setState(actions.changeEvent(analysis, event)); | |||
} | |||
}); | |||
}; | |||
deleteAnalysis = (analysis: string) => { | |||
return deleteAnalysis(analysis).then(() => { | |||
if (this.mounted) { | |||
this.updateGraphData( | |||
this.state.query.graph || DEFAULT_GRAPH, | |||
this.state.query.customMetrics | |||
); | |||
this.setState(actions.deleteAnalysis(analysis)); | |||
} | |||
}); | |||
}; | |||
deleteEvent = (analysis: string, event: string) => { | |||
return deleteEvent(event).then(() => { | |||
if (this.mounted) { | |||
this.setState(actions.deleteEvent(analysis, event)); | |||
} | |||
}); | |||
}; | |||
fetchActivity = ( | |||
project: string, | |||
statuses: ProjectActivityStatuses[], | |||
p: number, | |||
ps: number, | |||
additional?: RawQuery | |||
) => { | |||
const parameters = { | |||
project, | |||
statuses: serializeStringArray(statuses), | |||
p, | |||
ps, | |||
...getBranchLikeQuery(this.props.branchLike) | |||
}; | |||
return getProjectActivity({ ...additional, ...parameters }).then(({ analyses, paging }) => ({ | |||
analyses: analyses.map(analysis => ({ | |||
...analysis, | |||
date: parseDate(analysis.date) | |||
})) as ParsedAnalysis[], | |||
paging | |||
})); | |||
}; | |||
fetchMeasuresHistory = (metrics: string[]): Promise<MeasureHistory[]> => { | |||
if (metrics.length <= 0) { | |||
return Promise.resolve([]); | |||
} | |||
return getAllTimeMachineData({ | |||
component: this.props.component.key, | |||
metrics: metrics.join(), | |||
...getBranchLikeQuery(this.props.branchLike) | |||
}).then(({ measures }) => | |||
measures.map(measure => ({ | |||
metric: measure.metric, | |||
history: measure.history.map(analysis => ({ | |||
date: parseDate(analysis.date), | |||
value: analysis.value! | |||
})) | |||
})) | |||
); | |||
}; | |||
fetchAllActivities = (topLevelComponent: string) => { | |||
this.setState({ analysesLoading: true }); | |||
this.loadAllActivities(topLevelComponent).then( | |||
({ analyses }) => { | |||
if (this.mounted) { | |||
this.setState({ | |||
analyses, | |||
analysesLoading: false | |||
}); | |||
} | |||
}, | |||
() => { | |||
if (this.mounted) { | |||
this.setState({ analysesLoading: false }); | |||
} | |||
} | |||
); | |||
}; | |||
loadAllActivities = ( | |||
project: string, | |||
prevResult?: { analyses: ParsedAnalysis[]; paging: Paging } | |||
): Promise<{ analyses: ParsedAnalysis[]; paging: Paging }> => { | |||
if ( | |||
prevResult && | |||
prevResult.paging.pageIndex * prevResult.paging.pageSize >= prevResult.paging.total | |||
) { | |||
return Promise.resolve(prevResult); | |||
} | |||
const nextPage = prevResult ? prevResult.paging.pageIndex + 1 : 1; | |||
return this.fetchActivity( | |||
project, | |||
[ | |||
ProjectActivityStatuses.STATUS_PROCESSED, | |||
ProjectActivityStatuses.STATUS_LIVE_MEASURE_COMPUTE | |||
], | |||
nextPage, | |||
ACTIVITY_PAGE_SIZE | |||
).then(result => { | |||
if (!prevResult) { | |||
return this.loadAllActivities(project, result); | |||
} | |||
return this.loadAllActivities(project, { | |||
analyses: prevResult.analyses.concat(result.analyses), | |||
paging: result.paging | |||
}); | |||
}); | |||
}; | |||
getTopLevelComponent = (component: Component) => { | |||
let current = component.breadcrumbs.length - 1; | |||
while ( | |||
current > 0 && | |||
!['TRK', 'VW', 'APP'].includes(component.breadcrumbs[current].qualifier) | |||
) { | |||
current--; | |||
} | |||
return component.breadcrumbs[current].key; | |||
}; | |||
filterMetrics({ qualifier }: Component, metrics: Metric[]) { | |||
return ['VW', 'SVW'].includes(qualifier) | |||
? metrics.filter(metric => metric.key !== MetricKey.security_hotspots_reviewed) | |||
: metrics.filter(metric => metric.key !== MetricKey.security_review_rating); | |||
} | |||
firstLoadData(query: Query, component: Component) { | |||
const graphMetrics = getHistoryMetrics(query.graph || DEFAULT_GRAPH, query.customMetrics); | |||
const topLevelComponent = this.getTopLevelComponent(component); | |||
Promise.all([ | |||
this.fetchActivity( | |||
topLevelComponent, | |||
[ | |||
ProjectActivityStatuses.STATUS_PROCESSED, | |||
ProjectActivityStatuses.STATUS_LIVE_MEASURE_COMPUTE | |||
], | |||
1, | |||
ACTIVITY_PAGE_SIZE_FIRST_BATCH, | |||
serializeQuery(query) | |||
), | |||
getAllMetrics(), | |||
this.fetchMeasuresHistory(graphMetrics) | |||
]).then( | |||
([{ analyses }, metrics, measuresHistory]) => { | |||
if (this.mounted) { | |||
this.setState({ | |||
analyses, | |||
graphLoading: false, | |||
initialized: true, | |||
measuresHistory, | |||
metrics: this.filterMetrics(component, metrics) | |||
}); | |||
this.fetchAllActivities(topLevelComponent); | |||
} | |||
}, | |||
() => { | |||
if (this.mounted) { | |||
this.setState({ initialized: true, graphLoading: false }); | |||
} | |||
} | |||
); | |||
} | |||
updateGraphData = (graph: GraphType, customMetrics: string[]) => { | |||
const graphMetrics = getHistoryMetrics(graph, customMetrics); | |||
this.setState({ graphLoading: true }); | |||
this.fetchMeasuresHistory(graphMetrics).then( | |||
measuresHistory => { | |||
if (this.mounted) { | |||
this.setState({ graphLoading: false, measuresHistory }); | |||
} | |||
}, | |||
() => { | |||
if (this.mounted) { | |||
this.setState({ graphLoading: false, measuresHistory: [] }); | |||
} | |||
} | |||
); | |||
}; | |||
updateQuery = (newQuery: Query) => { | |||
const query = serializeUrlQuery({ | |||
...this.state.query, | |||
...newQuery | |||
}); | |||
this.props.router.push({ | |||
pathname: this.props.location.pathname, | |||
query: { | |||
...query, | |||
...getBranchLikeQuery(this.props.branchLike), | |||
id: this.props.component.key | |||
} | |||
}); | |||
}; | |||
shouldRedirect = () => { | |||
const locationQuery = this.props.location.query; | |||
if (!locationQuery) { | |||
return false; | |||
} | |||
const filtered = Object.keys(locationQuery).some( | |||
key => key !== 'id' && locationQuery[key] !== '' | |||
); | |||
const { graph, customGraphs } = getActivityGraph( | |||
PROJECT_ACTIVITY_GRAPH, | |||
this.props.component.key | |||
); | |||
const emptyCustomGraph = isCustomGraph(graph) && customGraphs.length <= 0; | |||
// if there is no filter, but there are saved preferences in the localStorage | |||
// also don't redirect to custom if there is no metrics selected for it | |||
return !filtered && graph != null && graph !== DEFAULT_GRAPH && !emptyCustomGraph; | |||
}; | |||
render() { | |||
return ( | |||
<ProjectActivityAppRenderer | |||
addCustomEvent={this.addCustomEvent} | |||
addVersion={this.addVersion} | |||
analyses={this.state.analyses} | |||
analysesLoading={this.state.analysesLoading} | |||
changeEvent={this.changeEvent} | |||
deleteAnalysis={this.deleteAnalysis} | |||
deleteEvent={this.deleteEvent} | |||
graphLoading={!this.state.initialized || this.state.graphLoading} | |||
initializing={!this.state.initialized} | |||
measuresHistory={this.state.measuresHistory} | |||
metrics={this.state.metrics} | |||
project={this.props.component} | |||
query={this.state.query} | |||
updateQuery={this.updateQuery} | |||
/> | |||
); | |||
} | |||
} | |||
const isFiltered = (searchParams: URLSearchParams) => { | |||
let filtered = false; | |||
searchParams.forEach((value, key) => { | |||
if (key !== 'id' && value !== '') { | |||
filtered = true; | |||
} | |||
}); | |||
return filtered; | |||
}; | |||
function RedirectWrapper(props: Props) { | |||
const [searchParams, setSearchParams] = useSearchParams(); | |||
const filtered = isFiltered(searchParams); | |||
<div className="layout-page project-activity-page"> | |||
<div className="layout-page-side-outer project-activity-page-side-outer boxed-group"> | |||
<ProjectActivityAnalysesList | |||
addCustomEvent={props.addCustomEvent} | |||
addVersion={props.addVersion} | |||
analyses={analyses} | |||
analysesLoading={props.analysesLoading} | |||
canAdmin={canAdmin} | |||
canDeleteAnalyses={canDeleteAnalyses} | |||
changeEvent={props.changeEvent} | |||
deleteAnalysis={props.deleteAnalysis} | |||
deleteEvent={props.deleteEvent} | |||
initializing={props.initializing} | |||
leakPeriodDate={ | |||
props.project.leakPeriodDate ? parseDate(props.project.leakPeriodDate) : undefined | |||
} | |||
project={props.project} | |||
query={props.query} | |||
updateQuery={props.updateQuery} | |||
/> | |||
</div> | |||
<div className="project-activity-layout-page-main"> | |||
<ProjectActivityGraphs | |||
analyses={analyses} | |||
leakPeriodDate={ | |||
props.project.leakPeriodDate ? parseDate(props.project.leakPeriodDate) : undefined | |||
} | |||
loading={props.graphLoading} | |||
measuresHistory={measuresHistory} | |||
metrics={props.metrics} | |||
project={props.project.key} | |||
query={query} | |||
updateQuery={props.updateQuery} | |||
/> | |||
</div> | |||
</div> | |||
</div> | |||
); | |||
const { graph, customGraphs } = getActivityGraph(PROJECT_ACTIVITY_GRAPH, props.component.key); | |||
const emptyCustomGraph = isCustomGraph(graph) && customGraphs.length <= 0; | |||
// if there is no filter, but there are saved preferences in the localStorage | |||
// also don't redirect to custom if there is no metrics selected for it | |||
const shouldRedirect = !filtered && graph != null && graph !== DEFAULT_GRAPH && !emptyCustomGraph; | |||
React.useEffect(() => { | |||
if (shouldRedirect) { | |||
const query = parseQuery(searchParams); | |||
const newQuery = { ...query, graph }; | |||
if (isCustomGraph(newQuery.graph)) { | |||
searchParams.set('custom_metrics', customGraphs.join(',')); | |||
} | |||
searchParams.set('graph', graph); | |||
setSearchParams(searchParams, { replace: true }); | |||
} | |||
}, [customGraphs, graph, searchParams, setSearchParams, shouldRedirect]); | |||
return shouldRedirect ? null : <ProjectActivityApp {...props} />; | |||
} | |||
export default withComponentContext(withRouter(RedirectWrapper)); |
@@ -1,416 +0,0 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2022 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 { useSearchParams } from 'react-router-dom'; | |||
import { getAllMetrics } from '../../../api/metrics'; | |||
import { | |||
changeEvent, | |||
createEvent, | |||
deleteAnalysis, | |||
deleteEvent, | |||
getProjectActivity, | |||
ProjectActivityStatuses | |||
} from '../../../api/projectActivity'; | |||
import { getAllTimeMachineData } from '../../../api/time-machine'; | |||
import withComponentContext from '../../../app/components/componentContext/withComponentContext'; | |||
import { | |||
DEFAULT_GRAPH, | |||
getActivityGraph, | |||
getHistoryMetrics, | |||
isCustomGraph | |||
} from '../../../components/activity-graph/utils'; | |||
import { Location, Router, withRouter } from '../../../components/hoc/withRouter'; | |||
import { getBranchLikeQuery } from '../../../helpers/branch-like'; | |||
import { parseDate } from '../../../helpers/dates'; | |||
import { serializeStringArray } from '../../../helpers/query'; | |||
import { BranchLike } from '../../../types/branch-like'; | |||
import { MetricKey } from '../../../types/metrics'; | |||
import { GraphType, MeasureHistory } from '../../../types/project-activity'; | |||
import { Component, Metric, Paging, ParsedAnalysis, RawQuery } from '../../../types/types'; | |||
import * as actions from '../actions'; | |||
import { | |||
customMetricsChanged, | |||
parseQuery, | |||
Query, | |||
serializeQuery, | |||
serializeUrlQuery | |||
} from '../utils'; | |||
import ProjectActivityApp from './ProjectActivityApp'; | |||
interface Props { | |||
branchLike?: BranchLike; | |||
component: Component; | |||
location: Location; | |||
router: Router; | |||
} | |||
export interface State { | |||
analyses: ParsedAnalysis[]; | |||
analysesLoading: boolean; | |||
graphLoading: boolean; | |||
initialized: boolean; | |||
metrics: Metric[]; | |||
measuresHistory: MeasureHistory[]; | |||
query: Query; | |||
} | |||
export const PROJECT_ACTIVITY_GRAPH = 'sonar_project_activity.graph'; | |||
const ACTIVITY_PAGE_SIZE_FIRST_BATCH = 100; | |||
const ACTIVITY_PAGE_SIZE = 500; | |||
export class ProjectActivityAppContainer extends React.PureComponent<Props, State> { | |||
mounted = false; | |||
constructor(props: Props) { | |||
super(props); | |||
this.state = { | |||
analyses: [], | |||
analysesLoading: false, | |||
graphLoading: true, | |||
initialized: false, | |||
measuresHistory: [], | |||
metrics: [], | |||
query: parseQuery(props.location.query) | |||
}; | |||
} | |||
componentDidMount() { | |||
this.mounted = true; | |||
this.firstLoadData(this.state.query, this.props.component); | |||
} | |||
componentDidUpdate(prevProps: Props) { | |||
if (prevProps.location.query !== this.props.location.query) { | |||
const query = parseQuery(this.props.location.query); | |||
if (query.graph !== this.state.query.graph || customMetricsChanged(this.state.query, query)) { | |||
if (this.state.initialized) { | |||
this.updateGraphData(query.graph || DEFAULT_GRAPH, query.customMetrics); | |||
} else { | |||
this.firstLoadData(query, this.props.component); | |||
} | |||
} | |||
this.setState({ query }); | |||
} | |||
} | |||
componentWillUnmount() { | |||
this.mounted = false; | |||
} | |||
addCustomEvent = (analysisKey: string, name: string, category?: string) => { | |||
return createEvent(analysisKey, name, category).then(({ analysis, ...event }) => { | |||
if (this.mounted) { | |||
this.setState(actions.addCustomEvent(analysis, event)); | |||
} | |||
}); | |||
}; | |||
addVersion = (analysis: string, version: string) => { | |||
return this.addCustomEvent(analysis, version, 'VERSION'); | |||
}; | |||
changeEvent = (eventKey: string, name: string) => { | |||
return changeEvent(eventKey, name).then(({ analysis, ...event }) => { | |||
if (this.mounted) { | |||
this.setState(actions.changeEvent(analysis, event)); | |||
} | |||
}); | |||
}; | |||
deleteAnalysis = (analysis: string) => { | |||
return deleteAnalysis(analysis).then(() => { | |||
if (this.mounted) { | |||
this.updateGraphData( | |||
this.state.query.graph || DEFAULT_GRAPH, | |||
this.state.query.customMetrics | |||
); | |||
this.setState(actions.deleteAnalysis(analysis)); | |||
} | |||
}); | |||
}; | |||
deleteEvent = (analysis: string, event: string) => { | |||
return deleteEvent(event).then(() => { | |||
if (this.mounted) { | |||
this.setState(actions.deleteEvent(analysis, event)); | |||
} | |||
}); | |||
}; | |||
fetchActivity = ( | |||
project: string, | |||
statuses: ProjectActivityStatuses[], | |||
p: number, | |||
ps: number, | |||
additional?: RawQuery | |||
) => { | |||
const parameters = { | |||
project, | |||
statuses: serializeStringArray(statuses), | |||
p, | |||
ps, | |||
...getBranchLikeQuery(this.props.branchLike) | |||
}; | |||
return getProjectActivity({ ...additional, ...parameters }).then(({ analyses, paging }) => ({ | |||
analyses: analyses.map(analysis => ({ | |||
...analysis, | |||
date: parseDate(analysis.date) | |||
})) as ParsedAnalysis[], | |||
paging | |||
})); | |||
}; | |||
fetchMeasuresHistory = (metrics: string[]): Promise<MeasureHistory[]> => { | |||
if (metrics.length <= 0) { | |||
return Promise.resolve([]); | |||
} | |||
return getAllTimeMachineData({ | |||
component: this.props.component.key, | |||
metrics: metrics.join(), | |||
...getBranchLikeQuery(this.props.branchLike) | |||
}).then(({ measures }) => | |||
measures.map(measure => ({ | |||
metric: measure.metric, | |||
history: measure.history.map(analysis => ({ | |||
date: parseDate(analysis.date), | |||
value: analysis.value! | |||
})) | |||
})) | |||
); | |||
}; | |||
fetchAllActivities = (topLevelComponent: string) => { | |||
this.setState({ analysesLoading: true }); | |||
this.loadAllActivities(topLevelComponent).then( | |||
({ analyses }) => { | |||
if (this.mounted) { | |||
this.setState({ | |||
analyses, | |||
analysesLoading: false | |||
}); | |||
} | |||
}, | |||
() => { | |||
if (this.mounted) { | |||
this.setState({ analysesLoading: false }); | |||
} | |||
} | |||
); | |||
}; | |||
loadAllActivities = ( | |||
project: string, | |||
prevResult?: { analyses: ParsedAnalysis[]; paging: Paging } | |||
): Promise<{ analyses: ParsedAnalysis[]; paging: Paging }> => { | |||
if ( | |||
prevResult && | |||
prevResult.paging.pageIndex * prevResult.paging.pageSize >= prevResult.paging.total | |||
) { | |||
return Promise.resolve(prevResult); | |||
} | |||
const nextPage = prevResult ? prevResult.paging.pageIndex + 1 : 1; | |||
return this.fetchActivity( | |||
project, | |||
[ | |||
ProjectActivityStatuses.STATUS_PROCESSED, | |||
ProjectActivityStatuses.STATUS_LIVE_MEASURE_COMPUTE | |||
], | |||
nextPage, | |||
ACTIVITY_PAGE_SIZE | |||
).then(result => { | |||
if (!prevResult) { | |||
return this.loadAllActivities(project, result); | |||
} | |||
return this.loadAllActivities(project, { | |||
analyses: prevResult.analyses.concat(result.analyses), | |||
paging: result.paging | |||
}); | |||
}); | |||
}; | |||
getTopLevelComponent = (component: Component) => { | |||
let current = component.breadcrumbs.length - 1; | |||
while ( | |||
current > 0 && | |||
!['TRK', 'VW', 'APP'].includes(component.breadcrumbs[current].qualifier) | |||
) { | |||
current--; | |||
} | |||
return component.breadcrumbs[current].key; | |||
}; | |||
filterMetrics({ qualifier }: Component, metrics: Metric[]) { | |||
return ['VW', 'SVW'].includes(qualifier) | |||
? metrics.filter(metric => metric.key !== MetricKey.security_hotspots_reviewed) | |||
: metrics.filter(metric => metric.key !== MetricKey.security_review_rating); | |||
} | |||
firstLoadData(query: Query, component: Component) { | |||
const graphMetrics = getHistoryMetrics(query.graph || DEFAULT_GRAPH, query.customMetrics); | |||
const topLevelComponent = this.getTopLevelComponent(component); | |||
Promise.all([ | |||
this.fetchActivity( | |||
topLevelComponent, | |||
[ | |||
ProjectActivityStatuses.STATUS_PROCESSED, | |||
ProjectActivityStatuses.STATUS_LIVE_MEASURE_COMPUTE | |||
], | |||
1, | |||
ACTIVITY_PAGE_SIZE_FIRST_BATCH, | |||
serializeQuery(query) | |||
), | |||
getAllMetrics(), | |||
this.fetchMeasuresHistory(graphMetrics) | |||
]).then( | |||
([{ analyses }, metrics, measuresHistory]) => { | |||
if (this.mounted) { | |||
this.setState({ | |||
analyses, | |||
graphLoading: false, | |||
initialized: true, | |||
measuresHistory, | |||
metrics: this.filterMetrics(component, metrics) | |||
}); | |||
this.fetchAllActivities(topLevelComponent); | |||
} | |||
}, | |||
() => { | |||
if (this.mounted) { | |||
this.setState({ initialized: true, graphLoading: false }); | |||
} | |||
} | |||
); | |||
} | |||
updateGraphData = (graph: GraphType, customMetrics: string[]) => { | |||
const graphMetrics = getHistoryMetrics(graph, customMetrics); | |||
this.setState({ graphLoading: true }); | |||
this.fetchMeasuresHistory(graphMetrics).then( | |||
measuresHistory => { | |||
if (this.mounted) { | |||
this.setState({ graphLoading: false, measuresHistory }); | |||
} | |||
}, | |||
() => { | |||
if (this.mounted) { | |||
this.setState({ graphLoading: false, measuresHistory: [] }); | |||
} | |||
} | |||
); | |||
}; | |||
updateQuery = (newQuery: Query) => { | |||
const query = serializeUrlQuery({ | |||
...this.state.query, | |||
...newQuery | |||
}); | |||
this.props.router.push({ | |||
pathname: this.props.location.pathname, | |||
query: { | |||
...query, | |||
...getBranchLikeQuery(this.props.branchLike), | |||
id: this.props.component.key | |||
} | |||
}); | |||
}; | |||
shouldRedirect = () => { | |||
const locationQuery = this.props.location.query; | |||
if (!locationQuery) { | |||
return false; | |||
} | |||
const filtered = Object.keys(locationQuery).some( | |||
key => key !== 'id' && locationQuery[key] !== '' | |||
); | |||
const { graph, customGraphs } = getActivityGraph( | |||
PROJECT_ACTIVITY_GRAPH, | |||
this.props.component.key | |||
); | |||
const emptyCustomGraph = isCustomGraph(graph) && customGraphs.length <= 0; | |||
// if there is no filter, but there are saved preferences in the localStorage | |||
// also don't redirect to custom if there is no metrics selected for it | |||
return !filtered && graph != null && graph !== DEFAULT_GRAPH && !emptyCustomGraph; | |||
}; | |||
render() { | |||
return ( | |||
<ProjectActivityApp | |||
addCustomEvent={this.addCustomEvent} | |||
addVersion={this.addVersion} | |||
analyses={this.state.analyses} | |||
analysesLoading={this.state.analysesLoading} | |||
changeEvent={this.changeEvent} | |||
deleteAnalysis={this.deleteAnalysis} | |||
deleteEvent={this.deleteEvent} | |||
graphLoading={!this.state.initialized || this.state.graphLoading} | |||
initializing={!this.state.initialized} | |||
measuresHistory={this.state.measuresHistory} | |||
metrics={this.state.metrics} | |||
project={this.props.component} | |||
query={this.state.query} | |||
updateQuery={this.updateQuery} | |||
/> | |||
); | |||
} | |||
} | |||
const isFiltered = (searchParams: URLSearchParams) => { | |||
let filtered = false; | |||
searchParams.forEach((value, key) => { | |||
if (key !== 'id' && value !== '') { | |||
filtered = true; | |||
} | |||
}); | |||
return filtered; | |||
}; | |||
function RedirectWrapper(props: Props) { | |||
const [searchParams, setSearchParams] = useSearchParams(); | |||
const filtered = isFiltered(searchParams); | |||
const { graph, customGraphs } = getActivityGraph(PROJECT_ACTIVITY_GRAPH, props.component.key); | |||
const emptyCustomGraph = isCustomGraph(graph) && customGraphs.length <= 0; | |||
// if there is no filter, but there are saved preferences in the localStorage | |||
// also don't redirect to custom if there is no metrics selected for it | |||
const shouldRedirect = !filtered && graph != null && graph !== DEFAULT_GRAPH && !emptyCustomGraph; | |||
React.useEffect(() => { | |||
if (shouldRedirect) { | |||
const query = parseQuery(searchParams); | |||
const newQuery = { ...query, graph }; | |||
if (isCustomGraph(newQuery.graph)) { | |||
searchParams.set('custom_metrics', customGraphs.join(',')); | |||
} | |||
searchParams.set('graph', graph); | |||
setSearchParams(searchParams, { replace: true }); | |||
} | |||
}, [customGraphs, graph, searchParams, setSearchParams, shouldRedirect]); | |||
return shouldRedirect ? null : <ProjectActivityAppContainer {...props} />; | |||
} | |||
export default withComponentContext(withRouter(RedirectWrapper)); |
@@ -0,0 +1,111 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2022 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 { Helmet } from 'react-helmet-async'; | |||
import A11ySkipTarget from '../../../components/a11y/A11ySkipTarget'; | |||
import Suggestions from '../../../components/embed-docs-modal/Suggestions'; | |||
import { parseDate } from '../../../helpers/dates'; | |||
import { translate } from '../../../helpers/l10n'; | |||
import { MeasureHistory } from '../../../types/project-activity'; | |||
import { Component, Metric, ParsedAnalysis } from '../../../types/types'; | |||
import { Query } from '../utils'; | |||
import './projectActivity.css'; | |||
import ProjectActivityAnalysesList from './ProjectActivityAnalysesList'; | |||
import ProjectActivityGraphs from './ProjectActivityGraphs'; | |||
import ProjectActivityPageFilters from './ProjectActivityPageFilters'; | |||
interface Props { | |||
addCustomEvent: (analysis: string, name: string, category?: string) => Promise<void>; | |||
addVersion: (analysis: string, version: string) => Promise<void>; | |||
analyses: ParsedAnalysis[]; | |||
analysesLoading: boolean; | |||
changeEvent: (event: string, name: string) => Promise<void>; | |||
deleteAnalysis: (analysis: string) => Promise<void>; | |||
deleteEvent: (analysis: string, event: string) => Promise<void>; | |||
graphLoading: boolean; | |||
initializing: boolean; | |||
project: Pick<Component, 'configuration' | 'key' | 'leakPeriodDate' | 'qualifier'>; | |||
metrics: Metric[]; | |||
measuresHistory: MeasureHistory[]; | |||
query: Query; | |||
updateQuery: (changes: Partial<Query>) => void; | |||
} | |||
export default function ProjectActivityAppRenderer(props: Props) { | |||
const { analyses, measuresHistory, query } = props; | |||
const { configuration } = props.project; | |||
const canAdmin = | |||
(props.project.qualifier === 'TRK' || props.project.qualifier === 'APP') && | |||
(configuration ? configuration.showHistory : false); | |||
const canDeleteAnalyses = configuration ? configuration.showHistory : false; | |||
return ( | |||
<div className="page page-limited" id="project-activity"> | |||
<Suggestions suggestions="project_activity" /> | |||
<Helmet defer={false} title={translate('project_activity.page')} /> | |||
<A11ySkipTarget anchor="activity_main" /> | |||
<ProjectActivityPageFilters | |||
category={query.category} | |||
from={query.from} | |||
project={props.project} | |||
to={query.to} | |||
updateQuery={props.updateQuery} | |||
/> | |||
<div className="layout-page project-activity-page"> | |||
<div className="layout-page-side-outer project-activity-page-side-outer boxed-group"> | |||
<ProjectActivityAnalysesList | |||
addCustomEvent={props.addCustomEvent} | |||
addVersion={props.addVersion} | |||
analyses={analyses} | |||
analysesLoading={props.analysesLoading} | |||
canAdmin={canAdmin} | |||
canDeleteAnalyses={canDeleteAnalyses} | |||
changeEvent={props.changeEvent} | |||
deleteAnalysis={props.deleteAnalysis} | |||
deleteEvent={props.deleteEvent} | |||
initializing={props.initializing} | |||
leakPeriodDate={ | |||
props.project.leakPeriodDate ? parseDate(props.project.leakPeriodDate) : undefined | |||
} | |||
project={props.project} | |||
query={props.query} | |||
updateQuery={props.updateQuery} | |||
/> | |||
</div> | |||
<div className="project-activity-layout-page-main"> | |||
<ProjectActivityGraphs | |||
analyses={analyses} | |||
leakPeriodDate={ | |||
props.project.leakPeriodDate ? parseDate(props.project.leakPeriodDate) : undefined | |||
} | |||
loading={props.graphLoading} | |||
measuresHistory={measuresHistory} | |||
metrics={props.metrics} | |||
project={props.project.key} | |||
query={query} | |||
updateQuery={props.updateQuery} | |||
/> | |||
</div> | |||
</div> | |||
</div> | |||
); | |||
} |
@@ -34,7 +34,7 @@ import { | |||
import { GraphType, MeasureHistory, Point, Serie } from '../../../types/project-activity'; | |||
import { Metric, ParsedAnalysis } from '../../../types/types'; | |||
import { datesQueryChanged, historyQueryChanged, Query } from '../utils'; | |||
import { PROJECT_ACTIVITY_GRAPH } from './ProjectActivityAppContainer'; | |||
import { PROJECT_ACTIVITY_GRAPH } from './ProjectActivityApp'; | |||
interface Props { | |||
analyses: ParsedAnalysis[]; |
@@ -25,7 +25,7 @@ import { mockComponent } from '../../../../helpers/mocks/component'; | |||
import { renderApp } from '../../../../helpers/testReactTestingUtils'; | |||
import { ComponentQualifier } from '../../../../types/component'; | |||
import { Component } from '../../../../types/types'; | |||
import ProjectActivityAppContainer from '../ProjectActivityAppContainer'; | |||
import ProjectActivityAppContainer from '../ProjectActivityApp'; | |||
jest.mock('../../../../api/time-machine', () => { | |||
const { mockPaging } = jest.requireActual('../../../../helpers/testMocks'); |
@@ -19,80 +19,114 @@ | |||
*/ | |||
import { shallow } from 'enzyme'; | |||
import * as React from 'react'; | |||
import { DEFAULT_GRAPH } from '../../../../components/activity-graph/utils'; | |||
import { parseDate } from '../../../../helpers/dates'; | |||
import ProjectActivityApp from '../ProjectActivityApp'; | |||
import { changeEvent, createEvent } from '../../../../api/projectActivity'; | |||
import { mockComponent } from '../../../../helpers/mocks/component'; | |||
import { | |||
mockAnalysisEvent, | |||
mockLocation, | |||
mockMetric, | |||
mockRouter | |||
} from '../../../../helpers/testMocks'; | |||
import { waitAndUpdate } from '../../../../helpers/testUtils'; | |||
import { ComponentQualifier } from '../../../../types/component'; | |||
import { MetricKey } from '../../../../types/metrics'; | |||
import { ProjectActivityApp } from '../ProjectActivityApp'; | |||
const ANALYSES = [ | |||
{ | |||
key: 'A1', | |||
date: parseDate('2016-10-27T16:33:50+0200'), | |||
events: [ | |||
{ | |||
key: 'E1', | |||
category: 'VERSION', | |||
name: '6.5-SNAPSHOT' | |||
} | |||
] | |||
}, | |||
{ | |||
key: 'A2', | |||
date: parseDate('2016-10-27T12:21:15+0200'), | |||
events: [] | |||
}, | |||
{ | |||
key: 'A3', | |||
date: parseDate('2016-10-26T12:17:29+0200'), | |||
events: [ | |||
{ | |||
key: 'E2', | |||
category: 'VERSION', | |||
name: '6.4' | |||
}, | |||
{ | |||
key: 'E3', | |||
category: 'OTHER', | |||
name: 'foo' | |||
} | |||
] | |||
} | |||
]; | |||
jest.mock('../../../../helpers/dates', () => ({ | |||
parseDate: jest.fn(date => `PARSED:${date}`) | |||
})); | |||
const DEFAULT_PROPS = { | |||
addCustomEvent: jest.fn().mockResolvedValue(undefined), | |||
addVersion: jest.fn().mockResolvedValue(undefined), | |||
analyses: ANALYSES, | |||
analysesLoading: false, | |||
branch: { isMain: true }, | |||
changeEvent: jest.fn().mockResolvedValue(undefined), | |||
deleteAnalysis: jest.fn().mockResolvedValue(undefined), | |||
deleteEvent: jest.fn().mockResolvedValue(undefined), | |||
graphLoading: false, | |||
initializing: false, | |||
project: { | |||
key: 'foo', | |||
leakPeriodDate: '2017-05-16T13:50:02+0200', | |||
qualifier: 'TRK' | |||
}, | |||
metrics: [{ id: '1', key: 'code_smells', name: 'Code Smells', type: 'INT' }], | |||
measuresHistory: [ | |||
{ | |||
metric: 'code_smells', | |||
history: [ | |||
{ date: parseDate('Fri Mar 04 2016 10:40:12 GMT+0100 (CET)'), value: '1749' }, | |||
{ date: parseDate('Fri Mar 04 2016 18:40:16 GMT+0100 (CET)'), value: '2286' } | |||
] | |||
} | |||
], | |||
query: { | |||
category: '', | |||
customMetrics: [], | |||
graph: DEFAULT_GRAPH, | |||
project: 'org.sonarsource.sonarqube:sonarqube' | |||
}, | |||
updateQuery: () => {} | |||
}; | |||
jest.mock('../../../../api/time-machine', () => { | |||
const { mockPaging } = jest.requireActual('../../../../helpers/testMocks'); | |||
return { | |||
getAllTimeMachineData: jest.fn().mockResolvedValue({ | |||
measures: [ | |||
{ | |||
metric: 'bugs', | |||
history: [{ date: '2022-01-01', value: '10' }] | |||
} | |||
], | |||
paging: mockPaging({ total: 1 }) | |||
}) | |||
}; | |||
}); | |||
jest.mock('../../../../api/metrics', () => { | |||
const { mockMetric } = jest.requireActual('../../../../helpers/testMocks'); | |||
return { | |||
getAllMetrics: jest.fn().mockResolvedValue([mockMetric()]) | |||
}; | |||
}); | |||
jest.mock('../../../../api/projectActivity', () => { | |||
const { mockAnalysis, mockPaging } = jest.requireActual('../../../../helpers/testMocks'); | |||
return { | |||
...jest.requireActual('../../../../api/projectActivity'), | |||
createEvent: jest.fn(), | |||
changeEvent: jest.fn(), | |||
getProjectActivity: jest.fn().mockResolvedValue({ | |||
analyses: [mockAnalysis({ key: 'foo' })], | |||
paging: mockPaging({ total: 1 }) | |||
}) | |||
}; | |||
}); | |||
it('should render correctly', () => { | |||
expect(shallow(<ProjectActivityApp {...DEFAULT_PROPS} />)).toMatchSnapshot(); | |||
expect(shallowRender()).toMatchSnapshot(); | |||
}); | |||
it('should filter metric correctly', () => { | |||
const wrapper = shallowRender(); | |||
let metrics = wrapper | |||
.instance() | |||
.filterMetrics(mockComponent({ qualifier: ComponentQualifier.Project }), [ | |||
mockMetric({ key: MetricKey.bugs }), | |||
mockMetric({ key: MetricKey.security_review_rating }) | |||
]); | |||
expect(metrics).toHaveLength(1); | |||
metrics = wrapper | |||
.instance() | |||
.filterMetrics(mockComponent({ qualifier: ComponentQualifier.Portfolio }), [ | |||
mockMetric({ key: MetricKey.bugs }), | |||
mockMetric({ key: MetricKey.security_hotspots_reviewed }) | |||
]); | |||
expect(metrics).toHaveLength(1); | |||
}); | |||
it('should correctly create and update custom events', async () => { | |||
const analysisKey = 'foo'; | |||
const name = 'bar'; | |||
const newName = 'baz'; | |||
const event = mockAnalysisEvent({ name }); | |||
(createEvent as jest.Mock).mockResolvedValueOnce({ analysis: analysisKey, ...event }); | |||
(changeEvent as jest.Mock).mockResolvedValueOnce({ | |||
analysis: analysisKey, | |||
...event, | |||
name: newName | |||
}); | |||
const wrapper = shallowRender(); | |||
await waitAndUpdate(wrapper); | |||
const instance = wrapper.instance(); | |||
instance.addCustomEvent(analysisKey, name); | |||
expect(createEvent).toHaveBeenCalledWith(analysisKey, name, undefined); | |||
await waitAndUpdate(wrapper); | |||
expect(wrapper.state().analyses[0].events[0]).toEqual(event); | |||
instance.changeEvent(event.key, newName); | |||
expect(changeEvent).toHaveBeenCalledWith(event.key, newName); | |||
await waitAndUpdate(wrapper); | |||
expect(wrapper.state().analyses[0].events[0]).toEqual({ ...event, name: newName }); | |||
}); | |||
function shallowRender(props: Partial<ProjectActivityApp['props']> = {}) { | |||
return shallow<ProjectActivityApp>( | |||
<ProjectActivityApp | |||
component={mockComponent({ breadcrumbs: [mockComponent()] })} | |||
location={mockLocation()} | |||
router={mockRouter()} | |||
{...props} | |||
/> | |||
); | |||
} |
@@ -1,132 +0,0 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2022 SonarSource SA | |||
* mailto:info AT sonarsource DOT com | |||
* | |||
* This program is free software; you can redistribute it and/or | |||
* modify it under the terms of the GNU Lesser General Public | |||
* License as published by the Free Software Foundation; either | |||
* version 3 of the License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||
* Lesser General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Lesser General Public License | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { shallow } from 'enzyme'; | |||
import * as React from 'react'; | |||
import { changeEvent, createEvent } from '../../../../api/projectActivity'; | |||
import { mockComponent } from '../../../../helpers/mocks/component'; | |||
import { | |||
mockAnalysisEvent, | |||
mockLocation, | |||
mockMetric, | |||
mockRouter | |||
} from '../../../../helpers/testMocks'; | |||
import { waitAndUpdate } from '../../../../helpers/testUtils'; | |||
import { ComponentQualifier } from '../../../../types/component'; | |||
import { MetricKey } from '../../../../types/metrics'; | |||
import { ProjectActivityAppContainer } from '../ProjectActivityAppContainer'; | |||
jest.mock('../../../../helpers/dates', () => ({ | |||
parseDate: jest.fn(date => `PARSED:${date}`) | |||
})); | |||
jest.mock('../../../../api/time-machine', () => { | |||
const { mockPaging } = jest.requireActual('../../../../helpers/testMocks'); | |||
return { | |||
getAllTimeMachineData: jest.fn().mockResolvedValue({ | |||
measures: [ | |||
{ | |||
metric: 'bugs', | |||
history: [{ date: '2022-01-01', value: '10' }] | |||
} | |||
], | |||
paging: mockPaging({ total: 1 }) | |||
}) | |||
}; | |||
}); | |||
jest.mock('../../../../api/metrics', () => { | |||
const { mockMetric } = jest.requireActual('../../../../helpers/testMocks'); | |||
return { | |||
getAllMetrics: jest.fn().mockResolvedValue([mockMetric()]) | |||
}; | |||
}); | |||
jest.mock('../../../../api/projectActivity', () => { | |||
const { mockAnalysis, mockPaging } = jest.requireActual('../../../../helpers/testMocks'); | |||
return { | |||
...jest.requireActual('../../../../api/projectActivity'), | |||
createEvent: jest.fn(), | |||
changeEvent: jest.fn(), | |||
getProjectActivity: jest.fn().mockResolvedValue({ | |||
analyses: [mockAnalysis({ key: 'foo' })], | |||
paging: mockPaging({ total: 1 }) | |||
}) | |||
}; | |||
}); | |||
it('should render correctly', () => { | |||
expect(shallowRender()).toMatchSnapshot(); | |||
}); | |||
it('should filter metric correctly', () => { | |||
const wrapper = shallowRender(); | |||
let metrics = wrapper | |||
.instance() | |||
.filterMetrics(mockComponent({ qualifier: ComponentQualifier.Project }), [ | |||
mockMetric({ key: MetricKey.bugs }), | |||
mockMetric({ key: MetricKey.security_review_rating }) | |||
]); | |||
expect(metrics).toHaveLength(1); | |||
metrics = wrapper | |||
.instance() | |||
.filterMetrics(mockComponent({ qualifier: ComponentQualifier.Portfolio }), [ | |||
mockMetric({ key: MetricKey.bugs }), | |||
mockMetric({ key: MetricKey.security_hotspots_reviewed }) | |||
]); | |||
expect(metrics).toHaveLength(1); | |||
}); | |||
it('should correctly create and update custom events', async () => { | |||
const analysisKey = 'foo'; | |||
const name = 'bar'; | |||
const newName = 'baz'; | |||
const event = mockAnalysisEvent({ name }); | |||
(createEvent as jest.Mock).mockResolvedValueOnce({ analysis: analysisKey, ...event }); | |||
(changeEvent as jest.Mock).mockResolvedValueOnce({ | |||
analysis: analysisKey, | |||
...event, | |||
name: newName | |||
}); | |||
const wrapper = shallowRender(); | |||
await waitAndUpdate(wrapper); | |||
const instance = wrapper.instance(); | |||
instance.addCustomEvent(analysisKey, name); | |||
expect(createEvent).toHaveBeenCalledWith(analysisKey, name, undefined); | |||
await waitAndUpdate(wrapper); | |||
expect(wrapper.state().analyses[0].events[0]).toEqual(event); | |||
instance.changeEvent(event.key, newName); | |||
expect(changeEvent).toHaveBeenCalledWith(event.key, newName); | |||
await waitAndUpdate(wrapper); | |||
expect(wrapper.state().analyses[0].events[0]).toEqual({ ...event, name: newName }); | |||
}); | |||
function shallowRender(props: Partial<ProjectActivityAppContainer['props']> = {}) { | |||
return shallow<ProjectActivityAppContainer>( | |||
<ProjectActivityAppContainer | |||
component={mockComponent({ breadcrumbs: [mockComponent()] })} | |||
location={mockLocation()} | |||
router={mockRouter()} | |||
{...props} | |||
/> | |||
); | |||
} |
@@ -0,0 +1,98 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2022 SonarSource SA | |||
* mailto:info AT sonarsource DOT com | |||
* | |||
* This program is free software; you can redistribute it and/or | |||
* modify it under the terms of the GNU Lesser General Public | |||
* License as published by the Free Software Foundation; either | |||
* version 3 of the License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||
* Lesser General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Lesser General Public License | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { shallow } from 'enzyme'; | |||
import * as React from 'react'; | |||
import { DEFAULT_GRAPH } from '../../../../components/activity-graph/utils'; | |||
import { parseDate } from '../../../../helpers/dates'; | |||
import ProjectActivityAppRenderer from '../ProjectActivityAppRenderer'; | |||
const ANALYSES = [ | |||
{ | |||
key: 'A1', | |||
date: parseDate('2016-10-27T16:33:50+0200'), | |||
events: [ | |||
{ | |||
key: 'E1', | |||
category: 'VERSION', | |||
name: '6.5-SNAPSHOT' | |||
} | |||
] | |||
}, | |||
{ | |||
key: 'A2', | |||
date: parseDate('2016-10-27T12:21:15+0200'), | |||
events: [] | |||
}, | |||
{ | |||
key: 'A3', | |||
date: parseDate('2016-10-26T12:17:29+0200'), | |||
events: [ | |||
{ | |||
key: 'E2', | |||
category: 'VERSION', | |||
name: '6.4' | |||
}, | |||
{ | |||
key: 'E3', | |||
category: 'OTHER', | |||
name: 'foo' | |||
} | |||
] | |||
} | |||
]; | |||
const DEFAULT_PROPS = { | |||
addCustomEvent: jest.fn().mockResolvedValue(undefined), | |||
addVersion: jest.fn().mockResolvedValue(undefined), | |||
analyses: ANALYSES, | |||
analysesLoading: false, | |||
branch: { isMain: true }, | |||
changeEvent: jest.fn().mockResolvedValue(undefined), | |||
deleteAnalysis: jest.fn().mockResolvedValue(undefined), | |||
deleteEvent: jest.fn().mockResolvedValue(undefined), | |||
graphLoading: false, | |||
initializing: false, | |||
project: { | |||
key: 'foo', | |||
leakPeriodDate: '2017-05-16T13:50:02+0200', | |||
qualifier: 'TRK' | |||
}, | |||
metrics: [{ id: '1', key: 'code_smells', name: 'Code Smells', type: 'INT' }], | |||
measuresHistory: [ | |||
{ | |||
metric: 'code_smells', | |||
history: [ | |||
{ date: parseDate('Fri Mar 04 2016 10:40:12 GMT+0100 (CET)'), value: '1749' }, | |||
{ date: parseDate('Fri Mar 04 2016 18:40:16 GMT+0100 (CET)'), value: '2286' } | |||
] | |||
} | |||
], | |||
query: { | |||
category: '', | |||
customMetrics: [], | |||
graph: DEFAULT_GRAPH, | |||
project: 'org.sonarsource.sonarqube:sonarqube' | |||
}, | |||
updateQuery: () => {} | |||
}; | |||
it('should render correctly', () => { | |||
expect(shallow(<ProjectActivityAppRenderer {...DEFAULT_PROPS} />)).toMatchSnapshot(); | |||
}); |
@@ -1,185 +1,72 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`should render correctly 1`] = ` | |||
<div | |||
className="page page-limited" | |||
id="project-activity" | |||
> | |||
<Suggestions | |||
suggestions="project_activity" | |||
/> | |||
<Helmet | |||
defer={false} | |||
encodeSpecialCharacters={true} | |||
prioritizeSeoTags={false} | |||
title="project_activity.page" | |||
/> | |||
<A11ySkipTarget | |||
anchor="activity_main" | |||
/> | |||
<ProjectActivityPageFilters | |||
category="" | |||
project={ | |||
Object { | |||
"key": "foo", | |||
"leakPeriodDate": "2017-05-16T13:50:02+0200", | |||
"qualifier": "TRK", | |||
} | |||
} | |||
updateQuery={[Function]} | |||
/> | |||
<div | |||
className="layout-page project-activity-page" | |||
> | |||
<div | |||
className="layout-page-side-outer project-activity-page-side-outer boxed-group" | |||
> | |||
<ProjectActivityAnalysesList | |||
addCustomEvent={[MockFunction]} | |||
addVersion={[MockFunction]} | |||
analyses={ | |||
Array [ | |||
Object { | |||
"date": 2016-10-27T14:33:50.000Z, | |||
"events": Array [ | |||
Object { | |||
"category": "VERSION", | |||
"key": "E1", | |||
"name": "6.5-SNAPSHOT", | |||
}, | |||
], | |||
"key": "A1", | |||
}, | |||
Object { | |||
"date": 2016-10-27T10:21:15.000Z, | |||
"events": Array [], | |||
"key": "A2", | |||
}, | |||
Object { | |||
"date": 2016-10-26T10:17:29.000Z, | |||
"events": Array [ | |||
Object { | |||
"category": "VERSION", | |||
"key": "E2", | |||
"name": "6.4", | |||
}, | |||
Object { | |||
"category": "OTHER", | |||
"key": "E3", | |||
"name": "foo", | |||
}, | |||
], | |||
"key": "A3", | |||
}, | |||
] | |||
} | |||
analysesLoading={false} | |||
canAdmin={false} | |||
canDeleteAnalyses={false} | |||
changeEvent={[MockFunction]} | |||
deleteAnalysis={[MockFunction]} | |||
deleteEvent={[MockFunction]} | |||
initializing={false} | |||
leakPeriodDate={2017-05-16T11:50:02.000Z} | |||
project={ | |||
Object { | |||
"key": "foo", | |||
"leakPeriodDate": "2017-05-16T13:50:02+0200", | |||
"qualifier": "TRK", | |||
} | |||
} | |||
query={ | |||
Object { | |||
"category": "", | |||
"customMetrics": Array [], | |||
"graph": "issues", | |||
"project": "org.sonarsource.sonarqube:sonarqube", | |||
} | |||
} | |||
updateQuery={[Function]} | |||
/> | |||
</div> | |||
<div | |||
className="project-activity-layout-page-main" | |||
> | |||
<ProjectActivityGraphs | |||
analyses={ | |||
Array [ | |||
Object { | |||
"date": 2016-10-27T14:33:50.000Z, | |||
"events": Array [ | |||
Object { | |||
"category": "VERSION", | |||
"key": "E1", | |||
"name": "6.5-SNAPSHOT", | |||
}, | |||
], | |||
"key": "A1", | |||
}, | |||
<ProjectActivityAppRenderer | |||
addCustomEvent={[Function]} | |||
addVersion={[Function]} | |||
analyses={Array []} | |||
analysesLoading={false} | |||
changeEvent={[Function]} | |||
deleteAnalysis={[Function]} | |||
deleteEvent={[Function]} | |||
graphLoading={true} | |||
initializing={true} | |||
measuresHistory={Array []} | |||
metrics={Array []} | |||
project={ | |||
Object { | |||
"breadcrumbs": Array [ | |||
Object { | |||
"breadcrumbs": Array [], | |||
"key": "my-project", | |||
"name": "MyProject", | |||
"qualifier": "TRK", | |||
"qualityGate": Object { | |||
"isDefault": true, | |||
"key": "30", | |||
"name": "Sonar way", | |||
}, | |||
"qualityProfiles": Array [ | |||
Object { | |||
"date": 2016-10-27T10:21:15.000Z, | |||
"events": Array [], | |||
"key": "A2", | |||
"deleted": false, | |||
"key": "my-qp", | |||
"language": "ts", | |||
"name": "Sonar way", | |||
}, | |||
Object { | |||
"date": 2016-10-26T10:17:29.000Z, | |||
"events": Array [ | |||
Object { | |||
"category": "VERSION", | |||
"key": "E2", | |||
"name": "6.4", | |||
}, | |||
Object { | |||
"category": "OTHER", | |||
"key": "E3", | |||
"name": "foo", | |||
}, | |||
], | |||
"key": "A3", | |||
}, | |||
] | |||
} | |||
leakPeriodDate={2017-05-16T11:50:02.000Z} | |||
loading={false} | |||
measuresHistory={ | |||
Array [ | |||
Object { | |||
"history": Array [ | |||
Object { | |||
"date": 2016-03-04T09:40:12.000Z, | |||
"value": "1749", | |||
}, | |||
Object { | |||
"date": 2016-03-04T17:40:16.000Z, | |||
"value": "2286", | |||
}, | |||
], | |||
"metric": "code_smells", | |||
}, | |||
] | |||
} | |||
metrics={ | |||
Array [ | |||
Object { | |||
"id": "1", | |||
"key": "code_smells", | |||
"name": "Code Smells", | |||
"type": "INT", | |||
}, | |||
] | |||
} | |||
project="foo" | |||
query={ | |||
Object { | |||
"category": "", | |||
"customMetrics": Array [], | |||
"graph": "issues", | |||
"project": "org.sonarsource.sonarqube:sonarqube", | |||
} | |||
} | |||
updateQuery={[Function]} | |||
/> | |||
</div> | |||
</div> | |||
</div> | |||
], | |||
"tags": Array [], | |||
}, | |||
], | |||
"key": "my-project", | |||
"name": "MyProject", | |||
"qualifier": "TRK", | |||
"qualityGate": Object { | |||
"isDefault": true, | |||
"key": "30", | |||
"name": "Sonar way", | |||
}, | |||
"qualityProfiles": Array [ | |||
Object { | |||
"deleted": false, | |||
"key": "my-qp", | |||
"language": "ts", | |||
"name": "Sonar way", | |||
}, | |||
], | |||
"tags": Array [], | |||
} | |||
} | |||
query={ | |||
Object { | |||
"category": "", | |||
"customMetrics": Array [], | |||
"from": undefined, | |||
"graph": "issues", | |||
"project": "", | |||
"selectedDate": undefined, | |||
"to": undefined, | |||
} | |||
} | |||
updateQuery={[Function]} | |||
/> | |||
`; |
@@ -1,72 +0,0 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`should render correctly 1`] = ` | |||
<ProjectActivityApp | |||
addCustomEvent={[Function]} | |||
addVersion={[Function]} | |||
analyses={Array []} | |||
analysesLoading={false} | |||
changeEvent={[Function]} | |||
deleteAnalysis={[Function]} | |||
deleteEvent={[Function]} | |||
graphLoading={true} | |||
initializing={true} | |||
measuresHistory={Array []} | |||
metrics={Array []} | |||
project={ | |||
Object { | |||
"breadcrumbs": Array [ | |||
Object { | |||
"breadcrumbs": Array [], | |||
"key": "my-project", | |||
"name": "MyProject", | |||
"qualifier": "TRK", | |||
"qualityGate": Object { | |||
"isDefault": true, | |||
"key": "30", | |||
"name": "Sonar way", | |||
}, | |||
"qualityProfiles": Array [ | |||
Object { | |||
"deleted": false, | |||
"key": "my-qp", | |||
"language": "ts", | |||
"name": "Sonar way", | |||
}, | |||
], | |||
"tags": Array [], | |||
}, | |||
], | |||
"key": "my-project", | |||
"name": "MyProject", | |||
"qualifier": "TRK", | |||
"qualityGate": Object { | |||
"isDefault": true, | |||
"key": "30", | |||
"name": "Sonar way", | |||
}, | |||
"qualityProfiles": Array [ | |||
Object { | |||
"deleted": false, | |||
"key": "my-qp", | |||
"language": "ts", | |||
"name": "Sonar way", | |||
}, | |||
], | |||
"tags": Array [], | |||
} | |||
} | |||
query={ | |||
Object { | |||
"category": "", | |||
"customMetrics": Array [], | |||
"from": undefined, | |||
"graph": "issues", | |||
"project": "", | |||
"selectedDate": undefined, | |||
"to": undefined, | |||
} | |||
} | |||
updateQuery={[Function]} | |||
/> | |||
`; |
@@ -0,0 +1,185 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`should render correctly 1`] = ` | |||
<div | |||
className="page page-limited" | |||
id="project-activity" | |||
> | |||
<Suggestions | |||
suggestions="project_activity" | |||
/> | |||
<Helmet | |||
defer={false} | |||
encodeSpecialCharacters={true} | |||
prioritizeSeoTags={false} | |||
title="project_activity.page" | |||
/> | |||
<A11ySkipTarget | |||
anchor="activity_main" | |||
/> | |||
<ProjectActivityPageFilters | |||
category="" | |||
project={ | |||
Object { | |||
"key": "foo", | |||
"leakPeriodDate": "2017-05-16T13:50:02+0200", | |||
"qualifier": "TRK", | |||
} | |||
} | |||
updateQuery={[Function]} | |||
/> | |||
<div | |||
className="layout-page project-activity-page" | |||
> | |||
<div | |||
className="layout-page-side-outer project-activity-page-side-outer boxed-group" | |||
> | |||
<ProjectActivityAnalysesList | |||
addCustomEvent={[MockFunction]} | |||
addVersion={[MockFunction]} | |||
analyses={ | |||
Array [ | |||
Object { | |||
"date": 2016-10-27T14:33:50.000Z, | |||
"events": Array [ | |||
Object { | |||
"category": "VERSION", | |||
"key": "E1", | |||
"name": "6.5-SNAPSHOT", | |||
}, | |||
], | |||
"key": "A1", | |||
}, | |||
Object { | |||
"date": 2016-10-27T10:21:15.000Z, | |||
"events": Array [], | |||
"key": "A2", | |||
}, | |||
Object { | |||
"date": 2016-10-26T10:17:29.000Z, | |||
"events": Array [ | |||
Object { | |||
"category": "VERSION", | |||
"key": "E2", | |||
"name": "6.4", | |||
}, | |||
Object { | |||
"category": "OTHER", | |||
"key": "E3", | |||
"name": "foo", | |||
}, | |||
], | |||
"key": "A3", | |||
}, | |||
] | |||
} | |||
analysesLoading={false} | |||
canAdmin={false} | |||
canDeleteAnalyses={false} | |||
changeEvent={[MockFunction]} | |||
deleteAnalysis={[MockFunction]} | |||
deleteEvent={[MockFunction]} | |||
initializing={false} | |||
leakPeriodDate={2017-05-16T11:50:02.000Z} | |||
project={ | |||
Object { | |||
"key": "foo", | |||
"leakPeriodDate": "2017-05-16T13:50:02+0200", | |||
"qualifier": "TRK", | |||
} | |||
} | |||
query={ | |||
Object { | |||
"category": "", | |||
"customMetrics": Array [], | |||
"graph": "issues", | |||
"project": "org.sonarsource.sonarqube:sonarqube", | |||
} | |||
} | |||
updateQuery={[Function]} | |||
/> | |||
</div> | |||
<div | |||
className="project-activity-layout-page-main" | |||
> | |||
<ProjectActivityGraphs | |||
analyses={ | |||
Array [ | |||
Object { | |||
"date": 2016-10-27T14:33:50.000Z, | |||
"events": Array [ | |||
Object { | |||
"category": "VERSION", | |||
"key": "E1", | |||
"name": "6.5-SNAPSHOT", | |||
}, | |||
], | |||
"key": "A1", | |||
}, | |||
Object { | |||
"date": 2016-10-27T10:21:15.000Z, | |||
"events": Array [], | |||
"key": "A2", | |||
}, | |||
Object { | |||
"date": 2016-10-26T10:17:29.000Z, | |||
"events": Array [ | |||
Object { | |||
"category": "VERSION", | |||
"key": "E2", | |||
"name": "6.4", | |||
}, | |||
Object { | |||
"category": "OTHER", | |||
"key": "E3", | |||
"name": "foo", | |||
}, | |||
], | |||
"key": "A3", | |||
}, | |||
] | |||
} | |||
leakPeriodDate={2017-05-16T11:50:02.000Z} | |||
loading={false} | |||
measuresHistory={ | |||
Array [ | |||
Object { | |||
"history": Array [ | |||
Object { | |||
"date": 2016-03-04T09:40:12.000Z, | |||
"value": "1749", | |||
}, | |||
Object { | |||
"date": 2016-03-04T17:40:16.000Z, | |||
"value": "2286", | |||
}, | |||
], | |||
"metric": "code_smells", | |||
}, | |||
] | |||
} | |||
metrics={ | |||
Array [ | |||
Object { | |||
"id": "1", | |||
"key": "code_smells", | |||
"name": "Code Smells", | |||
"type": "INT", | |||
}, | |||
] | |||
} | |||
project="foo" | |||
query={ | |||
Object { | |||
"category": "", | |||
"customMetrics": Array [], | |||
"graph": "issues", | |||
"project": "org.sonarsource.sonarqube:sonarqube", | |||
} | |||
} | |||
updateQuery={[Function]} | |||
/> | |||
</div> | |||
</div> | |||
</div> | |||
`; |
@@ -19,8 +19,8 @@ | |||
*/ | |||
import React from 'react'; | |||
import { Route } from 'react-router-dom'; | |||
import ProjectActivityAppContainer from './components/ProjectActivityAppContainer'; | |||
import ProjectActivityApp from './components/ProjectActivityApp'; | |||
const routes = () => <Route path="project/activity" element={<ProjectActivityAppContainer />} />; | |||
const routes = () => <Route path="project/activity" element={<ProjectActivityApp />} />; | |||
export default routes; |