diff --git a/ee/app/assets/javascripts/analytics/cycle_analytics/components/base.vue b/ee/app/assets/javascripts/analytics/cycle_analytics/components/base.vue index fce076eb833953a9e5d322306f036327ab64394f..6109e5afb88794e786abf826e0ff8d83beb3046f 100644 --- a/ee/app/assets/javascripts/analytics/cycle_analytics/components/base.vue +++ b/ee/app/assets/javascripts/analytics/cycle_analytics/components/base.vue @@ -17,6 +17,7 @@ import ValueStreamAggregationStatus from './value_stream_aggregation_status.vue' import ValueStreamAggregatingWarning from './value_stream_aggregating_warning.vue'; import ValueStreamEmptyState from './value_stream_empty_state.vue'; import ValueStreamSelect from './value_stream_select.vue'; +import DurationOverviewChart from './duration_overview_chart.vue'; export default { name: 'CycleAnalytics', @@ -33,6 +34,7 @@ export default { ValueStreamMetrics, ValueStreamSelect, UrlSync, + DurationOverviewChart, }, props: { emptyStateSvgPath: { @@ -265,7 +267,13 @@ export default { :dashboards-path="dashboardsPath" /> <div :class="[isOverviewStageSelected ? 'gl-mt-2' : 'gl-mt-6']"> - <duration-chart class="gl-mb-6" :stages="activeStages" :selected-stage="selectedStage" /> + <duration-overview-chart v-if="isOverviewStageSelected" class="gl-mb-6" /> + <duration-chart + v-else + class="gl-mb-6" + :stages="activeStages" + :selected-stage="selectedStage" + /> <type-of-work-charts v-if="shouldRenderTasksByType" class="gl-mb-6" /> </div> <stage-table diff --git a/ee/app/assets/javascripts/analytics/cycle_analytics/components/duration_overview_chart.vue b/ee/app/assets/javascripts/analytics/cycle_analytics/components/duration_overview_chart.vue new file mode 100644 index 0000000000000000000000000000000000000000..4a5ac480d9a552c566b570b55ddf290f279e60cc --- /dev/null +++ b/ee/app/assets/javascripts/analytics/cycle_analytics/components/duration_overview_chart.vue @@ -0,0 +1,219 @@ +<script> +import { GlAreaChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; +import { mapGetters, mapState } from 'vuex'; +import { GlAlert, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; +import dateFormat from '~/lib/dateformat'; +import { buildNullSeries, formatDurationOverviewTooltipMetric } from 'ee/analytics/shared/utils'; +import { isNumeric } from '~/lib/utils/number_utils'; +import { n__ } from '~/locale'; +import { progressiveSummation } from '../utils'; +import { + DURATION_CHART_Y_AXIS_TITLE, + DURATION_TOTAL_TIME_DESCRIPTION, + DURATION_TOTAL_TIME_LABEL, + DURATION_OVERVIEW_CHART_X_AXIS_DATE_FORMAT, + DURATION_OVERVIEW_CHART_X_AXIS_TOOLTIP_TITLE_DATE_FORMAT, + DURATION_OVERVIEW_CHART_NO_DATA, + DURATION_TOTAL_TIME_NO_DATA, +} from '../constants'; + +export default { + name: 'DurationOverviewChart', + components: { + GlAreaChart, + GlChartSeriesLabel, + GlIcon, + GlAlert, + ChartSkeletonLoader, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + data() { + return { + chart: null, + tooltipTitle: '', + tooltipContent: [], + activeDataSeries: '', + }; + }, + computed: { + ...mapState('durationChart', ['isLoading', 'errorMessage']), + ...mapGetters('durationChart', ['durationChartPlottableData']), + hasData() { + return Boolean( + !this.isLoading && + this.durationChartPlottableData.some(({ data }) => + data.some(([, metric]) => metric !== null), + ), + ); + }, + error() { + return this.errorMessage || DURATION_TOTAL_TIME_NO_DATA; + }, + chartData() { + const summedData = progressiveSummation(this.durationChartPlottableData); + + const nonNullSeries = []; + const nullSeries = []; + + summedData.forEach(({ name: seriesName, data: seriesData }) => { + const valuesSeries = { + name: seriesName, + data: seriesData, + }; + + const [nullData, nonNullData] = buildNullSeries({ + seriesData: [valuesSeries], + nullSeriesTitle: DURATION_OVERVIEW_CHART_NO_DATA, + }); + + const { data, name } = nonNullData; + + nonNullSeries.push({ data, name }); + nullSeries.push(nullData); + }); + + return [...nonNullSeries, ...nullSeries]; + }, + chartOptions() { + return { + xAxis: { + name: '', + type: 'time', + axisLabel: { + formatter: (date) => dateFormat(date, DURATION_OVERVIEW_CHART_X_AXIS_DATE_FORMAT), + }, + }, + yAxis: { + name: this.$options.i18n.yAxisTitle, + type: 'value', + axisLabel: { + formatter: (value) => value, + }, + }, + }; + }, + compiledChartOptions() { + return this.chart ? this.chart.getOption() : null; + }, + legendSeriesInfo() { + if (!this.compiledChartOptions) return []; + + const { series } = this.compiledChartOptions; + const seriesInfo = series.map(({ name, lineStyle: { color, type } }) => ({ + name, + color, + type, + })); + + const nonNullSeriesInfo = seriesInfo.filter(({ name }) => this.isNonNullSeriesData(name)); + const [nullSeriesItem] = seriesInfo.filter(({ name }) => !this.isNonNullSeriesData(name)); + + return [...nonNullSeriesInfo, nullSeriesItem]; + }, + }, + beforeDestroy() { + if (this.chart) { + this.chart.off('mouseover', this.onChartDataSeriesMouseOver); + this.chart.off('mouseout', this.onChartDataSeriesMouseOut); + } + }, + methods: { + isNonNullSeriesData(seriesName) { + return seriesName !== DURATION_OVERVIEW_CHART_NO_DATA; + }, + formatTooltipText({ seriesData }) { + const [dateTime] = seriesData[0].data; + this.tooltipTitle = dateFormat( + dateTime, + DURATION_OVERVIEW_CHART_X_AXIS_TOOLTIP_TITLE_DATE_FORMAT, + ); + + const nonNullSeries = seriesData.filter(({ seriesName }) => + this.isNonNullSeriesData(seriesName), + ); + + this.tooltipContent = nonNullSeries.map(({ seriesName, color, seriesId, dataIndex }, idx) => { + const data = this.durationChartPlottableData[idx].data[dataIndex]; + const [, metric] = data; + + return { + seriesId, + label: seriesName, + value: isNumeric(metric) + ? n__('%d day', '%d days', formatDurationOverviewTooltipMetric(metric)) + : this.$options.i18n.noData, + color, + }; + }); + }, + onChartCreated(chart) { + this.chart = chart; + + this.chart.on('mouseover', 'series', this.onChartDataSeriesMouseOver); + this.chart.on('mouseout', 'series', this.onChartDataSeriesMouseOut); + }, + onChartDataSeriesMouseOver({ seriesId }) { + this.activeDataSeries = seriesId; + }, + onChartDataSeriesMouseOut() { + this.activeDataSeries = null; + }, + }, + i18n: { + title: DURATION_TOTAL_TIME_LABEL, + tooltipText: DURATION_TOTAL_TIME_DESCRIPTION, + yAxisTitle: DURATION_CHART_Y_AXIS_TITLE, + noData: DURATION_OVERVIEW_CHART_NO_DATA, + }, +}; +</script> + +<template> + <chart-skeleton-loader v-if="isLoading" size="md" class="gl-my-4 gl-py-4" /> + <div + v-else + class="gl-display-flex gl-flex-direction-column" + data-testid="vsa-duration-overview-chart" + > + <h4 class="gl-mt-0"> + {{ $options.i18n.title }} <gl-icon + v-gl-tooltip.hover + name="information-o" + :title="$options.i18n.tooltipText" + /> + </h4> + <gl-area-chart + v-if="hasData" + :option="chartOptions" + :data="chartData" + :include-legend-avg-max="false" + :format-tooltip-text="formatTooltipText" + :legend-series-info="legendSeriesInfo" + @created="onChartCreated" + > + <template #tooltip-title> + <div>{{ tooltipTitle }}</div> + </template> + + <template #tooltip-content> + <div + v-for="metric in tooltipContent" + :key="metric.seriesId" + class="gl-display-flex gl-justify-content-space-between gl-line-height-24 gl-min-w-20" + :class="{ 'gl-font-weight-bold': activeDataSeries === metric.seriesId }" + > + <gl-chart-series-label class="gl-mr-7" :color="metric.color"> + {{ metric.label }} + </gl-chart-series-label> + <div>{{ metric.value }}</div> + </div> + </template> + </gl-area-chart> + <gl-alert v-else variant="info" :dismissible="false" class="gl-mt-3"> + {{ error }} + </gl-alert> + </div> +</template> diff --git a/ee/app/assets/javascripts/analytics/cycle_analytics/constants.js b/ee/app/assets/javascripts/analytics/cycle_analytics/constants.js index ec6f61b458da7d3f65df659d0d53fa03f99d6b15..2a172791d43929967281d71ec22a53a8e8fc75ab 100644 --- a/ee/app/assets/javascripts/analytics/cycle_analytics/constants.js +++ b/ee/app/assets/javascripts/analytics/cycle_analytics/constants.js @@ -40,6 +40,9 @@ export const METRICS_REQUESTS = [ }, ]; +export const DURATION_OVERVIEW_CHART_X_AXIS_DATE_FORMAT = 'd mmm'; +export const DURATION_OVERVIEW_CHART_X_AXIS_TOOLTIP_TITLE_DATE_FORMAT = 'd mmm yyyy'; +export const DURATION_OVERVIEW_CHART_NO_DATA = s__('CycleAnalytics|No data'); export const DURATION_CHART_X_AXIS_TITLE = s__('CycleAnalytics|Date'); export const DURATION_CHART_Y_AXIS_TITLE = s__('CycleAnalytics|Average time to completion (days)'); export const DURATION_CHART_Y_AXIS_TOOLTIP_TITLE = s__('CycleAnalytics|Average time to completion'); diff --git a/ee/app/assets/javascripts/analytics/cycle_analytics/store/modules/duration_chart/actions.js b/ee/app/assets/javascripts/analytics/cycle_analytics/store/modules/duration_chart/actions.js index 75810f60e5f2c9ea47be931dd05bd9c24a083d5a..24e179468952163a9b0fb4494ec17c7bc7b708f2 100644 --- a/ee/app/assets/javascripts/analytics/cycle_analytics/store/modules/duration_chart/actions.js +++ b/ee/app/assets/javascripts/analytics/cycle_analytics/store/modules/duration_chart/actions.js @@ -25,7 +25,7 @@ export const fetchDurationData = ({ dispatch, commit, rootGetters }) => { } = rootGetters; return Promise.all( activeStages.map((stage) => { - const { id } = stage; + const { id, name } = stage; return getDurationChart({ namespacePath, @@ -34,7 +34,7 @@ export const fetchDurationData = ({ dispatch, commit, rootGetters }) => { params: cycleAnalyticsRequestParams, }) .then(checkForDataError) - .then(({ data }) => ({ id, selected: true, data })); + .then(({ data }) => ({ id, name, selected: true, data })); }), ) .then((data) => commit(types.RECEIVE_DURATION_DATA_SUCCESS, data)) diff --git a/ee/app/assets/javascripts/analytics/cycle_analytics/store/modules/duration_chart/getters.js b/ee/app/assets/javascripts/analytics/cycle_analytics/store/modules/duration_chart/getters.js index a4d4ae21c556f8433a58ced36ee7efcdd16cfb1d..efecc6e87a9e78ac9430f8f42a87bdf6b5ea1f14 100644 --- a/ee/app/assets/javascripts/analytics/cycle_analytics/store/modules/duration_chart/getters.js +++ b/ee/app/assets/javascripts/analytics/cycle_analytics/store/modules/duration_chart/getters.js @@ -1,4 +1,4 @@ -import { getDurationChartData } from '../../../utils'; +import { getDurationChartData, getDurationOverviewChartData } from '../../../utils'; export const hasPlottableData = ({ durationData = [] }) => durationData.some(({ data }) => data.length); @@ -15,11 +15,7 @@ export const durationChartPlottableData = (state, _, rootState, rootGetters) => return []; } - const plottableData = getDurationChartData( - selectedStagesDurationData, - createdAfter, - createdBefore, - ); - - return plottableData; + return isOverviewStageSelected + ? getDurationOverviewChartData(selectedStagesDurationData) + : getDurationChartData(selectedStagesDurationData, createdAfter, createdBefore); }; diff --git a/ee/app/assets/javascripts/analytics/cycle_analytics/utils.js b/ee/app/assets/javascripts/analytics/cycle_analytics/utils.js index f18769836237cbb8c93bd4f3beecb93584268b22..ab06339a005fee22496f9ac88d20498e19c2e7c2 100644 --- a/ee/app/assets/javascripts/analytics/cycle_analytics/utils.js +++ b/ee/app/assets/javascripts/analytics/cycle_analytics/utils.js @@ -219,6 +219,169 @@ export const getDurationChartData = (data, startDate, endDate) => { return eventData; }; +/** + * Takes the duration data of a stage and references a Map of grouped data by date to determine whether to render a null series if a day does not have any data across all stages, or flatten the stage line if a stage does not have data on a specific day but other stages do. + * + * For example, given the following duration data: + * [ + * { average_duration_in_seconds: null, date: '2023-04-01'}, + * { average_duration_in_seconds: 259200, date: '2023-04-02'}, + * { average_duration_in_seconds: null, date: '2023-04-03'}, + * ] + * + * And the following Map: + * Map({ + * '2023-04-01' => [null, null, null], + * '2023-04-02' => [86400, 259200, null], + * '2023-04-03' => [432000, 172800, null], + * }) + * + * It will return the following array: + * [ + * ['2023-04-01', null], + * ['2023-04-02', 3], + * ['2023-04-03', 0], + * ] + * + * @param {Array} data - Array of objects with average duration in seconds and date + * @param {Map} groupedDataByDay - a map of dates from all stages with an array of duration values + * @returns {Array} - Array of arrays with dates and average duration in seconds converted to days + */ +export const formatDurationOverviewChartData = (data, groupedDataByDay) => { + return data.map(({ average_duration_in_seconds: value, date }) => { + const currentISODate = dateFormat(new Date(date), dateFormats.isoDate); + + const valuesByDay = groupedDataByDay.has(currentISODate) + ? groupedDataByDay.get(currentISODate) + : []; + + if (!valuesByDay.length) return [currentISODate, null]; + + const isNonNullSeries = valuesByDay.some((durationData) => isNumeric(durationData)); + + if (!isNumeric(value)) { + return [currentISODate, isNonNullSeries ? 0 : null]; + } + + return [currentISODate, secondsToDays(value)]; + }); +}; + +/** + * Takes the duration data for selected stages, groups them by day and either flattens the stage line on a specific day depending on the data across stages on that day, renders a null series or returns value to be plotted. + * + * The received data is expected to be the following format; One top level object in the array per stage, + * each potentially having multiple data entries. + * [ + * { + * id: 'issue', + * name: 'Issue', + * selected: true, + * data: [ + * { + * 'average_duration_in_seconds': 1234, + * 'date': '2019-09-02T18:25:43.511Z' + * }, + * ... + * ] + * }, + * ... + * ] + * + * The data is then computed and transformed into a format that can be passed to the chart: + * [ + * { + * name: 'Issue', + * data: [ + * ['2019-09-02', 7], + * ['2019-09-03', 10], + * ['2019-09-04', 8], + * ] + * } + * ... + * ] + * + * In the data above, each array i in the data array represents a point in the area chart with the following data: + * i[0] = date, displayed on x axis + * i[1] = metric, displayed on y axis + * + * @param {Array} data - The duration data for selected stages + * @returns {Array} An array of stage objects with their names and arrays containing their duration data + */ +export const getDurationOverviewChartData = (data) => { + const groupedDurationsByDay = groupDurationsByDay(flattenDurationChartData(data)); + + return data.map(({ name, data: chartData }) => ({ + name, + data: formatDurationOverviewChartData(chartData, groupedDurationsByDay), + })); +}; + +/** + * Takes the duration data for selected stages and progressively sums up the date values across stages in order to display a stacked area chart. + * + * For example, given the following duration data: + * [ + * { + * name: 'Issue', + * data: [ + * ['2023-04-05', 10], + * ['2023-04-06', 20], + * ... + * ] + * }, + * { + * name: 'Plan', + * data: [ + * ['2023-04-05', 20], + * ['2023-04-06', 30], + * ... + * ] + * }, + * ... + * ] + * + * It will return the stages with summed duration data: + * [ + * { + * name: 'Issue', + * data: [ + * ['2023-04-05', 10], + * ['2023-04-06', 20], + * ... + * ] + * }, + * { + * name: 'Plan', + * data: [ + * ['2023-04-05', 30], + * ['2023-04-06', 50], + * ... + * ] + * }, + * ... + * ] + * + * @param {Array} data - The duration data for selected stages + * @returns {Array} An array of stage objects with metadata and arrays containing summed duration data + */ +export const progressiveSummation = (data) => { + const tally = new Map(); + + return data.map(({ data: stageData, ...stageDetails }) => ({ + ...stageDetails, + data: stageData.map(([date, value]) => { + if (value === null) return [date, null]; + + const nextValue = tally.has(date) ? tally.get(date) + value : value; + + tally.set(date, nextValue); + + return [date, nextValue]; + }), + })); +}; + export const orderByDate = (a, b, dateFmt = (datetime) => new Date(datetime).getTime()) => dateFmt(a) - dateFmt(b); diff --git a/ee/app/assets/javascripts/analytics/dashboards/utils.js b/ee/app/assets/javascripts/analytics/dashboards/utils.js index a3389ace6e21100a1848ec1f0b1a6a5dc25415ce..6711620afc7052fb5fb04437414c1e56d11f0fbc 100644 --- a/ee/app/assets/javascripts/analytics/dashboards/utils.js +++ b/ee/app/assets/javascripts/analytics/dashboards/utils.js @@ -30,7 +30,7 @@ const trimZeros = (value) => * @param {Number} value - the metric value * @returns {Number} The number of fractional digits to render */ -const fractionDigits = (value) => { +export const fractionDigits = (value) => { const absVal = Math.abs(value); if (absVal === 0) { return 1; diff --git a/ee/app/assets/javascripts/analytics/shared/utils.js b/ee/app/assets/javascripts/analytics/shared/utils.js index 649611e53b388b811b77ec2042903afaec97889a..335249d68a04f9054f7b157dd51644d307498705 100644 --- a/ee/app/assets/javascripts/analytics/shared/utils.js +++ b/ee/app/assets/javascripts/analytics/shared/utils.js @@ -4,6 +4,7 @@ import { extractVSAFeaturesFromGON } from '~/analytics/shared/utils'; import dateFormat from '~/lib/dateformat'; import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { fractionDigits } from '../dashboards/utils'; import { DEFAULT_NULL_SERIES_OPTIONS, DEFAULT_SERIES_DATA_OPTIONS } from './constants'; export const formattedDate = (d) => dateFormat(d, dateFormats.defaultDate); @@ -290,3 +291,14 @@ export const pairDataAndLabels = ({ datasetNames, datasets = [], axisLabels }) = data: zip(axisLabels, dataset.data), })), ]; + +/** + * Takes average duration in days of a stage on a specific date and returns it with the correct amount digits after the decimal point + * @param {Number} metric - Average duration in days of a stage on a specific date + * @returns {number} Formatted metric with correct amount of digits after decimal point + */ +export const formatDurationOverviewTooltipMetric = (metric) => { + const decimalPlaces = fractionDigits(metric); + + return Number(metric.toFixed(decimalPlaces)); +}; diff --git a/ee/spec/features/groups/analytics/cycle_analytics/filters_and_data_spec.rb b/ee/spec/features/groups/analytics/cycle_analytics/filters_and_data_spec.rb index 295ab51164f42ec94e4e3b5bfd616f634a412ac6..23316ac32ef0587aa0b35ce8176266270ccfcc30 100644 --- a/ee/spec/features/groups/analytics/cycle_analytics/filters_and_data_spec.rb +++ b/ee/spec/features/groups/analytics/cycle_analytics/filters_and_data_spec.rb @@ -395,6 +395,7 @@ def vsa_stages(selected_group) expect(stage_name).to include(stage[:time]) expect(page).to have_selector('[data-testid="vsa-duration-chart"]') + expect(page).not_to have_selector('[data-testid="vsa-duration-overview-chart"]') end end @@ -405,10 +406,16 @@ def vsa_stages(selected_group) expect(page).to have_selector('[data-testid="vsa-stage-table"]') end + it 'displays the duration overview chart on the overview stage' do + expect(page).to have_selector('[data-testid="vsa-duration-overview-chart"]') + + expect(page).not_to have_selector('[data-testid="vsa-duration-chart"]') + end + it 'will have data available' do - duration_chart_content = page.find('[data-testid="vsa-duration-chart"]') - expect(duration_chart_content).not_to have_text(_("There is no data available. Please change your selection.")) - expect(duration_chart_content).to have_text(s_('CycleAnalytics|Average time to completion (days)')) + duration_overview_chart = page.find('[data-testid="vsa-duration-overview-chart"]') + expect(duration_overview_chart).not_to have_text(_("There is no data available. Please change your selection.")) + expect(duration_overview_chart).to have_text(s_('CycleAnalytics|Average time to completion (days)')) tasks_by_type_chart_content = page.find('.js-tasks-by-type-chart') expect(tasks_by_type_chart_content).not_to have_text(_("There is no data available. Please change your selection.")) @@ -422,9 +429,9 @@ def vsa_stages(selected_group) end it 'will filter the data' do - duration_chart_content = page.find('[data-testid="vsa-duration-chart"]') - expect(duration_chart_content).not_to have_text(s_('CycleAnalytics|Average time to completion (days)')) - expect(duration_chart_content).to have_text(s_("CycleAnalytics|There is no data for 'Total time' available. Adjust the current filters.")) + duration_overview_chart = page.find('[data-testid="vsa-duration-overview-chart"]') + expect(duration_overview_chart).not_to have_text(s_('CycleAnalytics|Average time to completion (days)')) + expect(duration_overview_chart).to have_text(s_("CycleAnalytics|There is no data for 'Total time' available. Adjust the current filters.")) tasks_by_type_chart_content = page.find('.js-tasks-by-type-chart') expect(tasks_by_type_chart_content).to have_text(_("There is no data available. Please change your selection.")) diff --git a/ee/spec/features/projects/analytics/cycle_analytics_spec.rb b/ee/spec/features/projects/analytics/cycle_analytics_spec.rb index 59750f2c77afcd790f618967ab494de5189b81af..2638a6023888b9a14a207f18e2e156408f9d2af2 100644 --- a/ee/spec/features/projects/analytics/cycle_analytics_spec.rb +++ b/ee/spec/features/projects/analytics/cycle_analytics_spec.rb @@ -12,7 +12,7 @@ let(:empty_state_selector) { '[data-testid="vsa-empty-state"]' } let(:value_stream_selector) { '[data-testid="dropdown-value-streams"]' } - let(:duration_chart_selector) { '[data-testid="vsa-duration-chart"]' } + let(:duration_chart_selector) { '[data-testid="vsa-duration-overview-chart"]' } let(:metrics_groups_selector) { '[data-testid="vsa-metrics-group"]' } let(:metrics_selector) { '[data-testid="vsa-metrics"]' } diff --git a/ee/spec/frontend/analytics/cycle_analytics/components/base_spec.js b/ee/spec/frontend/analytics/cycle_analytics/components/base_spec.js index a6f14dd8f79b00df2b99df41f09b101cc9a35b09..90c2c7c3931b6bd5b9aa7ec6bc1dcfea7bfbfbbb 100644 --- a/ee/spec/frontend/analytics/cycle_analytics/components/base_spec.js +++ b/ee/spec/frontend/analytics/cycle_analytics/components/base_spec.js @@ -6,6 +6,7 @@ import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import Component from 'ee/analytics/cycle_analytics/components/base.vue'; import DurationChart from 'ee/analytics/cycle_analytics/components/duration_chart.vue'; +import DurationOverviewChart from 'ee/analytics/cycle_analytics/components/duration_overview_chart.vue'; import TypeOfWorkCharts from 'ee/analytics/cycle_analytics/components/type_of_work_charts.vue'; import ValueStreamSelect from 'ee/analytics/cycle_analytics/components/value_stream_select.vue'; import ValueStreamAggregationStatus from 'ee/analytics/cycle_analytics/components/value_stream_aggregation_status.vue'; @@ -195,6 +196,10 @@ describe('EE Value Stream Analytics component', () => { expect(wrapper.findComponent(DurationChart).exists()).toBe(flag); }; + const displaysDurationOverviewChart = (flag) => { + expect(wrapper.findComponent(DurationOverviewChart).exists()).toBe(flag); + }; + const displaysTypeOfWork = (flag) => { expect(wrapper.findComponent(TypeOfWorkCharts).exists()).toBe(flag); }; @@ -246,6 +251,10 @@ describe('EE Value Stream Analytics component', () => { displaysDurationChart(false); }); + it('does not display the duration overview chart', () => { + displaysDurationOverviewChart(false); + }); + it('does not display the path navigation', () => { displaysPathNavigation(false); }); @@ -290,6 +299,10 @@ describe('EE Value Stream Analytics component', () => { displaysDurationChart(false); }); + it('does not display the duration overview chart', () => { + displaysDurationOverviewChart(false); + }); + it('does not display the path navigation', () => { displaysPathNavigation(false); }); @@ -330,8 +343,12 @@ describe('EE Value Stream Analytics component', () => { displaysTypeOfWork(true); }); - it('displays the duration chart', () => { - displaysDurationChart(true); + it('displays the duration overview chart', () => { + displaysDurationOverviewChart(true); + }); + + it('does not display the duration chart', () => { + displaysDurationChart(false); }); it('hides the stage table', () => { @@ -369,6 +386,10 @@ describe('EE Value Stream Analytics component', () => { it('displays the duration chart', () => { displaysDurationChart(true); }); + + it('does not display the duration overview chart', () => { + displaysDurationOverviewChart(false); + }); }); }); diff --git a/ee/spec/frontend/analytics/cycle_analytics/components/duration_overview_chart_spec.js b/ee/spec/frontend/analytics/cycle_analytics/components/duration_overview_chart_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..3dfa06c6bde492b624c100affcf05df7ff3e156a --- /dev/null +++ b/ee/spec/frontend/analytics/cycle_analytics/components/duration_overview_chart_spec.js @@ -0,0 +1,160 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import { GlAreaChart } from '@gitlab/ui/dist/charts'; +import { GlIcon } from '@gitlab/ui'; +import DurationOverviewChart from 'ee/analytics/cycle_analytics/components/duration_overview_chart.vue'; +import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; +import { + DURATION_TOTAL_TIME_DESCRIPTION, + DURATION_TOTAL_TIME_NO_DATA, + DURATION_OVERVIEW_CHART_NO_DATA, +} from 'ee/analytics/cycle_analytics/constants'; +import { + durationOverviewChartPlottableData as durationOverviewData, + durationOverviewDataSeries, + durationOverviewDataNullSeries, + summedDurationOverviewData, +} from '../mock_data'; + +Vue.use(Vuex); + +const fakeStore = ({ initialGetters, initialState, rootGetters, rootState }) => + new Vuex.Store({ + state: { + ...rootState, + }, + getters: { + isOverviewStageSelected: () => true, + ...rootGetters, + }, + modules: { + durationChart: { + namespaced: true, + getters: { + durationChartPlottableData: () => durationOverviewData, + ...initialGetters, + }, + state: { + isLoading: false, + ...initialState, + }, + }, + }, + }); + +describe('DurationOverviewChart', () => { + let wrapper; + let mockEChartInstance; + + const findContainer = () => wrapper.find('[data-testid="vsa-duration-overview-chart"]'); + const findChartDescription = () => wrapper.findComponent(GlIcon); + const findDurationOverviewChart = () => wrapper.findComponent(GlAreaChart); + const findLoader = () => wrapper.findComponent(ChartSkeletonLoader); + + const emitChartCreated = () => + findDurationOverviewChart().vm.$emit('created', mockEChartInstance); + + const mockChartOptionSeries = [...durationOverviewDataSeries, ...durationOverviewDataNullSeries]; + + const createComponent = ({ + stubs = {}, + initialState = {}, + initialGetters = {}, + rootGetters = {}, + rootState = {}, + } = {}) => { + mockEChartInstance = { + on: jest.fn(), + off: jest.fn(), + getOption: () => { + return { + series: mockChartOptionSeries, + }; + }, + }; + + wrapper = shallowMount(DurationOverviewChart, { + store: fakeStore({ initialState, initialGetters, rootGetters, rootState }), + stubs: { + ChartSkeletonLoader: true, + ...stubs, + }, + }); + }; + + describe('default', () => { + beforeEach(() => { + createComponent({}); + emitChartCreated(); + }); + + it('renders the chart', () => { + expect(findDurationOverviewChart().exists()).toBe(true); + }); + + it('renders the chart description', () => { + expect(findChartDescription().attributes('title')).toBe(DURATION_TOTAL_TIME_DESCRIPTION); + }); + + it('correctly sets the chart options data property', () => { + const chartDataProps = findDurationOverviewChart().props('data'); + + expect(chartDataProps).toStrictEqual([ + ...summedDurationOverviewData, + ...durationOverviewDataNullSeries, + ]); + }); + + it('correctly sets the chart legend-series-info property', () => { + const chartLegendSeriesInfoProps = findDurationOverviewChart().props('legendSeriesInfo'); + + const getNonNullSeriesInfo = ({ name }) => name !== DURATION_OVERVIEW_CHART_NO_DATA; + + const legendSeriesInfo = mockChartOptionSeries.map( + ({ name, lineStyle: { color, type } }) => ({ + name, + color, + type, + }), + ); + + const legendNonNullSeriesInfo = legendSeriesInfo.filter(getNonNullSeriesInfo); + + const [nullSeriesItem] = legendSeriesInfo.filter( + (seriesItem) => !getNonNullSeriesInfo(seriesItem), + ); + + expect(chartLegendSeriesInfoProps).toStrictEqual([ + ...legendNonNullSeriesInfo, + nullSeriesItem, + ]); + + expect(chartLegendSeriesInfoProps).toHaveLength(summedDurationOverviewData.length + 1); + }); + }); + + describe('with no chart data', () => { + beforeEach(() => { + createComponent({ + initialGetters: { + durationChartPlottableData: () => [], + }, + }); + }); + + it('renders the no data available message', () => { + expect(findContainer().text()).toContain(DURATION_TOTAL_TIME_NO_DATA); + }); + }); + + describe('when isLoading=true', () => { + beforeEach(() => { + createComponent({ initialState: { isLoading: true } }); + }); + + it('renders a loader', () => { + expect(findLoader().exists()).toBe(true); + }); + }); +}); diff --git a/ee/spec/frontend/analytics/cycle_analytics/mock_data.js b/ee/spec/frontend/analytics/cycle_analytics/mock_data.js index 119d17bff7dcd276ce1e7f84c8ef1c42e8bb7fbd..c5f3e71f4e4a809ab6ace0ea2e15c61f4834d9bd 100644 --- a/ee/spec/frontend/analytics/cycle_analytics/mock_data.js +++ b/ee/spec/frontend/analytics/cycle_analytics/mock_data.js @@ -1,4 +1,4 @@ -import { dataVizBlue500 } from '@gitlab/ui/scss_to_js/scss_variables'; +import { dataVizBlue500, dataVizOrange600 } from '@gitlab/ui/scss_to_js/scss_variables'; import { uniq } from 'lodash'; import valueStreamAnalyticsStages from 'test_fixtures/analytics/value_stream_analytics/stages.json'; import valueStreamAnalyticsSummary from 'test_fixtures/analytics/metrics/value_stream_analytics/summary.json'; @@ -24,6 +24,7 @@ import { TASKS_BY_TYPE_SUBJECT_ISSUE, OVERVIEW_STAGE_CONFIG, DURATION_CHART_Y_AXIS_TITLE, + DURATION_OVERVIEW_CHART_NO_DATA, } from 'ee/analytics/cycle_analytics/constants'; import * as types from 'ee/analytics/cycle_analytics/store/mutation_types'; import mutations from 'ee/analytics/cycle_analytics/store/mutations'; @@ -262,6 +263,7 @@ export const taskByTypeFilters = { export const transformedDurationData = [ { id: issueStage.id, + name: 'Issue', selected: true, data: [ { @@ -276,6 +278,7 @@ export const transformedDurationData = [ }, { id: planStage.id, + name: 'Plan', selected: true, data: [ { @@ -290,6 +293,7 @@ export const transformedDurationData = [ }, { id: codeStage.id, + name: 'Code', selected: true, data: [ { @@ -357,3 +361,68 @@ export const durationDataNullSeries = { name: `${DURATION_CHART_Y_AXIS_TITLE} no data series`, showSymbol: false, }; + +export const durationOverviewChartPlottableData = [ + { + name: 'Issue', + data: [ + ['2019-01-01', 13], + ['2019-01-02', 27], + ], + }, + { + name: 'Plan', + data: [ + ['2019-01-01', 25], + ['2019-01-02', 42], + ], + }, +]; + +export const summedDurationOverviewData = [ + { + name: 'Issue', + data: [ + ['2019-01-01', 13], + ['2019-01-02', 27], + ], + }, + { + name: 'Plan', + data: [ + ['2019-01-01', 38], + ['2019-01-02', 69], + ], + }, +]; + +export const durationOverviewDataSeries = summedDurationOverviewData.map((stageDetails, idx) => { + const colors = [dataVizBlue500, dataVizOrange600]; + + return { + ...stageDetails, + lineStyle: { + color: colors[idx], + type: 'solid', + }, + }; +}); + +export const durationOverviewDataNullSeries = summedDurationOverviewData.map(() => ({ + areaStyle: { + color: 'none', + }, + data: [ + ['2019-01-01', null], + ['2019-01-02', null], + ], + itemStyle: { + color: '#a4a3a8', + }, + lineStyle: { + color: '#a4a3a8', + type: 'dashed', + }, + name: DURATION_OVERVIEW_CHART_NO_DATA, + showSymbol: false, +})); diff --git a/ee/spec/frontend/analytics/cycle_analytics/store/modules/duration_chart/getters_spec.js b/ee/spec/frontend/analytics/cycle_analytics/store/modules/duration_chart/getters_spec.js index 1d5bf99c19fd5625984edc00c3e9b40728db8396..c579e582af81882a1224fb73e7d18be8ed34ebbb 100644 --- a/ee/spec/frontend/analytics/cycle_analytics/store/modules/duration_chart/getters_spec.js +++ b/ee/spec/frontend/analytics/cycle_analytics/store/modules/duration_chart/getters_spec.js @@ -2,7 +2,7 @@ import * as getters from 'ee/analytics/cycle_analytics/store/modules/duration_ch import { createdAfter, createdBefore } from 'jest/analytics/cycle_analytics/mock_data'; import { transformedDurationData, - durationChartPlottableData as mockDurationChartPlottableData, + durationOverviewChartPlottableData as mockDurationOverviewChartPlottableData, } from '../../../mock_data'; const rootState = { @@ -85,7 +85,7 @@ describe('DurationChart getters', () => { rootGetters, ); - expect(res).toEqual(expect.arrayContaining(mockDurationChartPlottableData)); + expect(res).toEqual(expect.arrayContaining(mockDurationOverviewChartPlottableData)); }); }); }); diff --git a/ee/spec/frontend/analytics/cycle_analytics/utils_spec.js b/ee/spec/frontend/analytics/cycle_analytics/utils_spec.js index a1ccbaa26fc5263f3b6015b7404d88dea1ce6902..628b48271f03b91065b9de82b108adb3c2fe2f71 100644 --- a/ee/spec/frontend/analytics/cycle_analytics/utils_spec.js +++ b/ee/spec/frontend/analytics/cycle_analytics/utils_spec.js @@ -8,6 +8,7 @@ import { getLabelEventsIdentifiers, flattenDurationChartData, getDurationChartData, + getDurationOverviewChartData, transformRawStages, getTasksByTypeData, flattenTaskByTypeSeries, @@ -17,6 +18,8 @@ import { formatMedianValuesWithOverview, generateFilterTextDescription, groupDurationsByDay, + progressiveSummation, + formatDurationOverviewChartData, } from 'ee/analytics/cycle_analytics/utils'; import { TASKS_BY_TYPE_SUBJECT_MERGE_REQUEST, @@ -39,6 +42,8 @@ import { transformedDurationData, flattenedDurationData, durationChartPlottableData, + durationOverviewChartPlottableData, + summedDurationOverviewData, issueStage, rawCustomStage, rawTasksByTypeData, @@ -197,6 +202,122 @@ describe('Value Stream Analytics utils', () => { }); }); + describe('formatDurationOverviewChartData', () => { + it('formats the duration overview chart data as expected', () => { + const durationData = [ + { average_duration_in_seconds: null, date: '2023-04-01' }, + { average_duration_in_seconds: 259200, date: '2023-04-02' }, + { average_duration_in_seconds: null, date: '2023-04-03' }, + ]; + + const groupedDataByDay = new Map(); + + groupedDataByDay.set('2023-04-01', [null, null, null]); + groupedDataByDay.set('2023-04-02', [86400, 259200, null]); + groupedDataByDay.set('2023-04-03', [432000, 172800, null]); + + const formattedDurationOverviewChartData = [ + ['2023-04-01', null], + ['2023-04-02', 3], + ['2023-04-03', 0], + ]; + + expect(formatDurationOverviewChartData(durationData, groupedDataByDay)).toStrictEqual( + formattedDurationOverviewChartData, + ); + }); + }); + + describe('getDurationOverviewChartData', () => { + const nulledData = [ + { + name: 'Plan', + data: [ + { average_duration_in_seconds: null, date: '2019-01-01T00:00:00.000Z' }, + { average_duration_in_seconds: null, date: '2019-01-02T00:00:00.000Z' }, + ], + }, + ]; + + const zerodData = [ + { + name: 'Issue', + data: [ + { average_duration_in_seconds: 0, date: '2019-01-01T00:00:00.000Z' }, + { average_duration_in_seconds: 0, date: '2019-01-02T00:00:00.000Z' }, + ], + }, + ]; + + const nullAndPositiveData = [ + { + name: 'Issue', + data: [ + { average_duration_in_seconds: null, date: '2019-01-01T00:00:00.000Z' }, + { average_duration_in_seconds: 259200, date: '2019-01-02T00:00:00.000Z' }, + ], + }, + ...nulledData, + ]; + + const nulledPlottableData = [ + { + name: 'Plan', + data: [ + ['2019-01-01', null], + ['2019-01-02', null], + ], + }, + ]; + + const zeroedPlottableData = [ + { + name: 'Issue', + data: [ + ['2019-01-01', 0], + ['2019-01-02', 0], + ], + }, + ]; + + const nullAndPositivePlottableData = [ + { + name: 'Issue', + data: [ + ['2019-01-01', null], + ['2019-01-02', 3], + ], + }, + { + name: 'Plan', + data: [ + ['2019-01-01', null], + ['2019-01-02', 0], + ], + }, + ]; + + it.each` + description | rawData | result + ${'with positive average durations'} | ${transformedDurationData} | ${durationOverviewChartPlottableData} + ${'with zeroes'} | ${zerodData} | ${zeroedPlottableData} + ${'with nulls'} | ${nulledData} | ${nulledPlottableData} + ${'with positive average durations and nulls'} | ${nullAndPositiveData} | ${nullAndPositivePlottableData} + `('computes the plottable data for each stage $description', ({ rawData, result }) => { + const plottableDurationOverviewData = getDurationOverviewChartData(rawData); + + expect(plottableDurationOverviewData).toEqual(expect.arrayContaining(result)); + }); + }); + + describe('progressiveSummation', () => { + it('progressively sums up the duration data as expected', () => { + expect(progressiveSummation(durationOverviewChartPlottableData)).toStrictEqual( + summedDurationOverviewData, + ); + }); + }); + describe('transformRawStages', () => { it('retains all the stage properties', () => { const transformed = transformRawStages([rawCustomStage]); diff --git a/ee/spec/frontend/analytics/shared/utils_spec.js b/ee/spec/frontend/analytics/shared/utils_spec.js index 5b0e33f592e4ba15e9484bc21222ceee38ddc752..4266c49bbfbf177ea74487b11055dfbb224fb453 100644 --- a/ee/spec/frontend/analytics/shared/utils_spec.js +++ b/ee/spec/frontend/analytics/shared/utils_spec.js @@ -5,6 +5,7 @@ import { buildNullSeries, toLocalDate, pairDataAndLabels, + formatDurationOverviewTooltipMetric, } from 'ee/analytics/shared/utils'; const rawValueStream = `{ @@ -402,3 +403,17 @@ describe('pairDataAndLabels', () => { }); }); }); + +describe('formatDurationOverviewTooltipMetric', () => { + it.each` + num | expected + ${2.2453} | ${2.2} + ${0.98445} | ${0.98} + ${0.08345} | ${0.083} + ${0.0094423} | ${0.0094} + `('should display $num as $expected', ({ num, expected }) => { + const formattedNum = formatDurationOverviewTooltipMetric(num); + + expect(formattedNum).toBe(expected); + }); +}); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 3b0e03eba7da8399f9a16b3944502218c40a7385..f87b9a25f71fd64160a06e4ba346633b5dcf3687 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -13007,6 +13007,9 @@ msgstr "" msgid "CycleAnalytics|New value stream…" msgstr "" +msgid "CycleAnalytics|No data" +msgstr "" + msgid "CycleAnalytics|Number of tasks" msgstr ""