diff --git a/doc/user/group/insights/index.md b/doc/user/group/insights/index.md index 2ded1b08de2b311c28867f98e845ce1b243929a8..251965904a1b1ed8a923f70a7b38f5d9f1cfb089 100644 --- a/doc/user/group/insights/index.md +++ b/doc/user/group/insights/index.md @@ -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 diff --git a/ee/app/assets/javascripts/insights/components/insights_chart.vue b/ee/app/assets/javascripts/insights/components/insights_chart.vue index 983f60aa63ff4ada297001b17a6219bbfd5d08c2..4fe8ce0cc29e23a48de7111948ccc8e71e3c6359 100644 --- a/ee/app/assets/javascripts/insights/components/insights_chart.vue +++ b/ee/app/assets/javascripts/insights/components/insights_chart.vue @@ -1,12 +1,23 @@ <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" diff --git a/ee/app/assets/javascripts/insights/components/insights_page.vue b/ee/app/assets/javascripts/insights/components/insights_page.vue index 0e3f959b8bc7cacab9235e6609e15651a0db1228..04b606972871cc04606160db981f9935669cacdc 100644 --- a/ee/app/assets/javascripts/insights/components/insights_page.vue +++ b/ee/app/assets/javascripts/insights/components/insights_page.vue @@ -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> diff --git a/ee/app/assets/javascripts/insights/constants.js b/ee/app/assets/javascripts/insights/constants.js index cb78a6248380b1998786a96aab9b50cc27d236fb..01b63687108d19c9dfb557155ef22305a5b09c5f 100644 --- a/ee/app/assets/javascripts/insights/constants.js +++ b/ee/app/assets/javascripts/insights/constants.js @@ -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'), +]; diff --git a/ee/app/assets/javascripts/insights/index.js b/ee/app/assets/javascripts/insights/index.js index 7004b7581dedc3dcc6eafc259b5027eae4ce20a2..cdf0991d744a4ed7bffed82fbec58092131af97c 100644 --- a/ee/app/assets/javascripts/insights/index.js +++ b/ee/app/assets/javascripts/insights/index.js @@ -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: { diff --git a/ee/app/assets/javascripts/insights/stores/modules/insights/mutations.js b/ee/app/assets/javascripts/insights/stores/modules/insights/mutations.js index 1cc846cea11a598933d5abea566f4a870ecee1fa..4035bd1940204b96e9b8f063f9a58c1cabd12efb 100644 --- a/ee/app/assets/javascripts/insights/stores/modules/insights/mutations.js +++ b/ee/app/assets/javascripts/insights/stores/modules/insights/mutations.js @@ -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, }; }, diff --git a/ee/app/views/groups/insights/show.html.haml b/ee/app/views/groups/insights/show.html.haml index fbee22a2ccc038821eac584abed9626cf98436c7..2d39b535055621ebacede79f5d4805d7cd6baf28 100644 --- a/ee/app/views/groups/insights/show.html.haml +++ b/ee/app/views/groups/insights/show.html.haml @@ -1,3 +1,3 @@ - @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)) diff --git a/ee/app/views/projects/insights/show.html.haml b/ee/app/views/projects/insights/show.html.haml index f4bd38f04dbf7c287e3a3c06d95a0831ba03583a..99fcb60025cb9a3e92c931f37844d7e3da8b49d0 100644 --- a/ee/app/views/projects/insights/show.html.haml +++ b/ee/app/views/projects/insights/show.html.haml @@ -1,3 +1,3 @@ - @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) diff --git a/ee/app/views/shared/_insights.html.haml b/ee/app/views/shared/_insights.html.haml index b2e77398790bdc1c9676957c972de920c01ede63..088f83addd54d7cf44951f4d99ab7bb2c095d1b1 100644 --- a/ee/app/views/shared/_insights.html.haml +++ b/ee/app/views/shared/_insights.html.haml @@ -1,4 +1,4 @@ - 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 } } diff --git a/ee/spec/frontend/insights/components/insights_chart_spec.js b/ee/spec/frontend/insights/components/insights_chart_spec.js index 10eadc6b9d9a7be12851e268c283ee70e5dbab3c..75be746f35c8b6d0378e5e630081f6a7f05c0f77 100644 --- a/ee/spec/frontend/insights/components/insights_chart_spec.js +++ b/ee/spec/frontend/insights/components/insights_chart_spec.js @@ -1,37 +1,62 @@ +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); + }, + ); + }); + }); }); diff --git a/ee/spec/frontend/insights/mock_data.js b/ee/spec/frontend/insights/mock_data.js index 33de00071ec045e4e97f193c714bb465d0dc0400..6a566d1b5d5a450ac6c62fad73807502994dbb32 100644 --- a/ee/spec/frontend/insights/mock_data.js +++ b/ee/spec/frontend/insights/mock_data.js @@ -114,3 +114,15 @@ export const doraSeries = [ symbolSize: 8, }, ]; + +export const chartDataSeriesParams = { + params: { + seriesName: 'bug', + }, +}; + +export const chartUndefinedDataSeriesParams = { + params: { + seriesName: 'undefined', + }, +}; diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 095ebbead51af8e063428ec7cb1c49b36f688623..b2f358fa500ae2e266ad1659452519065bd15277 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -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 ""