Commit 5a549aad authored by Ezekiel Kigbo's avatar Ezekiel Kigbo

Add group value stream metrics UI to project VSA

Add additional tests for the value_stream_metrics

Add additional metrics specs

Adds separate project metric requests and
adds updated stage metrics to project vsa

Minor refactor the value stream metrics

Changelog: changed
parent cb46d5b0
......@@ -2,10 +2,14 @@ import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
import { buildApiUrl } from './api_utils';
const PROJECT_VSA_METRICS_BASE = '/:request_path/-/analytics/value_stream_analytics';
const PROJECT_VSA_PATH_BASE = '/:request_path/-/analytics/value_stream_analytics/value_streams';
const PROJECT_VSA_STAGES_PATH = `${PROJECT_VSA_PATH_BASE}/:value_stream_id/stages`;
const PROJECT_VSA_STAGE_DATA_PATH = `${PROJECT_VSA_STAGES_PATH}/:stage_id`;
const buildProjectMetricsPath = (requestPath) =>
buildApiUrl(PROJECT_VSA_METRICS_BASE).replace(':request_path', requestPath);
const buildProjectValueStreamPath = (requestPath, valueStreamId = null) => {
if (valueStreamId) {
return buildApiUrl(PROJECT_VSA_STAGES_PATH)
......@@ -40,9 +44,7 @@ export const getProjectValueStreamMetrics = (requestPath, params) =>
axios.get(requestPath, { params });
/**
* Shared group VSA paths
* We share some endpoints across and group and project level VSA
* When used for project level VSA, requests should include the `project_id` in the params object
* Dedicated project VSA paths
*/
export const getValueStreamStageMedian = ({ requestPath, valueStreamId, stageId }, params = {}) => {
......@@ -62,3 +64,13 @@ export const getValueStreamStageCounts = ({ requestPath, valueStreamId, stageId
const stageBase = buildValueStreamStageDataPath({ requestPath, valueStreamId, stageId });
return axios.get(joinPaths(stageBase, 'count'), { params });
};
export const getValueStreamTimeSummaryMetrics = (requestPath, params = {}) => {
const metricBase = buildProjectMetricsPath(requestPath);
return axios.get(joinPaths(metricBase, 'time_summary'), { params });
};
export const getValueStreamSummaryMetrics = (requestPath, params = {}) => {
const metricBase = buildProjectMetricsPath(requestPath);
return axios.get(joinPaths(metricBase, 'summary'), { params });
};
......@@ -4,7 +4,9 @@ import Cookies from 'js-cookie';
import { mapActions, mapState, mapGetters } from 'vuex';
import PathNavigation from '~/cycle_analytics/components/path_navigation.vue';
import StageTable from '~/cycle_analytics/components/stage_table.vue';
import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue';
import { __ } from '~/locale';
import { METRICS_REQUESTS } from '../constants';
const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed';
......@@ -16,6 +18,7 @@ export default {
GlSprintf,
PathNavigation,
StageTable,
ValueStreamMetrics,
},
props: {
noDataSvgPath: {
......@@ -45,8 +48,9 @@ export default {
'daysInPast',
'permissions',
'stageCounts',
'endpoints',
]),
...mapGetters(['pathNavigationData']),
...mapGetters(['pathNavigationData', 'filterParams']),
displayStageEvents() {
const { selectedStageEvents, isLoadingStage, isEmptyStage } = this;
return selectedStageEvents.length && !isLoadingStage && !isEmptyStage;
......@@ -117,6 +121,7 @@ export default {
pageTitle: __('Value Stream Analytics'),
recentActivity: __('Recent Project Activity'),
},
METRICS_REQUESTS,
};
</script>
<template>
......@@ -130,54 +135,44 @@ export default {
:selected-stage="selectedStage"
@selected="onSelectStage"
/>
<gl-loading-icon v-if="isLoading" size="lg" />
<div v-else class="wrapper">
<!--
We wont have access to the stage counts until we move to a default value stream
For now we can use the `withStageCounts` flag to ensure we don't display empty stage counts
Related issue: https://gitlab.com/gitlab-org/gitlab/-/issues/326705
-->
<div class="card" data-testid="vsa-stage-overview-metrics">
<div class="card-header">{{ __('Recent Project Activity') }}</div>
<div class="d-flex justify-content-between">
<div v-for="item in summary" :key="item.title" class="gl-flex-grow-1 gl-text-center">
<h3 class="header">{{ item.value }}</h3>
<p class="text">{{ item.title }}</p>
</div>
<div class="flex-grow align-self-center text-center">
<div class="js-ca-dropdown dropdown inline">
<!-- eslint-disable-next-line @gitlab/vue-no-data-toggle -->
<button class="dropdown-menu-toggle" data-toggle="dropdown" type="button">
<span class="dropdown-label">
<gl-sprintf :message="$options.i18n.dropdownText">
<template #days>{{ daysInPast }}</template>
</gl-sprintf>
<gl-icon name="chevron-down" class="dropdown-menu-toggle-icon gl-top-3" />
</span>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li v-for="days in $options.dayRangeOptions" :key="`day-range-${days}`">
<a href="#" @click.prevent="handleDateSelect(days)">
<gl-sprintf :message="$options.i18n.dropdownText">
<template #days>{{ days }}</template>
</gl-sprintf>
</a>
</li>
</ul>
</div>
</div>
</div>
<div class="gl-text-right flex-grow align-self-center">
<div class="js-ca-dropdown dropdown inline">
<!-- eslint-disable-next-line @gitlab/vue-no-data-toggle -->
<button class="dropdown-menu-toggle" data-toggle="dropdown" type="button">
<span class="dropdown-label">
<gl-sprintf :message="$options.i18n.dropdownText">
<template #days>{{ daysInPast }}</template>
</gl-sprintf>
<gl-icon name="chevron-down" class="dropdown-menu-toggle-icon gl-top-3" />
</span>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li v-for="days in $options.dayRangeOptions" :key="`day-range-${days}`">
<a href="#" @click.prevent="handleDateSelect(days)">
<gl-sprintf :message="$options.i18n.dropdownText">
<template #days>{{ days }}</template>
</gl-sprintf>
</a>
</li>
</ul>
</div>
<stage-table
:is-loading="isLoading || isLoadingStage"
:stage-events="selectedStageEvents"
:selected-stage="selectedStage"
:stage-count="selectedStageCount"
:empty-state-title="emptyStageTitle"
:empty-state-message="emptyStageText"
:no-data-svg-path="noDataSvgPath"
:pagination="null"
/>
</div>
<value-stream-metrics
:request-path="endpoints.fullPath"
:request-params="filterParams"
:requests="$options.METRICS_REQUESTS"
/>
<gl-loading-icon v-if="isLoading" size="lg" />
<stage-table
v-else
:is-loading="isLoading || isLoadingStage"
:stage-events="selectedStageEvents"
:selected-stage="selectedStage"
:stage-count="selectedStageCount"
:empty-state-title="emptyStageTitle"
:empty-state-message="emptyStageText"
:no-data-svg-path="noDataSvgPath"
:pagination="null"
/>
</div>
</template>
<script>
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlPopover } from '@gitlab/ui';
import { GlSingleStat } from '@gitlab/ui/dist/charts';
import Api from 'ee/api';
import { flatten } from 'lodash';
import createFlash from '~/flash';
import { sprintf, __, s__ } from '~/locale';
import { OVERVIEW_METRICS, METRICS_POPOVER_CONTENT } from '../constants';
import { sprintf, s__ } from '~/locale';
import { METRICS_POPOVER_CONTENT } from '../constants';
import { removeFlash, prepareTimeMetricsData } from '../utils';
const requestData = ({ requestType, groupPath, requestParams }) => {
return requestType === OVERVIEW_METRICS.TIME_SUMMARY
? Api.cycleAnalyticsTimeSummaryData(groupPath, requestParams)
: Api.cycleAnalyticsSummaryData(groupPath, requestParams);
const requestData = ({ request, path, params, name }) => {
return request(path, params)
.then(({ data }) => data)
.catch(() => {
createFlash({
message: sprintf(
s__('There was an error while fetching value stream analytics %{name} data.'),
{ name },
),
});
});
};
const fetchMetricsData = (reqs = [], path, params) => {
const promises = reqs.map((r) => requestData({ ...r, path, params }));
return Promise.all(promises).then((responses) =>
prepareTimeMetricsData(flatten(responses), METRICS_POPOVER_CONTENT),
);
};
export default {
name: 'OverviewActivity',
name: 'ValueStreamMetrics',
components: {
GlSkeletonLoading,
GlSingleStat,
GlPopover,
GlSingleStat,
GlSkeletonLoading,
},
props: {
groupPath: {
requestPath: {
type: String,
required: true,
},
......@@ -29,6 +43,10 @@ export default {
type: Object,
required: true,
},
requests: {
type: Array,
required: true,
},
},
data() {
return {
......@@ -48,44 +66,15 @@ export default {
fetchData() {
removeFlash();
this.isLoading = true;
Promise.all([
this.fetchMetricsByType(OVERVIEW_METRICS.TIME_SUMMARY),
this.fetchMetricsByType(OVERVIEW_METRICS.RECENT_ACTIVITY),
])
.then(([timeSummaryData = [], recentActivityData = []]) => {
this.metrics = [
...prepareTimeMetricsData(timeSummaryData, METRICS_POPOVER_CONTENT),
...prepareTimeMetricsData(recentActivityData, METRICS_POPOVER_CONTENT),
];
return fetchMetricsData(this.requests, this.requestPath, this.requestParams)
.then((data) => {
this.metrics = data;
this.isLoading = false;
})
.catch(() => {
this.isLoading = false;
});
},
fetchMetricsByType(requestType) {
return requestData({
requestType,
groupPath: this.groupPath,
requestParams: this.requestParams,
})
.then(({ data }) => data)
.catch(() => {
const requestTypeName =
requestType === OVERVIEW_METRICS.TIME_SUMMARY
? __('time summary')
: __('recent activity');
createFlash({
message: sprintf(
s__(
'There was an error while fetching value stream analytics %{requestTypeName} data.',
),
{ requestTypeName },
),
});
});
},
},
};
</script>
......@@ -109,7 +98,6 @@ export default {
<template #title>
<span class="gl-display-block gl-text-left">{{ metric.label }}</span>
</template>
<span v-if="metric.description">{{ metric.description }}</span>
</gl-popover>
</div>
......
import {
getValueStreamTimeSummaryMetrics,
getValueStreamSummaryMetrics,
} from '~/api/analytics_api';
import { __, s__ } from '~/locale';
export const DEFAULT_DAYS_IN_PAST = 30;
......@@ -30,3 +34,35 @@ export const I18N_VSA_ERROR_STAGE_MEDIAN = __('There was an error fetching media
export const I18N_VSA_ERROR_SELECTED_STAGE = __(
'There was an error fetching data for the selected stage',
);
export const OVERVIEW_METRICS = {
TIME_SUMMARY: 'TIME_SUMMARY',
RECENT_ACTIVITY: 'RECENT_ACTIVITY',
};
export const METRICS_REQUESTS = [
{
request: getValueStreamTimeSummaryMetrics,
name: __('time summary'),
},
{
request: getValueStreamSummaryMetrics,
name: __('recent activity'),
},
];
export const METRICS_POPOVER_CONTENT = {
'lead-time': {
description: s__('ValueStreamAnalytics|Median time from issue created to issue closed.'),
},
'cycle-time': {
description: s__(
'ValueStreamAnalytics|Median time from issue first merge request created to issue closed.',
),
},
'new-issues': { description: s__('ValueStreamAnalytics|Number of new issues created.') },
deploys: { description: s__('ValueStreamAnalytics|Total number of deploys to production.') },
'deployment-frequency': {
description: s__('ValueStreamAnalytics|Average number of deployments to production per day.'),
},
};
import dateFormat from 'dateformat';
import { unescape } from 'lodash';
import { dateFormats } from '~/analytics/shared/constants';
import { hideFlash } from '~/flash';
import { sanitize } from '~/lib/dompurify';
import { roundToNearestHalf } from '~/lib/utils/common_utils';
import { getDateInPast } from '~/lib/utils/datetime/date_calculation_utility';
import { parseSeconds } from '~/lib/utils/datetime_utility';
import { slugify } from '~/lib/utils/text_utility';
import { s__, sprintf } from '../locale';
export const removeFlash = (type = 'alert') => {
const flashEl = document.querySelector(`.flash-${type}`);
if (flashEl) {
hideFlash(flashEl);
}
};
const mapToSummary = ({ value, ...rest }) => ({ ...rest, value: value || '-' });
export const decorateData = (data = {}) => {
......@@ -116,3 +125,36 @@ export const calculateFormattedDayInPast = (daysInPast, today = new Date()) => {
past: toIsoFormat(getDateInPast(today, daysInPast)),
};
};
/**
* @typedef {Object} MetricData
* @property {String} title - Title of the metric measured
* @property {String} value - String representing the decimal point value, e.g '1.5'
* @property {String} [unit] - String representing the decimal point value, e.g '1.5'
*
* @typedef {Object} TransformedMetricData
* @property {String} label - Title of the metric measured
* @property {String} value - String representing the decimal point value, e.g '1.5'
* @property {String} key - Slugified string based on the 'title'
* @property {String} description - String to display for a description
* @property {String} unit - String representing the decimal point value, e.g '1.5'
*/
/**
* Prepares metric data to be rendered in the metric_card component
*
* @param {MetricData[]} data - The metric data to be rendered
* @param {Object} popoverContent - Key value pair of data to display in the popover
* @returns {TransformedMetricData[]} An array of metrics ready to render in the metric_card
*/
export const prepareTimeMetricsData = (data = [], popoverContent = {}) =>
data.map(({ title: label, ...rest }) => {
const key = slugify(label);
return {
...rest,
label,
key,
description: popoverContent[key]?.description || '',
};
});
......@@ -4,11 +4,12 @@ import { mapActions, mapState, mapGetters } from 'vuex';
import PathNavigation from '~/cycle_analytics/components/path_navigation.vue';
import StageTable from '~/cycle_analytics/components/stage_table.vue';
import ValueStreamFilters from '~/cycle_analytics/components/value_stream_filters.vue';
import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue';
import { OVERVIEW_STAGE_ID } from '~/cycle_analytics/constants';
import UrlSync from '~/vue_shared/components/url_sync.vue';
import { toYmd } from '../../shared/utils';
import { METRICS_REQUESTS } from '../constants';
import DurationChart from './duration_chart.vue';
import Metrics from './metrics.vue';
import TypeOfWorkCharts from './type_of_work_charts.vue';
import ValueStreamSelect from './value_stream_select.vue';
......@@ -21,9 +22,9 @@ export default {
StageTable,
PathNavigation,
ValueStreamFilters,
ValueStreamMetrics,
ValueStreamSelect,
UrlSync,
Metrics,
},
props: {
emptyStateSvgPath: {
......@@ -140,6 +141,7 @@ export default {
this.updateStageTablePagination(data);
},
},
METRICS_REQUESTS,
};
</script>
<template>
......@@ -194,7 +196,11 @@ export default {
/>
<template v-else>
<template v-if="isOverviewStageSelected">
<metrics :group-path="currentGroupPath" :request-params="cycleAnalyticsRequestParams" />
<value-stream-metrics
:request-path="currentGroupPath"
:request-params="cycleAnalyticsRequestParams"
:requests="$options.METRICS_REQUESTS"
/>
<duration-chart class="gl-mt-3" :stages="activeStages" />
<type-of-work-charts />
</template>
......
......@@ -10,10 +10,10 @@ import {
import { debounce } from 'lodash';
import { mapGetters } from 'vuex';
import Api from 'ee/api';
import { removeFlash } from '~/cycle_analytics/utils';
import createFlash from '~/flash';
import { __ } from '~/locale';
import { DATA_REFETCH_DELAY } from '../../shared/constants';
import { removeFlash } from '../utils';
export default {
name: 'LabelsSelector',
......
<script>
import { GlDropdownDivider, GlSegmentedControl, GlIcon, GlSprintf } from '@gitlab/ui';
import { removeFlash } from '~/cycle_analytics/utils';
import createFlash from '~/flash';
import { s__, sprintf } from '~/locale';
import {
......@@ -8,7 +9,6 @@ import {
TASKS_BY_TYPE_SUBJECT_FILTER_OPTIONS,
TASKS_BY_TYPE_MAX_LABELS,
} from '../../constants';
import { removeFlash } from '../../utils';
import LabelsSelector from '../labels_selector.vue';
export default {
......
import {
getGroupValueStreamSummaryData,
getGroupValueStreamTimeSummaryData,
} from 'ee/api/analytics_api';
import { OVERVIEW_STAGE_ID } from '~/cycle_analytics/constants';
import { __, s__ } from '~/locale';
import { __ } from '~/locale';
export const EVENTS_LIST_ITEM_LIMIT = 50;
......@@ -19,11 +23,6 @@ export const TASKS_BY_TYPE_FILTERS = {
export const DEFAULT_VALUE_STREAM_ID = 'default';
export const OVERVIEW_METRICS = {
TIME_SUMMARY: 'TIME_SUMMARY',
RECENT_ACTIVITY: 'RECENT_ACTIVITY',
};
export const FETCH_VALUE_STREAM_DATA = 'fetchValueStreamData';
export const OVERVIEW_STAGE_CONFIG = {
......@@ -33,18 +32,13 @@ export const OVERVIEW_STAGE_CONFIG = {
icon: 'home',
};
export const METRICS_POPOVER_CONTENT = {
'lead-time': {
description: s__('ValueStreamAnalytics|Median time from issue created to issue closed.'),
export const METRICS_REQUESTS = [
{
request: getGroupValueStreamTimeSummaryData,
name: __('time summary'),
},
'cycle-time': {
description: s__(
'ValueStreamAnalytics|Median time from issue first merge request created to issue closed.',
),
{
request: getGroupValueStreamSummaryData,
name: __('recent activity'),
},
'new-issues': { description: s__('ValueStreamAnalytics|Number of new issues created.') },
deploys: { description: s__('ValueStreamAnalytics|Total number of deploys to production.') },
'deployment-frequency': {
description: s__('ValueStreamAnalytics|Average number of deployments to production per day.'),
},
};
];
import { removeFlash } from '~/cycle_analytics/utils';
import createFlash from '~/flash';
import httpStatus from '~/lib/utils/http_status';
import { __ } from '~/locale';
import { removeFlash } from '../utils';
import * as types from './mutation_types';
export * from './actions/filters';
......
......@@ -3,22 +3,15 @@ import { isNumber } from 'lodash';
import { dateFormats } from '~/analytics/shared/constants';
import { OVERVIEW_STAGE_ID } from '~/cycle_analytics/constants';
import { medianTimeToParsedSeconds } from '~/cycle_analytics/utils';
import createFlash, { hideFlash } from '~/flash';
import createFlash from '~/flash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { newDate, dayAfter, secondsToDays, getDatesInRange } from '~/lib/utils/datetime_utility';
import httpStatus from '~/lib/utils/http_status';
import { convertToSnakeCase, slugify } from '~/lib/utils/text_utility';
import { convertToSnakeCase } from '~/lib/utils/text_utility';
import { toYmd } from '../shared/utils';
const EVENT_TYPE_LABEL = 'label';
export const removeFlash = (type = 'alert') => {
const flashEl = document.querySelector(`.flash-${type}`);
if (flashEl) {
hideFlash(flashEl);
}
};
export const toggleSelectedLabel = ({ selectedLabelIds = [], value = null }) => {
if (!value) return selectedLabelIds;
return selectedLabelIds.includes(value)
......@@ -378,36 +371,3 @@ export const formatMedianValuesWithOverview = (medians = []) => {
[OVERVIEW_STAGE_ID]: overviewMedian ? medianTimeToParsedSeconds(overviewMedian) : '-',
};
};
/**
* @typedef {Object} MetricData
* @property {String} title - Title of the metric measured
* @property {String} value - String representing the decimal point value, e.g '1.5'
* @property {String} [unit] - String representing the decimal point value, e.g '1.5'
*
* @typedef {Object} TransformedMetricData
* @property {String} label - Title of the metric measured
* @property {String} value - String representing the decimal point value, e.g '1.5'
* @property {String} key - Slugified string based on the 'title'
* @property {String} description - String to display for a description
* @property {String} unit - String representing the decimal point value, e.g '1.5'
*/
/**
* Prepares metric data to be rendered in the metric_card component
*
* @param {MetricData[]} data - The metric data to be rendered
* @param {Object} popoverContent - Key value pair of data to display in the popover
* @returns {TransformedMetricData[]} An array of metrics ready to render in the metric_card
*/
export const prepareTimeMetricsData = (data = [], popoverContent = {}) =>
data.map(({ title: label, ...rest }) => {
const key = slugify(label);
return {
...rest,
label,
key,
description: popoverContent[key]?.description || '',
};
});
......@@ -15,8 +15,6 @@ export default {
epicIssuePath: '/api/:version/groups/:id/epics/:epic_iid/issues/:issue_id',
cycleAnalyticsTasksByTypePath: '/groups/:id/-/analytics/type_of_work/tasks_by_type',
cycleAnalyticsTopLabelsPath: '/groups/:id/-/analytics/type_of_work/tasks_by_type/top_labels',
cycleAnalyticsSummaryDataPath: '/groups/:id/-/analytics/value_stream_analytics/summary',
cycleAnalyticsTimeSummaryDataPath: '/groups/:id/-/analytics/value_stream_analytics/time_summary',
cycleAnalyticsGroupStagesAndEventsPath:
'/groups/:id/-/analytics/value_stream_analytics/value_streams/:value_stream_id/stages',
cycleAnalyticsValueStreamsPath: '/groups/:id/-/analytics/value_stream_analytics/value_streams',
......@@ -140,18 +138,6 @@ export default {
return axios.get(url, { params });
},
cycleAnalyticsSummaryData(groupId, params = {}) {
const url = Api.buildUrl(this.cycleAnalyticsSummaryDataPath).replace(':id', groupId);
return axios.get(url, { params });
},
cycleAnalyticsTimeSummaryData(groupId, params = {}) {
const url = Api.buildUrl(this.cycleAnalyticsTimeSummaryDataPath).replace(':id', groupId);
return axios.get(url, { params });
},
cycleAnalyticsGroupStagesAndEvents({ groupId, valueStreamId, params = {} }) {
const url = Api.buildUrl(this.cycleAnalyticsGroupStagesAndEventsPath)
.replace(':id', groupId)
......
import { buildApiUrl } from '~/api/api_utils';
import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
const GROUP_VSA_PATH_BASE =
'/groups/:id/-/analytics/value_stream_analytics/value_streams/:value_stream_id/stages/:stage_id';
const GROUP_VSA_BASE = '/groups/:id/-/analytics/value_stream_analytics';
const GROUP_VSA_STAGE_BASE = `${GROUP_VSA_BASE}/value_streams/:value_stream_id/stages/:stage_id`;
const buildGroupValueStreamPath = ({ groupId, valueStreamId = null, stageId = null }) =>
buildApiUrl(GROUP_VSA_PATH_BASE)
const buildGroupValueStreamPath = ({ groupId }) =>
buildApiUrl(GROUP_VSA_BASE).replace(':id', groupId);
const buildGroupValueStreamStagePath = ({ groupId, valueStreamId = null, stageId = null }) =>
buildApiUrl(GROUP_VSA_STAGE_BASE)
.replace(':id', groupId)
.replace(':value_stream_id', valueStreamId)
.replace(':stage_id', stageId);
......@@ -14,6 +18,12 @@ export const getGroupValueStreamStageMedian = (
{ groupId, valueStreamId, stageId },
params = {},
) => {
const stageBase = buildGroupValueStreamPath({ groupId, valueStreamId, stageId });
const stageBase = buildGroupValueStreamStagePath({ groupId, valueStreamId, stageId });
return axios.get(`${stageBase}/median`, { params });
};
export const getGroupValueStreamSummaryData = (groupId, params = {}) =>
axios.get(joinPaths(buildGroupValueStreamPath({ groupId }), 'summary'), { params });
export const getGroupValueStreamTimeSummaryData = (groupId, params = {}) =>
axios.get(joinPaths(buildGroupValueStreamPath({ groupId }), 'time_summary'), { params });
......@@ -5,7 +5,6 @@ import MockAdapter from 'axios-mock-adapter';
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 Metrics from 'ee/analytics/cycle_analytics/components/metrics.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 createStore from 'ee/analytics/cycle_analytics/store';
......@@ -20,6 +19,7 @@ import {
import PathNavigation from '~/cycle_analytics/components/path_navigation.vue';
import StageTable from '~/cycle_analytics/components/stage_table.vue';
import ValueStreamFilters from '~/cycle_analytics/components/value_stream_filters.vue';
import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue';
import {
OVERVIEW_STAGE_ID,
I18N_VSA_ERROR_STAGES,
......@@ -162,7 +162,7 @@ describe('EE Value Stream Analytics component', () => {
const findStageTable = () => wrapper.findComponent(StageTable);
const displaysMetrics = (flag) => {
expect(wrapper.findComponent(Metrics).exists()).toBe(flag);
expect(wrapper.findComponent(ValueStreamMetrics).exists()).toBe(flag);
};
const displaysStageTable = (flag) => {
......
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import { GlSingleStat } from '@gitlab/ui/dist/charts';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import Metrics from 'ee/analytics/cycle_analytics/components/metrics.vue';
import Api from 'ee/api';
import Api from 'ee/api'; // TODO: fix this for FOSS
import { group } from 'jest/cycle_analytics/mock_data';
import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue';
import createFlash from '~/flash';
import { timeMetricsData, recentActivityData } from '../mock_data';
const allMetrics = [...timeMetricsData, ...recentActivityData];
jest.mock('~/flash');
describe('Metrics', () => {
const { full_path: groupPath } = group;
describe('ValueStreamMetrics', () => {
const { full_path: requestPath } = group;
let wrapper;
const createComponent = ({ requestParams = {} } = {}) => {
return shallowMount(Metrics, {
return shallowMount(ValueStreamMetrics, {
propsData: {
groupPath,
requestPath,
requestParams,
},
});
};
const findAllMetrics = () => wrapper.findAllComponents(GlSingleStat);
const findAllMetrics = () => wrapper.findComponent(GlSingleStat).props('metrics');
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('with successful requests', () => {
......@@ -40,10 +44,19 @@ describe('Metrics', () => {
it.each(['cycleAnalyticsTimeSummaryData', 'cycleAnalyticsSummaryData'])(
'fetches data for the %s request',
(request) => {
expect(Api[request]).toHaveBeenCalledWith(groupPath, {});
expect(Api[request]).toHaveBeenCalledWith(requestPath, {});
},
);
it('will not display a loading icon', () => {
expect(wrapper.find(GlSkeletonLoading).exists()).toBe(false);
});
it('will display a loading icon if `true`', () => {
wrapper = createComponent({ isLoading: true });
expect(wrapper.find(GlSkeletonLoading).exists()).toBe(true);
});
describe('with additional params', () => {
beforeEach(async () => {
wrapper = createComponent({
......@@ -60,7 +73,7 @@ describe('Metrics', () => {
it.each(['cycleAnalyticsTimeSummaryData', 'cycleAnalyticsSummaryData'])(
'sends additional parameters as query paremeters in %s request',
(request) => {
expect(Api[request]).toHaveBeenCalledWith(groupPath, {
expect(Api[request]).toHaveBeenCalledWith(requestPath, {
'project_ids[]': [1],
created_after: '2020-01-01',
created_before: '2020-02-01',
......
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import { GlSingleStat } from '@gitlab/ui/dist/charts';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import Api from 'ee/api'; // TODO: fix this for FOSS
import { group } from 'jest/cycle_analytics/mock_data';
import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue';
import createFlash from '~/flash';
import { timeMetricsData, recentActivityData } from '../mock_data';
const allMetrics = [...timeMetricsData, ...recentActivityData];
jest.mock('~/flash');
describe('ValueStreamMetrics', () => {
const { full_path: requestPath } = group;
let wrapper;
const createComponent = ({ requestParams = {} } = {}) => {
return shallowMount(ValueStreamMetrics, {
propsData: {
requestPath,
requestParams,
},
});
};
const findMetrics = () => wrapper.findComponent(GlSingleStat).props('metrics');
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('with successful requests', () => {
beforeEach(async () => {
jest.spyOn(Api, 'cycleAnalyticsTimeSummaryData').mockResolvedValue({ data: timeMetricsData });
jest.spyOn(Api, 'cycleAnalyticsSummaryData').mockResolvedValue({ data: recentActivityData });
wrapper = createComponent();
await nextTick;
});
it.each(['cycleAnalyticsTimeSummaryData', 'cycleAnalyticsSummaryData'])(
'fetches data for the %s request',
(request) => {
expect(Api[request]).toHaveBeenCalledWith(requestPath, {});
},
);
it.each`
index | value | title | unit
${0} | ${timeMetricsData[0].value} | ${timeMetricsData[0].title} | ${timeMetricsData[0].unit}
${1} | ${timeMetricsData[1].value} | ${timeMetricsData[1].title} | ${timeMetricsData[1].unit}
${2} | ${recentActivityData[0].value} | ${recentActivityData[0].title} | ${recentActivityData[0].unit}
${3} | ${recentActivityData[1].value} | ${recentActivityData[1].title} | ${recentActivityData[1].unit}
${4} | ${recentActivityData[2].value} | ${recentActivityData[2].title} | ${recentActivityData[2].unit}
`(
'renders a single stat component for the $title with value and unit',
({ index, value, title, unit }) => {
const metric = findAllMetrics().at(index);
const expectedUnit = unit ?? '';
expect(metric.props('value')).toBe(value);
expect(metric.props('title')).toBe(title);
expect(metric.props('unit')).toBe(expectedUnit);
},
);
it('will not display a loading icon', () => {
expect(wrapper.find(GlSkeletonLoading).exists()).toBe(false);
});
it('will display a loading icon if `true`', () => {
wrapper = createComponent({ isLoading: true });
expect(wrapper.find(GlSkeletonLoading).exists()).toBe(true);
});
describe('with additional params', () => {
beforeEach(async () => {
wrapper = createComponent({
requestParams: {
'project_ids[]': [1],
created_after: '2020-01-01',
created_before: '2020-02-01',
},
});
await nextTick;
});
it.each(['cycleAnalyticsTimeSummaryData', 'cycleAnalyticsSummaryData'])(
'sends additional parameters as query paremeters in %s request',
(request) => {
expect(Api[request]).toHaveBeenCalledWith(requestPath, {
'project_ids[]': [1],
created_after: '2020-01-01',
created_before: '2020-02-01',
});
},
);
});
describe('metrics', () => {
it('sets the metrics component props', () => {
const metricsProps = findMetrics();
allMetrics.forEach((metric, index) => {
const currentProp = metricsProps[index];
expect(currentProp.label).toEqual(metric.title);
expect(currentProp.value).toEqual(metric.value);
expect(currentProp.unit).toEqual(metric.unit);
});
});
});
});
describe.each`
metric | failedRequest | succesfulRequest
${'time summary'} | ${'cycleAnalyticsTimeSummaryData'} | ${'cycleAnalyticsSummaryData'}
${'recent activity'} | ${'cycleAnalyticsSummaryData'} | ${'cycleAnalyticsTimeSummaryData'}
`('with the $failedRequest request failing', ({ metric, failedRequest, succesfulRequest }) => {
beforeEach(async () => {
jest.spyOn(Api, failedRequest).mockRejectedValue();
jest.spyOn(Api, succesfulRequest).mockResolvedValue(Promise.resolve({}));
wrapper = createComponent();
await wrapper.vm.$nextTick();
});
it('it should render a error message', () => {
expect(createFlash).toHaveBeenCalledWith({
message: `There was an error while fetching value stream analytics ${metric} data.`,
});
});
});
});
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