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'; ...@@ -2,10 +2,17 @@ import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility'; import { joinPaths } from '~/lib/utils/url_utility';
import { buildApiUrl } from './api_utils'; 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_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_STAGES_PATH = `${PROJECT_VSA_PATH_BASE}/:value_stream_id/stages`;
const PROJECT_VSA_STAGE_DATA_PATH = `${PROJECT_VSA_STAGES_PATH}/:stage_id`; 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) => { const buildProjectValueStreamPath = (requestPath, valueStreamId = null) => {
if (valueStreamId) { if (valueStreamId) {
return buildApiUrl(PROJECT_VSA_STAGES_PATH) return buildApiUrl(PROJECT_VSA_STAGES_PATH)
...@@ -40,9 +47,7 @@ export const getProjectValueStreamMetrics = (requestPath, params) => ...@@ -40,9 +47,7 @@ export const getProjectValueStreamMetrics = (requestPath, params) =>
axios.get(requestPath, { params }); axios.get(requestPath, { params });
/** /**
* Shared group VSA paths * Dedicated project 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
*/ */
export const getValueStreamStageMedian = ({ requestPath, valueStreamId, stageId }, params = {}) => { export const getValueStreamStageMedian = ({ requestPath, valueStreamId, stageId }, params = {}) => {
...@@ -62,3 +67,17 @@ export const getValueStreamStageCounts = ({ requestPath, valueStreamId, stageId ...@@ -62,3 +67,17 @@ export const getValueStreamStageCounts = ({ requestPath, valueStreamId, stageId
const stageBase = buildValueStreamStageDataPath({ requestPath, valueStreamId, stageId }); const stageBase = buildValueStreamStageDataPath({ requestPath, valueStreamId, stageId });
return axios.get(joinPaths(stageBase, 'count'), { params }); 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'; ...@@ -4,7 +4,9 @@ import Cookies from 'js-cookie';
import { mapActions, mapState, mapGetters } from 'vuex'; import { mapActions, mapState, mapGetters } from 'vuex';
import PathNavigation from '~/cycle_analytics/components/path_navigation.vue'; import PathNavigation from '~/cycle_analytics/components/path_navigation.vue';
import StageTable from '~/cycle_analytics/components/stage_table.vue'; import StageTable from '~/cycle_analytics/components/stage_table.vue';
import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { SUMMARY_METRICS_REQUEST, METRICS_REQUESTS } from '../constants';
const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed'; const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed';
...@@ -16,6 +18,7 @@ export default { ...@@ -16,6 +18,7 @@ export default {
GlSprintf, GlSprintf,
PathNavigation, PathNavigation,
StageTable, StageTable,
ValueStreamMetrics,
}, },
props: { props: {
noDataSvgPath: { noDataSvgPath: {
...@@ -45,8 +48,10 @@ export default { ...@@ -45,8 +48,10 @@ export default {
'daysInPast', 'daysInPast',
'permissions', 'permissions',
'stageCounts', 'stageCounts',
'endpoints',
'features',
]), ]),
...mapGetters(['pathNavigationData']), ...mapGetters(['pathNavigationData', 'filterParams']),
displayStageEvents() { displayStageEvents() {
const { selectedStageEvents, isLoadingStage, isEmptyStage } = this; const { selectedStageEvents, isLoadingStage, isEmptyStage } = this;
return selectedStageEvents.length && !isLoadingStage && !isEmptyStage; return selectedStageEvents.length && !isLoadingStage && !isEmptyStage;
...@@ -88,6 +93,9 @@ export default { ...@@ -88,6 +93,9 @@ export default {
} }
return 0; return 0;
}, },
metricsRequests() {
return this.features?.cycleAnalyticsForGroups ? METRICS_REQUESTS : SUMMARY_METRICS_REQUEST;
},
}, },
methods: { methods: {
...mapActions([ ...mapActions([
...@@ -122,62 +130,54 @@ export default { ...@@ -122,62 +130,54 @@ export default {
<template> <template>
<div class="cycle-analytics"> <div class="cycle-analytics">
<h3>{{ $options.i18n.pageTitle }}</h3> <h3>{{ $options.i18n.pageTitle }}</h3>
<path-navigation <div class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row">
v-if="displayPathNavigation" <path-navigation
class="js-path-navigation gl-w-full gl-pb-2" v-if="displayPathNavigation"
:loading="isLoading || isLoadingStage" class="js-path-navigation gl-w-full gl-pb-2"
:stages="pathNavigationData" :loading="isLoading || isLoadingStage"
:selected-stage="selectedStage" :stages="pathNavigationData"
@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"
:selected-stage="selectedStage" :selected-stage="selectedStage"
:stage-count="selectedStageCount" @selected="onSelectStage"
:empty-state-title="emptyStageTitle"
:empty-state-message="emptyStageText"
:no-data-svg-path="noDataSvgPath"
:pagination="null"
/> />
<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> </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> </div>
</template> </template>
<script> <script>
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlPopover } from '@gitlab/ui'; import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlPopover } from '@gitlab/ui';
import { GlSingleStat } from '@gitlab/ui/dist/charts'; import { GlSingleStat } from '@gitlab/ui/dist/charts';
import Api from 'ee/api'; import { flatten } from 'lodash';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { sprintf, __, s__ } from '~/locale'; import { sprintf, s__ } from '~/locale';
import { OVERVIEW_METRICS, METRICS_POPOVER_CONTENT } from '../constants'; import { METRICS_POPOVER_CONTENT } from '../constants';
import { removeFlash, prepareTimeMetricsData } from '../utils'; import { removeFlash, prepareTimeMetricsData } from '../utils';
const requestData = ({ requestType, groupPath, requestParams }) => { const requestData = ({ request, endpoint, path, params, name }) => {
return requestType === OVERVIEW_METRICS.TIME_SUMMARY return request({ endpoint, params, requestPath: path })
? Api.cycleAnalyticsTimeSummaryData(groupPath, requestParams) .then(({ data }) => data)
: Api.cycleAnalyticsSummaryData(groupPath, requestParams); .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 { export default {
name: 'OverviewActivity', name: 'ValueStreamMetrics',
components: { components: {
GlSkeletonLoading,
GlSingleStat,
GlPopover, GlPopover,
GlSingleStat,
GlSkeletonLoading,
}, },
props: { props: {
groupPath: { requestPath: {
type: String, type: String,
required: true, required: true,
}, },
...@@ -29,6 +44,10 @@ export default { ...@@ -29,6 +44,10 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
requests: {
type: Array,
required: true,
},
}, },
data() { data() {
return { return {
...@@ -48,44 +67,15 @@ export default { ...@@ -48,44 +67,15 @@ export default {
fetchData() { fetchData() {
removeFlash(); removeFlash();
this.isLoading = true; this.isLoading = true;
return fetchMetricsData(this.requests, this.requestPath, this.requestParams)
Promise.all([ .then((data) => {
this.fetchMetricsByType(OVERVIEW_METRICS.TIME_SUMMARY), this.metrics = data;
this.fetchMetricsByType(OVERVIEW_METRICS.RECENT_ACTIVITY),
])
.then(([timeSummaryData = [], recentActivityData = []]) => {
this.metrics = [
...prepareTimeMetricsData(timeSummaryData, METRICS_POPOVER_CONTENT),
...prepareTimeMetricsData(recentActivityData, METRICS_POPOVER_CONTENT),
];
this.isLoading = false; this.isLoading = false;
}) })
.catch(() => { .catch(() => {
this.isLoading = false; 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> </script>
...@@ -109,7 +99,6 @@ export default { ...@@ -109,7 +99,6 @@ export default {
<template #title> <template #title>
<span class="gl-display-block gl-text-left">{{ metric.label }}</span> <span class="gl-display-block gl-text-left">{{ metric.label }}</span>
</template> </template>
<span v-if="metric.description">{{ metric.description }}</span> <span v-if="metric.description">{{ metric.description }}</span>
</gl-popover> </gl-popover>
</div> </div>
......
import {
getValueStreamMetrics,
METRIC_TYPE_SUMMARY,
METRIC_TYPE_TIME_SUMMARY,
} from '~/api/analytics_api';
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
export const DEFAULT_DAYS_IN_PAST = 30; export const DEFAULT_DAYS_IN_PAST = 30;
...@@ -30,3 +35,37 @@ export const I18N_VSA_ERROR_STAGE_MEDIAN = __('There was an error fetching media ...@@ -30,3 +35,37 @@ export const I18N_VSA_ERROR_STAGE_MEDIAN = __('There was an error fetching media
export const I18N_VSA_ERROR_SELECTED_STAGE = __( export const I18N_VSA_ERROR_SELECTED_STAGE = __(
'There was an error fetching data for the 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 () => { ...@@ -24,6 +24,9 @@ export default () => {
requestPath, requestPath,
fullPath, fullPath,
}, },
features: {
cycleAnalyticsForGroups: Boolean(gon?.licensed_features?.cycleAnalyticsForGroups),
},
}); });
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
......
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { DEFAULT_DAYS_TO_DISPLAY } from '../constants'; import { DEFAULT_DAYS_TO_DISPLAY } from '../constants';
import { decorateData, formatMedianValues, calculateFormattedDayInPast } from '../utils'; import { formatMedianValues, calculateFormattedDayInPast } from '../utils';
import * as types from './mutation_types'; import * as types from './mutation_types';
export default { export default {
[types.INITIALIZE_VSA](state, { endpoints }) { [types.INITIALIZE_VSA](state, { endpoints, features }) {
state.endpoints = endpoints; state.endpoints = endpoints;
const { now, past } = calculateFormattedDayInPast(DEFAULT_DAYS_TO_DISPLAY); const { now, past } = calculateFormattedDayInPast(DEFAULT_DAYS_TO_DISPLAY);
state.createdBefore = now; state.createdBefore = now;
state.createdAfter = past; state.createdAfter = past;
state.features = features;
}, },
[types.SET_LOADING](state, loadingState) { [types.SET_LOADING](state, loadingState) {
state.isLoading = loadingState; state.isLoading = loadingState;
...@@ -48,9 +49,7 @@ export default { ...@@ -48,9 +49,7 @@ export default {
state.hasError = false; state.hasError = false;
}, },
[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, data) { [types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, data) {
const { summary } = decorateData(data);
state.permissions = data?.permissions || {}; state.permissions = data?.permissions || {};
state.summary = summary;
state.hasError = false; state.hasError = false;
}, },
[types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR](state) { [types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR](state) {
......
...@@ -2,6 +2,7 @@ import { DEFAULT_DAYS_TO_DISPLAY } from '../constants'; ...@@ -2,6 +2,7 @@ import { DEFAULT_DAYS_TO_DISPLAY } from '../constants';
export default () => ({ export default () => ({
id: null, id: null,
features: {},
endpoints: {}, endpoints: {},
daysInPast: DEFAULT_DAYS_TO_DISPLAY, daysInPast: DEFAULT_DAYS_TO_DISPLAY,
createdAfter: null, createdAfter: null,
......
import dateFormat from 'dateformat'; import dateFormat from 'dateformat';
import { unescape } from 'lodash'; import { unescape } from 'lodash';
import { dateFormats } from '~/analytics/shared/constants'; import { dateFormats } from '~/analytics/shared/constants';
import { hideFlash } from '~/flash';
import { sanitize } from '~/lib/dompurify'; import { sanitize } from '~/lib/dompurify';
import { roundToNearestHalf } from '~/lib/utils/common_utils'; import { roundToNearestHalf } from '~/lib/utils/common_utils';
import { getDateInPast } from '~/lib/utils/datetime/date_calculation_utility'; import { getDateInPast } from '~/lib/utils/datetime/date_calculation_utility';
import { parseSeconds } from '~/lib/utils/datetime_utility'; import { parseSeconds } from '~/lib/utils/datetime_utility';
import { slugify } from '~/lib/utils/text_utility';
import { s__, sprintf } from '../locale'; import { s__, sprintf } from '../locale';
const mapToSummary = ({ value, ...rest }) => ({ ...rest, value: value || '-' }); export const removeFlash = (type = 'alert') => {
const flashEl = document.querySelector(`.flash-${type}`);
export const decorateData = (data = {}) => { if (flashEl) {
const { summary } = data; hideFlash(flashEl);
return { }
summary: summary?.map((item) => mapToSummary(item)) || [],
};
}; };
/** /**
...@@ -116,3 +116,36 @@ export const calculateFormattedDayInPast = (daysInPast, today = new Date()) => { ...@@ -116,3 +116,36 @@ export const calculateFormattedDayInPast = (daysInPast, today = new Date()) => {
past: toIsoFormat(getDateInPast(today, daysInPast)), 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'; ...@@ -4,11 +4,12 @@ import { mapActions, mapState, mapGetters } from 'vuex';
import PathNavigation from '~/cycle_analytics/components/path_navigation.vue'; import PathNavigation from '~/cycle_analytics/components/path_navigation.vue';
import StageTable from '~/cycle_analytics/components/stage_table.vue'; import StageTable from '~/cycle_analytics/components/stage_table.vue';
import ValueStreamFilters from '~/cycle_analytics/components/value_stream_filters.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 { OVERVIEW_STAGE_ID } from '~/cycle_analytics/constants';
import UrlSync from '~/vue_shared/components/url_sync.vue'; import UrlSync from '~/vue_shared/components/url_sync.vue';
import { toYmd } from '../../shared/utils'; import { toYmd } from '../../shared/utils';
import { METRICS_REQUESTS } from '../constants';
import DurationChart from './duration_chart.vue'; import DurationChart from './duration_chart.vue';
import Metrics from './metrics.vue';
import TypeOfWorkCharts from './type_of_work_charts.vue'; import TypeOfWorkCharts from './type_of_work_charts.vue';
import ValueStreamSelect from './value_stream_select.vue'; import ValueStreamSelect from './value_stream_select.vue';
...@@ -21,9 +22,9 @@ export default { ...@@ -21,9 +22,9 @@ export default {
StageTable, StageTable,
PathNavigation, PathNavigation,
ValueStreamFilters, ValueStreamFilters,
ValueStreamMetrics,
ValueStreamSelect, ValueStreamSelect,
UrlSync, UrlSync,
Metrics,
}, },
props: { props: {
emptyStateSvgPath: { emptyStateSvgPath: {
...@@ -140,6 +141,7 @@ export default { ...@@ -140,6 +141,7 @@ export default {
this.updateStageTablePagination(data); this.updateStageTablePagination(data);
}, },
}, },
METRICS_REQUESTS,
}; };
</script> </script>
<template> <template>
...@@ -194,7 +196,11 @@ export default { ...@@ -194,7 +196,11 @@ export default {
/> />
<template v-else> <template v-else>
<template v-if="isOverviewStageSelected"> <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" /> <duration-chart class="gl-mt-3" :stages="activeStages" />
<type-of-work-charts /> <type-of-work-charts />
</template> </template>
......
...@@ -10,10 +10,10 @@ import { ...@@ -10,10 +10,10 @@ import {
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import Api from 'ee/api'; import Api from 'ee/api';
import { removeFlash } from '~/cycle_analytics/utils';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { DATA_REFETCH_DELAY } from '../../shared/constants'; import { DATA_REFETCH_DELAY } from '../../shared/constants';
import { removeFlash } from '../utils';
export default { export default {
name: 'LabelsSelector', name: 'LabelsSelector',
......
<script> <script>
import { GlDropdownDivider, GlSegmentedControl, GlIcon, GlSprintf } from '@gitlab/ui'; import { GlDropdownDivider, GlSegmentedControl, GlIcon, GlSprintf } from '@gitlab/ui';
import { removeFlash } from '~/cycle_analytics/utils';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
import { import {
...@@ -8,7 +9,6 @@ import { ...@@ -8,7 +9,6 @@ import {
TASKS_BY_TYPE_SUBJECT_FILTER_OPTIONS, TASKS_BY_TYPE_SUBJECT_FILTER_OPTIONS,
TASKS_BY_TYPE_MAX_LABELS, TASKS_BY_TYPE_MAX_LABELS,
} from '../../constants'; } from '../../constants';
import { removeFlash } from '../../utils';
import LabelsSelector from '../labels_selector.vue'; import LabelsSelector from '../labels_selector.vue';
export default { 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 { OVERVIEW_STAGE_ID } from '~/cycle_analytics/constants';
import { __, s__ } from '~/locale'; import { __ } from '~/locale';
export const EVENTS_LIST_ITEM_LIMIT = 50; export const EVENTS_LIST_ITEM_LIMIT = 50;
...@@ -19,11 +21,6 @@ export const TASKS_BY_TYPE_FILTERS = { ...@@ -19,11 +21,6 @@ export const TASKS_BY_TYPE_FILTERS = {
export const DEFAULT_VALUE_STREAM_ID = 'default'; 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 FETCH_VALUE_STREAM_DATA = 'fetchValueStreamData';
export const OVERVIEW_STAGE_CONFIG = { export const OVERVIEW_STAGE_CONFIG = {
...@@ -33,18 +30,15 @@ export const OVERVIEW_STAGE_CONFIG = { ...@@ -33,18 +30,15 @@ export const OVERVIEW_STAGE_CONFIG = {
icon: 'home', icon: 'home',
}; };
export const METRICS_POPOVER_CONTENT = { export const METRICS_REQUESTS = [
'lead-time': { {
description: s__('ValueStreamAnalytics|Median time from issue created to issue closed.'), endpoint: METRIC_TYPE_TIME_SUMMARY,
request: getGroupValueStreamMetrics,
name: __('time summary'),
}, },
'cycle-time': { {
description: s__( endpoint: METRIC_TYPE_SUMMARY,
'ValueStreamAnalytics|Median time from issue first merge request created to issue closed.', 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 createFlash from '~/flash';
import httpStatus from '~/lib/utils/http_status'; import httpStatus from '~/lib/utils/http_status';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { removeFlash } from '../utils';
import * as types from './mutation_types'; import * as types from './mutation_types';
export * from './actions/filters'; export * from './actions/filters';
......
...@@ -3,22 +3,15 @@ import { isNumber } from 'lodash'; ...@@ -3,22 +3,15 @@ import { isNumber } from 'lodash';
import { dateFormats } from '~/analytics/shared/constants'; import { dateFormats } from '~/analytics/shared/constants';
import { OVERVIEW_STAGE_ID } from '~/cycle_analytics/constants'; import { OVERVIEW_STAGE_ID } from '~/cycle_analytics/constants';
import { medianTimeToParsedSeconds } from '~/cycle_analytics/utils'; import { medianTimeToParsedSeconds } from '~/cycle_analytics/utils';
import createFlash, { hideFlash } from '~/flash'; import createFlash from '~/flash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { newDate, dayAfter, secondsToDays, getDatesInRange } from '~/lib/utils/datetime_utility'; import { newDate, dayAfter, secondsToDays, getDatesInRange } from '~/lib/utils/datetime_utility';
import httpStatus from '~/lib/utils/http_status'; 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'; import { toYmd } from '../shared/utils';
const EVENT_TYPE_LABEL = 'label'; 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 }) => { export const toggleSelectedLabel = ({ selectedLabelIds = [], value = null }) => {
if (!value) return selectedLabelIds; if (!value) return selectedLabelIds;
return selectedLabelIds.includes(value) return selectedLabelIds.includes(value)
...@@ -378,36 +371,3 @@ export const formatMedianValuesWithOverview = (medians = []) => { ...@@ -378,36 +371,3 @@ export const formatMedianValuesWithOverview = (medians = []) => {
[OVERVIEW_STAGE_ID]: overviewMedian ? medianTimeToParsedSeconds(overviewMedian) : '-', [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 { ...@@ -15,8 +15,6 @@ export default {
epicIssuePath: '/api/:version/groups/:id/epics/:epic_iid/issues/:issue_id', epicIssuePath: '/api/:version/groups/:id/epics/:epic_iid/issues/:issue_id',
cycleAnalyticsTasksByTypePath: '/groups/:id/-/analytics/type_of_work/tasks_by_type', cycleAnalyticsTasksByTypePath: '/groups/:id/-/analytics/type_of_work/tasks_by_type',
cycleAnalyticsTopLabelsPath: '/groups/:id/-/analytics/type_of_work/tasks_by_type/top_labels', 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: cycleAnalyticsGroupStagesAndEventsPath:
'/groups/:id/-/analytics/value_stream_analytics/value_streams/:value_stream_id/stages', '/groups/:id/-/analytics/value_stream_analytics/value_streams/:value_stream_id/stages',
cycleAnalyticsValueStreamsPath: '/groups/:id/-/analytics/value_stream_analytics/value_streams', cycleAnalyticsValueStreamsPath: '/groups/:id/-/analytics/value_stream_analytics/value_streams',
...@@ -140,18 +138,6 @@ export default { ...@@ -140,18 +138,6 @@ export default {
return axios.get(url, { params }); 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 = {} }) { cycleAnalyticsGroupStagesAndEvents({ groupId, valueStreamId, params = {} }) {
const url = Api.buildUrl(this.cycleAnalyticsGroupStagesAndEventsPath) const url = Api.buildUrl(this.cycleAnalyticsGroupStagesAndEventsPath)
.replace(':id', groupId) .replace(':id', groupId)
......
import { METRIC_TYPE_SUMMARY } from '~/api/analytics_api';
import { buildApiUrl } from '~/api/api_utils'; import { buildApiUrl } from '~/api/api_utils';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
const GROUP_VSA_PATH_BASE = const GROUP_VSA_BASE = '/groups/:id/-/analytics/value_stream_analytics';
'/groups/:id/-/analytics/value_stream_analytics/value_streams/:value_stream_id/stages/:stage_id'; const GROUP_VSA_STAGE_BASE = `${GROUP_VSA_BASE}/value_streams/:value_stream_id/stages/:stage_id`;
const buildGroupValueStreamPath = ({ groupId, valueStreamId = null, stageId = null }) => const buildGroupValueStreamPath = ({ groupId }) =>
buildApiUrl(GROUP_VSA_PATH_BASE) buildApiUrl(GROUP_VSA_BASE).replace(':id', groupId);
const buildGroupValueStreamStagePath = ({ groupId, valueStreamId = null, stageId = null }) =>
buildApiUrl(GROUP_VSA_STAGE_BASE)
.replace(':id', groupId) .replace(':id', groupId)
.replace(':value_stream_id', valueStreamId) .replace(':value_stream_id', valueStreamId)
.replace(':stage_id', stageId); .replace(':stage_id', stageId);
...@@ -14,6 +19,12 @@ export const getGroupValueStreamStageMedian = ( ...@@ -14,6 +19,12 @@ export const getGroupValueStreamStageMedian = (
{ groupId, valueStreamId, stageId }, { groupId, valueStreamId, stageId },
params = {}, params = {},
) => { ) => {
const stageBase = buildGroupValueStreamPath({ groupId, valueStreamId, stageId }); const stageBase = buildGroupValueStreamStagePath({ groupId, valueStreamId, stageId });
return axios.get(`${stageBase}/median`, { params }); 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'; ...@@ -5,7 +5,6 @@ import MockAdapter from 'axios-mock-adapter';
import Vuex from 'vuex'; import Vuex from 'vuex';
import Component from 'ee/analytics/cycle_analytics/components/base.vue'; import Component from 'ee/analytics/cycle_analytics/components/base.vue';
import DurationChart from 'ee/analytics/cycle_analytics/components/duration_chart.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 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 ValueStreamSelect from 'ee/analytics/cycle_analytics/components/value_stream_select.vue';
import createStore from 'ee/analytics/cycle_analytics/store'; import createStore from 'ee/analytics/cycle_analytics/store';
...@@ -20,6 +19,7 @@ import { ...@@ -20,6 +19,7 @@ import {
import PathNavigation from '~/cycle_analytics/components/path_navigation.vue'; import PathNavigation from '~/cycle_analytics/components/path_navigation.vue';
import StageTable from '~/cycle_analytics/components/stage_table.vue'; import StageTable from '~/cycle_analytics/components/stage_table.vue';
import ValueStreamFilters from '~/cycle_analytics/components/value_stream_filters.vue'; import ValueStreamFilters from '~/cycle_analytics/components/value_stream_filters.vue';
import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue';
import { import {
OVERVIEW_STAGE_ID, OVERVIEW_STAGE_ID,
I18N_VSA_ERROR_STAGES, I18N_VSA_ERROR_STAGES,
...@@ -162,7 +162,7 @@ describe('EE Value Stream Analytics component', () => { ...@@ -162,7 +162,7 @@ describe('EE Value Stream Analytics component', () => {
const findStageTable = () => wrapper.findComponent(StageTable); const findStageTable = () => wrapper.findComponent(StageTable);
const displaysMetrics = (flag) => { const displaysMetrics = (flag) => {
expect(wrapper.findComponent(Metrics).exists()).toBe(flag); expect(wrapper.findComponent(ValueStreamMetrics).exists()).toBe(flag);
}; };
const displaysStageTable = (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 { ...@@ -14,7 +14,6 @@ import {
flattenTaskByTypeSeries, flattenTaskByTypeSeries,
orderByDate, orderByDate,
toggleSelectedLabel, toggleSelectedLabel,
prepareTimeMetricsData,
prepareStageErrors, prepareStageErrors,
formatMedianValuesWithOverview, formatMedianValuesWithOverview,
} from 'ee/analytics/cycle_analytics/utils'; } from 'ee/analytics/cycle_analytics/utils';
...@@ -23,7 +22,6 @@ import { createdAfter, createdBefore, rawStageMedians } from 'jest/cycle_analyti ...@@ -23,7 +22,6 @@ import { createdAfter, createdBefore, rawStageMedians } from 'jest/cycle_analyti
import { OVERVIEW_STAGE_ID } from '~/cycle_analytics/constants'; import { OVERVIEW_STAGE_ID } from '~/cycle_analytics/constants';
import { medianTimeToParsedSeconds } from '~/cycle_analytics/utils'; import { medianTimeToParsedSeconds } from '~/cycle_analytics/utils';
import { getDatesInRange } from '~/lib/utils/datetime_utility'; import { getDatesInRange } from '~/lib/utils/datetime_utility';
import { slugify } from '~/lib/utils/text_utility';
import { import {
customStageEvents as events, customStageEvents as events,
customStageLabelEvents as labelEvents, customStageLabelEvents as labelEvents,
...@@ -35,7 +33,6 @@ import { ...@@ -35,7 +33,6 @@ import {
issueStage, issueStage,
rawCustomStage, rawCustomStage,
rawTasksByTypeData, rawTasksByTypeData,
timeMetricsData,
} from './mock_data'; } from './mock_data';
const labelEventIds = labelEvents.map((ev) => ev.identifier); const labelEventIds = labelEvents.map((ev) => ev.identifier);
...@@ -343,34 +340,6 @@ describe('Value Stream Analytics utils', () => { ...@@ -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', () => { describe('formatMedianValuesWithOverview', () => {
const calculatedMedians = formatMedianValuesWithOverview(rawStageMedians); const calculatedMedians = formatMedianValuesWithOverview(rawStageMedians);
......
...@@ -291,53 +291,6 @@ describe('Api', () => { ...@@ -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', () => { describe('cycleAnalyticsValueStreams', () => {
it('fetches custom value streams', (done) => { it('fetches custom value streams', (done) => {
const response = [{ name: 'value stream 1', id: 1 }]; const response = [{ name: 'value stream 1', id: 1 }];
......
...@@ -33802,9 +33802,6 @@ msgstr "" ...@@ -33802,9 +33802,6 @@ msgstr ""
msgid "There was an error while fetching the table data. Please refresh the page to try again." msgid "There was an error while fetching the table data. Please refresh the page to try again."
msgstr "" 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." msgid "There was an error while fetching value stream analytics data."
msgstr "" msgstr ""
...@@ -36640,9 +36637,15 @@ msgstr "" ...@@ -36640,9 +36637,15 @@ msgstr ""
msgid "ValueStreamAnalytics|Median time from issue first merge request created to issue closed." msgid "ValueStreamAnalytics|Median time from issue first merge request created to issue closed."
msgstr "" msgstr ""
msgid "ValueStreamAnalytics|Number of commits pushed to the default branch"
msgstr ""
msgid "ValueStreamAnalytics|Number of new issues created." msgid "ValueStreamAnalytics|Number of new issues created."
msgstr "" msgstr ""
msgid "ValueStreamAnalytics|There was an error while fetching value stream analytics %{requestTypeName} data."
msgstr ""
msgid "ValueStreamAnalytics|Total number of deploys to production." msgid "ValueStreamAnalytics|Total number of deploys to production."
msgstr "" msgstr ""
......
...@@ -7,6 +7,7 @@ RSpec.describe 'Value Stream Analytics', :js do ...@@ -7,6 +7,7 @@ RSpec.describe 'Value Stream Analytics', :js do
let_it_be(:guest) { create(:user) } let_it_be(:guest) { create(:user) }
let_it_be(:project) { create(:project, :repository) } let_it_be(:project) { create(:project, :repository) }
let_it_be(:stage_table_selector) { '[data-testid="vsa-stage-table"]' } 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(:issue) { create(:issue, project: project, created_at: 2.days.ago) }
let(:milestone) { create(:milestone, project: project) } let(:milestone) { create(:milestone, project: project) }
...@@ -26,11 +27,13 @@ RSpec.describe 'Value Stream Analytics', :js do ...@@ -26,11 +27,13 @@ RSpec.describe 'Value Stream Analytics', :js do
wait_for_requests wait_for_requests
end end
it 'shows pipeline summary' do it 'displays metrics' do
expect(new_issues_counter).to have_content('-') aggregate_failures 'with relevant values' do
expect(commits_counter).to have_content('-') expect(new_issues_counter).to have_content('-')
expect(deploys_counter).to have_content('-') expect(commits_counter).to have_content('-')
expect(deployment_frequency_counter).to have_content('-') expect(deploys_counter).to have_content('-')
expect(deployment_frequency_counter).to have_content('-')
end
end end
it 'shows active stage with empty message' do it 'shows active stage with empty message' do
...@@ -60,11 +63,15 @@ RSpec.describe 'Value Stream Analytics', :js do ...@@ -60,11 +63,15 @@ RSpec.describe 'Value Stream Analytics', :js do
visit project_cycle_analytics_path(project) visit project_cycle_analytics_path(project)
end end
it 'shows pipeline summary' do it 'displays metrics' do
expect(new_issues_counter).to have_content('1') metrics_tiles = page.find(metrics_selector)
expect(commits_counter).to have_content('2')
expect(deploys_counter).to have_content('1') aggregate_failures 'with relevant values' do
expect(deployment_frequency_counter).to have_content('0') 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 end
it 'shows data on each stage', :sidekiq_might_not_need_inline do it 'shows data on each stage', :sidekiq_might_not_need_inline do
...@@ -96,7 +103,7 @@ RSpec.describe 'Value Stream Analytics', :js do ...@@ -96,7 +103,7 @@ RSpec.describe 'Value Stream Analytics', :js do
end end
it 'shows only relevant data' do 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 end
end end
...@@ -116,7 +123,7 @@ RSpec.describe 'Value Stream Analytics', :js do ...@@ -116,7 +123,7 @@ RSpec.describe 'Value Stream Analytics', :js do
end end
it 'does not show the commit stats' do 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 end
it 'needs permissions to see restricted stages' do it 'needs permissions to see restricted stages' do
...@@ -130,28 +137,29 @@ RSpec.describe 'Value Stream Analytics', :js do ...@@ -130,28 +137,29 @@ RSpec.describe 'Value Stream Analytics', :js do
end end
end end
def new_issues_counter def find_metric_tile(sel)
find(:xpath, "//p[contains(text(),'New Issue')]/preceding-sibling::h3") page.find("#{metrics_selector} #{sel}")
end end
def commits_counter_selector # When now use proper pluralization for the metric names, which affects the id
"//p[contains(text(),'Commits')]/preceding-sibling::h3" def new_issue_counter
find_metric_tile("#new-issue")
end end
def commits_counter def new_issues_counter
find(:xpath, commits_counter_selector) find_metric_tile("#new-issues")
end end
def deploys_counter def commits_counter
find(:xpath, "//p[contains(text(),'Deploy')]/preceding-sibling::h3", match: :first) find_metric_tile("#commits")
end end
def deployment_frequency_counter_selector def deploys_counter
"//p[contains(text(),'Deployment Frequency')]/preceding-sibling::h3" find_metric_tile("#deploys")
end end
def deployment_frequency_counter def deployment_frequency_counter
find(:xpath, deployment_frequency_counter_selector) find_metric_tile("#deployment-frequency")
end end
def expect_issue_to_be_present def expect_issue_to_be_present
......
...@@ -6,6 +6,7 @@ import { extendedWrapper } from 'helpers/vue_test_utils_helper'; ...@@ -6,6 +6,7 @@ import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import BaseComponent from '~/cycle_analytics/components/base.vue'; import BaseComponent from '~/cycle_analytics/components/base.vue';
import PathNavigation from '~/cycle_analytics/components/path_navigation.vue'; import PathNavigation from '~/cycle_analytics/components/path_navigation.vue';
import StageTable from '~/cycle_analytics/components/stage_table.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 { NOT_ENOUGH_DATA_ERROR } from '~/cycle_analytics/constants';
import initState from '~/cycle_analytics/store/state'; import initState from '~/cycle_analytics/store/state';
import { import {
...@@ -23,6 +24,7 @@ const selectedStageEvents = issueEvents.events; ...@@ -23,6 +24,7 @@ const selectedStageEvents = issueEvents.events;
const noDataSvgPath = 'path/to/no/data'; const noDataSvgPath = 'path/to/no/data';
const noAccessSvgPath = 'path/to/no/access'; const noAccessSvgPath = 'path/to/no/access';
const selectedStageCount = stageCounts[selectedStage.id]; const selectedStageCount = stageCounts[selectedStage.id];
const fullPath = 'full/path/to/foo';
Vue.use(Vuex); Vue.use(Vuex);
...@@ -34,6 +36,7 @@ const defaultState = { ...@@ -34,6 +36,7 @@ const defaultState = {
createdBefore, createdBefore,
createdAfter, createdAfter,
stageCounts, stageCounts,
endpoints: { fullPath },
}; };
function createStore({ initialState = {}, initialGetters = {} }) { function createStore({ initialState = {}, initialGetters = {} }) {
...@@ -45,6 +48,10 @@ function createStore({ initialState = {}, initialGetters = {} }) { ...@@ -45,6 +48,10 @@ function createStore({ initialState = {}, initialGetters = {} }) {
}, },
getters: { getters: {
pathNavigationData: () => transformedProjectStagePathData, pathNavigationData: () => transformedProjectStagePathData,
filterParams: () => ({
created_after: createdAfter,
created_before: createdBefore,
}),
...initialGetters, ...initialGetters,
}, },
}); });
...@@ -67,11 +74,17 @@ function createComponent({ initialState, initialGetters } = {}) { ...@@ -67,11 +74,17 @@ function createComponent({ initialState, initialGetters } = {}) {
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findPathNavigation = () => wrapper.findComponent(PathNavigation); const findPathNavigation = () => wrapper.findComponent(PathNavigation);
const findOverviewMetrics = () => wrapper.findByTestId('vsa-stage-overview-metrics'); const findOverviewMetrics = () => wrapper.findComponent(ValueStreamMetrics);
const findStageTable = () => wrapper.findComponent(StageTable); const findStageTable = () => wrapper.findComponent(StageTable);
const findStageEvents = () => findStageTable().props('stageEvents'); const findStageEvents = () => findStageTable().props('stageEvents');
const findEmptyStageTitle = () => wrapper.findComponent(GlEmptyState).props('title'); 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', () => { describe('Value stream analytics component', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({ initialState: { selectedStage, selectedStageEvents } }); wrapper = createComponent({ initialState: { selectedStage, selectedStageEvents } });
...@@ -94,6 +107,10 @@ describe('Value stream analytics component', () => { ...@@ -94,6 +107,10 @@ describe('Value stream analytics component', () => {
expect(findOverviewMetrics().exists()).toBe(true); expect(findOverviewMetrics().exists()).toBe(true);
}); });
it('passes requests prop to the metrics component', () => {
hasMetricsRequests(['recent activity']);
});
it('renders the stage table', () => { it('renders the stage table', () => {
expect(findStageTable().exists()).toBe(true); expect(findStageTable().exists()).toBe(true);
}); });
...@@ -110,6 +127,16 @@ describe('Value stream analytics component', () => { ...@@ -110,6 +127,16 @@ describe('Value stream analytics component', () => {
expect(findLoadingIcon().exists()).toBe(false); 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', () => { describe('isLoading = true', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({ wrapper = createComponent({
...@@ -121,14 +148,14 @@ describe('Value stream analytics component', () => { ...@@ -121,14 +148,14 @@ describe('Value stream analytics component', () => {
expect(findPathNavigation().props('loading')).toBe(true); 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', () => { it('does not render the stage table', () => {
expect(findStageTable().exists()).toBe(false); expect(findStageTable().exists()).toBe(false);
}); });
it('renders the overview metrics', () => {
expect(findOverviewMetrics().exists()).toBe(true);
});
it('renders the loading icon', () => { it('renders the loading icon', () => {
expect(findLoadingIcon().exists()).toBe(true); expect(findLoadingIcon().exists()).toBe(true);
}); });
......
...@@ -15,8 +15,11 @@ export const getStageByTitle = (stages, title) => ...@@ -15,8 +15,11 @@ export const getStageByTitle = (stages, title) =>
const fixtureEndpoints = { const fixtureEndpoints = {
customizableCycleAnalyticsStagesAndEvents: 'projects/analytics/value_stream_analytics/stages', customizableCycleAnalyticsStagesAndEvents: 'projects/analytics/value_stream_analytics/stages',
stageEvents: (stage) => `projects/analytics/value_stream_analytics/events/${stage}`, 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( export const customizableStagesAndEvents = getJSONFixture(
fixtureEndpoints.customizableCycleAnalyticsStagesAndEvents, fixtureEndpoints.customizableCycleAnalyticsStagesAndEvents,
); );
......
...@@ -6,8 +6,6 @@ import { ...@@ -6,8 +6,6 @@ import {
selectedStage, selectedStage,
rawIssueEvents, rawIssueEvents,
issueEvents, issueEvents,
rawData,
convertedData,
selectedValueStream, selectedValueStream,
rawValueStreamStages, rawValueStreamStages,
valueStreamStages, valueStreamStages,
...@@ -90,18 +88,17 @@ describe('Project Value Stream Analytics mutations', () => { ...@@ -90,18 +88,17 @@ describe('Project Value Stream Analytics mutations', () => {
}); });
it.each` it.each`
mutation | payload | stateKey | value 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} | ${'daysInPast'} | ${DEFAULT_DAYS_TO_DISPLAY}
${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdAfter'} | ${mockCreatedAfter} ${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdAfter'} | ${mockCreatedAfter}
${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdBefore'} | ${mockCreatedBefore} ${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdBefore'} | ${mockCreatedBefore}
${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true} ${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true}
${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false} ${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false}
${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream} ${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_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]} ${types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS} | ${{ stages: rawValueStreamStages }} | ${'stages'} | ${valueStreamStages}
${types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS} | ${{ stages: rawValueStreamStages }} | ${'stages'} | ${valueStreamStages} ${types.RECEIVE_STAGE_MEDIANS_SUCCESS} | ${rawStageMedians} | ${'medians'} | ${formattedStageMedians}
${types.RECEIVE_STAGE_MEDIANS_SUCCESS} | ${rawStageMedians} | ${'medians'} | ${formattedStageMedians} ${types.RECEIVE_STAGE_COUNTS_SUCCESS} | ${rawStageCounts} | ${'stageCounts'} | ${stageCounts}
${types.RECEIVE_STAGE_COUNTS_SUCCESS} | ${rawStageCounts} | ${'stageCounts'} | ${stageCounts}
`( `(
'$mutation with $payload will set $stateKey to $value', '$mutation with $payload will set $stateKey to $value',
({ mutation, payload, stateKey, value }) => { ({ mutation, payload, stateKey, value }) => {
......
import { useFakeDate } from 'helpers/fake_date'; import { useFakeDate } from 'helpers/fake_date';
import { import {
decorateData,
transformStagesForPathNavigation, transformStagesForPathNavigation,
timeSummaryForPathNavigation, timeSummaryForPathNavigation,
medianTimeToParsedSeconds, medianTimeToParsedSeconds,
formatMedianValues, formatMedianValues,
filterStagesByHiddenStatus, filterStagesByHiddenStatus,
calculateFormattedDayInPast, calculateFormattedDayInPast,
prepareTimeMetricsData,
} from '~/cycle_analytics/utils'; } from '~/cycle_analytics/utils';
import { slugify } from '~/lib/utils/text_utility';
import { import {
selectedStage, selectedStage,
rawData,
convertedData,
allowedStages, allowedStages,
stageMedians, stageMedians,
pathNavIssueMetric, pathNavIssueMetric,
rawStageMedians, rawStageMedians,
metricsData,
} from './mock_data'; } from './mock_data';
describe('Value stream analytics utils', () => { 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', () => { describe('transformStagesForPathNavigation', () => {
const stages = allowedStages; const stages = allowedStages;
const response = transformStagesForPathNavigation({ const response = transformStagesForPathNavigation({
...@@ -129,4 +113,32 @@ describe('Value stream analytics utils', () => { ...@@ -129,4 +113,32 @@ describe('Value stream analytics utils', () => {
expect(calculateFormattedDayInPast(5)).toEqual({ now: '1815-12-10', past: '1815-12-05' }); 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 ...@@ -51,4 +51,21 @@ RSpec.describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do
end end
end 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 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