Skip to content
代码片段 群组 项目
提交 01126172 编辑于 作者: Ezekiel Kigbo's avatar Ezekiel Kigbo
浏览文件
No related branches found
No related tags found
无相关合并请求
显示
338 个添加32 个删除
......@@ -56,6 +56,16 @@ By default, insights display all available dimensions on the chart.
To exclude a dimension, from the legend below the chart, select the name of the dimension.
### Drill down on charts
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/372215/) in GitLab 16.7.
You can drill down into the data of the **Bugs created per month by priority** and **Bugs created per month by severity** charts from the [default configuration file](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/fixtures/insights/default.yml).
To view a drill-down report of the data for a specific priority or severity in a month:
- On the chart, select the bar stack you want to drill down on.
## Configure group insights
GitLab reads insights from the
......
<script>
import { GlColumnChart, GlLineChart, GlStackedColumnChart } from '@gitlab/ui/dist/charts';
import {
GlColumnChart,
GlLineChart,
GlStackedColumnChart,
GlChartSeriesLabel,
} from '@gitlab/ui/dist/charts';
import { isNumber } from 'lodash';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
import ChartTooltipText from 'ee/analytics/shared/components/chart_tooltip_text.vue';
import { visitUrl, mergeUrlParams, joinPaths } from '~/lib/utils/url_utility';
import { CHART_TYPES, INSIGHTS_NO_DATA_TOOLTIP } from '../constants';
import {
CHART_TYPES,
INSIGHTS_NO_DATA_TOOLTIP,
INSIGHTS_DRILLTHROUGH_PATH_SUFFIXES,
INSIGHTS_CHARTS_SUPPORT_DRILLDOWN,
} from '../constants';
import InsightsChartError from './insights_chart_error.vue';
const CHART_HEIGHT = 300;
......@@ -54,6 +65,15 @@ export default {
InsightsChartError,
ChartSkeletonLoader,
ChartTooltipText,
GlChartSeriesLabel,
},
inject: {
fullPath: {
default: '',
},
isProject: {
default: false,
},
},
props: {
loaded: {
......@@ -81,6 +101,11 @@ export default {
required: false,
default: null,
},
dataSourceType: {
type: String,
required: false,
default: '',
},
error: {
type: String,
required: false,
......@@ -92,7 +117,9 @@ export default {
svgs: {},
tooltipTitle: null,
tooltipValue: null,
tooltipContent: [],
chart: null,
activeSeriesId: null,
};
},
computed: {
......@@ -109,6 +136,7 @@ export default {
yAxis: {
minInterval: 1,
},
cursor: 'auto',
};
if (this.type === this.$options.chartTypes.LINE) {
......@@ -127,6 +155,16 @@ export default {
};
}
if (this.supportsDrillDown) {
options = {
...options,
cursor: 'pointer',
emphasis: {
focus: 'series',
},
};
}
return { dataZoom: [this.dataZoomConfig], ...options };
},
isColumnChart() {
......@@ -138,6 +176,36 @@ export default {
isLineChart() {
return this.type === this.$options.chartTypes.LINE;
},
supportsDrillDown() {
return (
INSIGHTS_CHARTS_SUPPORT_DRILLDOWN.includes(this.title) &&
this.type === this.$options.chartTypes.STACKED_BAR
);
},
namespacePath() {
return this.isProject ? this.fullPath : joinPaths('groups', this.fullPath);
},
drillThroughPathSuffix() {
const { groupPathSuffix, projectPathSuffix } = INSIGHTS_DRILLTHROUGH_PATH_SUFFIXES[
this.dataSourceType
];
return this.isProject ? projectPathSuffix : groupPathSuffix;
},
chartItemUrl() {
return joinPaths(
'/',
gon.relative_url_root || '',
this.namespacePath,
this.drillThroughPathSuffix,
);
},
},
beforeDestroy() {
if (this.chart && this.supportsDrillDown) {
this.chart.off('mouseover', this.onChartDataSeriesMouseOver);
this.chart.off('mouseout', this.onChartDataSeriesMouseOut);
}
},
methods: {
setSvg(name) {
......@@ -155,6 +223,21 @@ export default {
onChartCreated(chart) {
this.chart = chart;
this.setSvg('scroll-handle');
if (this.supportsDrillDown) {
this.chart.on('mouseover', 'series', this.onChartDataSeriesMouseOver);
this.chart.on('mouseout', 'series', this.onChartDataSeriesMouseOut);
}
},
onChartItemClicked({ params }) {
const { seriesName } = params;
const canDrillDown = seriesName !== 'undefined' && this.supportsDrillDown;
if (!canDrillDown) return;
const chartItemUrlWithParams = mergeUrlParams({ label_name: seriesName }, this.chartItemUrl);
visitUrl(chartItemUrlWithParams);
},
formatTooltipText(params) {
const { seriesData } = params;
......@@ -163,6 +246,34 @@ export default {
this.tooltipTitle = params.value;
this.tooltipValue = tooltipValue;
},
formatBarChartTooltip({ value: title, seriesData }) {
this.tooltipTitle = title;
this.tooltipContent = seriesData
.map(({ borderColor, seriesId, seriesName, seriesIndex, value }) => ({
color: borderColor,
seriesId,
seriesName,
seriesIndex,
value: value ?? INSIGHTS_NO_DATA_TOOLTIP,
}))
.reverse();
},
onChartDataSeriesMouseOver({ seriesId }) {
this.activeSeriesId = seriesId;
},
onChartDataSeriesMouseOut() {
this.activeSeriesId = null;
},
activeChartSeriesLabelStyles(seriesId) {
if (!this.activeSeriesId) return null;
const isSeriesActive = this.activeSeriesId === seriesId;
return {
'gl-font-weight-bold': isSeriesActive,
'gl-opacity-4': !isSeriesActive,
};
},
},
height: CHART_HEIGHT,
chartTypes: CHART_TYPES,
......@@ -204,8 +315,25 @@ export default {
:x-axis-title="data.xAxisTitle"
:y-axis-title="data.yAxisTitle"
:option="chartOptions"
:format-tooltip-text="formatBarChartTooltip"
@created="onChartCreated"
/>
@chartItemClicked="onChartItemClicked"
>
<template #tooltip-title>{{ tooltipTitle }}</template>
<template #tooltip-content>
<div
v-for="{ seriesId, seriesName, color, value } in tooltipContent"
:key="seriesId"
class="gl-display-flex gl-justify-content-space-between gl-line-height-20 gl-min-w-20"
:class="activeChartSeriesLabelStyles(seriesId)"
>
<gl-chart-series-label class="gl-mr-7 gl-font-sm" :color="color">
{{ seriesName }}
</gl-chart-series-label>
<div class="gl-font-weight-bold">{{ value }}</div>
</div>
</template>
</gl-stacked-column-chart>
<template v-else-if="loaded && isLineChart">
<gl-line-chart
v-bind="$attrs"
......
......@@ -70,13 +70,16 @@ export default {
<h4 class="text-center">{{ pageConfig.title }}</h4>
<div class="insights-charts" data-testid="insights-charts">
<insights-chart
v-for="({ loaded, type, description, data, error }, key, index) in chartData"
v-for="(
{ loaded, type, description, data, dataSourceType, error }, key, index
) in chartData"
:key="index"
:loaded="loaded"
:type="type"
:title="key"
:description="description"
:data="data"
:data-source-type="dataSourceType"
:error="error"
/>
</div>
......
......@@ -26,3 +26,19 @@ export const INSIGHTS_DATA_SOURCE_DORA = 'dora';
export const INSIGHTS_NO_DATA_TOOLTIP = __('No data available');
export const INSIGHTS_REPORT_DROPDOWN_EMPTY_TEXT = __('Select report');
export const ISSUABLE_TYPES = {
ISSUE: 'issue',
};
export const INSIGHTS_DRILLTHROUGH_PATH_SUFFIXES = {
[ISSUABLE_TYPES.ISSUE]: {
groupPathSuffix: '-/issues_analytics',
projectPathSuffix: '-/analytics/issues_analytics',
},
};
export const INSIGHTS_CHARTS_SUPPORT_DRILLDOWN = [
__('Bugs created per month by Priority'),
__('Bugs created per month by Severity'),
];
......@@ -5,7 +5,7 @@ import store from './stores';
export default () => {
const el = document.querySelector('#js-insights-pane');
const { endpoint, queryEndpoint, notice } = el.dataset;
const { endpoint, queryEndpoint, notice, namespaceType, fullPath } = el.dataset;
const router = createRouter(endpoint);
if (!el) return null;
......@@ -17,6 +17,10 @@ export default () => {
components: {
Insights,
},
provide: {
fullPath,
isProject: namespaceType === 'project',
},
render(createElement) {
return createElement('insights', {
props: {
......
......@@ -21,12 +21,15 @@ export default {
},
[types.RECEIVE_CHART_SUCCESS](state, { chart, data }) {
const { type, description } = chart;
const { type, description, query } = chart;
const { issuable_type: issuableType, metric } = query.params || {};
state.chartData[chart.title] = {
type,
description,
data: transformChartDataForGlCharts(chart, data),
dataSourceType: issuableType ?? metric,
loaded: true,
};
},
......
- @no_container = true
= render('shared/insights', endpoint: group_insights_path(@group, format: :json), query_endpoint: query_group_insights_path(@group))
= render('shared/insights', endpoint: group_insights_path(@group, format: :json), full_path: @group.full_path, namespace_type: 'group', query_endpoint: query_group_insights_path(@group))
- @no_container = true
= render('shared/insights', endpoint: namespace_project_insights_path(@project.namespace, @project, format: :json), query_endpoint: query_namespace_project_insights_path(@project.namespace, @project), notice: project_insights_config.notice_text)
= render('shared/insights', endpoint: namespace_project_insights_path(@project.namespace, @project, format: :json), full_path: @project.full_path, namespace_type: 'project', query_endpoint: query_namespace_project_insights_path(@project.namespace, @project), notice: project_insights_config.notice_text)
- page_title _('Insights')
%div{ class: container_class }
#js-insights-pane{ data: { endpoint: endpoint, query_endpoint: query_endpoint, notice: local_assigns[:notice] } }
#js-insights-pane{ data: { endpoint: endpoint, query_endpoint: query_endpoint, notice: local_assigns[:notice], namespace_type: namespace_type, full_path: full_path } }
import { nextTick } from 'vue';
import { GlColumnChart, GlLineChart, GlStackedColumnChart } from '@gitlab/ui/dist/charts';
import { shallowMount } from '@vue/test-utils';
import { visitUrl } from '~/lib/utils/url_utility';
import InsightsChart from 'ee/insights/components/insights_chart.vue';
import InsightsChartError from 'ee/insights/components/insights_chart_error.vue';
import { CHART_TYPES } from 'ee/insights/constants';
import {
CHART_TYPES,
INSIGHTS_CHARTS_SUPPORT_DRILLDOWN,
INSIGHTS_DRILLTHROUGH_PATH_SUFFIXES,
ISSUABLE_TYPES,
} from 'ee/insights/constants';
import {
chartInfo,
barChartData,
lineChartData,
stackedBarChartData,
chartDataSeriesParams,
chartUndefinedDataSeriesParams,
} from 'ee_jest/insights/mock_data';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
const DEFAULT_PROPS = {
loaded: false,
type: chartInfo.type,
title: chartInfo.title,
data: null,
error: '',
};
jest.mock('~/lib/utils/url_utility', () => ({
...jest.requireActual('~/lib/utils/url_utility'),
visitUrl: jest.fn().mockName('visitUrlMock'),
}));
describe('Insights chart component', () => {
let wrapper;
const factory = (propsData) =>
shallowMount(InsightsChart, {
propsData,
const groupPath = 'test';
const projectPath = 'test/project';
const DEFAULT_PROVIDE = {
fullPath: groupPath,
isProject: false,
};
const createWrapper = ({ props = {}, provide = DEFAULT_PROVIDE } = {}) => {
wrapper = shallowMount(InsightsChart, {
propsData: {
loaded: true,
type: chartInfo.type,
title: chartInfo.title,
data: null,
error: '',
...props,
},
provide,
stubs: { 'gl-column-chart': true, 'insights-chart-error': true },
});
};
const findChart = (component) => wrapper.findComponent(component);
describe('when chart is loading', () => {
it('displays the chart loader in the container', () => {
wrapper = factory(DEFAULT_PROPS);
createWrapper({ props: { loaded: false } });
expect(wrapper.findComponent(ChartSkeletonLoader).exists()).toBe(true);
});
......@@ -44,16 +69,38 @@ describe('Insights chart component', () => {
${CHART_TYPES.STACKED_BAR} | ${GlStackedColumnChart} | ${'GlStackedColumnChart'} | ${stackedBarChartData}
${CHART_TYPES.PIE} | ${GlColumnChart} | ${'GlColumnChart'} | ${barChartData}
`('when chart is loaded', ({ type, component, name, data }) => {
it(`when ${type} is passed: displays the a ${name}-chart in container and not the loader`, () => {
wrapper = factory({
...DEFAULT_PROPS,
loaded: true,
type,
data,
let chartComponent;
beforeEach(() => {
createWrapper({
props: {
type,
data,
},
});
chartComponent = findChart(component);
});
it(`when ${type} is passed: displays the ${name} chart in container and not the loader`, () => {
expect(wrapper.findComponent(ChartSkeletonLoader).exists()).toBe(false);
expect(wrapper.findComponent(component).exists()).toBe(true);
expect(chartComponent.exists()).toBe(true);
});
it('should have cursor property set to `auto`', () => {
expect(chartComponent.props('option')).toEqual(
expect.objectContaining({
cursor: 'auto',
}),
);
});
it('should not drill down when clicking on chart item', async () => {
chartComponent.vm.$emit('chartItemClicked', chartDataSeriesParams);
await nextTick();
expect(visitUrl).not.toHaveBeenCalled();
});
});
......@@ -61,10 +108,11 @@ describe('Insights chart component', () => {
const error = 'my error';
beforeEach(() => {
wrapper = factory({
...DEFAULT_PROPS,
data: {},
error,
createWrapper({
props: {
data: {},
error,
},
});
});
......@@ -73,4 +121,80 @@ describe('Insights chart component', () => {
expect(wrapper.findComponent(InsightsChartError).exists()).toBe(true);
});
});
describe('when chart supports drilling down', () => {
const dataSourceType = ISSUABLE_TYPES.ISSUE;
const supportedChartProps = {
type: CHART_TYPES.STACKED_BAR,
data: stackedBarChartData,
dataSourceType,
};
const { groupPathSuffix, projectPathSuffix } = INSIGHTS_DRILLTHROUGH_PATH_SUFFIXES[
dataSourceType
];
describe.each(INSIGHTS_CHARTS_SUPPORT_DRILLDOWN)('`%s` chart', (chartTitle) => {
beforeEach(() => {
createWrapper({
props: { title: chartTitle, ...supportedChartProps },
});
});
it('should set correct hover interaction properties', () => {
expect(findChart(GlStackedColumnChart).props('option')).toEqual(
expect.objectContaining({
cursor: 'pointer',
emphasis: {
focus: 'series',
},
}),
);
});
it('should not drill down when clicking on `undefined` chart item', async () => {
findChart(GlStackedColumnChart).vm.$emit(
'chartItemClicked',
chartUndefinedDataSeriesParams,
);
await nextTick();
expect(visitUrl).not.toHaveBeenCalled();
});
it.each`
isProject | relativeUrlRoot | fullPath | pathSuffix
${false} | ${'/'} | ${groupPath} | ${groupPathSuffix}
${true} | ${'/'} | ${projectPath} | ${projectPathSuffix}
${false} | ${'/path'} | ${groupPath} | ${groupPathSuffix}
${true} | ${'/path'} | ${projectPath} | ${projectPathSuffix}
`(
'should drill down to the correct URL when clicking on a chart item',
async ({ isProject, relativeUrlRoot, fullPath, pathSuffix }) => {
const {
params: { seriesName },
} = chartDataSeriesParams;
const rootPath = relativeUrlRoot === '/' ? '' : relativeUrlRoot;
const namespacePath = isProject ? fullPath : `groups/${fullPath}`;
const expectedDrillDownUrl = `${rootPath}/${namespacePath}/${pathSuffix}?label_name=${seriesName}`;
gon.relative_url_root = relativeUrlRoot;
createWrapper({
props: { title: chartTitle, ...supportedChartProps },
provide: { isProject, fullPath },
});
findChart(GlStackedColumnChart).vm.$emit('chartItemClicked', chartDataSeriesParams);
await nextTick();
expect(visitUrl).toHaveBeenCalledTimes(1);
expect(visitUrl).toHaveBeenCalledWith(expectedDrillDownUrl);
},
);
});
});
});
......@@ -114,3 +114,15 @@ export const doraSeries = [
symbolSize: 8,
},
];
export const chartDataSeriesParams = {
params: {
seriesName: 'bug',
},
};
export const chartUndefinedDataSeriesParams = {
params: {
seriesName: 'undefined',
},
};
......@@ -8975,6 +8975,12 @@ msgstr ""
msgid "Browse templates"
msgstr ""
 
msgid "Bugs created per month by Priority"
msgstr ""
msgid "Bugs created per month by Severity"
msgstr ""
msgid "Build cannot be erased"
msgstr ""
 
0% 加载中 .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册