Commit 0f8901d6 authored by Martin Wortschack's avatar Martin Wortschack

Migrate contribution analytics chart

- Ports the charts on the
contributio analytics page from
d3 to echarts
parent 28cfd6d0
export const GRADIENT_COLORS = ['#000', '#a7a7a7'];
export const GRADIENT_OPACITY = ['0', '0.4'];
export const INVERSE_GRADIENT_COLORS = ['#a7a7a7', '#000'];
export const INVERSE_GRADIENT_OPACITY = ['0.4', '0'];
---
title: Move contribution analytics chart to echarts
merge_request: 24272
author:
type: other
<script>
import { GlColumnChart } from '@gitlab/ui/dist/charts';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue';
const CHART_HEIGHT = 220;
export default {
components: {
GlColumnChart,
ResizableChartContainer,
},
props: {
chartData: {
type: Array,
required: true,
},
xAxisTitle: {
type: String,
required: false,
default: '',
},
yAxisTitle: {
type: String,
required: false,
default: '',
},
},
data() {
return {
svgs: {},
};
},
computed: {
dataZoomConfig() {
const handleIcon = this.svgs['scroll-handle'];
return handleIcon ? { handleIcon } : {};
},
chartOptions() {
return {
dataZoom: [this.dataZoomConfig],
};
},
seriesData() {
return { full: this.chartData };
},
},
methods: {
setSvg(name) {
return getSvgIconPathContent(name)
.then(path => {
if (path) {
this.$set(this.svgs, name, `path://${path}`);
}
})
.catch(e => {
// eslint-disable-next-line no-console, @gitlab/i18n/no-non-i18n-strings
console.error('SVG could not be rendered correctly: ', e);
});
},
onChartCreated() {
this.setSvg('scroll-handle');
},
},
height: CHART_HEIGHT,
};
</script>
<template>
<resizable-chart-container>
<gl-column-chart
slot-scope="{ width }"
v-bind="$attrs"
:width="width"
:height="$options.height"
:data="seriesData"
:x-axis-title="xAxisTitle"
:y-axis-title="yAxisTitle"
x-axis-type="category"
:option="chartOptions"
@created="onChartCreated"
/>
</resizable-chart-container>
</template>
import Vue from 'vue';
import { sortBy } from 'lodash';
import ColumnChart from './components/column_chart.vue';
import { __ } from '~/locale';
const sortByValue = data => sortBy(data, item => item[1]).reverse();
const allValuesEmpty = graphData =>
graphData.reduce((acc, data) => acc + Math.min(0, data[1]), 0) === 0;
export default dataEl => {
if (!dataEl) return;
const data = JSON.parse(dataEl.innerHTML);
const outputElIds = ['push', 'issues_closed', 'merge_requests_created'];
const xAxisType = 'category';
const xAxisTitle = __('User');
const formattedData = {
push: [],
issues_closed: [],
merge_requests_created: [],
};
outputElIds.forEach(id => {
data[id].data.forEach((d, index) => {
formattedData[id].push([data.labels[index], d]);
});
});
const pushesEl = document.getElementById('js_pushes_chart_vue');
if (allValuesEmpty(formattedData.push)) {
// eslint-disable-next-line no-new
new Vue({
el: pushesEl,
components: {
ColumnChart,
},
render(h) {
return h(ColumnChart, {
props: {
chartData: sortByValue(formattedData.push),
xAxisTitle,
yAxisTitle: __('Pushes'),
xAxisType,
},
});
},
});
}
const mergeRequestEl = document.getElementById('js_merge_requests_chart_vue');
if (allValuesEmpty(formattedData.merge_requests_created)) {
// eslint-disable-next-line no-new
new Vue({
el: mergeRequestEl,
components: {
ColumnChart,
},
render(h) {
return h(ColumnChart, {
props: {
chartData: sortByValue(formattedData.merge_requests_created),
xAxisTitle,
yAxisTitle: __('Merge Requests created'),
xAxisType,
},
});
},
});
}
const issueEl = document.getElementById('js_issues_chart_vue');
if (allValuesEmpty(formattedData.issues_closed)) {
// eslint-disable-next-line no-new
new Vue({
el: issueEl,
components: {
ColumnChart,
},
render(h) {
return h(ColumnChart, {
props: {
chartData: sortByValue(formattedData.issues_closed),
xAxisTitle,
yAxisTitle: __('Issues closed'),
xAxisType,
},
});
},
});
}
};
import Vue from 'vue'; import initContributionAanalyticsCharts from 'ee/analytics/contribution_analytics/contribution_analytics_bundle';
import _ from 'underscore';
import initGroupMemberContributions from 'ee/group_member_contributions'; import initGroupMemberContributions from 'ee/group_member_contributions';
import BarChart from '~/vue_shared/components/bar_chart.vue';
import { __ } from '~/locale';
function sortByValue(data) {
return _.sortBy(data, 'value').reverse();
}
function allValuesEmpty(graphData) {
const emptyCount = graphData.reduce((acc, data) => acc + Math.min(0, data.value), 0);
return emptyCount === 0;
}
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const dataEl = document.getElementById('js-analytics-data'); const dataEl = document.getElementById('js-analytics-data');
if (dataEl) { if (dataEl) {
const data = JSON.parse(dataEl.innerHTML); initContributionAanalyticsCharts(dataEl);
const outputElIds = ['push', 'issues_closed', 'merge_requests_created'];
const formattedData = {
push: [],
issues_closed: [],
merge_requests_created: [],
};
outputElIds.forEach(id => {
data[id].data.forEach((d, index) => {
formattedData[id].push({
name: data.labels[index],
value: d,
});
});
});
initGroupMemberContributions(); initGroupMemberContributions();
const pushesEl = document.getElementById('js_pushes_chart_vue');
if (allValuesEmpty(formattedData.push)) {
// eslint-disable-next-line no-new
new Vue({
el: pushesEl,
components: {
BarChart,
},
render(createElement) {
return createElement('bar-chart', {
props: {
graphData: sortByValue(formattedData.push),
yAxisLabel: __('Pushes'),
},
});
},
});
}
const mergeRequestEl = document.getElementById('js_merge_requests_chart_vue');
if (allValuesEmpty(formattedData.merge_requests_created)) {
// eslint-disable-next-line no-new
new Vue({
el: mergeRequestEl,
components: {
BarChart,
},
render(createElement) {
return createElement('bar-chart', {
props: {
graphData: sortByValue(formattedData.merge_requests_created),
yAxisLabel: __('Merge Requests created'),
},
});
},
});
}
const issueEl = document.getElementById('js_issues_chart_vue');
if (allValuesEmpty(formattedData.issues_closed)) {
// eslint-disable-next-line no-new
new Vue({
el: issueEl,
components: {
BarChart,
},
render(createElement) {
return createElement('bar-chart', {
props: {
graphData: sortByValue(formattedData.issues_closed),
yAxisLabel: __('Issues closed'),
},
});
},
});
}
} }
}); });
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Contribution Analytics Column Chart matches the snapshot 1`] = `
<div>
<gl-column-chart-stub
data="[object Object]"
height="220"
option="[object Object]"
width="0"
xaxistitle="Username"
xaxistype="category"
yaxistitle="Pushes"
/>
</div>
`;
import { mount } from '@vue/test-utils';
import Component from 'ee/analytics/contribution_analytics/components/column_chart.vue';
const mockChartData = [['root', 100], ['desiree', 30], ['katlyn', 70], ['myrtis', 0]];
describe('Contribution Analytics Column Chart', () => {
let wrapper;
beforeEach(() => {
wrapper = mount(Component, {
propsData: {
chartData: mockChartData,
xAxisTitle: 'Username',
yAxisTitle: 'Pushes',
},
stubs: {
'gl-column-chart': true,
},
});
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
});
import Vue from 'vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import BarChart from '~/vue_shared/components/bar_chart.vue';
function getRandomArbitrary(min, max) {
return Math.random() * (max - min) + min;
}
function generateRandomData(dataNumber) {
const randomGraphData = [];
for (let i = 1; i <= dataNumber; i += 1) {
randomGraphData.push({
name: `random ${i}`,
value: parseInt(getRandomArbitrary(1, 8), 10),
});
}
return randomGraphData;
}
describe('Bar chart component', () => {
let barChart;
const graphData = generateRandomData(10);
beforeEach(() => {
const BarChartComponent = Vue.extend(BarChart);
barChart = mountComponent(BarChartComponent, {
graphData,
yAxisLabel: 'data',
});
});
afterEach(() => {
barChart.$destroy();
});
it('calculates the padding for even distribution across bars', () => {
barChart.vbWidth = 1000;
const result = barChart.calculatePadding(30);
// since padding can't be higher than 1 and lower than 0
// for more info: https://github.com/d3/d3-scale#band-scales
expect(result).not.toBeLessThan(0);
expect(result).not.toBeGreaterThan(1);
});
it('formats the tooltip title', () => {
const tooltipTitle = barChart.setTooltipTitle(barChart.graphData[0]);
expect(tooltipTitle).toContain('random 1:');
});
it('has a translates the bar graphs on across the X axis', () => {
barChart.panX = 100;
expect(barChart.barTranslationTransform).toEqual('translate(100, 0)');
});
it('translates the scroll indicator to the far right side', () => {
barChart.vbWidth = 500;
expect(barChart.scrollIndicatorTransform).toEqual('translate(420, 0)');
});
it('translates the x-axis to the bottom of the viewbox and pan coordinates', () => {
barChart.panX = 100;
barChart.vbHeight = 250;
expect(barChart.xAxisLocation).toEqual('translate(100, 250)');
});
it('rotates the x axis labels a total of 90 degress (CCW)', () => {
const xAxisLabel = barChart.$el.querySelector('.x-axis').querySelectorAll('text')[0];
expect(xAxisLabel.getAttribute('transform')).toEqual('rotate(-90)');
});
});
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