@@ -98,6 +98,7 @@ export function ActivityPanel(props: ActivityPanelProps) { | |||
'overview.activity.graph_shows_data_for_x', | |||
displayedMetrics.map(metricKey => localizeMetric(metricKey)).join(', ') | |||
)} | |||
canShowDataAsTable={false} | |||
graph={graph} | |||
graphs={graphs} | |||
leakPeriodDate={shownLeakPeriodDate} |
@@ -39,6 +39,7 @@ exports[`should render correctly 1`] = ` | |||
<GraphsHistory | |||
analyses={Array []} | |||
ariaLabel="overview.activity.graph_shows_data_for_x.metric.bugs.name, metric.code_smells.name, metric.vulnerabilities.name" | |||
canShowDataAsTable={false} | |||
graph="issues" | |||
graphs={ | |||
Array [ | |||
@@ -208,6 +209,7 @@ exports[`should render correctly 2`] = ` | |||
<GraphsHistory | |||
analyses={Array []} | |||
ariaLabel="overview.activity.graph_shows_data_for_x.metric.bugs.name, metric.code_smells.name, metric.vulnerabilities.name" | |||
canShowDataAsTable={false} | |||
graph="issues" | |||
graphs={ | |||
Array [ |
@@ -0,0 +1,188 @@ | |||
/* | |||
* 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 { filter, slice, sortBy } from 'lodash'; | |||
import * as React from 'react'; | |||
import { FormattedMessage } from 'react-intl'; | |||
import EventInner from '../../apps/projectActivity/components/EventInner'; | |||
import { translate, translateWithParameters } from '../../helpers/l10n'; | |||
import { formatMeasure } from '../../helpers/measures'; | |||
import { ParsedAnalysis, Serie } from '../../types/project-activity'; | |||
import { Button } from '../controls/buttons'; | |||
import Modal from '../controls/Modal'; | |||
import DateFormatter from '../intl/DateFormatter'; | |||
import TimeFormatter from '../intl/TimeFormatter'; | |||
import { Alert } from '../ui/Alert'; | |||
import { getAnalysisEventsForDate } from './utils'; | |||
export interface DataTableModalProps { | |||
analyses: ParsedAnalysis[]; | |||
graphEndDate?: Date; | |||
graphStartDate?: Date; | |||
series: Serie[]; | |||
onClose: () => void; | |||
} | |||
type DataTableEntry = { date: Date } & { [x: string]: string | undefined }; | |||
const MAX_DATA_TABLE_ROWS = 100; | |||
export default function DataTableModal(props: DataTableModalProps) { | |||
const { analyses, series, graphEndDate, graphStartDate } = props; | |||
if (series.length === 0) { | |||
return renderModal( | |||
props, | |||
<Alert variant="info"> | |||
{translate('project_activity.graphs.data_table.no_data_warning')} | |||
</Alert> | |||
); | |||
} | |||
const tableData = series.reduce((acc, serie) => { | |||
const data = filter( | |||
serie.data, | |||
// Make sure we respect the date filtering. On the graph, this is done by dynamically | |||
// "zooming" on the series. Here, we actually have to "cut off" part of the serie's | |||
// data points. | |||
({ x }) => { | |||
if (graphEndDate && x > graphEndDate) { | |||
return false; | |||
} | |||
if (graphStartDate && x < graphStartDate) { | |||
return false; | |||
} | |||
return true; | |||
} | |||
); | |||
data.forEach(({ x, y }) => { | |||
const key = x.getTime(); | |||
if (acc[key] === undefined) { | |||
acc[key] = { date: x } as DataTableEntry; | |||
} | |||
if (y !== undefined && !(typeof y === 'number' && isNaN(y))) { | |||
acc[key][serie.name] = formatMeasure(y, serie.type); | |||
} | |||
}); | |||
return acc; | |||
}, {} as { [x: number]: DataTableEntry }); | |||
const metrics = series.map(({ name }) => name); | |||
const rows = slice( | |||
sortBy(Object.values(tableData), ({ date }) => -date), | |||
0, | |||
MAX_DATA_TABLE_ROWS | |||
).map(({ date, ...values }) => ( | |||
<tr key={date.getTime()}> | |||
<td className="nowrap"> | |||
<DateFormatter long={true} date={date} /> | |||
<div className="small note"> | |||
<TimeFormatter date={date} /> | |||
</div> | |||
</td> | |||
{metrics.map(metric => ( | |||
<td key={metric} className="thin nowrap"> | |||
{values[metric] || '-'} | |||
</td> | |||
))} | |||
<td> | |||
<ul> | |||
{getAnalysisEventsForDate(analyses, date).map(event => ( | |||
<li className="little-spacer-bottom" key={event.key}> | |||
<EventInner event={event} readonly={true} /> | |||
</li> | |||
))} | |||
</ul> | |||
</td> | |||
</tr> | |||
)); | |||
const rowCount = rows.length; | |||
if (rowCount === 0) { | |||
const start = graphStartDate && <DateFormatter long={true} date={graphStartDate} />; | |||
const end = graphEndDate && <DateFormatter long={true} date={graphEndDate} />; | |||
let suffix = ''; | |||
if (start && end) { | |||
suffix = '_x_y'; | |||
} else if (start) { | |||
suffix = '_x'; | |||
} else if (end) { | |||
suffix = '_y'; | |||
} | |||
return renderModal( | |||
props, | |||
<Alert variant="info"> | |||
<FormattedMessage | |||
defaultMessage={translate( | |||
`project_activity.graphs.data_table.no_data_warning_check_dates${suffix}` | |||
)} | |||
id={`project_activity.graphs.data_table.no_data_warning_check_dates${suffix}`} | |||
values={{ start, end }} | |||
/> | |||
</Alert> | |||
); | |||
} | |||
return renderModal( | |||
props, | |||
<> | |||
{rowCount === MAX_DATA_TABLE_ROWS && ( | |||
<Alert variant="info"> | |||
{translateWithParameters( | |||
'project_activity.graphs.data_table.max_lines_warning', | |||
MAX_DATA_TABLE_ROWS | |||
)} | |||
</Alert> | |||
)} | |||
<table className="spacer-top data zebra"> | |||
<thead> | |||
<tr> | |||
<th>{translate('date')}</th> | |||
{series.map(serie => ( | |||
<th key={serie.name} className="thin nowrap"> | |||
{serie.translatedName} | |||
</th> | |||
))} | |||
<th>{translate('events')}</th> | |||
</tr> | |||
</thead> | |||
<tbody>{rows}</tbody> | |||
</table> | |||
</> | |||
); | |||
} | |||
function renderModal(props: DataTableModalProps, children: React.ReactNode) { | |||
const heading = translate('project_activity.graphs.data_table.title'); | |||
return ( | |||
<Modal onRequestClose={props.onClose} contentLabel={heading} size="medium"> | |||
<div className="modal-head"> | |||
<h2>{heading}</h2> | |||
</div> | |||
<div className="modal-body modal-container">{children}</div> | |||
<div className="modal-foot"> | |||
<Button onClick={props.onClose}>{translate('close')}</Button> | |||
</div> | |||
</Modal> | |||
); | |||
} |
@@ -20,15 +20,21 @@ | |||
import * as React from 'react'; | |||
import { AutoSizer } from 'react-virtualized/dist/commonjs/AutoSizer'; | |||
import AdvancedTimeline from '../../components/charts/AdvancedTimeline'; | |||
import { translate } from '../../helpers/l10n'; | |||
import { formatMeasure, getShortType } from '../../helpers/measures'; | |||
import { MeasureHistory, Serie } from '../../types/project-activity'; | |||
import { AnalysisEvent } from '../../types/types'; | |||
import { ParsedAnalysis } from '../../types/types'; | |||
import { Button } from '../controls/buttons'; | |||
import ModalButton from '../controls/ModalButton'; | |||
import DataTableModal from './DataTableModal'; | |||
import GraphsLegendCustom from './GraphsLegendCustom'; | |||
import GraphsLegendStatic from './GraphsLegendStatic'; | |||
import GraphsTooltips from './GraphsTooltips'; | |||
import { getAnalysisEventsForDate } from './utils'; | |||
interface Props { | |||
events: AnalysisEvent[]; | |||
analyses: ParsedAnalysis[]; | |||
canShowDataAsTable?: boolean; | |||
graph: string; | |||
graphEndDate?: Date; | |||
graphStartDate?: Date; | |||
@@ -69,7 +75,8 @@ export default class GraphHistory extends React.PureComponent<Props, State> { | |||
render() { | |||
const { | |||
events, | |||
analyses, | |||
canShowDataAsTable = true, | |||
graph, | |||
graphEndDate, | |||
graphStartDate, | |||
@@ -83,6 +90,7 @@ export default class GraphHistory extends React.PureComponent<Props, State> { | |||
graphDescription | |||
} = this.props; | |||
const { tooltipIdx, tooltipXPos } = this.state; | |||
const events = getAnalysisEventsForDate(analyses, selectedDate); | |||
return ( | |||
<div className="activity-graph-container flex-grow display-flex-column display-flex-stretch display-flex-justify-center"> | |||
@@ -132,6 +140,24 @@ export default class GraphHistory extends React.PureComponent<Props, State> { | |||
)} | |||
</AutoSizer> | |||
</div> | |||
{canShowDataAsTable && ( | |||
<ModalButton | |||
modal={({ onClose }) => ( | |||
<DataTableModal | |||
analyses={analyses} | |||
graphEndDate={graphEndDate} | |||
graphStartDate={graphStartDate} | |||
series={series} | |||
onClose={onClose} | |||
/> | |||
)}> | |||
{({ onClick }) => ( | |||
<Button className="a11y-hidden" onClick={onClick}> | |||
{translate('project_activity.graphs.open_in_table')} | |||
</Button> | |||
)} | |||
</ModalButton> | |||
)} | |||
</div> | |||
); | |||
} |
@@ -31,6 +31,7 @@ import { getSeriesMetricType, hasHistoryData, isCustomGraph } from './utils'; | |||
interface Props { | |||
analyses: ParsedAnalysis[]; | |||
ariaLabel?: string; | |||
canShowDataAsTable?: boolean; | |||
graph: GraphType; | |||
graphs: Serie[][]; | |||
graphEndDate?: Date; | |||
@@ -63,31 +64,19 @@ export default class GraphsHistory extends React.PureComponent<Props, State> { | |||
} | |||
} | |||
getSelectedDateEvents = () => { | |||
const { selectedDate } = this.state; | |||
const { analyses } = this.props; | |||
if (analyses && selectedDate) { | |||
const analysis = analyses.find(a => a.date.valueOf() === selectedDate.valueOf()); | |||
if (analysis) { | |||
return analysis.events; | |||
} | |||
} | |||
return []; | |||
}; | |||
updateTooltip = (selectedDate?: Date) => { | |||
this.setState({ selectedDate }); | |||
}; | |||
render() { | |||
const { graph, loading, series, ariaLabel } = this.props; | |||
const { analyses, graph, loading, series, ariaLabel, canShowDataAsTable } = this.props; | |||
const isCustom = isCustomGraph(graph); | |||
if (loading) { | |||
return ( | |||
<div className="activity-graph-container flex-grow display-flex-column display-flex-stretch display-flex-justify-center"> | |||
<div className="text-center"> | |||
<DeferredSpinner loading={loading} /> | |||
<DeferredSpinner ariaLabel={translate('loading')} loading={loading} /> | |||
</div> | |||
</div> | |||
); | |||
@@ -114,14 +103,14 @@ export default class GraphsHistory extends React.PureComponent<Props, State> { | |||
</div> | |||
); | |||
} | |||
const events = this.getSelectedDateEvents(); | |||
const showAreas = [GraphType.coverage, GraphType.duplications].includes(graph); | |||
return ( | |||
<div className="display-flex-justify-center display-flex-column display-flex-stretch flex-grow"> | |||
{this.props.graphs.map((graphSeries, idx) => { | |||
return ( | |||
<GraphHistory | |||
events={events} | |||
analyses={analyses} | |||
canShowDataAsTable={canShowDataAsTable} | |||
graph={graph} | |||
graphEndDate={this.props.graphEndDate} | |||
graphStartDate={this.props.graphStartDate} |
@@ -23,7 +23,7 @@ import ZoomTimeLine from '../../components/charts/ZoomTimeLine'; | |||
import { Serie } from '../../types/project-activity'; | |||
import { hasHistoryData } from './utils'; | |||
interface Props { | |||
interface GraphsZoomProps { | |||
graphEndDate?: Date; | |||
graphStartDate?: Date; | |||
leakPeriodDate?: Date; | |||
@@ -34,13 +34,14 @@ interface Props { | |||
updateGraphZoom: (from?: Date, to?: Date) => void; | |||
} | |||
export default function GraphsZoom(props: Props) { | |||
export default function GraphsZoom(props: GraphsZoomProps) { | |||
if (props.loading || !hasHistoryData(props.series)) { | |||
return null; | |||
} | |||
return ( | |||
<div className="activity-graph-zoom"> | |||
// We hide this for screen readers; they should use date inputs instead. | |||
<div className="activity-graph-zoom" aria-hidden={true}> | |||
<AutoSizer disableHeight={true}> | |||
{({ width }) => ( | |||
<ZoomTimeLine |
@@ -36,6 +36,19 @@ exports[`should correctly render a graph 1`] = ` | |||
<Component /> | |||
</AutoSizer> | |||
</div> | |||
<div | |||
className="spacer-top big-spacer-bottom small" | |||
> | |||
<div | |||
className="display-flex-justify-center" | |||
> | |||
<ModalButton | |||
modal={[Function]} | |||
> | |||
<Component /> | |||
</ModalButton> | |||
</div> | |||
</div> | |||
</div> | |||
`; | |||
@@ -76,5 +89,18 @@ exports[`should correctly render a graph: custom 1`] = ` | |||
<Component /> | |||
</AutoSizer> | |||
</div> | |||
<div | |||
className="spacer-top big-spacer-bottom small" | |||
> | |||
<div | |||
className="display-flex-justify-center" | |||
> | |||
<ModalButton | |||
modal={[Function]} | |||
> | |||
<Component /> | |||
</ModalButton> | |||
</div> | |||
</div> | |||
</div> | |||
`; |
@@ -5,8 +5,43 @@ exports[`should correctly render a graph 1`] = ` | |||
className="display-flex-justify-center display-flex-column display-flex-stretch flex-grow" | |||
> | |||
<GraphHistory | |||
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": "OTHER", | |||
"key": "E2", | |||
"name": "foo", | |||
}, | |||
Object { | |||
"category": "VERSION", | |||
"key": "E3", | |||
"name": "6.4", | |||
}, | |||
], | |||
"key": "A3", | |||
}, | |||
] | |||
} | |||
ariaLabel="project_activity.graphs.explanation_x.metric.bugs.name" | |||
events={Array []} | |||
graph="issues" | |||
isCustom={false} | |||
key="0" | |||
@@ -50,8 +85,43 @@ exports[`should correctly render multiple graphs 1`] = ` | |||
className="display-flex-justify-center display-flex-column display-flex-stretch flex-grow" | |||
> | |||
<GraphHistory | |||
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": "OTHER", | |||
"key": "E2", | |||
"name": "foo", | |||
}, | |||
Object { | |||
"category": "VERSION", | |||
"key": "E3", | |||
"name": "6.4", | |||
}, | |||
], | |||
"key": "A3", | |||
}, | |||
] | |||
} | |||
ariaLabel="project_activity.graphs.explanation_x.metric.bugs.name" | |||
events={Array []} | |||
graph="issues" | |||
isCustom={false} | |||
key="0" | |||
@@ -88,8 +158,43 @@ exports[`should correctly render multiple graphs 1`] = ` | |||
updateTooltip={[Function]} | |||
/> | |||
<GraphHistory | |||
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": "OTHER", | |||
"key": "E2", | |||
"name": "foo", | |||
}, | |||
Object { | |||
"category": "VERSION", | |||
"key": "E3", | |||
"name": "6.4", | |||
}, | |||
], | |||
"key": "A3", | |||
}, | |||
] | |||
} | |||
ariaLabel="project_activity.graphs.explanation_x.metric.bugs.name" | |||
events={Array []} | |||
graph="issues" | |||
isCustom={false} | |||
key="1" |
@@ -23,7 +23,7 @@ import { localizeMetric } from '../../helpers/measures'; | |||
import { get, save } from '../../helpers/storage'; | |||
import { MetricKey } from '../../types/metrics'; | |||
import { GraphType, MeasureHistory, Serie } from '../../types/project-activity'; | |||
import { Dict, Metric } from '../../types/types'; | |||
import { Dict, Metric, ParsedAnalysis } from '../../types/types'; | |||
export const DEFAULT_GRAPH = GraphType.issues; | |||
@@ -159,6 +159,16 @@ export function getActivityGraph( | |||
}; | |||
} | |||
export function getAnalysisEventsForDate(analyses: ParsedAnalysis[], date?: Date) { | |||
if (date) { | |||
const analysis = analyses.find(a => a.date.valueOf() === date.valueOf()); | |||
if (analysis) { | |||
return analysis.events; | |||
} | |||
} | |||
return []; | |||
} | |||
function findMetric(key: string, metrics: Metric[] | Dict<Metric>) { | |||
if (Array.isArray(metrics)) { | |||
return metrics.find(metric => metric.key === key); |
@@ -112,6 +112,7 @@ list_of_issues=List of issues | |||
list_of_projects=List of projects | |||
load_more=Load more | |||
load_verb=Load | |||
loading=Loading | |||
login=Login | |||
major=Major | |||
manual=Manual | |||
@@ -1551,6 +1552,13 @@ project_activity.graphs.custom.no_history=There isn't enough data to generate an | |||
project_activity.graphs.custom.metric_no_history=This metric has no historical data to display. | |||
project_activity.graphs.custom.search=Search for a metric by name | |||
project_activity.graphs.custom.type_x_message=Only "{0}" metrics are available with your current selection. | |||
project_activity.graphs.open_in_table=Show the graph data in a table | |||
project_activity.graphs.data_table.title=Graph data in table format | |||
project_activity.graphs.data_table.max_lines_warning=Only the {0} most recent data entries are shown. If you want to see different data, change the date filters on the main page. | |||
project_activity.graphs.data_table.no_data_warning=There is no data for the selected series. | |||
project_activity.graphs.data_table.no_data_warning_check_dates_x=There is no data for the selected date range (everything after {start}). Try modifying the date filters on the main page. | |||
project_activity.graphs.data_table.no_data_warning_check_dates_y=There is no data for the selected date range (everything before {end}). Try modifying the date filters on the main page. | |||
project_activity.graphs.data_table.no_data_warning_check_dates_x_y=There is no data for the selected date range ({start} to {end}). Try modifying the date filters on the main page. | |||
project_activity.custom_metric.covered_lines=Covered Lines | |||