Commit 40e30112 authored by Mike Greiling's avatar Mike Greiling

Merge branch 'psi-burndown-charts-vue' into 'master'

Convert Burndown charts to gitlab-ui component

See merge request gitlab-org/gitlab!15463
parents 2b607f09 6c220a80
import { select, mouse } from 'd3-selection';
import { bisector, max } from 'd3-array';
import { timeFormat } from 'd3-time-format';
import { scaleTime, scaleLinear } from 'd3-scale';
import { axisBottom, axisLeft } from 'd3-axis';
import { line } from 'd3-shape';
import { transition } from 'd3-transition';
import { easeLinear } from 'd3-ease';
import { s__ } from '~/locale';
const d3 = {
select,
mouse,
bisector,
max,
timeFormat,
scaleTime,
scaleLinear,
axisBottom,
axisLeft,
line,
transition,
easeLinear,
};
const margin = { top: 5, right: 65, bottom: 30, left: 50 };
// const parseDate = d3.timeFormat('%Y-%m-%d');
const bisectDate = d3.bisector(d => d.date).left;
const tooltipPadding = { x: 8, y: 3 };
const tooltipDistance = 15;
export default class BurndownChart {
constructor({ container, startDate, dueDate }) {
this.canvas = d3
.select(container)
.append('svg')
.attr('height', '100%')
.attr('width', '100%');
// create svg nodes
this.chartGroup = this.canvas.append('g').attr('class', 'chart');
this.xAxisGroup = this.chartGroup.append('g').attr('class', 'x axis');
this.yAxisGroup = this.chartGroup.append('g').attr('class', 'y axis');
this.idealLinePath = this.chartGroup.append('path').attr('class', 'ideal line');
this.actualLinePath = this.chartGroup.append('path').attr('class', 'actual line');
this.xAxisGroup.append('line').attr('class', 'domain-line');
// create y-axis label
this.label = s__('BurndownChartLabel|Remaining');
const yAxisLabel = this.yAxisGroup.append('g').attr('class', 'axis-label');
this.yAxisLabelText = yAxisLabel.append('text').text(this.label);
this.yAxisLabelBBox = this.yAxisLabelText.node().getBBox();
this.yAxisLabelLineA = yAxisLabel.append('line');
this.yAxisLabelLineB = yAxisLabel.append('line');
// create chart legend
this.chartLegendGroup = this.chartGroup.append('g').attr('class', 'legend');
this.chartLegendGroup.append('rect');
this.chartLegendIdealKey = this.chartLegendGroup.append('g');
this.chartLegendIdealKey.append('line').attr('class', 'ideal line');
this.chartLegendIdealKey.append('text').text(s__('BurndownChartLabel|Guideline'));
this.chartLegendIdealKeyBBox = this.chartLegendIdealKey
.select('text')
.node()
.getBBox();
this.chartLegendActualKey = this.chartLegendGroup.append('g');
this.chartLegendActualKey.append('line').attr('class', 'actual line');
this.chartLegendActualKey.append('text').text(s__('BurndownChartLabel|Progress'));
this.chartLegendActualKeyBBox = this.chartLegendActualKey
.select('text')
.node()
.getBBox();
// create tooltips
this.chartFocus = this.chartGroup
.append('g')
.attr('class', 'focus')
.style('display', 'none');
this.chartFocus.append('circle').attr('r', 4);
this.tooltipGroup = this.chartFocus.append('g').attr('class', 'chart-tooltip');
this.tooltipGroup
.append('rect')
.attr('rx', 3)
.attr('ry', 3);
this.tooltipGroup.append('text');
this.chartOverlay = this.chartGroup
.append('rect')
.attr('class', 'overlay')
.on('mouseover', () => this.chartFocus.style('display', null))
.on('mouseout', () => this.chartFocus.style('display', 'none'))
.on('mousemove', () => this.handleMousemove());
// parse start and due dates
this.startDate = new Date(startDate);
this.dueDate = new Date(dueDate);
// get width and height
const dimensions = this.canvas.node().getBoundingClientRect();
this.width = dimensions.width;
this.height = dimensions.height;
this.chartWidth = this.width - (margin.left + margin.right);
this.chartHeight = this.height - (margin.top + margin.bottom);
// set default scale domains
this.xMax = this.dueDate;
this.yMax = 1;
// create scales
this.xScale = d3
.scaleTime()
.range([0, this.chartWidth])
.domain([this.startDate, this.xMax]);
this.yScale = d3
.scaleLinear()
.range([this.chartHeight, 0])
.domain([0, this.yMax]);
// create axes
this.xAxis = d3
.axisBottom()
.scale(this.xScale)
.tickFormat(d3.timeFormat('%b %-d'))
.tickPadding(6)
.tickSize(4, 0);
this.yAxis = d3
.axisLeft()
.scale(this.yScale)
.tickPadding(6)
.tickSize(4, 0);
// create lines
this.line = d3
.line()
.x(d => this.xScale(new Date(d.date)))
.y(d => this.yScale(d.value));
// render the chart
this.scheduleRender();
}
// set data and force re-render
setData(data, { label = s__('BurndownChartLabel|Remaining'), animate } = {}) {
this.data = data
.map(datum => ({
date: new Date(datum[0]),
value: parseInt(datum[1], 10),
}))
.sort((a, b) => a.date - b.date);
// adjust axis domain to correspond with data
this.xMax = Math.max(d3.max(this.data, d => d.date) || 0, this.dueDate);
this.yMax = d3.max(this.data, d => d.value) || 1;
this.xScale.domain([this.startDate, this.xMax]);
this.yScale.domain([0, this.yMax]);
// calculate the bounding box for the axis label if updated
// (this must be done here to prevent layout thrashing)
if (this.label !== label) {
this.label = label;
this.yAxisLabelBBox = this.yAxisLabelText
.text(label)
.node()
.getBBox();
}
// set ideal line data
if (this.data.length > 1) {
const idealStart = this.data[0] || { date: this.startDate, value: 0 };
const idealEnd = { date: this.dueDate, value: 0 };
this.idealData = [idealStart, idealEnd];
}
this.scheduleLineAnimation = Boolean(animate);
this.scheduleRender();
}
handleMousemove() {
if (!this.data) return;
const mouseOffsetX = d3.mouse(this.chartOverlay.node())[0];
const dateOffset = this.xScale.invert(mouseOffsetX);
const i = bisectDate(this.data, dateOffset, 1);
const d0 = this.data[i - 1];
const d1 = this.data[i];
if (d1 == null || dateOffset - d0.date < d1.date - dateOffset) {
this.renderTooltip(d0);
} else {
this.renderTooltip(d1);
}
}
// reset width and height to match the svg element, then re-render if necessary
handleResize() {
const dimensions = this.canvas.node().getBoundingClientRect();
if (this.width !== dimensions.width || this.height !== dimensions.height) {
this.width = dimensions.width;
this.height = dimensions.height;
// adjust axis range to correspond with chart size
this.chartWidth = this.width - (margin.left + margin.right);
this.chartHeight = this.height - (margin.top + margin.bottom);
this.xScale.range([0, this.chartWidth]);
this.yScale.range([this.chartHeight, 0]);
this.scheduleRender();
}
}
scheduleRender() {
if (this.queuedRender == null) {
this.queuedRender = requestAnimationFrame(() => this.render());
}
}
render() {
this.queuedRender = null;
this.renderedTooltipPoint = null; // force tooltip re-render
this.xAxis.ticks(Math.floor(this.chartWidth / 120));
this.yAxis.ticks(Math.min(Math.floor(this.chartHeight / 60), this.yMax));
this.chartGroup.attr('transform', `translate(${margin.left}, ${margin.top})`);
this.xAxisGroup.attr('transform', `translate(0, ${this.chartHeight})`);
this.xAxisGroup.call(this.xAxis);
this.yAxisGroup.call(this.yAxis);
// replace x-axis line with one which continues into the right margin
this.xAxisGroup.select('.domain').remove();
this.xAxisGroup
.select('.domain-line')
.attr('x1', 0)
.attr('x2', this.chartWidth + margin.right);
// update y-axis label
const axisLabelOffset = this.yAxisLabelBBox.height / 2 - margin.left;
const axisLabelPadding = (this.chartHeight - this.yAxisLabelBBox.width - 10) / 2;
this.yAxisLabelText
.attr('y', 0 - margin.left)
.attr('x', 0 - this.chartHeight / 2)
.attr('dy', '1em')
.style('text-anchor', 'middle')
.attr('transform', 'rotate(-90)');
this.yAxisLabelLineA
.attr('x1', axisLabelOffset)
.attr('x2', axisLabelOffset)
.attr('y1', 0)
.attr('y2', axisLabelPadding);
this.yAxisLabelLineB
.attr('x1', axisLabelOffset)
.attr('x2', axisLabelOffset)
.attr('y1', this.chartHeight - axisLabelPadding)
.attr('y2', this.chartHeight);
// update legend
const legendPadding = 10;
const legendSpacing = 5;
const idealBBox = this.chartLegendIdealKeyBBox;
const actualBBox = this.chartLegendActualKeyBBox;
const keyWidth = Math.ceil(Math.max(idealBBox.width, actualBBox.width));
const keyHeight = Math.ceil(Math.max(idealBBox.height, actualBBox.height));
const idealKeyOffset = legendPadding;
const actualKeyOffset = legendPadding + keyHeight + legendSpacing;
const legendWidth = legendPadding * 2 + 24 + keyWidth;
const legendHeight = legendPadding * 2 + keyHeight * 2 + legendSpacing;
const legendOffset = this.chartWidth + margin.right - legendWidth - 1;
this.chartLegendGroup
.select('rect')
.attr('width', legendWidth)
.attr('height', legendHeight);
this.chartLegendGroup
.selectAll('text')
.attr('x', 24)
.attr('dy', '1em');
this.chartLegendGroup
.selectAll('line')
.attr('y1', keyHeight / 2)
.attr('y2', keyHeight / 2)
.attr('x1', 0)
.attr('x2', 18);
this.chartLegendGroup.attr('transform', `translate(${legendOffset}, 0)`);
this.chartLegendIdealKey.attr('transform', `translate(${legendPadding}, ${idealKeyOffset})`);
this.chartLegendActualKey.attr('transform', `translate(${legendPadding}, ${actualKeyOffset})`);
// update overlay
this.chartOverlay
.attr('fill', 'none')
.attr('pointer-events', 'all')
.attr('width', this.chartWidth)
.attr('height', this.chartHeight);
// render lines if data available
if (this.data != null && this.data.length > 1) {
this.actualLinePath.datum(this.data).attr('d', this.line);
this.idealLinePath.datum(this.idealData).attr('d', this.line);
if (this.scheduleLineAnimation === true) {
this.scheduleLineAnimation = false;
// hide tooltips until animation is finished
this.chartFocus.attr('opacity', 0);
this.constructor.animateLinePath(this.actualLinePath, 800, () => {
this.chartFocus.attr('opacity', null);
});
}
}
}
renderTooltip(datum) {
if (this.renderedTooltipPoint === datum) return;
this.renderedTooltipPoint = datum;
// generate tooltip content
const format = d3.timeFormat('%b %-d, %Y');
const tooltip = `${datum.value} ${this.label} / ${format(datum.date)}`;
// move the tooltip point of origin to the point on the graph
const x = this.xScale(datum.date);
const y = this.yScale(datum.value);
const textSize = this.tooltipGroup
.select('text')
.text(tooltip)
.node()
.getBBox();
const width = textSize.width + tooltipPadding.x * 2;
const height = textSize.height + tooltipPadding.y * 2;
// calculate bounraries
const xMin = 0 - x - margin.left;
const yMin = 0 - y - margin.top;
const xMax = this.chartWidth + margin.right - x - width;
const yMax = this.chartHeight + margin.bottom - y - height;
// try to fit tooltip above point
let xOffset = 0 - Math.floor(width / 2);
let yOffset = 0 - tooltipDistance - height;
if (yOffset <= yMin) {
// else try to fit tooltip to the right
xOffset = tooltipDistance;
yOffset = 0 - Math.floor(height / 2);
if (xOffset >= xMax) {
// else place tooltip on the left
xOffset = 0 - tooltipDistance - width;
}
}
// ensure coordinates keep the entire tooltip in-bounds
xOffset = Math.max(xMin, Math.min(xMax, xOffset));
yOffset = Math.max(yMin, Math.min(yMax, yOffset));
// move everything into place
this.chartFocus.attr('transform', `translate(${x}, ${y})`);
this.tooltipGroup.attr('transform', `translate(${xOffset}, ${yOffset})`);
this.tooltipGroup
.select('text')
.attr('dy', '1em')
.attr('x', tooltipPadding.x)
.attr('y', tooltipPadding.y);
this.tooltipGroup
.select('rect')
.attr('width', width)
.attr('height', height);
}
animateResize(seconds = 5) {
this.ticksLeft = this.ticksLeft || 0;
if (this.ticksLeft <= 0) {
const interval = setInterval(() => {
this.ticksLeft -= 1;
if (this.ticksLeft <= 0) {
clearInterval(interval);
}
this.handleResize();
}, 20);
}
this.ticksLeft = seconds * 50;
}
static animateLinePath(path, duration = 1000, cb) {
const lineLength = path.node().getTotalLength();
const linearTransition = d3
.transition()
.duration(duration)
.ease(d3.easeLinear);
path
.attr('stroke-dasharray', `${lineLength} ${lineLength}`)
.attr('stroke-dashoffset', lineLength)
.transition(linearTransition)
.attr('stroke-dashoffset', 0)
.on('end', () => {
path.attr('stroke-dasharray', null);
if (cb) cb();
});
}
}
<script>
import { GlButton, GlButtonGroup } from '@gitlab/ui';
import { GlLineChart } from '@gitlab/ui/charts';
import dateFormat from 'dateformat';
import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue';
import { s__, __, sprintf } from '~/locale';
export default {
components: {
GlButton,
GlButtonGroup,
GlLineChart,
ResizableChartContainer,
},
props: {
startDate: {
type: String,
required: true,
},
dueDate: {
type: String,
required: true,
},
openIssuesCount: {
type: Array,
required: false,
default: () => [],
},
openIssuesWeight: {
type: Array,
required: false,
default: () => [],
},
},
data() {
return {
issuesSelected: true,
tooltip: {
title: '',
content: '',
},
};
},
computed: {
dataSeries() {
let name;
let data;
if (this.issuesSelected) {
name = s__('BurndownChartLabel|Open issues');
data = this.openIssuesCount;
} else {
name = s__('BurndownChartLabel|Open issue weight');
data = this.openIssuesWeight;
}
const series = [
{
name,
data: data.map(d => [new Date(d[0]), d[1]]),
},
];
if (series[0] && series[0].data.length >= 2) {
const idealStart = [new Date(this.startDate), data[0][1]];
const idealEnd = [new Date(this.dueDate), 0];
const idealData = [idealStart, idealEnd];
series.push({
name: __('Guideline'),
data: idealData,
silent: true,
symbolSize: 0,
lineStyle: {
color: '#ddd',
type: 'dashed',
},
});
}
return series;
},
options() {
return {
xAxis: {
name: '',
type: 'time',
axisLine: {
show: true,
},
},
yAxis: {
name: this.issuesSelected ? __('Total issues') : __('Total weight'),
axisLine: {
show: true,
},
splitLine: {
show: false,
},
},
tooltip: {
trigger: 'item',
formatter: () => '',
},
};
},
},
methods: {
formatTooltipText(params) {
const [seriesData] = params.seriesData;
this.tooltip.title = dateFormat(params.value, 'dd mmm yyyy');
if (this.issuesSelected) {
this.tooltip.content = sprintf(__('%{total} open issues'), {
total: seriesData.value[1],
});
} else {
this.tooltip.content = sprintf(__('%{total} open issue weight'), {
total: seriesData.value[1],
});
}
},
showIssueCount() {
this.issuesSelected = true;
},
showIssueWeight() {
this.issuesSelected = false;
},
},
};
</script>
<template>
<div data-qa-selector="burndown_chart">
<div class="burndown-header d-flex align-items-center">
<h3>{{ __('Burndown chart') }}</h3>
<gl-button-group class="ml-3 js-burndown-data-selector">
<gl-button
ref="totalIssuesButton"
:variant="issuesSelected ? 'primary' : 'inverted-primary'"
size="sm"
@click="showIssueCount"
>
{{ __('Issues') }}
</gl-button>
<gl-button
ref="totalWeightButton"
:variant="issuesSelected ? 'inverted-primary' : 'primary'"
size="sm"
data-qa-selector="weight_button"
@click="showIssueWeight"
>
{{ __('Issue weight') }}
</gl-button>
</gl-button-group>
</div>
<resizable-chart-container class="burndown-chart">
<gl-line-chart
slot-scope="{ width }"
:width="width"
:data="dataSeries"
:option="options"
:format-tooltip-text="formatTooltipText"
>
<template slot="tooltipTitle">{{ tooltip.title }}</template>
<template slot="tooltipContent">{{ tooltip.content }}</template>
</gl-line-chart>
</resizable-chart-container>
</div>
</template>
import Vue from 'vue';
import $ from 'jquery';
import Cookies from 'js-cookie';
import BurndownChart from './burndown_chart';
import BurndownChart from './components/burndown_chart.vue';
import BurndownChartData from './burndown_chart_data';
import Flash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { s__, __ } from '~/locale';
import { __ } from '~/locale';
export default () => {
// handle hint dismissal
......@@ -32,44 +33,22 @@ export default () => {
const openIssuesCount = chartData.map(d => [d[0], d[1]]);
const openIssuesWeight = chartData.map(d => [d[0], d[2]]);
const chart = new BurndownChart({ container, startDate, dueDate });
let currentView = 'count';
chart.setData(openIssuesCount, {
label: s__('BurndownChartLabel|Open issues'),
animate: true,
});
$('.js-burndown-data-selector').on('click', 'button', function switchData() {
const $this = $(this);
const show = $this.data('show');
if (currentView !== show) {
currentView = show;
$this
.removeClass('btn-inverted')
.siblings()
.addClass('btn-inverted');
switch (show) {
case 'count':
chart.setData(openIssuesCount, {
label: s__('BurndownChartLabel|Open issues'),
animate: true,
});
break;
case 'weight':
chart.setData(openIssuesWeight, {
label: s__('BurndownChartLabel|Open issue weight'),
animate: true,
});
break;
default:
break;
}
}
return new Vue({
el: container,
components: {
BurndownChart,
},
render(createElement) {
return createElement('burndown-chart', {
props: {
startDate,
dueDate,
openIssuesCount,
openIssuesWeight,
},
});
},
});
window.addEventListener('resize', () => chart.animateResize(1));
$(document).on('click', '.js-sidebar-toggle', () => chart.animateResize(2));
})
.catch(() => new Flash(__('Error loading burndown chart data')));
}
......
......@@ -63,17 +63,8 @@
.burndown-chart {
width: 100%;
height: 380px;
margin: 5px 0;
@include media-breakpoint-down(sm) {
height: 320px;
}
@include media-breakpoint-down(xs) {
height: 200px;
}
.axis {
font-size: 12px;
......
......@@ -6,14 +6,6 @@
= warning
- if can_generate_chart?(milestone, burndown)
.burndown-header
%h3
Burndown chart
.btn-group.js-burndown-data-selector
%button.btn.btn-sm.btn-primary{ data: { show: 'count' } }
Issues
%button.btn.btn-sm.btn-primary.btn-inverted{ data: { show: 'weight', qa_selector: 'weight_button' } }
Issue weight
.burndown-chart{ data: { start_date: burndown.start_date.strftime("%Y-%m-%d"),
due_date: burndown.due_date.strftime("%Y-%m-%d"),
burndown_events_path: expose_url(burndown_endpoint), qa_selector: 'burndown_chart' } }
......
---
title: Style burndown charts with gitlab-ui
merge_request: 15463
author:
type: changed
......@@ -2,7 +2,7 @@
require 'spec_helper'
describe 'Burndown charts' do
describe 'Burndown charts', :js do
let(:current_user) { create(:user) }
let(:milestone) do
create(:milestone, project: project,
......
import { shallowMount } from '@vue/test-utils';
import BurndownChart from 'ee/burndown_chart/components/burndown_chart.vue';
describe('burndown_chart', () => {
let wrapper;
const issuesButton = () => wrapper.find({ ref: 'totalIssuesButton' });
const weightButton = () => wrapper.find({ ref: 'totalWeightButton' });
const defaultProps = {
startDate: '2019-08-07T00:00:00.000Z',
dueDate: '2019-09-09T00:00:00.000Z',
openIssuesCount: [],
openIssuesWeight: [],
};
const createComponent = (props = {}) => {
wrapper = shallowMount(BurndownChart, {
propsData: {
...defaultProps,
...props,
},
});
};
it('inclues Issues and Issue weight buttons', () => {
createComponent();
expect(issuesButton().text()).toBe('Issues');
expect(weightButton().text()).toBe('Issue weight');
});
it('defaults to total issues', () => {
createComponent();
expect(issuesButton().attributes('variant')).toBe('primary');
expect(weightButton().attributes('variant')).toBe('inverted-primary');
});
it('toggles Issue weight', () => {
createComponent();
weightButton().vm.$emit('click');
expect(issuesButton().attributes('variant')).toBe('inverted-primary');
expect(weightButton().attributes('variant')).toBe('primary');
});
describe('with single point', () => {
it('does not show guideline', () => {
createComponent({
openIssuesCount: [{ '2019-08-07T00:00:00.000Z': 100 }],
});
const data = wrapper.vm.dataSeries;
expect(data.length).toBe(1);
expect(data[0].name).not.toBe('Guideline');
});
});
describe('with multiple points', () => {
it('shows guideline', () => {
createComponent({
openIssuesCount: [
{ '2019-08-07T00:00:00.000Z': 100 },
{ '2019-08-08T00:00:00.000Z': 99 },
{ '2019-09-08T00:00:00.000Z': 1 },
],
});
const data = wrapper.vm.dataSeries;
expect(data.length).toBe(2);
expect(data[1].name).toBe('Guideline');
});
});
});
......@@ -375,6 +375,12 @@ msgstr ""
msgid "%{title} changes"
msgstr ""
msgid "%{total} open issue weight"
msgstr ""
msgid "%{total} open issues"
msgstr ""
msgid "%{unstaged} unstaged and %{staged} staged changes"
msgstr ""
......@@ -2649,7 +2655,7 @@ msgstr ""
msgid "Built-in"
msgstr ""
msgid "BurndownChartLabel|Guideline"
msgid "Burndown chart"
msgstr ""
msgid "BurndownChartLabel|Open issue weight"
......@@ -2658,12 +2664,6 @@ msgstr ""
msgid "BurndownChartLabel|Open issues"
msgstr ""
msgid "BurndownChartLabel|Progress"
msgstr ""
msgid "BurndownChartLabel|Remaining"
msgstr ""
msgid "Business"
msgstr ""
......@@ -8303,6 +8303,9 @@ msgstr ""
msgid "GroupsTree|Search by name"
msgstr ""
msgid "Guideline"
msgstr ""
msgid "HTTP Basic: Access denied\\nYou must use a personal access token with 'api' scope for Git over HTTP.\\nYou can generate one at %{profile_personal_access_tokens_url}"
msgstr ""
......@@ -8943,6 +8946,9 @@ msgstr ""
msgid "Issue was closed by %{name} %{reason}"
msgstr ""
msgid "Issue weight"
msgstr ""
msgid "IssueBoards|Board"
msgstr ""
......@@ -17062,9 +17068,15 @@ msgstr ""
msgid "Total artifacts size: %{total_size}"
msgstr ""
msgid "Total issues"
msgstr ""
msgid "Total test time for all commits/merges"
msgstr ""
msgid "Total weight"
msgstr ""
msgid "Total: %{total}"
msgstr ""
......
......@@ -8,13 +8,17 @@ module QA
class Show < ::QA::Page::Base
view 'ee/app/views/shared/milestones/_burndown.html.haml' do
element :burndown_chart
element :weight_button
end
view 'ee/app/views/shared/milestones/_weight.html.haml' do
element :total_issue_weight_value
end
view 'ee/app/assets/javascripts/burndown_chart/components/burndown_chart.vue' do
element :burndown_chart
element :weight_button
end
def click_weight_button
click_element(:weight_button)
end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment