Commit 0b214b40 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '327435-migrate-project-vsa-metrics' into 'master'

Migrate project VSA metrics to use dedicated project metrics endpoints

See merge request gitlab-org/gitlab!66835
parents 855457e2 9e46d9e3
......@@ -2,10 +2,17 @@ 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`;
export const METRIC_TYPE_SUMMARY = 'summary';
export const METRIC_TYPE_TIME_SUMMARY = 'time_summary';
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 +47,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 +67,17 @@ export const getValueStreamStageCounts = ({ requestPath, valueStreamId, stageId
const stageBase = buildValueStreamStageDataPath({ requestPath, valueStreamId, stageId });
return axios.get(joinPaths(stageBase, 'count'), { params });
};
export const getValueStreamMetrics = ({
endpoint = METRIC_TYPE_SUMMARY,
requestPath,
params = {},
}) => {
const metricBase = buildProjectMetricsPath(requestPath);
return axios.get(joinPaths(metricBase, endpoint), { 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 { SUMMARY_METRICS_REQUEST, 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,10 @@ export default {
'daysInPast',
'permissions',
'stageCounts',
'endpoints',
'features',
]),
...mapGetters(['pathNavigationData']),
...mapGetters(['pathNavigationData', 'filterParams']),
displayStageEvents() {
const { selectedStageEvents, isLoadingStage, isEmptyStage } = this;
return selectedStageEvents.length && !isLoadingStage && !isEmptyStage;
......@@ -88,6 +93,9 @@ export default {
}
return 0;
},
metricsRequests() {
return this.features?.cycleAnalyticsForGroups ? METRICS_REQUESTS : SUMMARY_METRICS_REQUEST;
},
},
methods: {
...mapActions([
......@@ -122,62 +130,54 @@ export default {
<template>
<div class="cycle-analytics">
<h3>{{ $options.i18n.pageTitle }}</h3>
<path-navigation
v-if="displayPathNavigation"
class="js-path-navigation gl-w-full gl-pb-2"
:loading="isLoading || isLoadingStage"
:stages="pathNavigationData"
: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>
<stage-table
:is-loading="isLoading || isLoadingStage"
:stage-events="selectedStageEvents"
<div class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row">
<path-navigation
v-if="displayPathNavigation"
class="js-path-navigation gl-w-full gl-pb-2"
:loading="isLoading || isLoadingStage"
:stages="pathNavigationData"
:selected-stage="selectedStage"
:stage-count="selectedStageCount"
:empty-state-title="emptyStageTitle"
:empty-state-message="emptyStageText"
:no-data-svg-path="noDataSvgPath"
:pagination="null"
@selected="onSelectStage"
/>
<div class="gl-flex-grow gl-align-self-end">
<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>
<value-stream-metrics
:request-path="endpoints.fullPath"
:request-params="filterParams"
:requests="metricsRequests"
/>
<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, endpoint, path, params, name }) => {
return request({ endpoint, params, requestPath: path })
.then(({ data }) => data)
.catch(() => {
const message = sprintf(
s__(
'ValueStreamAnalytics|There was an error while fetching value stream analytics %{requestTypeName} data.',
),
{ requestTypeName: name },
);
createFlash({ message });
});
};
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 +44,10 @@ export default {
type: Object,
required: true,
},
requests: {
type: Array,
required: true,
},
},
data() {
return {
......@@ -48,44 +67,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 +99,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 {
getValueStreamMetrics,
METRIC_TYPE_SUMMARY,
METRIC_TYPE_TIME_SUMMARY,
} from '~/api/analytics_api';
import { __, s__ } from '~/locale';
export const DEFAULT_DAYS_IN_PAST = 30;
......@@ -30,3 +35,37 @@ 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_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-issue': { description: s__('ValueStreamAnalytics|Number of new issues created.') },
'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.'),
},
commits: {
description: s__('ValueStreamAnalytics|Number of commits pushed to the default branch'),
},
};
export const SUMMARY_METRICS_REQUEST = [
{ endpoint: METRIC_TYPE_SUMMARY, name: __('recent activity'), request: getValueStreamMetrics },
];
export const METRICS_REQUESTS = [
{ endpoint: METRIC_TYPE_TIME_SUMMARY, name: __('time summary'), request: getValueStreamMetrics },
...SUMMARY_METRICS_REQUEST,
];
......@@ -24,6 +24,9 @@ export default () => {
requestPath,
fullPath,
},
features: {
cycleAnalyticsForGroups: Boolean(gon?.licensed_features?.cycleAnalyticsForGroups),
},
});
// eslint-disable-next-line no-new
......
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { DEFAULT_DAYS_TO_DISPLAY } from '../constants';
import { decorateData, formatMedianValues, calculateFormattedDayInPast } from '../utils';
import { formatMedianValues, calculateFormattedDayInPast } from '../utils';
import * as types from './mutation_types';
export default {
[types.INITIALIZE_VSA](state, { endpoints }) {
[types.INITIALIZE_VSA](state, { endpoints, features }) {
state.endpoints = endpoints;
const { now, past } = calculateFormattedDayInPast(DEFAULT_DAYS_TO_DISPLAY);
state.createdBefore = now;
state.createdAfter = past;
state.features = features;
},
[types.SET_LOADING](state, loadingState) {
state.isLoading = loadingState;
......@@ -48,9 +49,7 @@ export default {
state.hasError = false;
},
[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, data) {
const { summary } = decorateData(data);
state.permissions = data?.permissions || {};
state.summary = summary;
state.hasError = false;
},
[types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR](state) {
......
......@@ -2,6 +2,7 @@ import { DEFAULT_DAYS_TO_DISPLAY } from '../constants';
export default () => ({
id: null,
features: {},
endpoints: {},
daysInPast: DEFAULT_DAYS_TO_DISPLAY,
createdAfter: null,
......
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';
const mapToSummary = ({ value, ...rest }) => ({ ...rest, value: value || '-' });
export const decorateData = (data = {}) => {
const { summary } = data;
return {
summary: summary?.map((item) => mapToSummary(item)) || [],
};
export const removeFlash = (type = 'alert') => {
const flashEl = document.querySelector(`.flash-${type}`);
if (flashEl) {
hideFlash(flashEl);
}
};
/**
......@@ -116,3 +116,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 { getGroupValueStreamMetrics } from 'ee/api/analytics_api';
import { METRIC_TYPE_SUMMARY, METRIC_TYPE_TIME_SUMMARY } from '~/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 +21,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 +30,15 @@ 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 = [
{
endpoint: METRIC_TYPE_TIME_SUMMARY,
request: getGroupValueStreamMetrics,
name: __('time summary'),
},
'cycle-time': {
description: s__(
'ValueStreamAnalytics|Median time from issue first merge request created to issue closed.',
),
{
endpoint: METRIC_TYPE_SUMMARY,
request: getGroupValueStreamMetrics,
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 { METRIC_TYPE_SUMMARY } from '~/api/analytics_api';
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 +19,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 getGroupValueStreamMetrics = ({
endpoint = METRIC_TYPE_SUMMARY,
requestPath: groupId,
params = {},
}) => axios.get(joinPaths(buildGroupValueStreamPath({ groupId }), endpoint), { 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 { 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 { group } from 'jest/cycle_analytics/mock_data';
import createFlash from '~/flash';
import { timeMetricsData, recentActivityData } from '../mock_data';
jest.mock('~/flash');
describe('Metrics', () => {
const { full_path: groupPath } = group;
let wrapper;
const createComponent = ({ requestParams = {} } = {}) => {
return shallowMount(Metrics, {
propsData: {
groupPath,
requestParams,
},
});
};
const findAllMetrics = () => wrapper.findAllComponents(GlSingleStat);
afterEach(() => {
wrapper.destroy();
});
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(groupPath, {});
},
);
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(groupPath, {
'project_ids[]': [1],
created_after: '2020-01-01',
created_before: '2020-02-01',
});
},
);
});
describe('metrics', () => {
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);
},
);
});
});
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.`,
});
});
});
});
......@@ -14,7 +14,6 @@ import {
flattenTaskByTypeSeries,
orderByDate,
toggleSelectedLabel,
prepareTimeMetricsData,
prepareStageErrors,
formatMedianValuesWithOverview,
} from 'ee/analytics/cycle_analytics/utils';
......@@ -23,7 +22,6 @@ import { createdAfter, createdBefore, rawStageMedians } from 'jest/cycle_analyti
import { OVERVIEW_STAGE_ID } from '~/cycle_analytics/constants';
import { medianTimeToParsedSeconds } from '~/cycle_analytics/utils';
import { getDatesInRange } from '~/lib/utils/datetime_utility';
import { slugify } from '~/lib/utils/text_utility';
import {
customStageEvents as events,
customStageLabelEvents as labelEvents,
......@@ -35,7 +33,6 @@ import {
issueStage,
rawCustomStage,
rawTasksByTypeData,
timeMetricsData,
} from './mock_data';
const labelEventIds = labelEvents.map((ev) => ev.identifier);
......@@ -343,34 +340,6 @@ describe('Value Stream Analytics utils', () => {
});
});
describe('prepareTimeMetricsData', () => {
let prepared;
const [{ title: firstTitle }, { title: secondTitle }] = timeMetricsData;
const firstKey = slugify(firstTitle);
const secondKey = slugify(secondTitle);
beforeEach(() => {
prepared = prepareTimeMetricsData(timeMetricsData, {
[firstKey]: { description: 'Is a value that is good' },
});
});
it('will add a `key` based on the title', () => {
expect(prepared).toMatchObject([{ key: firstKey }, { key: secondKey }]);
});
it('will add a `label` key', () => {
expect(prepared).toMatchObject([{ label: 'Lead Time' }, { label: 'Cycle Time' }]);
});
it('will add a popover description using the key if it is provided', () => {
expect(prepared).toMatchObject([
{ description: 'Is a value that is good' },
{ description: '' },
]);
});
});
describe('formatMedianValuesWithOverview', () => {
const calculatedMedians = formatMedianValuesWithOverview(rawStageMedians);
......
......@@ -291,53 +291,6 @@ describe('Api', () => {
});
});
describe('cycleAnalyticsSummaryData', () => {
it('fetches value stream analytics summary data', (done) => {
const response = [
{ value: 0, title: 'New Issues' },
{ value: 0, title: 'Deploys' },
];
const params = { ...defaultParams };
const expectedUrl = `${dummyValueStreamAnalyticsUrlRoot}/summary`;
mock.onGet(expectedUrl).reply(httpStatus.OK, response);
Api.cycleAnalyticsSummaryData(groupId, params)
.then((responseObj) =>
expectRequestWithCorrectParameters(responseObj, {
response,
params,
expectedUrl,
}),
)
.then(done)
.catch(done.fail);
});
});
describe('cycleAnalyticsTimeSummaryData', () => {
it('fetches value stream analytics summary data', (done) => {
const response = [
{ value: '10.0', title: 'Lead time', unit: 'per day' },
{ value: '2.0', title: 'Cycle Time', unit: 'per day' },
];
const params = { ...defaultParams };
const expectedUrl = `${dummyValueStreamAnalyticsUrlRoot}/time_summary`;
mock.onGet(expectedUrl).reply(httpStatus.OK, response);
Api.cycleAnalyticsTimeSummaryData(groupId, params)
.then((responseObj) =>
expectRequestWithCorrectParameters(responseObj, {
response,
params,
expectedUrl,
}),
)
.then(done)
.catch(done.fail);
});
});
describe('cycleAnalyticsValueStreams', () => {
it('fetches custom value streams', (done) => {
const response = [{ name: 'value stream 1', id: 1 }];
......
......@@ -33802,9 +33802,6 @@ msgstr ""
msgid "There was an error while fetching the table data. Please refresh the page to try again."
msgstr ""
msgid "There was an error while fetching value stream analytics %{requestTypeName} data."
msgstr ""
msgid "There was an error while fetching value stream analytics data."
msgstr ""
......@@ -36640,9 +36637,15 @@ msgstr ""
msgid "ValueStreamAnalytics|Median time from issue first merge request created to issue closed."
msgstr ""
msgid "ValueStreamAnalytics|Number of commits pushed to the default branch"
msgstr ""
msgid "ValueStreamAnalytics|Number of new issues created."
msgstr ""
msgid "ValueStreamAnalytics|There was an error while fetching value stream analytics %{requestTypeName} data."
msgstr ""
msgid "ValueStreamAnalytics|Total number of deploys to production."
msgstr ""
......
......@@ -7,6 +7,7 @@ RSpec.describe 'Value Stream Analytics', :js do
let_it_be(:guest) { create(:user) }
let_it_be(:project) { create(:project, :repository) }
let_it_be(:stage_table_selector) { '[data-testid="vsa-stage-table"]' }
let_it_be(:metrics_selector) { "[data-testid='vsa-time-metrics']" }
let(:issue) { create(:issue, project: project, created_at: 2.days.ago) }
let(:milestone) { create(:milestone, project: project) }
......@@ -26,11 +27,13 @@ RSpec.describe 'Value Stream Analytics', :js do
wait_for_requests
end
it 'shows pipeline summary' do
expect(new_issues_counter).to have_content('-')
expect(commits_counter).to have_content('-')
expect(deploys_counter).to have_content('-')
expect(deployment_frequency_counter).to have_content('-')
it 'displays metrics' do
aggregate_failures 'with relevant values' do
expect(new_issues_counter).to have_content('-')
expect(commits_counter).to have_content('-')
expect(deploys_counter).to have_content('-')
expect(deployment_frequency_counter).to have_content('-')
end
end
it 'shows active stage with empty message' do
......@@ -60,11 +63,15 @@ RSpec.describe 'Value Stream Analytics', :js do
visit project_cycle_analytics_path(project)
end
it 'shows pipeline summary' do
expect(new_issues_counter).to have_content('1')
expect(commits_counter).to have_content('2')
expect(deploys_counter).to have_content('1')
expect(deployment_frequency_counter).to have_content('0')
it 'displays metrics' do
metrics_tiles = page.find(metrics_selector)
aggregate_failures 'with relevant values' do
expect(metrics_tiles).to have_content('Commit')
expect(metrics_tiles).to have_content('Deploy')
expect(metrics_tiles).to have_content('Deployment Frequency')
expect(metrics_tiles).to have_content('New Issue')
end
end
it 'shows data on each stage', :sidekiq_might_not_need_inline do
......@@ -96,7 +103,7 @@ RSpec.describe 'Value Stream Analytics', :js do
end
it 'shows only relevant data' do
expect(new_issues_counter).to have_content('1')
expect(new_issue_counter).to have_content('1')
end
end
end
......@@ -116,7 +123,7 @@ RSpec.describe 'Value Stream Analytics', :js do
end
it 'does not show the commit stats' do
expect(page).to have_no_selector(:xpath, commits_counter_selector)
expect(page.find(metrics_selector)).not_to have_selector("#commits")
end
it 'needs permissions to see restricted stages' do
......@@ -130,28 +137,29 @@ RSpec.describe 'Value Stream Analytics', :js do
end
end
def new_issues_counter
find(:xpath, "//p[contains(text(),'New Issue')]/preceding-sibling::h3")
def find_metric_tile(sel)
page.find("#{metrics_selector} #{sel}")
end
def commits_counter_selector
"//p[contains(text(),'Commits')]/preceding-sibling::h3"
# When now use proper pluralization for the metric names, which affects the id
def new_issue_counter
find_metric_tile("#new-issue")
end
def commits_counter
find(:xpath, commits_counter_selector)
def new_issues_counter
find_metric_tile("#new-issues")
end
def deploys_counter
find(:xpath, "//p[contains(text(),'Deploy')]/preceding-sibling::h3", match: :first)
def commits_counter
find_metric_tile("#commits")
end
def deployment_frequency_counter_selector
"//p[contains(text(),'Deployment Frequency')]/preceding-sibling::h3"
def deploys_counter
find_metric_tile("#deploys")
end
def deployment_frequency_counter
find(:xpath, deployment_frequency_counter_selector)
find_metric_tile("#deployment-frequency")
end
def expect_issue_to_be_present
......
......@@ -6,6 +6,7 @@ import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import BaseComponent from '~/cycle_analytics/components/base.vue';
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 { NOT_ENOUGH_DATA_ERROR } from '~/cycle_analytics/constants';
import initState from '~/cycle_analytics/store/state';
import {
......@@ -23,6 +24,7 @@ const selectedStageEvents = issueEvents.events;
const noDataSvgPath = 'path/to/no/data';
const noAccessSvgPath = 'path/to/no/access';
const selectedStageCount = stageCounts[selectedStage.id];
const fullPath = 'full/path/to/foo';
Vue.use(Vuex);
......@@ -34,6 +36,7 @@ const defaultState = {
createdBefore,
createdAfter,
stageCounts,
endpoints: { fullPath },
};
function createStore({ initialState = {}, initialGetters = {} }) {
......@@ -45,6 +48,10 @@ function createStore({ initialState = {}, initialGetters = {} }) {
},
getters: {
pathNavigationData: () => transformedProjectStagePathData,
filterParams: () => ({
created_after: createdAfter,
created_before: createdBefore,
}),
...initialGetters,
},
});
......@@ -67,11 +74,17 @@ function createComponent({ initialState, initialGetters } = {}) {
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findPathNavigation = () => wrapper.findComponent(PathNavigation);
const findOverviewMetrics = () => wrapper.findByTestId('vsa-stage-overview-metrics');
const findOverviewMetrics = () => wrapper.findComponent(ValueStreamMetrics);
const findStageTable = () => wrapper.findComponent(StageTable);
const findStageEvents = () => findStageTable().props('stageEvents');
const findEmptyStageTitle = () => wrapper.findComponent(GlEmptyState).props('title');
const hasMetricsRequests = (reqs) => {
const foundReqs = findOverviewMetrics().props('requests');
expect(foundReqs.length).toEqual(reqs.length);
expect(foundReqs.map(({ name }) => name)).toEqual(reqs);
};
describe('Value stream analytics component', () => {
beforeEach(() => {
wrapper = createComponent({ initialState: { selectedStage, selectedStageEvents } });
......@@ -94,6 +107,10 @@ describe('Value stream analytics component', () => {
expect(findOverviewMetrics().exists()).toBe(true);
});
it('passes requests prop to the metrics component', () => {
hasMetricsRequests(['recent activity']);
});
it('renders the stage table', () => {
expect(findStageTable().exists()).toBe(true);
});
......@@ -110,6 +127,16 @@ describe('Value stream analytics component', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
describe('with `cycleAnalyticsForGroups=true` license', () => {
beforeEach(() => {
wrapper = createComponent({ initialState: { features: { cycleAnalyticsForGroups: true } } });
});
it('passes requests prop to the metrics component', () => {
hasMetricsRequests(['time summary', 'recent activity']);
});
});
describe('isLoading = true', () => {
beforeEach(() => {
wrapper = createComponent({
......@@ -121,14 +148,14 @@ describe('Value stream analytics component', () => {
expect(findPathNavigation().props('loading')).toBe(true);
});
it('does not render the overview metrics', () => {
expect(findOverviewMetrics().exists()).toBe(false);
});
it('does not render the stage table', () => {
expect(findStageTable().exists()).toBe(false);
});
it('renders the overview metrics', () => {
expect(findOverviewMetrics().exists()).toBe(true);
});
it('renders the loading icon', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
......
......@@ -15,8 +15,11 @@ export const getStageByTitle = (stages, title) =>
const fixtureEndpoints = {
customizableCycleAnalyticsStagesAndEvents: 'projects/analytics/value_stream_analytics/stages',
stageEvents: (stage) => `projects/analytics/value_stream_analytics/events/${stage}`,
metricsData: 'projects/analytics/value_stream_analytics/summary',
};
export const metricsData = getJSONFixture(fixtureEndpoints.metricsData);
export const customizableStagesAndEvents = getJSONFixture(
fixtureEndpoints.customizableCycleAnalyticsStagesAndEvents,
);
......
......@@ -6,8 +6,6 @@ import {
selectedStage,
rawIssueEvents,
issueEvents,
rawData,
convertedData,
selectedValueStream,
rawValueStreamStages,
valueStreamStages,
......@@ -90,18 +88,17 @@ describe('Project Value Stream Analytics mutations', () => {
});
it.each`
mutation | payload | stateKey | value
${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'daysInPast'} | ${DEFAULT_DAYS_TO_DISPLAY}
${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdAfter'} | ${mockCreatedAfter}
${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdBefore'} | ${mockCreatedBefore}
${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true}
${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false}
${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream}
${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${rawData} | ${'summary'} | ${convertedData.summary}
${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]}
${types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS} | ${{ stages: rawValueStreamStages }} | ${'stages'} | ${valueStreamStages}
${types.RECEIVE_STAGE_MEDIANS_SUCCESS} | ${rawStageMedians} | ${'medians'} | ${formattedStageMedians}
${types.RECEIVE_STAGE_COUNTS_SUCCESS} | ${rawStageCounts} | ${'stageCounts'} | ${stageCounts}
mutation | payload | stateKey | value
${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'daysInPast'} | ${DEFAULT_DAYS_TO_DISPLAY}
${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdAfter'} | ${mockCreatedAfter}
${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdBefore'} | ${mockCreatedBefore}
${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true}
${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false}
${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream}
${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]}
${types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS} | ${{ stages: rawValueStreamStages }} | ${'stages'} | ${valueStreamStages}
${types.RECEIVE_STAGE_MEDIANS_SUCCESS} | ${rawStageMedians} | ${'medians'} | ${formattedStageMedians}
${types.RECEIVE_STAGE_COUNTS_SUCCESS} | ${rawStageCounts} | ${'stageCounts'} | ${stageCounts}
`(
'$mutation with $payload will set $stateKey to $value',
({ mutation, payload, stateKey, value }) => {
......
import { useFakeDate } from 'helpers/fake_date';
import {
decorateData,
transformStagesForPathNavigation,
timeSummaryForPathNavigation,
medianTimeToParsedSeconds,
formatMedianValues,
filterStagesByHiddenStatus,
calculateFormattedDayInPast,
prepareTimeMetricsData,
} from '~/cycle_analytics/utils';
import { slugify } from '~/lib/utils/text_utility';
import {
selectedStage,
rawData,
convertedData,
allowedStages,
stageMedians,
pathNavIssueMetric,
rawStageMedians,
metricsData,
} from './mock_data';
describe('Value stream analytics utils', () => {
describe('decorateData', () => {
const result = decorateData(rawData);
it('returns the summary data', () => {
expect(result.summary).toEqual(convertedData.summary);
});
it('returns `-` for summary data that has no value', () => {
const singleSummaryResult = decorateData({
stats: [],
permissions: { issue: true },
summary: [{ value: null, title: 'Commits' }],
});
expect(singleSummaryResult.summary).toEqual([{ value: '-', title: 'Commits' }]);
});
});
describe('transformStagesForPathNavigation', () => {
const stages = allowedStages;
const response = transformStagesForPathNavigation({
......@@ -129,4 +113,32 @@ describe('Value stream analytics utils', () => {
expect(calculateFormattedDayInPast(5)).toEqual({ now: '1815-12-10', past: '1815-12-05' });
});
});
describe('prepareTimeMetricsData', () => {
let prepared;
const [first, second] = metricsData;
const firstKey = slugify(first.title);
const secondKey = slugify(second.title);
beforeEach(() => {
prepared = prepareTimeMetricsData([first, second], {
[firstKey]: { description: 'Is a value that is good' },
});
});
it('will add a `key` based on the title', () => {
expect(prepared).toMatchObject([{ key: firstKey }, { key: secondKey }]);
});
it('will add a `label` key', () => {
expect(prepared).toMatchObject([{ label: 'New Issues' }, { label: 'Commits' }]);
});
it('will add a popover description using the key if it is provided', () => {
expect(prepared).toMatchObject([
{ description: 'Is a value that is good' },
{ description: '' },
]);
});
});
});
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import { GlSingleStat } from '@gitlab/ui/dist/charts';
import { shallowMount } from '@vue/test-utils';
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 createFlash from '~/flash';
import { group, metricsData } from './mock_data';
jest.mock('~/flash');
describe('ValueStreamMetrics', () => {
let wrapper;
let mockGetValueStreamSummaryMetrics;
const { full_path: requestPath } = group;
const fakeReqName = 'Mock metrics';
const metricsRequestFactory = () => ({
request: mockGetValueStreamSummaryMetrics,
endpoint: METRIC_TYPE_SUMMARY,
name: fakeReqName,
});
const createComponent = ({ requestParams = {} } = {}) => {
return shallowMount(ValueStreamMetrics, {
propsData: {
requestPath,
requestParams,
requests: [metricsRequestFactory()],
},
});
};
const findMetrics = () => wrapper.findAllComponents(GlSingleStat);
const expectToHaveRequest = (fields) => {
expect(mockGetValueStreamSummaryMetrics).toHaveBeenCalledWith({
endpoint: METRIC_TYPE_SUMMARY,
requestPath,
...fields,
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('with successful requests', () => {
beforeEach(() => {
mockGetValueStreamSummaryMetrics = jest.fn().mockResolvedValue({ data: metricsData });
wrapper = createComponent();
});
it('will display a loader with pending requests', async () => {
await wrapper.vm.$nextTick();
expect(wrapper.find(GlSkeletonLoading).exists()).toBe(true);
});
describe('with data loaded', () => {
beforeEach(async () => {
await waitForPromises();
});
it('fetches data from the value stream analytics endpoint', () => {
expectToHaveRequest({ params: {} });
});
it.each`
index | value | title | unit
${0} | ${metricsData[0].value} | ${metricsData[0].title} | ${metricsData[0].unit}
${1} | ${metricsData[1].value} | ${metricsData[1].title} | ${metricsData[1].unit}
${2} | ${metricsData[2].value} | ${metricsData[2].title} | ${metricsData[2].unit}
${3} | ${metricsData[3].value} | ${metricsData[3].title} | ${metricsData[3].unit}
`(
'renders a single stat component for the $title with value and unit',
({ index, value, title, unit }) => {
const metric = findMetrics().at(index);
expect(metric.props()).toMatchObject({ value, title, unit: unit ?? '' });
},
);
it('will not display a loading icon', () => {
expect(wrapper.find(GlSkeletonLoading).exists()).toBe(false);
});
describe('with additional params', () => {
beforeEach(async () => {
wrapper = createComponent({
requestParams: {
'project_ids[]': [1],
created_after: '2020-01-01',
created_before: '2020-02-01',
},
});
await waitForPromises();
});
it('fetches data for the `getValueStreamSummaryMetrics` request', () => {
expectToHaveRequest({
params: {
'project_ids[]': [1],
created_after: '2020-01-01',
created_before: '2020-02-01',
},
});
});
});
});
});
describe('with a request failing', () => {
beforeEach(async () => {
mockGetValueStreamSummaryMetrics = jest.fn().mockRejectedValue();
wrapper = createComponent();
await waitForPromises();
});
it('it should render an error message', () => {
expect(createFlash).toHaveBeenCalledWith({
message: `There was an error while fetching value stream analytics ${fakeReqName} data.`,
});
});
});
});
......@@ -51,4 +51,21 @@ RSpec.describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do
end
end
end
describe Projects::Analytics::CycleAnalytics::SummaryController, type: :controller do
render_views
let(:params) { { namespace_id: group, project_id: project, value_stream_id: value_stream_id } }
before do
project.add_developer(user)
sign_in(user)
end
it "projects/analytics/value_stream_analytics/summary" do
get(:show, params: params, format: :json)
expect(response).to be_successful
end
end
end
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