Commit 2f50c69a authored by Mike Greiling's avatar Mike Greiling

Merge branch '12593-Convert-issue-analytics-chart-into-ECharts' into 'master'

Convert Issue Analytics chart into ECharts

See merge request gitlab-org/gitlab-ee!15389
parents 2d463acb 9df19781
<script> <script>
import { s__ } from '~/locale';
import { mapGetters, mapActions, mapState } from 'vuex'; import { mapGetters, mapActions, mapState } from 'vuex';
import Chart from 'chart.js'; import { engineeringNotation, sum, average } from '@gitlab/ui/utils/number_utils';
import { GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon } from '@gitlab/ui';
import bp from '~/breakpoints'; import { GlColumnChart, GlChartLegend } from '@gitlab/ui/charts';
import { getMonthNames } from '~/lib/utils/datetime_utility'; import { getMonthNames } from '~/lib/utils/datetime_utility';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import EmptyState from './empty_state.vue'; import EmptyState from './empty_state.vue';
import { CHART_OPTNS, CHART_COLORS } from '../constants';
export default { export default {
components: { components: {
EmptyState, EmptyState,
GlLoadingIcon, GlLoadingIcon,
GlColumnChart,
GlChartLegend,
}, },
props: { props: {
endpoint: { endpoint: {
...@@ -24,31 +27,38 @@ export default { ...@@ -24,31 +27,38 @@ export default {
}, },
data() { data() {
return { return {
drawChart: true, svgs: {},
chartOptions: { chart: null,
...CHART_OPTNS, seriesInfo: [
}, {
showPopover: false, type: 'solid',
popoverTitle: '', name: s__('IssuesAnalytics | Issues created'),
popoverContent: '', color: '#1F78D1',
popoverPositionLeft: true, },
],
}; };
}, },
computed: { computed: {
...mapState('issueAnalytics', ['chartData', 'loading']), ...mapState('issueAnalytics', ['chartData', 'loading']),
...mapGetters('issueAnalytics', ['hasFilters', 'appliedFilters']), ...mapGetters('issueAnalytics', ['hasFilters', 'appliedFilters']),
chartLabels() { data() {
const { chartData, chartHasData } = this; const { chartData, chartHasData } = this;
const labels = []; const data = [];
if (chartHasData()) { if (chartHasData()) {
Object.keys(chartData).forEach(label => { Object.keys(chartData).forEach(key => {
const date = new Date(label); const date = new Date(key);
labels.push(`${getMonthNames(true)[date.getUTCMonth()]} ${date.getUTCFullYear()}`); const label = `${getMonthNames(true)[date.getUTCMonth()]} ${date.getUTCFullYear()}`;
const val = chartData[key];
data.push([label, val]);
}); });
} }
return labels; return data;
},
chartLabels() {
return this.data.map(val => val[0]);
}, },
chartDateRange() { chartDateRange() {
return `${this.chartLabels[0]} - ${this.chartLabels[this.chartLabels.length - 1]}`; return `${this.chartLabels[0]} - ${this.chartLabels[this.chartLabels.length - 1]}`;
...@@ -62,14 +72,28 @@ export default { ...@@ -62,14 +72,28 @@ export default {
showFiltersEmptyState() { showFiltersEmptyState() {
return !this.loading && !this.showChart && this.hasFilters; return !this.loading && !this.showChart && this.hasFilters;
}, },
chartOptions() {
return {
dataZoom: [
{
type: 'slider',
startValue: 0,
handleIcon: this.svgs['scroll-handle'],
}, },
watch: { ],
chartData() { };
// If chart data changes we need to redraw chart
if (this.chartHasData()) {
this.drawChart = true;
}
}, },
series() {
return this.data.map(val => val[1]);
},
seriesAverage() {
return engineeringNotation(average(...this.series), 0);
},
seriesTotal() {
return engineeringNotation(sum(...this.series));
},
},
watch: {
appliedFilters() { appliedFilters() {
this.fetchChartData(this.endpoint); this.fetchChartData(this.endpoint);
}, },
...@@ -79,95 +103,32 @@ export default { ...@@ -79,95 +103,32 @@ export default {
} }
}, },
}, },
created() {
this.setSvg('scroll-handle');
},
mounted() { mounted() {
this.fetchChartData(this.endpoint); this.fetchChartData(this.endpoint);
}, },
updated() {
// Only render chart when DOM is ready
if (this.showChart && this.drawChart) {
this.$nextTick(() => {
this.createChart();
});
}
},
methods: { methods: {
...mapActions('issueAnalytics', ['fetchChartData']), ...mapActions('issueAnalytics', ['fetchChartData']),
createChart() { onCreated(chart) {
const { chartData, chartOptions, chartLabels } = this; this.chart = chart;
const largeBreakpoints = ['md', 'lg'];
// Reset spacing of chart item on large screens
if (largeBreakpoints.includes(bp.getBreakpointSize())) {
chartOptions.barValueSpacing = 12;
}
// Render chart when DOM has been updated
this.$nextTick(() => {
const ctx = this.$refs.issuesChart.getContext('2d');
this.drawChart = false;
return new Chart(ctx, {
type: 'bar',
data: {
labels: chartLabels,
datasets: [
{
...CHART_COLORS,
data: Object.values(chartData),
},
],
},
options: {
...chartOptions,
tooltips: {
enabled: false,
custom: tooltip => this.generateCustomTooltip(tooltip, ctx.canvas),
},
},
});
});
},
generateCustomTooltip(tooltip, canvas) {
if (!tooltip.opacity) {
this.showPopover = false;
return;
}
// Find Y Location on page
let top; // Find Y Location on page
if (tooltip.yAlign === 'above') {
top = tooltip.y - tooltip.caretSize - tooltip.caretPadding;
} else {
top = tooltip.y + tooltip.caretSize + tooltip.caretPadding;
}
[this.popoverTitle] = tooltip.title;
[this.popoverContent] = tooltip.body[0].lines;
this.showPopover = true;
this.$nextTick(() => {
const tooltipEl = this.$refs.chartTooltip;
const tooltipWidth = tooltipEl.getBoundingClientRect().width;
const tooltipLeftOffest = window.innerWidth - tooltipWidth;
const tooltipLeftPosition = canvas.offsetLeft + tooltip.caretX;
this.popoverPositionLeft = tooltipLeftPosition < tooltipLeftOffest;
tooltipEl.style.top = `${canvas.offsetTop + top}px`;
// Move tooltip to the right if too close to the left
if (this.popoverPositionLeft) {
tooltipEl.style.left = `${tooltipLeftPosition}px`;
} else {
tooltipEl.style.left = `${tooltipLeftPosition - tooltipWidth}px`;
}
});
}, },
chartHasData() { chartHasData() {
if (!this.chartData) { if (!this.chartData) {
return false; return false;
} }
return Object.values(this.chartData).reduce((acc, value) => acc + parseInt(value, 10), 0) > 0; return Object.values(this.chartData).some(val => val > 0);
},
setSvg(name) {
getSvgIconPathContent(name)
.then(path => {
if (path) {
this.$set(this.svgs, name, `path://${path}`);
}
})
.catch(() => {});
}, },
}, },
}; };
...@@ -177,36 +138,29 @@ export default { ...@@ -177,36 +138,29 @@ export default {
<div v-if="loading" class="issues-analytics-loading text-center"> <div v-if="loading" class="issues-analytics-loading text-center">
<gl-loading-icon :inline="true" :size="4" /> <gl-loading-icon :inline="true" :size="4" />
</div> </div>
<div v-if="showChart" class="issues-analytics-chart"> <div v-if="showChart" class="issues-analytics-chart">
<h4 class="chart-title">{{ s__('IssuesAnalytics|Issues created per month') }}</h4> <h4 class="chart-title">{{ s__('IssuesAnalytics|Issues created per month') }}</h4>
<gl-column-chart
data-qa-selector="issues_analytics_graph"
:data="{ Full: data }"
:option="chartOptions"
:y-axis-title="s__('IssuesAnalytics|Issues Created')"
:x-axis-title="s__('IssuesAnalytics|Last 12 months') + ' (' + chartDateRange + ')'"
x-axis-type="category"
@created="onCreated"
/>
<div class="d-flex"> <div class="d-flex">
<div class="chart-legend d-none d-sm-block bold align-self-center"> <gl-chart-legend v-if="chart" :chart="chart" :series-info="seriesInfo" />
{{ s__('IssuesAnalytics|Issues Created') }} <div class="issues-analytics-legend">
</div> <span>{{ s__('IssuesAnalytics|Total:') }} {{ seriesTotal }}</span>
<div class="chart-canvas-wrapper" data-qa-selector="issues_analytics_graph"> <span>&#8226;</span>
<canvas ref="issuesChart" height="300" class="append-bottom-15"></canvas> <span>{{ s__('IssuesAnalytics|Avg/Month:') }} {{ seriesAverage }}</span>
</div>
</div>
<p class="bold text-center">
{{ s__('IssuesAnalytics|Last 12 months') }} ({{ chartDateRange }})
</p>
<div
ref="chartTooltip"
:class="[
showPopover ? 'show' : 'hide',
popoverPositionLeft ? 'bs-popover-right' : 'bs-popover-left',
]"
class="popover no-pointer-events"
role="tooltip"
>
<div class="arrow"></div>
<h3 class="popover-header">{{ popoverTitle }}</h3>
<div class="popover-body">
<span class="popover-label">{{ s__('IssuesAnalytics|Issues Created') }}</span>
{{ popoverContent }}
</div> </div>
</div> </div>
</div> </div>
<empty-state <empty-state
v-if="showFiltersEmptyState" v-if="showFiltersEmptyState"
image="illustrations/issues.svg" image="illustrations/issues.svg"
...@@ -217,6 +171,7 @@ export default { ...@@ -217,6 +171,7 @@ export default {
) )
" "
/> />
<empty-state <empty-state
v-if="showNoDataEmptyState" v-if="showNoDataEmptyState"
image="illustrations/monitoring/getting_started.svg" image="illustrations/monitoring/getting_started.svg"
......
import { barChartOptions } from '~/lib/utils/chart_utils';
const defaultOptions = barChartOptions();
export const CHART_OPTNS = {
...defaultOptions,
scaleOverlay: true,
pointHitDetectionRadius: 2,
barValueSpacing: 2,
scales: {
xAxes: [
{
gridLines: {
display: false,
drawBorder: false,
color: 'transparent',
},
},
],
yAxes: [
{
gridLines: {
color: '#DFDFDF',
drawBorder: false,
drawTicks: false,
},
ticks: {
padding: 10,
},
},
],
},
};
export const CHART_COLORS = {
backgroundColor: 'rgba(31,120,209,0.1)',
borderColor: 'rgba(31,120,209,1)',
hoverBackgroundColor: 'rgba(31,120,209,0.3)',
borderWidth: 1,
};
...@@ -8,55 +8,13 @@ ...@@ -8,55 +8,13 @@
.chart-title { .chart-title {
margin: $gl-padding-24 0 $gl-padding-32; margin: $gl-padding-24 0 $gl-padding-32;
} }
.chart-legend {
width: $gl-padding-32;
transform: rotate(-90deg);
white-space: nowrap;
margin-right: $gl-col-padding;
}
.chart-canvas-wrapper {
width: 100%;
@include media-breakpoint-up(lg) {
width: 90%;
}
}
.popover {
border: 0;
border-radius: $border-radius-small;
box-shadow: 0 1px 4px 0 $black-transparent;
.arrow {
top: $gl-padding-8;
&::before {
border-right-color: $issues-analytics-popover-boarder-color;
}
&::after {
border-right-color: $gray-light;
}
}
.popover-header {
background: $gray-light;
}
.popover-body,
.popover-header {
font-weight: normal;
padding: $gl-padding-8 $gl-padding-8 $gl-padding-top;
}
.popover-label {
margin-right: $gl-padding-32;
}
}
} }
.issues-analytics-loading { .issues-analytics-loading {
padding-top: $header-height * 2; padding-top: $header-height * 2;
} }
.issues-analytics-legend {
font-size: $gl-font-size-small;
color: $gl-text-color-secondary;
}
---
title: Convert Issue Analytics chart into ECharts
merge_request: 15389
author:
type: other
import Vue from 'vue'; import Vue from 'vue';
import IssuesAnalytics from 'ee/issues_analytics/components/issues_analytics.vue'; import IssuesAnalytics from 'ee/issues_analytics/components/issues_analytics.vue';
import EmptyState from 'ee/issues_analytics/components/empty_state.vue';
import { shallowMount } from '@vue/test-utils';
import { createStore } from 'ee/issues_analytics/stores'; import { createStore } from 'ee/issues_analytics/stores';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
describe('Issues Analytics component', () => { describe('Issues Analytics component', () => {
let vm; let vm;
...@@ -9,24 +10,6 @@ describe('Issues Analytics component', () => { ...@@ -9,24 +10,6 @@ describe('Issues Analytics component', () => {
let mountComponent; let mountComponent;
const Component = Vue.extend(IssuesAnalytics); const Component = Vue.extend(IssuesAnalytics);
const mockChartData = { '2017-11': 0, '2017-12': 2 }; const mockChartData = { '2017-11': 0, '2017-12': 2 };
const mockTooltipData = {
y: 1,
x: 1,
title: ['Jul 2018'],
opacity: 1,
body: [
{
lines: ['1'],
},
],
caretHeight: 1,
caretPadding: 1,
};
const mockCanvas = {
offsetLeft: 1,
offsetTop: 1,
};
beforeEach(() => { beforeEach(() => {
store = createStore(); store = createStore();
...@@ -38,11 +21,18 @@ describe('Issues Analytics component', () => { ...@@ -38,11 +21,18 @@ describe('Issues Analytics component', () => {
endpoint: gl.TEST_HOST, endpoint: gl.TEST_HOST,
filterBlockEl: document.querySelector('#mock-filter'), filterBlockEl: document.querySelector('#mock-filter'),
}; };
return mountComponentWithStore(Component, { store, props });
return shallowMount(Component, {
propsData: props,
stubs: {
GlColumnChart: true,
EmptyState,
},
store,
}).vm;
}; };
vm = mountComponent(); vm = mountComponent();
spyOn(vm, 'createChart').and.stub();
}); });
afterEach(() => { afterEach(() => {
...@@ -72,21 +62,6 @@ describe('Issues Analytics component', () => { ...@@ -72,21 +62,6 @@ describe('Issues Analytics component', () => {
}); });
}); });
it('renders chart tooltip with the correct details', done => {
const [popoverTitle] = mockTooltipData.title;
const [popoverContent] = mockTooltipData.body[0].lines;
vm.$store.state.issueAnalytics.chartData = mockChartData;
vm.generateCustomTooltip(mockTooltipData, mockCanvas);
vm.$nextTick(() => {
expect(vm.showPopover).toBe(true);
expect(vm.popoverTitle).toEqual(popoverTitle);
expect(vm.popoverContent).toEqual(popoverContent);
done();
});
});
it('fetches data when filters are applied', done => { it('fetches data when filters are applied', done => {
vm.$store.state.issueAnalytics.filters = '?hello=world'; vm.$store.state.issueAnalytics.filters = '?hello=world';
......
...@@ -8374,9 +8374,15 @@ msgstr "" ...@@ -8374,9 +8374,15 @@ msgstr ""
msgid "Issues, merge requests, pushes, and comments." msgid "Issues, merge requests, pushes, and comments."
msgstr "" msgstr ""
msgid "IssuesAnalytics | Issues created"
msgstr ""
msgid "IssuesAnalytics|After you begin creating issues for your projects, we can start tracking and displaying metrics for them" msgid "IssuesAnalytics|After you begin creating issues for your projects, we can start tracking and displaying metrics for them"
msgstr "" msgstr ""
msgid "IssuesAnalytics|Avg/Month:"
msgstr ""
msgid "IssuesAnalytics|Issues Created" msgid "IssuesAnalytics|Issues Created"
msgstr "" msgstr ""
...@@ -8395,6 +8401,9 @@ msgstr "" ...@@ -8395,6 +8401,9 @@ msgstr ""
msgid "IssuesAnalytics|To widen your search, change or remove filters in the filter bar above" msgid "IssuesAnalytics|To widen your search, change or remove filters in the filter bar above"
msgstr "" msgstr ""
msgid "IssuesAnalytics|Total:"
msgstr ""
msgid "It must have a header row and at least two columns: the first column is the issue title and the second column is the issue description. The separator is automatically detected." msgid "It must have a header row and at least two columns: the first column is the issue title and the second column is the issue description. The separator is automatically detected."
msgstr "" msgstr ""
......
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