Commit 9124f81f authored by Martin Wortschack's avatar Martin Wortschack Committed by Brandon Labuschagne

CI/CD analytics: Add metric tiles

Changelog: added
EE: true
parent 032df049
<script>
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import { flatten } from 'lodash';
import { flatten, isEqual } from 'lodash';
import createFlash from '~/flash';
import { sprintf, s__ } from '~/locale';
import { redirectTo } from '~/lib/utils/url_utility';
import { METRICS_POPOVER_CONTENT } from '../constants';
import { removeFlash, prepareTimeMetricsData } from '../utils';
import MetricTile from './metric_tile.vue';
......@@ -48,6 +47,11 @@ export default {
type: Array,
required: true,
},
filterFn: {
type: Function,
required: false,
default: null,
},
},
data() {
return {
......@@ -56,8 +60,10 @@ export default {
};
},
watch: {
requestParams() {
requestParams(newVal, oldVal) {
if (!isEqual(newVal, oldVal)) {
this.fetchData();
}
},
},
mounted() {
......@@ -69,25 +75,13 @@ export default {
this.isLoading = true;
return fetchMetricsData(this.requests, this.requestPath, this.requestParams)
.then((data) => {
this.metrics = data;
this.metrics = this.filterFn ? this.filterFn(data) : data;
this.isLoading = false;
})
.catch(() => {
this.isLoading = false;
});
},
hasLinks(links) {
return links?.length && links[0].url;
},
clickHandler({ links }) {
if (this.hasLinks(links)) {
redirectTo(links[0].url);
}
},
getDecimalPlaces(value) {
const parsedFloat = parseFloat(value);
return Number.isNaN(parsedFloat) || Number.isInteger(parsedFloat) ? 0 : 1;
},
},
};
</script>
......
......@@ -45,7 +45,8 @@ export default {
:chart-data="chart.data"
:area-chart-options="chartOptions"
>
{{ dateRange }}
<p>{{ dateRange }}</p>
<slot name="metrics" :selected-chart="selectedChart"></slot>
<template #tooltip-title>
<slot name="tooltip-title"></slot>
</template>
......
<script>
import * as Sentry from '@sentry/browser';
import * as DoraApi from 'ee/api/dora_api';
import { toYmd } from '~/analytics/shared/utils';
import createFlash from '~/flash';
import { s__, sprintf } from '~/locale';
import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue';
import { SUMMARY_METRICS_REQUEST } from '~/cycle_analytics/constants';
import CiCdAnalyticsCharts from '~/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue';
import DoraChartHeader from './dora_chart_header.vue';
import {
......@@ -19,11 +22,16 @@ import {
} from './static_data/deployment_frequency';
import { apiDataToChartSeries, seriesToAverageSeries } from './util';
const VISIBLE_METRICS = ['deploys', 'deployment-frequency', 'deployment_frequency'];
const filterFn = (data) =>
data.filter((d) => VISIBLE_METRICS.includes(d.identifier)).map(({ links, ...rest }) => rest);
export default {
name: 'DeploymentFrequencyCharts',
components: {
CiCdAnalyticsCharts,
DoraChartHeader,
ValueStreamMetrics,
},
inject: {
projectPath: {
......@@ -56,6 +64,9 @@ export default {
data: this.chartData[chart.id],
}));
},
metricsRequestPath() {
return this.projectPath ? this.projectPath : `groups/${this.groupPath}`;
},
},
async mounted() {
const results = await Promise.allSettled(
......@@ -114,9 +125,23 @@ export default {
);
}
},
methods: {
getMetricsRequestParams(selectedChart) {
const {
requestParams: { start_date },
} = allChartDefinitions[selectedChart];
return {
created_after: toYmd(start_date),
};
},
},
areaChartOptions,
chartDescriptionText,
chartDocumentationHref,
metricsRequest: SUMMARY_METRICS_REQUEST,
filterFn,
};
</script>
<template>
......@@ -126,6 +151,15 @@ export default {
:chart-description-text="$options.chartDescriptionText"
:chart-documentation-href="$options.chartDocumentationHref"
/>
<ci-cd-analytics-charts :charts="charts" :chart-options="$options.areaChartOptions" />
<ci-cd-analytics-charts :charts="charts" :chart-options="$options.areaChartOptions">
<template #metrics="{ selectedChart }">
<value-stream-metrics
:request-path="metricsRequestPath"
:requests="$options.metricsRequest"
:request-params="getMetricsRequestParams(selectedChart)"
:filter-fn="$options.filterFn"
/>
</template>
</ci-cd-analytics-charts>
</div>
</template>
......@@ -5,6 +5,8 @@ import lastWeekData from 'test_fixtures/api/dora/metrics/daily_deployment_freque
import lastMonthData from 'test_fixtures/api/dora/metrics/daily_deployment_frequency_for_last_month.json';
import last90DaysData from 'test_fixtures/api/dora/metrics/daily_deployment_frequency_for_last_90_days.json';
import { useFixturesFakeDate } from 'helpers/fake_date';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import httpStatus from '~/lib/utils/http_status';
......@@ -12,6 +14,14 @@ import CiCdAnalyticsCharts from '~/vue_shared/components/ci_cd_analytics/ci_cd_a
jest.mock('~/flash');
const makeMockCiCdAnalyticsCharts = ({ selectedChart = 0 } = {}) => ({
render() {
return this.$scopedSlots.metrics({
selectedChart,
});
},
});
describe('deployment_frequency_charts.vue', () => {
useFixturesFakeDate();
......@@ -36,7 +46,7 @@ describe('deployment_frequency_charts.vue', () => {
};
const createComponent = (mountOptions = defaultMountOptions) => {
wrapper = shallowMount(DeploymentFrequencyCharts, mountOptions);
wrapper = extendedWrapper(shallowMount(DeploymentFrequencyCharts, mountOptions));
};
// Initializes the mock endpoint to return a specific set of deployment
......@@ -55,6 +65,8 @@ describe('deployment_frequency_charts.vue', () => {
.replyOnce(httpStatus.OK, data);
};
const findValueStreamMetrics = () => wrapper.findComponent(ValueStreamMetrics);
afterEach(() => {
wrapper.destroy();
wrapper = null;
......@@ -99,6 +111,31 @@ describe('deployment_frequency_charts.vue', () => {
it('renders a header', () => {
expect(wrapper.findComponent(DoraChartHeader).exists()).toBe(true);
});
describe('value stream metrics', () => {
beforeEach(() => {
createComponent({
...defaultMountOptions,
stubs: {
CiCdAnalyticsCharts: makeMockCiCdAnalyticsCharts({
selectedChart: 1,
}),
},
});
});
it('renders the value stream metrics component', () => {
const metricsComponent = findValueStreamMetrics();
expect(metricsComponent.exists()).toBe(true);
});
it('passes the selectedChart correctly and computes the requestParams', () => {
const metricsComponent = findValueStreamMetrics();
expect(metricsComponent.props('requestParams')).toMatchObject({
created_after: '2015-06-04',
});
});
});
});
describe('when there are network errors', () => {
......
......@@ -5,6 +5,8 @@ import metricsData from 'test_fixtures/projects/analytics/value_stream_analytics
import waitForPromises from 'helpers/wait_for_promises';
import { METRIC_TYPE_SUMMARY } from '~/api/analytics_api';
import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue';
import { METRICS_POPOVER_CONTENT } from '~/cycle_analytics/constants';
import { prepareTimeMetricsData } from '~/cycle_analytics/utils';
import MetricTile from '~/cycle_analytics/components/metric_tile.vue';
import createFlash from '~/flash';
import { group } from './mock_data';
......@@ -14,6 +16,7 @@ jest.mock('~/flash');
describe('ValueStreamMetrics', () => {
let wrapper;
let mockGetValueStreamSummaryMetrics;
let mockFilterFn;
const { full_path: requestPath } = group;
const fakeReqName = 'Mock metrics';
......@@ -23,12 +26,13 @@ describe('ValueStreamMetrics', () => {
name: fakeReqName,
});
const createComponent = ({ requestParams = {} } = {}) => {
const createComponent = (props = {}) => {
return shallowMount(ValueStreamMetrics, {
propsData: {
requestPath,
requestParams,
requestParams: {},
requests: [metricsRequestFactory()],
...props,
},
});
};
......@@ -104,6 +108,35 @@ describe('ValueStreamMetrics', () => {
expect(wrapper.find(GlSkeletonLoading).exists()).toBe(false);
});
describe('filterFn', () => {
const transferedMetricsData = prepareTimeMetricsData(metricsData, METRICS_POPOVER_CONTENT);
it('with a filter function, will call the function with the metrics data', async () => {
const filteredData = [
{ identifier: 'issues', value: '3', title: 'New Issues', description: 'foo' },
];
mockFilterFn = jest.fn(() => filteredData);
wrapper = createComponent({
filterFn: mockFilterFn,
});
await waitForPromises();
expect(mockFilterFn).toHaveBeenCalledWith(transferedMetricsData);
expect(wrapper.vm.metrics).toEqual(filteredData);
});
it('without a filter function, it will only update the metrics', async () => {
wrapper = createComponent();
await waitForPromises();
expect(mockFilterFn).not.toHaveBeenCalled();
expect(wrapper.vm.metrics).toEqual(transferedMetricsData);
});
});
describe('with additional params', () => {
beforeEach(async () => {
wrapper = createComponent({
......
import { GlSegmentedControl } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { GlSegmentedControl } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import CiCdAnalyticsAreaChart from '~/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue';
import CiCdAnalyticsCharts from '~/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue';
import { transformedAreaChartData, chartOptions } from '../mock_data';
......@@ -29,12 +29,15 @@ const DEFAULT_PROPS = {
describe('~/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue', () => {
let wrapper;
const createWrapper = (props = {}) =>
shallowMount(CiCdAnalyticsCharts, {
const createWrapper = (props = {}, slots = {}) =>
shallowMountExtended(CiCdAnalyticsCharts, {
propsData: {
...DEFAULT_PROPS,
...props,
},
scopedSlots: {
...slots,
},
});
afterEach(() => {
......@@ -44,20 +47,20 @@ describe('~/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue', (
}
});
describe('segmented control', () => {
let segmentedControl;
const findMetricsSlot = () => wrapper.findByTestId('metrics-slot');
const findSegmentedControl = () => wrapper.findComponent(GlSegmentedControl);
describe('segmented control', () => {
beforeEach(() => {
wrapper = createWrapper();
segmentedControl = wrapper.find(GlSegmentedControl);
});
it('should default to the first chart', () => {
expect(segmentedControl.props('checked')).toBe(0);
expect(findSegmentedControl().props('checked')).toBe(0);
});
it('should use the title and index as values', () => {
const options = segmentedControl.props('options');
const options = findSegmentedControl().props('options');
expect(options).toHaveLength(3);
expect(options).toEqual([
{
......@@ -76,7 +79,7 @@ describe('~/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue', (
});
it('should select a different chart on change', async () => {
segmentedControl.vm.$emit('input', 1);
findSegmentedControl().vm.$emit('input', 1);
const chart = wrapper.find(CiCdAnalyticsAreaChart);
......@@ -91,4 +94,24 @@ describe('~/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue', (
wrapper = createWrapper({ charts: [] });
expect(wrapper.find(CiCdAnalyticsAreaChart).exists()).toBe(false);
});
describe('slots', () => {
beforeEach(() => {
wrapper = createWrapper(
{},
{
metrics: '<div data-testid="metrics-slot">selected chart: {{props.selectedChart}}</div>',
},
);
});
it('renders a metrics slot', async () => {
const selectedChart = 1;
findSegmentedControl().vm.$emit('input', selectedChart);
await nextTick();
expect(findMetricsSlot().text()).toBe(`selected chart: ${selectedChart}`);
});
});
});
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