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

Add group value stream metrics UI to project VSA

Add additional tests for the value_stream_metrics

Add additional metrics specs

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

Minor refactor the value stream metrics

Changelog: changed
parent cb46d5b0
...@@ -2,10 +2,14 @@ import axios from '~/lib/utils/axios_utils'; ...@@ -2,10 +2,14 @@ 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`;
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 +44,7 @@ export const getProjectValueStreamMetrics = (requestPath, params) => ...@@ -40,9 +44,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 +64,13 @@ export const getValueStreamStageCounts = ({ requestPath, valueStreamId, stageId ...@@ -62,3 +64,13 @@ 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 getValueStreamTimeSummaryMetrics = (requestPath, params = {}) => {
const metricBase = buildProjectMetricsPath(requestPath);
return axios.get(joinPaths(metricBase, 'time_summary'), { params });
};
export const getValueStreamSummaryMetrics = (requestPath, params = {}) => {
const metricBase = buildProjectMetricsPath(requestPath);
return axios.get(joinPaths(metricBase, 'summary'), { params });
};
...@@ -4,7 +4,9 @@ import Cookies from 'js-cookie'; ...@@ -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 { 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,9 @@ export default { ...@@ -45,8 +48,9 @@ export default {
'daysInPast', 'daysInPast',
'permissions', 'permissions',
'stageCounts', 'stageCounts',
'endpoints',
]), ]),
...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;
...@@ -117,6 +121,7 @@ export default { ...@@ -117,6 +121,7 @@ export default {
pageTitle: __('Value Stream Analytics'), pageTitle: __('Value Stream Analytics'),
recentActivity: __('Recent Project Activity'), recentActivity: __('Recent Project Activity'),
}, },
METRICS_REQUESTS,
}; };
</script> </script>
<template> <template>
...@@ -130,54 +135,44 @@ export default { ...@@ -130,54 +135,44 @@ export default {
:selected-stage="selectedStage" :selected-stage="selectedStage"
@selected="onSelectStage" @selected="onSelectStage"
/> />
<gl-loading-icon v-if="isLoading" size="lg" /> <div class="gl-text-right flex-grow align-self-center">
<div v-else class="wrapper"> <div class="js-ca-dropdown dropdown inline">
<!-- <!-- eslint-disable-next-line @gitlab/vue-no-data-toggle -->
We wont have access to the stage counts until we move to a default value stream <button class="dropdown-menu-toggle" data-toggle="dropdown" type="button">
For now we can use the `withStageCounts` flag to ensure we don't display empty stage counts <span class="dropdown-label">
Related issue: https://gitlab.com/gitlab-org/gitlab/-/issues/326705 <gl-sprintf :message="$options.i18n.dropdownText">
--> <template #days>{{ daysInPast }}</template>
<div class="card" data-testid="vsa-stage-overview-metrics"> </gl-sprintf>
<div class="card-header">{{ __('Recent Project Activity') }}</div> <gl-icon name="chevron-down" class="dropdown-menu-toggle-icon gl-top-3" />
<div class="d-flex justify-content-between"> </span>
<div v-for="item in summary" :key="item.title" class="gl-flex-grow-1 gl-text-center"> </button>
<h3 class="header">{{ item.value }}</h3> <ul class="dropdown-menu dropdown-menu-right">
<p class="text">{{ item.title }}</p> <li v-for="days in $options.dayRangeOptions" :key="`day-range-${days}`">
</div> <a href="#" @click.prevent="handleDateSelect(days)">
<div class="flex-grow align-self-center text-center"> <gl-sprintf :message="$options.i18n.dropdownText">
<div class="js-ca-dropdown dropdown inline"> <template #days>{{ days }}</template>
<!-- eslint-disable-next-line @gitlab/vue-no-data-toggle --> </gl-sprintf>
<button class="dropdown-menu-toggle" data-toggle="dropdown" type="button"> </a>
<span class="dropdown-label"> </li>
<gl-sprintf :message="$options.i18n.dropdownText"> </ul>
<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> </div>
<stage-table
:is-loading="isLoading || isLoadingStage"
:stage-events="selectedStageEvents"
:selected-stage="selectedStage"
:stage-count="selectedStageCount"
:empty-state-title="emptyStageTitle"
:empty-state-message="emptyStageText"
:no-data-svg-path="noDataSvgPath"
:pagination="null"
/>
</div> </div>
<value-stream-metrics
:request-path="endpoints.fullPath"
:request-params="filterParams"
:requests="$options.METRICS_REQUESTS"
/>
<gl-loading-icon v-if="isLoading" size="lg" />
<stage-table
v-else
:is-loading="isLoading || isLoadingStage"
:stage-events="selectedStageEvents"
:selected-stage="selectedStage"
:stage-count="selectedStageCount"
:empty-state-title="emptyStageTitle"
:empty-state-message="emptyStageText"
:no-data-svg-path="noDataSvgPath"
:pagination="null"
/>
</div> </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, path, params, name }) => {
return requestType === OVERVIEW_METRICS.TIME_SUMMARY return request(path, params)
? Api.cycleAnalyticsTimeSummaryData(groupPath, requestParams) .then(({ data }) => data)
: Api.cycleAnalyticsSummaryData(groupPath, requestParams); .catch(() => {
createFlash({
message: sprintf(
s__('There was an error while fetching value stream analytics %{name} data.'),
{ name },
),
});
});
};
const fetchMetricsData = (reqs = [], path, params) => {
const promises = reqs.map((r) => requestData({ ...r, path, params }));
return Promise.all(promises).then((responses) =>
prepareTimeMetricsData(flatten(responses), METRICS_POPOVER_CONTENT),
);
}; };
export default { 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 +43,10 @@ export default { ...@@ -29,6 +43,10 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
requests: {
type: Array,
required: true,
},
}, },
data() { data() {
return { return {
...@@ -48,44 +66,15 @@ export default { ...@@ -48,44 +66,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 +98,6 @@ export default { ...@@ -109,7 +98,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 {
getValueStreamTimeSummaryMetrics,
getValueStreamSummaryMetrics,
} 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 +34,35 @@ export const I18N_VSA_ERROR_STAGE_MEDIAN = __('There was an error fetching media ...@@ -30,3 +34,35 @@ 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_REQUESTS = [
{
request: getValueStreamTimeSummaryMetrics,
name: __('time summary'),
},
{
request: getValueStreamSummaryMetrics,
name: __('recent activity'),
},
];
export const METRICS_POPOVER_CONTENT = {
'lead-time': {
description: s__('ValueStreamAnalytics|Median time from issue created to issue closed.'),
},
'cycle-time': {
description: s__(
'ValueStreamAnalytics|Median time from issue first merge request created to issue closed.',
),
},
'new-issues': { description: s__('ValueStreamAnalytics|Number of new issues created.') },
deploys: { description: s__('ValueStreamAnalytics|Total number of deploys to production.') },
'deployment-frequency': {
description: s__('ValueStreamAnalytics|Average number of deployments to production per day.'),
},
};
import dateFormat from 'dateformat'; import 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';
export const removeFlash = (type = 'alert') => {
const flashEl = document.querySelector(`.flash-${type}`);
if (flashEl) {
hideFlash(flashEl);
}
};
const mapToSummary = ({ value, ...rest }) => ({ ...rest, value: value || '-' }); const mapToSummary = ({ value, ...rest }) => ({ ...rest, value: value || '-' });
export const decorateData = (data = {}) => { export const decorateData = (data = {}) => {
...@@ -116,3 +125,36 @@ export const calculateFormattedDayInPast = (daysInPast, today = new Date()) => { ...@@ -116,3 +125,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 {
getGroupValueStreamSummaryData,
getGroupValueStreamTimeSummaryData,
} from 'ee/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 +23,6 @@ export const TASKS_BY_TYPE_FILTERS = { ...@@ -19,11 +23,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 +32,13 @@ export const OVERVIEW_STAGE_CONFIG = { ...@@ -33,18 +32,13 @@ 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.'), request: getGroupValueStreamTimeSummaryData,
name: __('time summary'),
}, },
'cycle-time': { {
description: s__( request: getGroupValueStreamSummaryData,
'ValueStreamAnalytics|Median time from issue first merge request created to issue closed.', 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 { 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 +18,12 @@ export const getGroupValueStreamStageMedian = ( ...@@ -14,6 +18,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 getGroupValueStreamSummaryData = (groupId, params = {}) =>
axios.get(joinPaths(buildGroupValueStreamPath({ groupId }), 'summary'), { params });
export const getGroupValueStreamTimeSummaryData = (groupId, params = {}) =>
axios.get(joinPaths(buildGroupValueStreamPath({ groupId }), 'time_summary'), { params });
...@@ -5,7 +5,6 @@ import MockAdapter from 'axios-mock-adapter'; ...@@ -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 { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import { GlSingleStat } from '@gitlab/ui/dist/charts'; import { GlSingleStat } from '@gitlab/ui/dist/charts';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import Metrics from 'ee/analytics/cycle_analytics/components/metrics.vue'; import Api from 'ee/api'; // TODO: fix this for FOSS
import Api from 'ee/api';
import { group } from 'jest/cycle_analytics/mock_data'; import { group } from 'jest/cycle_analytics/mock_data';
import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { timeMetricsData, recentActivityData } from '../mock_data'; import { timeMetricsData, recentActivityData } from '../mock_data';
const allMetrics = [...timeMetricsData, ...recentActivityData];
jest.mock('~/flash'); jest.mock('~/flash');
describe('Metrics', () => { describe('ValueStreamMetrics', () => {
const { full_path: groupPath } = group; const { full_path: requestPath } = group;
let wrapper; let wrapper;
const createComponent = ({ requestParams = {} } = {}) => { const createComponent = ({ requestParams = {} } = {}) => {
return shallowMount(Metrics, { return shallowMount(ValueStreamMetrics, {
propsData: { propsData: {
groupPath, requestPath,
requestParams, requestParams,
}, },
}); });
}; };
const findAllMetrics = () => wrapper.findAllComponents(GlSingleStat); const findAllMetrics = () => wrapper.findComponent(GlSingleStat).props('metrics');
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null;
}); });
describe('with successful requests', () => { describe('with successful requests', () => {
...@@ -40,10 +44,19 @@ describe('Metrics', () => { ...@@ -40,10 +44,19 @@ describe('Metrics', () => {
it.each(['cycleAnalyticsTimeSummaryData', 'cycleAnalyticsSummaryData'])( it.each(['cycleAnalyticsTimeSummaryData', 'cycleAnalyticsSummaryData'])(
'fetches data for the %s request', 'fetches data for the %s request',
(request) => { (request) => {
expect(Api[request]).toHaveBeenCalledWith(groupPath, {}); expect(Api[request]).toHaveBeenCalledWith(requestPath, {});
}, },
); );
it('will not display a loading icon', () => {
expect(wrapper.find(GlSkeletonLoading).exists()).toBe(false);
});
it('will display a loading icon if `true`', () => {
wrapper = createComponent({ isLoading: true });
expect(wrapper.find(GlSkeletonLoading).exists()).toBe(true);
});
describe('with additional params', () => { describe('with additional params', () => {
beforeEach(async () => { beforeEach(async () => {
wrapper = createComponent({ wrapper = createComponent({
...@@ -60,7 +73,7 @@ describe('Metrics', () => { ...@@ -60,7 +73,7 @@ describe('Metrics', () => {
it.each(['cycleAnalyticsTimeSummaryData', 'cycleAnalyticsSummaryData'])( it.each(['cycleAnalyticsTimeSummaryData', 'cycleAnalyticsSummaryData'])(
'sends additional parameters as query paremeters in %s request', 'sends additional parameters as query paremeters in %s request',
(request) => { (request) => {
expect(Api[request]).toHaveBeenCalledWith(groupPath, { expect(Api[request]).toHaveBeenCalledWith(requestPath, {
'project_ids[]': [1], 'project_ids[]': [1],
created_after: '2020-01-01', created_after: '2020-01-01',
created_before: '2020-02-01', created_before: '2020-02-01',
......
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import { GlSingleStat } from '@gitlab/ui/dist/charts';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import Api from 'ee/api'; // TODO: fix this for FOSS
import { group } from 'jest/cycle_analytics/mock_data';
import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue';
import createFlash from '~/flash';
import { timeMetricsData, recentActivityData } from '../mock_data';
const allMetrics = [...timeMetricsData, ...recentActivityData];
jest.mock('~/flash');
describe('ValueStreamMetrics', () => {
const { full_path: requestPath } = group;
let wrapper;
const createComponent = ({ requestParams = {} } = {}) => {
return shallowMount(ValueStreamMetrics, {
propsData: {
requestPath,
requestParams,
},
});
};
const findMetrics = () => wrapper.findComponent(GlSingleStat).props('metrics');
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('with successful requests', () => {
beforeEach(async () => {
jest.spyOn(Api, 'cycleAnalyticsTimeSummaryData').mockResolvedValue({ data: timeMetricsData });
jest.spyOn(Api, 'cycleAnalyticsSummaryData').mockResolvedValue({ data: recentActivityData });
wrapper = createComponent();
await nextTick;
});
it.each(['cycleAnalyticsTimeSummaryData', 'cycleAnalyticsSummaryData'])(
'fetches data for the %s request',
(request) => {
expect(Api[request]).toHaveBeenCalledWith(requestPath, {});
},
);
it.each`
index | value | title | unit
${0} | ${timeMetricsData[0].value} | ${timeMetricsData[0].title} | ${timeMetricsData[0].unit}
${1} | ${timeMetricsData[1].value} | ${timeMetricsData[1].title} | ${timeMetricsData[1].unit}
${2} | ${recentActivityData[0].value} | ${recentActivityData[0].title} | ${recentActivityData[0].unit}
${3} | ${recentActivityData[1].value} | ${recentActivityData[1].title} | ${recentActivityData[1].unit}
${4} | ${recentActivityData[2].value} | ${recentActivityData[2].title} | ${recentActivityData[2].unit}
`(
'renders a single stat component for the $title with value and unit',
({ index, value, title, unit }) => {
const metric = findAllMetrics().at(index);
const expectedUnit = unit ?? '';
expect(metric.props('value')).toBe(value);
expect(metric.props('title')).toBe(title);
expect(metric.props('unit')).toBe(expectedUnit);
},
);
it('will not display a loading icon', () => {
expect(wrapper.find(GlSkeletonLoading).exists()).toBe(false);
});
it('will display a loading icon if `true`', () => {
wrapper = createComponent({ isLoading: true });
expect(wrapper.find(GlSkeletonLoading).exists()).toBe(true);
});
describe('with additional params', () => {
beforeEach(async () => {
wrapper = createComponent({
requestParams: {
'project_ids[]': [1],
created_after: '2020-01-01',
created_before: '2020-02-01',
},
});
await nextTick;
});
it.each(['cycleAnalyticsTimeSummaryData', 'cycleAnalyticsSummaryData'])(
'sends additional parameters as query paremeters in %s request',
(request) => {
expect(Api[request]).toHaveBeenCalledWith(requestPath, {
'project_ids[]': [1],
created_after: '2020-01-01',
created_before: '2020-02-01',
});
},
);
});
describe('metrics', () => {
it('sets the metrics component props', () => {
const metricsProps = findMetrics();
allMetrics.forEach((metric, index) => {
const currentProp = metricsProps[index];
expect(currentProp.label).toEqual(metric.title);
expect(currentProp.value).toEqual(metric.value);
expect(currentProp.unit).toEqual(metric.unit);
});
});
});
});
describe.each`
metric | failedRequest | succesfulRequest
${'time summary'} | ${'cycleAnalyticsTimeSummaryData'} | ${'cycleAnalyticsSummaryData'}
${'recent activity'} | ${'cycleAnalyticsSummaryData'} | ${'cycleAnalyticsTimeSummaryData'}
`('with the $failedRequest request failing', ({ metric, failedRequest, succesfulRequest }) => {
beforeEach(async () => {
jest.spyOn(Api, failedRequest).mockRejectedValue();
jest.spyOn(Api, succesfulRequest).mockResolvedValue(Promise.resolve({}));
wrapper = createComponent();
await wrapper.vm.$nextTick();
});
it('it should render a error message', () => {
expect(createFlash).toHaveBeenCalledWith({
message: `There was an error while fetching value stream analytics ${metric} data.`,
});
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment