Commit 094e8485 authored by David O'Regan's avatar David O'Regan

Merge branch '342984-display-average-median-for-dora4-in-graphs' into 'master'

Add median line to Lead Time CI/CD chart

See merge request gitlab-org/gitlab!78999
parents e67b1bd9 f16769f1
...@@ -2,12 +2,14 @@ ...@@ -2,12 +2,14 @@
import * as DoraApi from 'ee/api/dora_api'; import * as DoraApi from 'ee/api/dora_api';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { humanizeTimeInterval } from '~/lib/utils/datetime_utility'; import { humanizeTimeInterval } from '~/lib/utils/datetime_utility';
import { s__ } from '~/locale'; import { s__, sprintf } from '~/locale';
import CiCdAnalyticsCharts from '~/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue'; import CiCdAnalyticsCharts from '~/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue';
import DoraChartHeader from './dora_chart_header.vue'; import DoraChartHeader from './dora_chart_header.vue';
import { import {
allChartDefinitions, allChartDefinitions,
areaChartOptions, areaChartOptions,
averageSeriesOptions,
medianSeriesTitle,
chartDescriptionText, chartDescriptionText,
chartDocumentationHref, chartDocumentationHref,
LAST_WEEK, LAST_WEEK,
...@@ -15,7 +17,11 @@ import { ...@@ -15,7 +17,11 @@ import {
LAST_90_DAYS, LAST_90_DAYS,
CHART_TITLE, CHART_TITLE,
} from './static_data/lead_time'; } from './static_data/lead_time';
import { buildNullSeriesForLeadTimeChart, apiDataToChartSeries } from './util'; import {
buildNullSeriesForLeadTimeChart,
apiDataToChartSeries,
seriesToMedianSeries,
} from './util';
export default { export default {
name: 'LeadTimeCharts', name: 'LeadTimeCharts',
...@@ -33,6 +39,11 @@ export default { ...@@ -33,6 +39,11 @@ export default {
default: '', default: '',
}, },
}, },
chartInDays: {
[LAST_WEEK]: 7,
[LAST_MONTH]: 30,
[LAST_90_DAYS]: 90,
},
data() { data() {
return { return {
chartData: { chartData: {
...@@ -71,9 +82,21 @@ export default { ...@@ -71,9 +82,21 @@ export default {
requestParams, requestParams,
); );
this.chartData[id] = buildNullSeriesForLeadTimeChart( const seriesData = apiDataToChartSeries(apiData, startDate, endDate, CHART_TITLE, null);
apiDataToChartSeries(apiData, startDate, endDate, CHART_TITLE, null), const nullSeries = buildNullSeriesForLeadTimeChart(seriesData);
);
const { data } = seriesData[0];
const medianSeries = {
...averageSeriesOptions,
...seriesToMedianSeries(
data,
sprintf(medianSeriesTitle, { days: this.$options.chartInDays[id] }),
),
};
// TODO: Refactor buildNullSeriesForLeadTimeChart into 2 separate utils to clean this up
// https://gitlab.com/gitlab-org/gitlab/-/issues/351318
this.chartData[id] = [nullSeries[1], medianSeries, nullSeries[0]];
}), }),
); );
...@@ -92,9 +115,29 @@ export default { ...@@ -92,9 +115,29 @@ export default {
methods: { methods: {
formatTooltipText(params) { formatTooltipText(params) {
this.tooltipTitle = params.value; this.tooltipTitle = params.value;
const seconds = params.seriesData[1].data[1];
this.tooltipValue = seconds != null ? humanizeTimeInterval(seconds) : null; const leadTimeSeries = params.seriesData[0];
if (leadTimeSeries.data?.length) {
const leadTimeValue = leadTimeSeries.data[1];
const medianSeries = params.seriesData[1];
const { seriesName: medianSeriesName } = medianSeries;
const medianSeriesValue = medianSeries.data[1];
this.tooltipValue = [
{
title: this.$options.i18n.medianLeadTime,
value: humanizeTimeInterval(leadTimeValue),
},
{
title: medianSeriesName,
value: humanizeTimeInterval(medianSeriesValue),
},
];
} else {
this.tooltipValue = null;
}
}, },
/** /**
* Validates that exactly one of [this.projectPath, this.groupPath] has been * Validates that exactly one of [this.projectPath, this.groupPath] has been
...@@ -132,8 +175,8 @@ export default { ...@@ -132,8 +175,8 @@ export default {
chartDocumentationHref, chartDocumentationHref,
i18n: { i18n: {
flashMessage: s__('DORA4Metrics|Something went wrong while getting lead time data.'), flashMessage: s__('DORA4Metrics|Something went wrong while getting lead time data.'),
chartHeaderText: s__('DORA4Metrics|Lead time'), chartHeaderText: CHART_TITLE,
medianLeadTime: s__('DORA4Metrics|Median lead time'), medianLeadTime: CHART_TITLE,
noMergeRequestsDeployed: s__('DORA4Metrics|No merge requests were deployed during this period'), noMergeRequestsDeployed: s__('DORA4Metrics|No merge requests were deployed during this period'),
}, },
}; };
...@@ -160,9 +203,15 @@ export default { ...@@ -160,9 +203,15 @@ export default {
<template v-if="tooltipValue === null"> <template v-if="tooltipValue === null">
{{ $options.i18n.noMergeRequestsDeployed }} {{ $options.i18n.noMergeRequestsDeployed }}
</template> </template>
<div v-else class="gl-display-flex gl-align-items-flex-end"> <div v-else class="gl-display-flex gl-flex-direction-column">
<div class="gl-mr-5">{{ $options.i18n.medianLeadTime }}</div> <div
<div class="gl-font-weight-bold" data-testid="tooltip-value">{{ tooltipValue }}</div> v-for="metric in tooltipValue"
:key="metric.title"
class="gl-display-flex gl-justify-content-space-between"
>
<div class="gl-mr-5">{{ metric.title }}</div>
<div class="gl-font-weight-bold" data-testid="tooltip-value">{{ metric.value }}</div>
</div>
</div> </div>
</template> </template>
</ci-cd-analytics-charts> </ci-cd-analytics-charts>
......
...@@ -3,7 +3,9 @@ import { s__ } from '~/locale'; ...@@ -3,7 +3,9 @@ import { s__ } from '~/locale';
export * from './shared'; export * from './shared';
export const CHART_TITLE = s__('DORA4Metrics|Lead time'); export const CHART_TITLE = s__('DORA4Metrics|Lead time for changes');
export const medianSeriesTitle = s__('DORA4Metrics|Median (last %{days}d)');
export const areaChartOptions = { export const areaChartOptions = {
xAxis: { xAxis: {
......
import { dataVizBlue500, dataVizOrange600 } from '@gitlab/ui/scss_to_js/scss_variables'; import { dataVizBlue500, gray300 } from '@gitlab/ui/scss_to_js/scss_variables';
import dateFormat from 'dateformat'; import dateFormat from 'dateformat';
import { merge, cloneDeep } from 'lodash'; import { merge, cloneDeep } from 'lodash';
import { getDatesInRange, nDaysBefore, getStartOfDay } from '~/lib/utils/datetime_utility'; import { getDatesInRange, nDaysBefore, getStartOfDay } from '~/lib/utils/datetime_utility';
import { median } from '~/lib/utils/number_utils';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
/** /**
...@@ -124,6 +125,7 @@ export const buildNullSeriesForLeadTimeChart = (seriesData) => { ...@@ -124,6 +125,7 @@ export const buildNullSeriesForLeadTimeChart = (seriesData) => {
}, },
areaStyle: { areaStyle: {
color: dataVizBlue500, color: dataVizBlue500,
opacity: 0,
}, },
itemStyle: { itemStyle: {
color: dataVizBlue500, color: dataVizBlue500,
...@@ -135,13 +137,13 @@ export const buildNullSeriesForLeadTimeChart = (seriesData) => { ...@@ -135,13 +137,13 @@ export const buildNullSeriesForLeadTimeChart = (seriesData) => {
data: nullSeriesData, data: nullSeriesData,
lineStyle: { lineStyle: {
type: 'dashed', type: 'dashed',
color: dataVizOrange600, color: gray300,
}, },
areaStyle: { areaStyle: {
color: 'none', color: 'none',
}, },
itemStyle: { itemStyle: {
color: dataVizOrange600, color: gray300,
}, },
}; };
...@@ -168,3 +170,21 @@ export const seriesToAverageSeries = (chartSeriesData, seriesName) => { ...@@ -168,3 +170,21 @@ export const seriesToAverageSeries = (chartSeriesData, seriesName) => {
data: chartSeriesData.map((day) => [day[0], average]), data: chartSeriesData.map((day) => [day[0], average]),
}; };
}; };
/**
* Converts a data series into a formatted median series
*
* @param {Array} chartSeriesData Correctly formatted chart series data
*
* @returns {Object} An object containing the series name and an array of original data keys with the median of the dataset as each value.
*/
export const seriesToMedianSeries = (chartSeriesData, seriesName) => {
if (!chartSeriesData) return {};
const medianValue = median(chartSeriesData.filter((day) => day[1] !== null).map((day) => day[1]));
return {
name: seriesName,
data: chartSeriesData.map((day) => [day[0], medianValue]),
};
};
...@@ -37,10 +37,10 @@ Array [ ...@@ -37,10 +37,10 @@ Array [
], ],
], ],
"itemStyle": Object { "itemStyle": Object {
"color": "#b24800", "color": "#999",
}, },
"lineStyle": Object { "lineStyle": Object {
"color": "#b24800", "color": "#999",
"type": "dashed", "type": "dashed",
}, },
"name": "No merge requests were deployed during this period", "name": "No merge requests were deployed during this period",
...@@ -48,6 +48,7 @@ Array [ ...@@ -48,6 +48,7 @@ Array [
Object { Object {
"areaStyle": Object { "areaStyle": Object {
"color": "#5772ff", "color": "#5772ff",
"opacity": 0,
}, },
"data": Array [ "data": Array [
Array [ Array [
......
...@@ -59,7 +59,7 @@ describe('lead_time_charts.vue', () => { ...@@ -59,7 +59,7 @@ describe('lead_time_charts.vue', () => {
mock.restore(); mock.restore();
}); });
const getTooltipValue = () => wrapper.find('[data-testid="tooltip-value"]').text(); const getTooltipValues = () => wrapper.findAll('[data-testid="tooltip-value"]');
const findCiCdAnalyticsCharts = () => wrapper.findComponent(CiCdAnalyticsCharts); const findCiCdAnalyticsCharts = () => wrapper.findComponent(CiCdAnalyticsCharts);
describe('when there are no network errors', () => { describe('when there are no network errors', () => {
...@@ -103,7 +103,7 @@ describe('lead_time_charts.vue', () => { ...@@ -103,7 +103,7 @@ describe('lead_time_charts.vue', () => {
await axios.waitForAll(); await axios.waitForAll();
const params = { seriesData: [{}, { data: ['Apr 7', 5328] }] }; const params = { seriesData: [{ data: ['Apr 7', 5328] }, { data: ['Apr 7', 4000] }, {}] };
// Simulate the child CiCdAnalyticsCharts component calling the // Simulate the child CiCdAnalyticsCharts component calling the
// function bound to the `format-tooltip-text`. // function bound to the `format-tooltip-text`.
...@@ -112,7 +112,10 @@ describe('lead_time_charts.vue', () => { ...@@ -112,7 +112,10 @@ describe('lead_time_charts.vue', () => {
await nextTick(); await nextTick();
expect(getTooltipValue()).toBe('1.5 hours'); const toolTipValues = getTooltipValues();
expect(toolTipValues.at(0).text()).toBe('1.5 hours');
expect(toolTipValues.at(1).text()).toBe('1.1 hours');
}); });
}); });
}); });
......
...@@ -3,6 +3,7 @@ import { ...@@ -3,6 +3,7 @@ import {
apiDataToChartSeries, apiDataToChartSeries,
buildNullSeriesForLeadTimeChart, buildNullSeriesForLeadTimeChart,
seriesToAverageSeries, seriesToAverageSeries,
seriesToMedianSeries,
} from 'ee/dora/components/util'; } from 'ee/dora/components/util';
describe('ee/dora/components/util.js', () => { describe('ee/dora/components/util.js', () => {
...@@ -76,6 +77,7 @@ describe('ee/dora/components/util.js', () => { ...@@ -76,6 +77,7 @@ describe('ee/dora/components/util.js', () => {
}, },
areaStyle: { areaStyle: {
color: expect.any(String), color: expect.any(String),
opacity: 0,
}, },
itemStyle: { itemStyle: {
color: expect.any(String), color: expect.any(String),
...@@ -249,4 +251,40 @@ describe('ee/dora/components/util.js', () => { ...@@ -249,4 +251,40 @@ describe('ee/dora/components/util.js', () => {
}); });
}); });
}); });
describe('seriesToMedianSeries', () => {
const seriesName = 'Median';
it('returns an empty object if chart data is undefined', () => {
const data = seriesToMedianSeries(undefined, seriesName);
expect(data).toStrictEqual({});
});
it('returns an empty object if chart data is blank', () => {
const data = seriesToMedianSeries(null, seriesName);
expect(data).toStrictEqual({});
});
it('returns the correct median values', () => {
const data = seriesToMedianSeries(
[
['Jul 1', 1],
['Jul 2', 3],
['Jul 3', 10],
],
seriesName,
);
expect(data).toStrictEqual({
name: seriesName,
data: [
['Jul 1', 3],
['Jul 2', 3],
['Jul 3', 3],
],
});
});
});
}); });
...@@ -10856,10 +10856,10 @@ msgstr "" ...@@ -10856,10 +10856,10 @@ msgstr ""
msgid "DORA4Metrics|Deployment frequency" msgid "DORA4Metrics|Deployment frequency"
msgstr "" msgstr ""
msgid "DORA4Metrics|Lead time" msgid "DORA4Metrics|Lead time for changes"
msgstr "" msgstr ""
msgid "DORA4Metrics|Median lead time" msgid "DORA4Metrics|Median (last %{days}d)"
msgstr "" msgstr ""
msgid "DORA4Metrics|No merge requests were deployed during this period" msgid "DORA4Metrics|No merge requests were deployed during this period"
......
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