diff options
14 files changed, 403 insertions, 103 deletions
diff --git a/server/sonar-web/src/main/js/apps/component-measures/app.js b/server/sonar-web/src/main/js/apps/component-measures/app.js index babee01e50d..4f39850bd89 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/app.js +++ b/server/sonar-web/src/main/js/apps/component-measures/app.js @@ -27,6 +27,9 @@ import AllMeasuresList from './components/AllMeasures'; import MeasureDetails from './components/MeasureDetails'; import MeasureDrilldownTree from './components/MeasureDrilldownTree'; import MeasureDrilldownList from './components/MeasureDrilldownList'; +import MeasureHistory from './components/MeasureHistory'; + +import { checkHistoryExistence } from './hooks'; import './styles.css'; @@ -55,6 +58,7 @@ window.sonarqube.appStarted.then(options => { <IndexRedirect to="tree"/> <Route path="tree" component={MeasureDrilldownTree}/> <Route path="list" component={MeasureDrilldownList}/> + <Route path="history" component={MeasureHistory} onEnter={checkHistoryExistence}/> </Route> </Route> diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/MeasureDetails.js b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureDetails.js index adeef588454..fa15d267c52 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/components/MeasureDetails.js +++ b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureDetails.js @@ -25,7 +25,7 @@ import MeasureDrilldown from './MeasureDrilldown'; import { enhanceWithLeak } from '../utils'; import { getMeasuresAndMeta } from '../../../api/measures'; -import { getLeakPeriodLabel } from '../../../helpers/periods'; +import { getLeakPeriod, getPeriodDate, getPeriodLabel } from '../../../helpers/periods'; export default class MeasureDetails extends React.Component { state = {}; @@ -92,7 +92,9 @@ export default class MeasureDetails extends React.Component { } const { tab } = this.props.params; - const leakPeriodLabel = getLeakPeriodLabel(periods); + const leakPeriod = getLeakPeriod(periods); + const leakPeriodLabel = getPeriodLabel(leakPeriod); + const leakPeriodDate = getPeriodDate(leakPeriod); return ( <div className="measure-details"> @@ -102,7 +104,10 @@ export default class MeasureDetails extends React.Component { leakPeriodLabel={leakPeriodLabel}/> {measure && ( - <MeasureDrilldown metric={this.metric} tab={tab}> + <MeasureDrilldown + metric={this.metric} + tab={tab} + leakPeriodDate={leakPeriodDate}> {this.props.children} </MeasureDrilldown> )} diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/MeasureDrilldown.js b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureDrilldown.js index a66d23ebcda..839e1869862 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/components/MeasureDrilldown.js +++ b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureDrilldown.js @@ -22,14 +22,16 @@ import { Link } from 'react-router'; import IconList from './IconList'; import IconTree from './IconTree'; + +import { hasHistory } from '../utils'; import { translate } from '../../../helpers/l10n'; export default class MeasureDrilldown extends React.Component { render () { - const { metric, children } = this.props; + const { children, metric, ...other } = this.props; const { component } = this.context; - const child = React.cloneElement(children, { component, metric }); + const child = React.cloneElement(children, { component, metric, ...other }); return ( <div className="measure-details-drilldown"> @@ -50,6 +52,15 @@ export default class MeasureDrilldown extends React.Component { {translate('component_measures.tab.list')} </Link> </li> + {hasHistory(metric.key) && ( + <li> + <Link + activeClassName="active" + to={{ pathname: `${metric.key}/history`, query: { id: component.key } }}> + {translate('component_measures.tab.history')} + </Link> + </li> + )} </ul> {child} diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/MeasureHistory.js b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureHistory.js new file mode 100644 index 00000000000..db778d79ac9 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureHistory.js @@ -0,0 +1,182 @@ +/* + * SonarQube + * Copyright (C) 2009-2016 SonarSource SA + * mailto:contact 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 _ from 'underscore'; +import moment from 'moment'; +import React from 'react'; + +import Spinner from './Spinner'; +import Timeline from '../../../components/charts/Timeline'; +import { getTimeMachineData } from '../../../api/time-machine'; +import { getEvents } from '../../../api/events'; +import { formatMeasure, getShortType } from '../../../helpers/measures'; + +const HEIGHT = 280; + +function parseValue (value, type) { + return type === 'RATING' && typeof value === 'string' ? value.charCodeAt(0) - 'A'.charCodeAt(0) + 1 : value; +} + +export default class MeasureHistory extends React.Component { + state = { + components: [], + selected: null, + fetching: true + }; + + componentDidMount () { + this.mounted = true; + this.fetchHistory(); + } + + componentDidUpdate (nextProps, nextState, nextContext) { + if ((nextProps.metric !== this.props.metric) || + (nextContext.component !== this.context.component)) { + this.fetchHistory(); + } + } + + componentWillUnmount () { + this.mounted = false; + } + + fetchHistory () { + const { metric } = this.props; + + Promise.all([ + this.fetchTimeMachineData(metric.key), + this.fetchEvents() + ]).then(responses => { + if (this.mounted) { + this.setState({ + snapshots: responses[0], + events: responses[1], + fetching: false + }); + } + }); + } + + fetchTimeMachineData (currentMetric, comparisonMetric) { + const metricsToRequest = [currentMetric]; + + if (comparisonMetric) { + metricsToRequest.push(comparisonMetric); + } + + return getTimeMachineData(this.props.component.key, metricsToRequest.join()).then(r => { + return r[0].cells.map(cell => { + return { + date: moment(cell.d).toDate(), + values: cell.v + }; + }); + }); + } + + fetchEvents () { + return getEvents(this.props.component.key, 'Version').then(r => { + const events = r.map(event => { + return { version: event.n, date: moment(event.dt).toDate() }; + }); + + return _.sortBy(events, 'date'); + }); + } + + renderLineChart (snapshots, metric, index) { + if (!metric) { + return null; + } + + if (snapshots.length < 2) { + return this.renderWhenNoHistoricalData(); + } + + const data = snapshots.map(snapshot => { + return { + x: snapshot.date, + y: parseValue(snapshot.values[index], metric.type) + }; + }); + + const formatValue = (value) => formatMeasure(value, metric.type); + const formatYTick = (tick) => formatMeasure(tick, getShortType(metric.type)); + + return ( + <div style={{ height: HEIGHT }}> + <Timeline key={metric.key} + data={data} + metricType={metric.type} + events={this.state.events} + height={HEIGHT} + interpolate="linear" + formatValue={formatValue} + formatYTick={formatYTick} + leakPeriodDate={this.props.leakPeriodDate} + padding={[25, 25, 25, 60]}/> + </div> + ); + } + + renderLineCharts () { + const { metric } = this.props; + + return ( + <div> + {this.renderLineChart(this.state.snapshots, metric, 0)} + {this.renderLineChart(this.state.snapshots, this.state.comparisonMetric, 1)} + </div> + ); + } + + render () { + const { fetching, snapshots } = this.state; + + if (fetching) { + return ( + <div className="measure-details-history"> + <div className="note text-center" style={{ lineHeight: `${HEIGHT}px` }}> + <Spinner/> + </div> + </div> + ); + } + + if (!snapshots || snapshots.length < 2) { + return ( + <div className="measure-details-history"> + <div className="note text-center" style={{ lineHeight: `${HEIGHT}px` }}> + There is no historical data. + </div> + </div> + ); + } + + return ( + <div className="measure-details-history"> + {this.renderLineCharts()} + </div> + ); + } +} + +MeasureHistory.contextTypes = { + component: React.PropTypes.object +}; diff --git a/server/sonar-web/src/main/js/apps/component-measures/hooks.js b/server/sonar-web/src/main/js/apps/component-measures/hooks.js new file mode 100644 index 00000000000..d9d58658fc4 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/component-measures/hooks.js @@ -0,0 +1,31 @@ +/* + * SonarQube + * Copyright (C) 2009-2016 SonarSource SA + * mailto:contact 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 { hasHistory } from './utils'; + +export function checkHistoryExistence (nextState, replace) { + const { metricKey } = nextState.params; + + if (!hasHistory(metricKey)) { + replace({ + pathname: metricKey, + query: nextState.location.query + }); + } +} diff --git a/server/sonar-web/src/main/js/apps/component-measures/styles.css b/server/sonar-web/src/main/js/apps/component-measures/styles.css index 09e238d3e02..72c75b3503d 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/styles.css +++ b/server/sonar-web/src/main/js/apps/component-measures/styles.css @@ -200,3 +200,7 @@ .measures-details-components-empty { padding: 10px; } + +.measure-details-history { + padding: 10px; +} diff --git a/server/sonar-web/src/main/js/apps/component-measures/utils.js b/server/sonar-web/src/main/js/apps/component-measures/utils.js index b3eef46c626..f94af95f5c3 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/utils.js +++ b/server/sonar-web/src/main/js/apps/component-measures/utils.js @@ -82,3 +82,7 @@ export function enhanceWithSingleMeasure (components) { }) .filter(component => component.value != null || component.leak != null); } + +export function hasHistory (metricKey) { + return metricKey.indexOf('new_') !== 0; +} diff --git a/server/sonar-web/src/main/js/apps/overview/components/domain-timeline.js b/server/sonar-web/src/main/js/apps/overview/components/domain-timeline.js index f6be8bb4554..8309907aba5 100644 --- a/server/sonar-web/src/main/js/apps/overview/components/domain-timeline.js +++ b/server/sonar-web/src/main/js/apps/overview/components/domain-timeline.js @@ -26,7 +26,7 @@ import { getTimeMachineData } from '../../../api/time-machine'; import { getEvents } from '../../../api/events'; import { formatMeasure, groupByDomain } from '../../../helpers/measures'; import { getShortType } from '../helpers/metrics'; -import { Timeline } from './timeline-chart'; +import Timeline from './../../../components/charts/Timeline'; import { translate } from '../../../helpers/l10n'; diff --git a/server/sonar-web/src/main/js/apps/overview/components/timeline-chart.js b/server/sonar-web/src/main/js/components/charts/Timeline.js index 59bd3b146fb..8688f458d3e 100644 --- a/server/sonar-web/src/main/js/apps/overview/components/timeline-chart.js +++ b/server/sonar-web/src/main/js/components/charts/Timeline.js @@ -23,11 +23,10 @@ import d3 from 'd3'; import moment from 'moment'; import React from 'react'; -import { ResizeMixin } from '../../../components/mixins/resize-mixin'; -import { TooltipsMixin } from '../../../components/mixins/tooltips-mixin'; +import { ResizeMixin } from '../mixins/resize-mixin'; +import { TooltipsMixin } from '../mixins/tooltips-mixin'; - -export const Timeline = React.createClass({ +const Timeline = React.createClass({ propTypes: { data: React.PropTypes.arrayOf(React.PropTypes.object).isRequired, padding: React.PropTypes.arrayOf(React.PropTypes.number), @@ -37,30 +36,33 @@ export const Timeline = React.createClass({ mixins: [ResizeMixin, TooltipsMixin], - getDefaultProps() { + getDefaultProps () { return { padding: [10, 10, 10, 10], interpolate: 'basis' }; }, - getInitialState() { - return { width: this.props.width, height: this.props.height }; + getInitialState () { + return { + width: this.props.width, + height: this.props.height + }; }, - getRatingScale(availableHeight) { + getRatingScale (availableHeight) { return d3.scale.ordinal() .domain([5, 4, 3, 2, 1]) .rangePoints([availableHeight, 0]); }, - getLevelScale(availableHeight) { + getLevelScale (availableHeight) { return d3.scale.ordinal() .domain(['ERROR', 'WARN', 'OK']) .rangePoints([availableHeight, 0]); }, - getYScale(availableHeight) { + getYScale (availableHeight) { if (this.props.metricType === 'RATING') { return this.getRatingScale(availableHeight); } else if (this.props.metricType === 'LEVEL') { @@ -73,51 +75,65 @@ export const Timeline = React.createClass({ } }, - handleEventMouseEnter(event) { + handleEventMouseEnter (event) { $(`.js-event-circle-${event.date.getTime()}`).tooltip('show'); }, - handleEventMouseLeave(event) { + handleEventMouseLeave (event) { $(`.js-event-circle-${event.date.getTime()}`).tooltip('hide'); }, renderHorizontalGrid (xScale, yScale) { - let hasTicks = typeof yScale.ticks === 'function'; - let ticks = hasTicks ? yScale.ticks(4) : yScale.domain(); + const hasTicks = typeof yScale.ticks === 'function'; + const ticks = hasTicks ? yScale.ticks(4) : yScale.domain(); + if (!ticks.length) { ticks.push(yScale.domain()[1]); } - let grid = ticks.map(tick => { - let opts = { + + const grid = ticks.map(tick => { + const opts = { x: xScale.range()[0], y: yScale(tick) }; - return <g key={tick}> - <text className="line-chart-tick line-chart-tick-x" dx="-1em" dy="0.3em" - textAnchor="end" {...opts}>{this.props.formatYTick(tick)}</text> - <line className="line-chart-grid" - x1={xScale.range()[0]} - x2={xScale.range()[1]} - y1={yScale(tick)} - y2={yScale(tick)}/> - </g>; + + return ( + <g key={tick}> + <text className="line-chart-tick line-chart-tick-x" dx="-1em" dy="0.3em" + textAnchor="end" {...opts}>{this.props.formatYTick(tick)}</text> + <line className="line-chart-grid" + x1={xScale.range()[0]} + x2={xScale.range()[1]} + y1={yScale(tick)} + y2={yScale(tick)}/> + </g> + ); }); + return <g>{grid}</g>; }, renderTicks (xScale, yScale) { - let format = xScale.tickFormat(7); + const format = xScale.tickFormat(7); let ticks = xScale.ticks(7); + ticks = _.initial(ticks).map((tick, index) => { - let nextTick = index + 1 < ticks.length ? ticks[index + 1] : xScale.domain()[1]; - let x = (xScale(tick) + xScale(nextTick)) / 2; - let y = yScale.range()[0]; - return <text key={index} - className="line-chart-tick" - x={x} - y={y} - dy="1.5em">{format(tick)}</text>; + const nextTick = index + 1 < ticks.length ? ticks[index + 1] : xScale.domain()[1]; + const x = (xScale(tick) + xScale(nextTick)) / 2; + const y = yScale.range()[0]; + + return ( + <text + key={index} + className="line-chart-tick" + x={x} + y={y} + dy="1.5em"> + {format(tick)} + </text> + ); }); + return <g>{ticks}</g>; }, @@ -125,49 +141,56 @@ export const Timeline = React.createClass({ if (!this.props.leakPeriodDate) { return null; } - let opts = { + + const opts = { x: xScale(this.props.leakPeriodDate), y: _.last(yScale.range()), width: xScale.range()[1] - xScale(this.props.leakPeriodDate), height: _.first(yScale.range()) - _.last(yScale.range()), fill: '#fbf3d5' }; + return <rect {...opts}/>; }, renderLine (xScale, yScale) { - let p = d3.svg.line() + const p = d3.svg.line() .x(d => xScale(d.x)) .y(d => yScale(d.y)) .interpolate(this.props.interpolate); + return <path className="line-chart-path" d={p(this.props.data)}/>; }, - renderEvents(xScale, yScale) { - let points = this.props.events + renderEvents (xScale, yScale) { + const points = this.props.events .map(event => { - let snapshot = this.props.data.find(d => d.x.getTime() === event.date.getTime()); + const snapshot = this.props.data.find(d => d.x.getTime() === event.date.getTime()); return _.extend(event, { snapshot }); }) .filter(event => event.snapshot) .map(event => { - let key = `${event.date.getTime()}-${event.snapshot.y}`; - let className = `line-chart-point js-event-circle-${event.date.getTime()}`; - let tooltip = [ + const key = `${event.date.getTime()}-${event.snapshot.y}`; + const className = `line-chart-point js-event-circle-${event.date.getTime()}`; + const tooltip = [ `<span class="nowrap">${event.version}</span>`, `<span class="nowrap">${moment(event.date).format('LL')}</span>`, `<span class="nowrap">${event.snapshot.y ? this.props.formatValue(event.snapshot.y) : '—'}</span>` ].join('<br>'); - return <circle key={key} - className={className} - r="4" - cx={xScale(event.snapshot.x)} - cy={yScale(event.snapshot.y)} - onMouseEnter={this.handleEventMouseEnter.bind(this, event)} - onMouseLeave={this.handleEventMouseLeave.bind(this, event)} - data-toggle="tooltip" - data-title={tooltip}/>; + + return ( + <circle key={key} + className={className} + r="4" + cx={xScale(event.snapshot.x)} + cy={yScale(event.snapshot.y)} + onMouseEnter={this.handleEventMouseEnter.bind(this, event)} + onMouseLeave={this.handleEventMouseLeave.bind(this, event)} + data-toggle="tooltip" + data-title={tooltip}/> + ); }); + return <g>{points}</g>; }, @@ -176,23 +199,27 @@ export const Timeline = React.createClass({ return <div/>; } - let availableWidth = this.state.width - this.props.padding[1] - this.props.padding[3]; - let availableHeight = this.state.height - this.props.padding[0] - this.props.padding[2]; + 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]; - let xScale = d3.time.scale() + const xScale = d3.time.scale() .domain(d3.extent(this.props.data, d => d.x || 0)) .range([0, availableWidth]) .clamp(true); - let yScale = this.getYScale(availableHeight); - - return <svg className="line-chart" width={this.state.width} height={this.state.height}> - <g transform={`translate(${this.props.padding[3]}, ${this.props.padding[0]})`}> - {this.renderLeak(xScale, yScale)} - {this.renderHorizontalGrid(xScale, yScale)} - {this.renderTicks(xScale, yScale)} - {this.renderLine(xScale, yScale)} - {this.renderEvents(xScale, yScale)} - </g> - </svg>; + const yScale = this.getYScale(availableHeight); + + return ( + <svg className="line-chart" width={this.state.width} height={this.state.height}> + <g transform={`translate(${this.props.padding[3]}, ${this.props.padding[0]})`}> + {this.renderLeak(xScale, yScale)} + {this.renderHorizontalGrid(xScale, yScale)} + {this.renderTicks(xScale, yScale)} + {this.renderLine(xScale, yScale)} + {this.renderEvents(xScale, yScale)} + </g> + </svg> + ); } }); + +export default Timeline; diff --git a/server/sonar-web/src/main/js/helpers/measures.js b/server/sonar-web/src/main/js/helpers/measures.js index 6059a3af3cf..2e1e6ac1331 100644 --- a/server/sonar-web/src/main/js/helpers/measures.js +++ b/server/sonar-web/src/main/js/helpers/measures.js @@ -71,6 +71,21 @@ export function groupByDomain (metrics) { } +/** + * Return corresponding "short" for better display in UI + * @param {string} type + * @returns {string} + */ +export function getShortType (type) { + if (type === 'INT') { + return 'SHORT_INT'; + } else if (type === 'WORK_DUR') { + return 'SHORT_WORK_DUR'; + } + return type; +} + + /* * Helpers */ diff --git a/server/sonar-web/src/main/js/helpers/periods.js b/server/sonar-web/src/main/js/helpers/periods.js index 48006199d2a..b87a15ca004 100644 --- a/server/sonar-web/src/main/js/helpers/periods.js +++ b/server/sonar-web/src/main/js/helpers/periods.js @@ -17,6 +17,7 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +import moment from 'moment'; import { translate, translateWithParameters } from './l10n'; export function getLeakPeriod (periods) { @@ -41,6 +42,14 @@ export function getPeriodLabel (period) { return translateWithParameters(`overview.period.${period.mode}`, parameter); } +export function getPeriodDate (period) { + if (!period) { + return null; + } + + return moment(period.date).toDate(); +} + export function getLeakPeriodLabel (periods) { return getPeriodLabel(getLeakPeriod(periods)); } diff --git a/server/sonar-web/src/main/less/components/graphics.less b/server/sonar-web/src/main/less/components/graphics.less index c3fff6218e1..f825a84e1b0 100644 --- a/server/sonar-web/src/main/less/components/graphics.less +++ b/server/sonar-web/src/main/less/components/graphics.less @@ -266,3 +266,42 @@ text.max-results-reached-message { .link-no-underline; } } + +.line-chart { + +} + +.line-chart-path { + fill: none; + stroke: @blue; + stroke-width: 2px; +} + +.line-chart-point { + fill: #fff; + stroke: @darkBlue; + stroke-width: 2px; +} + +.line-chart-backdrop { + +} + +.line-chart-tick { + fill: @secondFontColor; + font-size: 12px; + text-anchor: middle; +} + +.line-chart-tick-x { + text-anchor: end; +} + +.line-chart-tick-x-right { + text-anchor: start; +} + +.line-chart-grid { + shape-rendering: crispedges; + stroke: #eee; +} diff --git a/server/sonar-web/src/main/less/pages/overview.less b/server/sonar-web/src/main/less/pages/overview.less index 35a9a7c7a02..e11b40a93c3 100644 --- a/server/sonar-web/src/main/less/pages/overview.less +++ b/server/sonar-web/src/main/less/pages/overview.less @@ -452,40 +452,9 @@ position: absolute; } - .line-chart-path { - fill: none; - stroke: @blue; - stroke-width: 2px; - } - - .line-chart-point { - fill: #fff; - stroke: @darkBlue; - stroke-width: 2px; - } - .line-chart-backdrop { fill: #fbf3d5; } - - .line-chart-tick { - fill: @secondFontColor; - font-size: 12px; - text-anchor: middle; - } - - .line-chart-tick-x { - text-anchor: end; - } - - .line-chart-tick-x-right { - text-anchor: start; - } - - .line-chart-grid { - shape-rendering: crispedges; - stroke: #eee; - } } .overview-timeline-1 { diff --git a/server/sonar-web/tests/apps/overview/components/timeline-chart-test.js b/server/sonar-web/tests/apps/overview/components/timeline-chart-test.js index 842c3405744..cbe1b80d83d 100644 --- a/server/sonar-web/tests/apps/overview/components/timeline-chart-test.js +++ b/server/sonar-web/tests/apps/overview/components/timeline-chart-test.js @@ -2,7 +2,7 @@ import { expect } from 'chai'; import React from 'react'; import TestUtils from 'react-addons-test-utils'; -import { Timeline } from '../../../../src/main/js/apps/overview/components/timeline-chart'; +import Timeline from '../../../../src/main/js/components/charts/Timeline'; const ZERO_DATA = [ |