Commit fb3928ce authored by Enrique Alcántara's avatar Enrique Alcántara

Merge branch 'nfriend-add-project-lead-time-graphs' into 'master'

Add project-level DORA lead time graphs

See merge request gitlab-org/gitlab!57872
parents 969d0e86 6213ed6a
...@@ -254,6 +254,37 @@ export const timeIntervalInWords = (intervalInSeconds) => { ...@@ -254,6 +254,37 @@ export const timeIntervalInWords = (intervalInSeconds) => {
: secondsText; : secondsText;
}; };
/**
* Similar to `timeIntervalInWords`, but rounds the return value
* to 1/10th of the largest time unit. For example:
*
* 30 => 30 seconds
* 90 => 1.5 minutes
* 7200 => 2 hours
* 86400 => 1 day
* ... etc.
*
* The largest supported unit is "days".
*
* @param {Number} intervalInSeconds The time interval in seconds
* @returns {String} A humanized description of the time interval
*/
export const humanizeTimeInterval = (intervalInSeconds) => {
if (intervalInSeconds < 60 /* = 1 minute */) {
const seconds = Math.round(intervalInSeconds * 10) / 10;
return n__('%d second', '%d seconds', seconds);
} else if (intervalInSeconds < 3600 /* = 1 hour */) {
const minutes = Math.round(intervalInSeconds / 6) / 10;
return n__('%d minute', '%d minutes', minutes);
} else if (intervalInSeconds < 86400 /* = 1 day */) {
const hours = Math.round(intervalInSeconds / 360) / 10;
return n__('%d hour', '%d hours', hours);
}
const days = Math.round(intervalInSeconds / 8640) / 10;
return n__('%d day', '%d days', days);
};
export const dateInWords = (date, abbreviated = false, hideYear = false) => { export const dateInWords = (date, abbreviated = false, hideYear = false) => {
if (!date) return date; if (!date) return date;
......
...@@ -3,8 +3,6 @@ import { GlTabs, GlTab } from '@gitlab/ui'; ...@@ -3,8 +3,6 @@ import { GlTabs, GlTab } from '@gitlab/ui';
import { mergeUrlParams, updateHistory, getParameterValues } from '~/lib/utils/url_utility'; import { mergeUrlParams, updateHistory, getParameterValues } from '~/lib/utils/url_utility';
import PipelineCharts from './pipeline_charts.vue'; import PipelineCharts from './pipeline_charts.vue';
const charts = ['pipelines', 'deployments'];
export default { export default {
components: { components: {
GlTabs, GlTabs,
...@@ -12,6 +10,8 @@ export default { ...@@ -12,6 +10,8 @@ export default {
PipelineCharts, PipelineCharts,
DeploymentFrequencyCharts: () => DeploymentFrequencyCharts: () =>
import('ee_component/projects/pipelines/charts/components/deployment_frequency_charts.vue'), import('ee_component/projects/pipelines/charts/components/deployment_frequency_charts.vue'),
LeadTimeCharts: () =>
import('ee_component/projects/pipelines/charts/components/lead_time_charts.vue'),
}, },
inject: { inject: {
shouldRenderDeploymentFrequencyCharts: { shouldRenderDeploymentFrequencyCharts: {
...@@ -24,20 +24,29 @@ export default { ...@@ -24,20 +24,29 @@ export default {
selectedTab: 0, selectedTab: 0,
}; };
}, },
computed: {
charts() {
if (this.shouldRenderDeploymentFrequencyCharts) {
return ['pipelines', 'deployments', 'lead-time'];
}
return ['pipelines', 'lead-time'];
},
},
created() { created() {
this.selectTab(); this.selectTab();
window.addEventListener('popstate', this.selectTab); window.addEventListener('popstate', this.selectTab);
}, },
methods: { methods: {
selectTab() { selectTab() {
const [chart] = getParameterValues('chart') || charts; const [chart] = getParameterValues('chart') || this.charts;
const tab = charts.indexOf(chart); const tab = this.charts.indexOf(chart);
this.selectedTab = tab >= 0 ? tab : 0; this.selectedTab = tab >= 0 ? tab : 0;
}, },
onTabChange(index) { onTabChange(index) {
if (index !== this.selectedTab) { if (index !== this.selectedTab) {
this.selectedTab = index; this.selectedTab = index;
const path = mergeUrlParams({ chart: charts[index] }, window.location.pathname); const path = mergeUrlParams({ chart: this.charts[index] }, window.location.pathname);
updateHistory({ url: path, title: window.title }); updateHistory({ url: path, title: window.title });
} }
}, },
...@@ -46,14 +55,16 @@ export default { ...@@ -46,14 +55,16 @@ export default {
</script> </script>
<template> <template>
<div> <div>
<gl-tabs v-if="shouldRenderDeploymentFrequencyCharts" :value="selectedTab" @input="onTabChange"> <gl-tabs :value="selectedTab" @input="onTabChange">
<gl-tab :title="__('Pipelines')"> <gl-tab :title="__('Pipelines')">
<pipeline-charts /> <pipeline-charts />
</gl-tab> </gl-tab>
<gl-tab :title="__('Deployments')"> <gl-tab v-if="shouldRenderDeploymentFrequencyCharts" :title="__('Deployments')">
<deployment-frequency-charts /> <deployment-frequency-charts />
</gl-tab> </gl-tab>
<gl-tab :title="__('Lead Time')">
<lead-time-charts />
</gl-tab>
</gl-tabs> </gl-tabs>
<pipeline-charts v-else />
</div> </div>
</template> </template>
<script>
import { GlLink } from '@gitlab/ui';
import * as DoraApi from 'ee/api/dora_api';
import createFlash from '~/flash';
import { humanizeTimeInterval } from '~/lib/utils/datetime_utility';
import { s__ } from '~/locale';
import CiCdAnalyticsCharts from '~/projects/pipelines/charts/components/ci_cd_analytics_charts.vue';
import {
allChartDefinitions,
areaChartOptions,
chartDescriptionText,
chartDocumentationHref,
LAST_WEEK,
LAST_MONTH,
LAST_90_DAYS,
CHART_TITLE,
} from './static_data/lead_time';
import { buildNullSeriesForLeadTimeChart, apiDataToChartSeries } from './util';
export default {
name: 'LeadTimeCharts',
components: {
GlLink,
CiCdAnalyticsCharts,
},
inject: {
projectPath: {
type: String,
default: '',
},
},
data() {
return {
chartData: {
[LAST_WEEK]: [],
[LAST_MONTH]: [],
[LAST_90_DAYS]: [],
},
tooltipTitle: null,
tooltipValue: null,
};
},
computed: {
charts() {
return allChartDefinitions.map((chart) => ({
...chart,
data: this.chartData[chart.id],
}));
},
},
async mounted() {
const results = await Promise.allSettled(
allChartDefinitions.map(async ({ id, requestParams, startDate, endDate }) => {
const { data: apiData } = await DoraApi.getProjectDoraMetrics(
this.projectPath,
DoraApi.LEAD_TIME_FOR_CHANGES,
requestParams,
);
this.chartData[id] = buildNullSeriesForLeadTimeChart(
apiDataToChartSeries(apiData, startDate, endDate, CHART_TITLE, null),
);
}),
);
const requestErrors = results.filter((r) => r.status === 'rejected').map((r) => r.reason);
if (requestErrors.length) {
const allErrorMessages = requestErrors.join('\n');
createFlash({
message: s__('DORA4Metrics|Something went wrong while getting lead time data.'),
error: new Error(`Something went wrong while getting lead time data:\n${allErrorMessages}`),
captureError: true,
});
}
},
methods: {
formatTooltipText(params) {
this.tooltipTitle = params.value;
const seconds = params.seriesData[1].data[1];
this.tooltipValue = seconds != null ? humanizeTimeInterval(seconds) : null;
},
},
allChartDefinitions,
areaChartOptions,
chartDescriptionText,
chartDocumentationHref,
};
</script>
<template>
<div>
<h4 class="gl-my-4">{{ s__('DORA4|Lead time charts') }}</h4>
<p data-testid="help-text">
{{ $options.chartDescriptionText }}
<gl-link :href="$options.chartDocumentationHref">
{{ __('Learn more.') }}
</gl-link>
</p>
<!-- Using renderer="canvas" here, otherwise the area chart coloring doesn't work if the
first value in the series is `null`. This appears to have been fixed in ECharts v5,
so once we upgrade, we can go back to using the default renderer (SVG). -->
<ci-cd-analytics-charts
:charts="charts"
:chart-options="$options.areaChartOptions"
:format-tooltip-text="formatTooltipText"
renderer="canvas"
>
<template #tooltip-title> {{ tooltipTitle }} </template>
<template #tooltip-content>
<template v-if="tooltipValue === null">
{{ s__('DORA4Metrics|No merge requests were deployed during this period') }}
</template>
<div v-else class="gl-display-flex gl-align-items-flex-end">
<div class="gl-mr-5">{{ s__('DORA4Metrics|Median lead time') }}</div>
<div class="gl-font-weight-bold" data-testid="tooltip-value">{{ tooltipValue }}</div>
</div>
</template>
</ci-cd-analytics-charts>
</div>
</template>
import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
export * from './shared';
export const CHART_TITLE = s__('DORA4Metrics|Lead time');
export const areaChartOptions = {
xAxis: {
name: s__('DORA4Metrics|Date'),
type: 'category',
},
yAxis: {
name: s__('DORA4Metrics|Days from merge to deploy'),
type: 'value',
minInterval: 1,
axisLabel: {
formatter(seconds) {
// 86400 = the number of seconds in 1 day
return (seconds / 86400).toFixed(1);
},
},
},
};
export const chartDescriptionText = s__(
'DORA4Metrics|These charts display the median time between a merge request being merged and deployed to production, as part of the DORA 4 metrics.',
);
export const chartDocumentationHref = helpPagePath('user/analytics/ci_cd_analytics.html', {
anchor: 'lead-time-charts',
});
import { dataVizBlue500, dataVizOrange600 } from '@gitlab/ui/scss_to_js/scss_variables';
import dateFormat from 'dateformat'; import dateFormat from 'dateformat';
import { merge, cloneDeep } from 'lodash';
import { getDatesInRange, nDaysBefore, getStartOfDay } from '~/lib/utils/datetime_utility'; import { getDatesInRange, nDaysBefore, getStartOfDay } from '~/lib/utils/datetime_utility';
import { s__ } from '~/locale';
/** /**
* Converts the raw data fetched from the * Converts the raw data fetched from the
...@@ -45,3 +48,99 @@ export const apiDataToChartSeries = (apiData, startDate, endDate, seriesName, em ...@@ -45,3 +48,99 @@ export const apiDataToChartSeries = (apiData, startDate, endDate, seriesName, em
}, },
]; ];
}; };
/**
* Linearly interpolates between two values
*
* @param {Number} valueAtT0 The value at t = 0
* @param {Number} valueAtT1 The value at t = 1
* @param {Number} t The current value of t
*
* @returns {Number} The result of the linear interpolation.
*/
const lerp = (valueAtT0, valueAtT1, t) => {
return valueAtT0 * (1 - t) + valueAtT1 * t;
};
/**
* Builds a second series that visually represents the "no data" (i.e. "null")
* data points, and returns a new series Array that includes both the "null"
* and "non-null" data sets.
* This function returns new series data and does not modify the original instance.
*
* @param {Array} seriesData The lead time series data that has already been processed
* by the `apiDataToChartSeries` function above.
* @returns {Array} A new series Array
*/
export const buildNullSeriesForLeadTimeChart = (seriesData) => {
const nonNullSeries = cloneDeep(seriesData[0]);
// Loop through the series data and build a list of all the "gaps". A "gap" is
// a section of the data set that only include `null` values. Each gap object
// includes the start and end indices and the start and end values of the gap.
const seriesGaps = [];
let currentGap = null;
nonNullSeries.data.forEach(([, value], index) => {
if (value == null && currentGap == null) {
currentGap = {};
if (index > 0) {
currentGap.startIndex = index - 1;
const [, previousValue] = nonNullSeries.data[index - 1];
currentGap.startValue = previousValue;
}
seriesGaps.push(currentGap);
} else if (value != null && currentGap != null) {
currentGap.endIndex = index;
currentGap.endValue = value;
currentGap = null;
}
});
// Create a copy of the non-null series, but with all the data point values set to `null`
const nullSeriesData = nonNullSeries.data.map(([date]) => [date, null]);
// Render each of the gaps to the "null" series. Values are determined by linearly
// interpolating between the start and end values.
seriesGaps.forEach((gap) => {
const startIndex = gap.startIndex ?? 0;
const startValue = gap.startValue ?? gap.endValue ?? 0;
const endIndex = gap.endIndex ?? nonNullSeries.data.length - 1;
const endValue = gap.endValue ?? gap.startValue ?? 0;
for (let i = startIndex; i <= endIndex; i += 1) {
const t = (i - startIndex) / (endIndex - startIndex);
nullSeriesData[i][1] = lerp(startValue, endValue, t);
}
});
merge(nonNullSeries, {
lineStyle: {
color: dataVizBlue500,
},
areaStyle: {
color: dataVizBlue500,
},
itemStyle: {
color: dataVizBlue500,
},
});
const nullSeries = {
name: s__('DORA4Metrics|No merge requests were deployed during this period'),
data: nullSeriesData,
lineStyle: {
type: 'dashed',
color: dataVizOrange600,
},
areaStyle: {
color: 'none',
},
itemStyle: {
color: dataVizOrange600,
},
};
return [nullSeries, nonNullSeries];
};
---
title: Add DORA 4 lead time charts to project-level CI/CD Analytics page
merge_request: 57872
author:
type: added
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ee_component/projects/pipelines/charts/components/deployment_frequency_charts.vue when there are no network errors converts the data from the API into data usable by the chart component 1`] = ` exports[`deployment_frequency_charts.vue when there are no network errors converts the data from the API into data usable by the chart component 1`] = `
Array [ Array [
Object { Object {
"data": Array [ "data": Array [
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ee/projects/pipelines/charts/components/util.js lead time data returns the correct lead time chart data after all processing of the API response 1`] = `
Array [
Object {
"areaStyle": Object {
"color": "none",
},
"data": Array [
Array [
"Jun 27",
86400,
],
Array [
"Jun 28",
86400,
],
Array [
"Jun 29",
86400,
],
Array [
"Jun 30",
86400,
],
Array [
"Jul 1",
432000,
],
Array [
"Jul 2",
518400,
],
Array [
"Jul 3",
604800,
],
],
"itemStyle": Object {
"color": "#b24800",
},
"lineStyle": Object {
"color": "#b24800",
"type": "dashed",
},
"name": "No merge requests were deployed during this period",
},
Object {
"areaStyle": Object {
"color": "#5772ff",
},
"data": Array [
Array [
"Jun 27",
null,
],
Array [
"Jun 28",
null,
],
Array [
"Jun 29",
null,
],
Array [
"Jun 30",
86400,
],
Array [
"Jul 1",
432000,
],
Array [
"Jul 2",
null,
],
Array [
"Jul 3",
604800,
],
],
"itemStyle": Object {
"color": "#5772ff",
},
"lineStyle": Object {
"color": "#5772ff",
},
"name": "Lead time",
},
]
`;
...@@ -20,7 +20,7 @@ const last90DaysData = getJSONFixture( ...@@ -20,7 +20,7 @@ const last90DaysData = getJSONFixture(
'api/dora/metrics/daily_deployment_frequency_for_last_90_days.json', 'api/dora/metrics/daily_deployment_frequency_for_last_90_days.json',
); );
describe('ee_component/projects/pipelines/charts/components/deployment_frequency_charts.vue', () => { describe('deployment_frequency_charts.vue', () => {
useFixturesFakeDate(); useFixturesFakeDate();
let DeploymentFrequencyCharts; let DeploymentFrequencyCharts;
......
import { GlSprintf, GlLink } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { useFixturesFakeDate } from 'helpers/fake_date';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import httpStatus from '~/lib/utils/http_status';
import CiCdAnalyticsCharts from '~/projects/pipelines/charts/components/ci_cd_analytics_charts.vue';
jest.mock('~/flash');
const lastWeekData = getJSONFixture(
'api/dora/metrics/daily_lead_time_for_changes_for_last_week.json',
);
const lastMonthData = getJSONFixture(
'api/dora/metrics/daily_lead_time_for_changes_for_last_month.json',
);
const last90DaysData = getJSONFixture(
'api/dora/metrics/daily_lead_time_for_changes_for_last_90_days.json',
);
describe('lead_time_charts.vue', () => {
useFixturesFakeDate();
let LeadTimeCharts;
// Import the component _after_ the date has been set using `useFakeDate`, so
// that any calls to `new Date()` during module initialization use the fake date
beforeAll(async () => {
LeadTimeCharts = (
await import('ee_component/projects/pipelines/charts/components/lead_time_charts.vue')
).default;
});
let wrapper;
let mock;
const createComponent = (mountFn = shallowMount) => {
wrapper = mountFn(LeadTimeCharts, {
provide: {
projectPath: 'test/project',
},
stubs: { GlSprintf },
});
};
// Initializes the mock endpoint to return a specific set of lead time data for a given "from" date.
const setUpMockLeadTime = ({ start_date, data }) => {
mock
.onGet(/projects\/test%2Fproject\/dora\/metrics/, {
params: {
metric: 'lead_time_for_changes',
interval: 'daily',
per_page: 100,
end_date: '2015-07-04T00:00:00+0000',
start_date,
},
})
.replyOnce(httpStatus.OK, data);
};
afterEach(() => {
wrapper.destroy();
mock.restore();
});
const findHelpText = () => wrapper.find('[data-testid="help-text"]');
const findDocLink = () => findHelpText().find(GlLink);
const getTooltipValue = () => wrapper.find('[data-testid="tooltip-value"]').text();
const findCiCdAnalyticsCharts = () => wrapper.find(CiCdAnalyticsCharts);
describe('when there are no network errors', () => {
beforeEach(async () => {
mock = new MockAdapter(axios);
setUpMockLeadTime({
start_date: '2015-06-27T00:00:00+0000',
data: lastWeekData,
});
setUpMockLeadTime({
start_date: '2015-06-04T00:00:00+0000',
data: lastMonthData,
});
setUpMockLeadTime({
start_date: '2015-04-05T00:00:00+0000',
data: last90DaysData,
});
createComponent();
await axios.waitForAll();
});
it('makes 3 GET requests - one for each chart', () => {
expect(mock.history.get).toHaveLength(3);
});
it('does not show a flash message', () => {
expect(createFlash).not.toHaveBeenCalled();
});
it('renders description text', () => {
expect(findHelpText().text()).toMatchInterpolatedText(
'These charts display the median time between a merge request being merged and deployed to production, as part of the DORA 4 metrics. Learn more.',
);
});
it('renders a link to the documentation', () => {
expect(findDocLink().attributes().href).toBe(
'/help/user/analytics/ci_cd_analytics.html#lead-time-charts',
);
});
describe('methods', () => {
describe('formatTooltipText', () => {
it('displays a humanized version of the time interval in the tooltip', async () => {
createComponent(mount);
await axios.waitForAll();
const params = { seriesData: [{}, { data: ['Apr 7', 5328] }] };
// Simulate the child CiCdAnalyticsCharts component calling the
// function bound to the `format-tooltip-text`.
const formatTooltipText = findCiCdAnalyticsCharts().vm.$attrs['format-tooltip-text'];
formatTooltipText(params);
await wrapper.vm.$nextTick();
expect(getTooltipValue()).toBe('1.5 hours');
});
});
});
});
describe('when there are network errors', () => {
beforeEach(async () => {
mock = new MockAdapter(axios);
createComponent();
await axios.waitForAll();
});
it('shows a flash message', () => {
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash.mock.calls[0]).toEqual([
{
message: 'Something went wrong while getting lead time data.',
captureError: true,
error: expect.any(Error),
},
]);
});
});
});
import { apiDataToChartSeries } from 'ee/projects/pipelines/charts/components/util'; import {
apiDataToChartSeries,
buildNullSeriesForLeadTimeChart,
} from 'ee/projects/pipelines/charts/components/util';
const lastWeekData = getJSONFixture(
'api/dora/metrics/daily_lead_time_for_changes_for_last_week.json',
);
describe('ee/projects/pipelines/charts/components/util.js', () => { describe('ee/projects/pipelines/charts/components/util.js', () => {
describe('apiDataToChartSeries', () => { describe('apiDataToChartSeries', () => {
...@@ -35,4 +42,174 @@ describe('ee/projects/pipelines/charts/components/util.js', () => { ...@@ -35,4 +42,174 @@ describe('ee/projects/pipelines/charts/components/util.js', () => {
expect(apiDataToChartSeries(apiData, startDate, endDate, chartTitle)).toEqual(expected); expect(apiDataToChartSeries(apiData, startDate, endDate, chartTitle)).toEqual(expected);
}); });
}); });
describe('buildNullSeriesForLeadTimeChart', () => {
it('returns series data with the expected styles and text', () => {
const inputSeries = [
{
name: 'Chart title',
data: [],
},
];
const expectedSeries = [
{
name: 'No merge requests were deployed during this period',
data: expect.any(Array),
lineStyle: {
color: expect.any(String),
type: 'dashed',
},
areaStyle: {
color: 'none',
},
itemStyle: {
color: expect.any(String),
},
},
{
name: 'Chart title',
data: expect.any(Array),
lineStyle: {
color: expect.any(String),
},
areaStyle: {
color: expect.any(String),
},
itemStyle: {
color: expect.any(String),
},
},
];
expect(buildNullSeriesForLeadTimeChart(inputSeries)).toEqual(expectedSeries);
});
describe('series data', () => {
describe('non-empty series', () => {
it('returns the provided non-empty series data unmodified as the second series', () => {
const inputSeries = [
{
data: [
['Mar 1', 4],
['Mar 2', null],
['Mar 3', null],
['Mar 4', 10],
],
},
];
const actualSeries = buildNullSeriesForLeadTimeChart(inputSeries);
expect(actualSeries[1]).toMatchObject(inputSeries[0]);
});
});
describe('empty series', () => {
const compareSeriesData = (inputSeriesData, expectedEmptySeriesData) => {
const actualEmptySeriesData = buildNullSeriesForLeadTimeChart([
{ data: inputSeriesData },
])[0].data;
expect(actualEmptySeriesData).toEqual(expectedEmptySeriesData);
};
describe('when the data contains a gap in the middle of the data set', () => {
it('builds the "no data" series by linealy interpolating between the provided data points', () => {
const inputSeriesData = [
['Mar 1', 4],
['Mar 2', null],
['Mar 3', null],
['Mar 4', 10],
];
const expectedEmptySeriesData = [
['Mar 1', 4],
['Mar 2', 6],
['Mar 3', 8],
['Mar 4', 10],
];
compareSeriesData(inputSeriesData, expectedEmptySeriesData);
});
});
describe('when the data contains a gap at the beginning of the data set', () => {
it('fills in the gap using the first non-null data point value', () => {
const inputSeriesData = [
['Mar 1', null],
['Mar 2', null],
['Mar 3', null],
['Mar 4', 10],
];
const expectedEmptySeriesData = [
['Mar 1', 10],
['Mar 2', 10],
['Mar 3', 10],
['Mar 4', 10],
];
compareSeriesData(inputSeriesData, expectedEmptySeriesData);
});
});
describe('when the data contains a gap at the end of the data set', () => {
it('fills in the gap using the last non-null data point value', () => {
const inputSeriesData = [
['Mar 1', 10],
['Mar 2', null],
['Mar 3', null],
['Mar 4', null],
];
const expectedEmptySeriesData = [
['Mar 1', 10],
['Mar 2', 10],
['Mar 3', 10],
['Mar 4', 10],
];
compareSeriesData(inputSeriesData, expectedEmptySeriesData);
});
});
describe('when the data contains all null values', () => {
it('fills the empty series with all zeros', () => {
const inputSeriesData = [
['Mar 1', null],
['Mar 2', null],
['Mar 3', null],
['Mar 4', null],
];
const expectedEmptySeriesData = [
['Mar 1', 0],
['Mar 2', 0],
['Mar 3', 0],
['Mar 4', 0],
];
compareSeriesData(inputSeriesData, expectedEmptySeriesData);
});
});
});
});
});
describe('lead time data', () => {
it('returns the correct lead time chart data after all processing of the API response', () => {
const chartData = buildNullSeriesForLeadTimeChart(
apiDataToChartSeries(
lastWeekData,
new Date(2015, 5, 27, 10),
new Date(2015, 6, 4, 10),
'Lead time',
null,
),
);
expect(chartData).toMatchSnapshot();
});
});
}); });
...@@ -9690,18 +9690,39 @@ msgstr "" ...@@ -9690,18 +9690,39 @@ msgstr ""
msgid "DORA4Metrics|Date" msgid "DORA4Metrics|Date"
msgstr "" msgstr ""
msgid "DORA4Metrics|Days from merge to deploy"
msgstr ""
msgid "DORA4Metrics|Deployments" msgid "DORA4Metrics|Deployments"
msgstr "" msgstr ""
msgid "DORA4Metrics|Deployments charts" msgid "DORA4Metrics|Deployments charts"
msgstr "" msgstr ""
msgid "DORA4Metrics|Lead time"
msgstr ""
msgid "DORA4Metrics|Median lead time"
msgstr ""
msgid "DORA4Metrics|No merge requests were deployed during this period"
msgstr ""
msgid "DORA4Metrics|Something went wrong while getting deployment frequency data" msgid "DORA4Metrics|Something went wrong while getting deployment frequency data"
msgstr "" msgstr ""
msgid "DORA4Metrics|Something went wrong while getting lead time data."
msgstr ""
msgid "DORA4Metrics|These charts display the frequency of deployments to the production environment, as part of the DORA 4 metrics. The environment must be named %{codeStart}production%{codeEnd} for its data to appear in these charts." msgid "DORA4Metrics|These charts display the frequency of deployments to the production environment, as part of the DORA 4 metrics. The environment must be named %{codeStart}production%{codeEnd} for its data to appear in these charts."
msgstr "" msgstr ""
msgid "DORA4Metrics|These charts display the median time between a merge request being merged and deployed to production, as part of the DORA 4 metrics."
msgstr ""
msgid "DORA4|Lead time charts"
msgstr ""
msgid "Dashboard" msgid "Dashboard"
msgstr "" msgstr ""
......
...@@ -178,6 +178,30 @@ describe('timeIntervalInWords', () => { ...@@ -178,6 +178,30 @@ describe('timeIntervalInWords', () => {
}); });
}); });
describe('humanizeTimeInterval', () => {
it.each`
intervalInSeconds | expected
${0} | ${'0 seconds'}
${1} | ${'1 second'}
${1.48} | ${'1.5 seconds'}
${2} | ${'2 seconds'}
${60} | ${'1 minute'}
${91} | ${'1.5 minutes'}
${120} | ${'2 minutes'}
${3600} | ${'1 hour'}
${5401} | ${'1.5 hours'}
${7200} | ${'2 hours'}
${86400} | ${'1 day'}
${129601} | ${'1.5 days'}
${172800} | ${'2 days'}
`(
'returns "$expected" when the time interval is $intervalInSeconds seconds',
({ intervalInSeconds, expected }) => {
expect(datetimeUtility.humanizeTimeInterval(intervalInSeconds)).toBe(expected);
},
);
});
describe('dateInWords', () => { describe('dateInWords', () => {
const date = new Date('07/01/2016'); const date = new Date('07/01/2016');
......
...@@ -10,6 +10,7 @@ import PipelineCharts from '~/projects/pipelines/charts/components/pipeline_char ...@@ -10,6 +10,7 @@ import PipelineCharts from '~/projects/pipelines/charts/components/pipeline_char
jest.mock('~/lib/utils/url_utility'); jest.mock('~/lib/utils/url_utility');
const DeploymentFrequencyChartsStub = { name: 'DeploymentFrequencyCharts', render: () => {} }; const DeploymentFrequencyChartsStub = { name: 'DeploymentFrequencyCharts', render: () => {} };
const LeadTimeChartsStub = { name: 'LeadTimeCharts', render: () => {} };
describe('ProjectsPipelinesChartsApp', () => { describe('ProjectsPipelinesChartsApp', () => {
let wrapper; let wrapper;
...@@ -25,6 +26,7 @@ describe('ProjectsPipelinesChartsApp', () => { ...@@ -25,6 +26,7 @@ describe('ProjectsPipelinesChartsApp', () => {
}, },
stubs: { stubs: {
DeploymentFrequencyCharts: DeploymentFrequencyChartsStub, DeploymentFrequencyCharts: DeploymentFrequencyChartsStub,
LeadTimeCharts: LeadTimeChartsStub,
}, },
}, },
mountOptions, mountOptions,
...@@ -44,6 +46,7 @@ describe('ProjectsPipelinesChartsApp', () => { ...@@ -44,6 +46,7 @@ describe('ProjectsPipelinesChartsApp', () => {
const findGlTabs = () => wrapper.find(GlTabs); const findGlTabs = () => wrapper.find(GlTabs);
const findAllGlTab = () => wrapper.findAll(GlTab); const findAllGlTab = () => wrapper.findAll(GlTab);
const findGlTabAt = (i) => findAllGlTab().at(i); const findGlTabAt = (i) => findAllGlTab().at(i);
const findLeadTimeCharts = () => wrapper.find(LeadTimeChartsStub);
const findDeploymentFrequencyCharts = () => wrapper.find(DeploymentFrequencyChartsStub); const findDeploymentFrequencyCharts = () => wrapper.find(DeploymentFrequencyChartsStub);
const findPipelineCharts = () => wrapper.find(PipelineCharts); const findPipelineCharts = () => wrapper.find(PipelineCharts);
...@@ -51,15 +54,23 @@ describe('ProjectsPipelinesChartsApp', () => { ...@@ -51,15 +54,23 @@ describe('ProjectsPipelinesChartsApp', () => {
expect(findPipelineCharts().exists()).toBe(true); expect(findPipelineCharts().exists()).toBe(true);
}); });
it('renders the lead time charts', () => {
expect(findLeadTimeCharts().exists()).toBe(true);
});
describe('when shouldRenderDeploymentFrequencyCharts is true', () => { describe('when shouldRenderDeploymentFrequencyCharts is true', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ provide: { shouldRenderDeploymentFrequencyCharts: true } }); createComponent({ provide: { shouldRenderDeploymentFrequencyCharts: true } });
}); });
it('renders the deployment frequency charts in a tab', () => { it('renders the expected tabs', () => {
expect(findGlTabs().exists()).toBe(true); expect(findGlTabs().exists()).toBe(true);
expect(findGlTabAt(0).attributes('title')).toBe('Pipelines'); expect(findGlTabAt(0).attributes('title')).toBe('Pipelines');
expect(findGlTabAt(1).attributes('title')).toBe('Deployments'); expect(findGlTabAt(1).attributes('title')).toBe('Deployments');
expect(findGlTabAt(2).attributes('title')).toBe('Lead Time');
});
it('renders the deployment frequency charts', () => {
expect(findDeploymentFrequencyCharts().exists()).toBe(true); expect(findDeploymentFrequencyCharts().exists()).toBe(true);
}); });
...@@ -108,6 +119,7 @@ describe('ProjectsPipelinesChartsApp', () => { ...@@ -108,6 +119,7 @@ describe('ProjectsPipelinesChartsApp', () => {
describe('when provided with a query param', () => { describe('when provided with a query param', () => {
it.each` it.each`
chart | tab chart | tab
${'lead-time'} | ${'2'}
${'deployments'} | ${'1'} ${'deployments'} | ${'1'}
${'pipelines'} | ${'0'} ${'pipelines'} | ${'0'}
${'fake'} | ${'0'} ${'fake'} | ${'0'}
...@@ -160,8 +172,13 @@ describe('ProjectsPipelinesChartsApp', () => { ...@@ -160,8 +172,13 @@ describe('ProjectsPipelinesChartsApp', () => {
createComponent({ provide: { shouldRenderDeploymentFrequencyCharts: false } }); createComponent({ provide: { shouldRenderDeploymentFrequencyCharts: false } });
}); });
it('renders the expected tabs', () => {
expect(findGlTabs().exists()).toBe(true);
expect(findGlTabAt(0).attributes('title')).toBe('Pipelines');
expect(findGlTabAt(1).attributes('title')).toBe('Lead Time');
});
it('does not render the deployment frequency charts in a tab', () => { it('does not render the deployment frequency charts in a tab', () => {
expect(findGlTabs().exists()).toBe(false);
expect(findDeploymentFrequencyCharts().exists()).toBe(false); expect(findDeploymentFrequencyCharts().exists()).toBe(false);
}); });
}); });
......
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