Commit e835db2a authored by Heinrich Lee Yu's avatar Heinrich Lee Yu

Merge branch 'revert-f11ff249' into 'master'

Revert "Merge branch '326695-migrate-project-vsa-navigation-to-path-navigation' into 'master'" [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!62574
parents c265fbf2 98b535c5
<script> <script>
import { GlIcon, GlEmptyState, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; import { GlIcon, GlEmptyState, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import { mapActions, mapState, mapGetters } from 'vuex'; import { mapActions, mapState } from 'vuex';
import PathNavigation from '~/cycle_analytics/components/path_navigation.vue';
import { __ } from '~/locale'; import { __ } from '~/locale';
import banner from './banner.vue'; import banner from './banner.vue';
import stageCodeComponent from './stage_code_component.vue'; import stageCodeComponent from './stage_code_component.vue';
...@@ -30,7 +29,6 @@ export default { ...@@ -30,7 +29,6 @@ export default {
'stage-staging-component': stageStagingComponent, 'stage-staging-component': stageStagingComponent,
'stage-production-component': stageComponent, 'stage-production-component': stageComponent,
'stage-nav-item': stageNavItem, 'stage-nav-item': stageNavItem,
PathNavigation,
}, },
props: { props: {
noDataSvgPath: { noDataSvgPath: {
...@@ -58,7 +56,6 @@ export default { ...@@ -58,7 +56,6 @@ export default {
'summary', 'summary',
'startDate', 'startDate',
]), ]),
...mapGetters(['pathNavigationData']),
displayStageEvents() { displayStageEvents() {
const { selectedStageEvents, isLoadingStage, isEmptyStage } = this; const { selectedStageEvents, isLoadingStage, isEmptyStage } = this;
return selectedStageEvents.length && !isLoadingStage && !isEmptyStage; return selectedStageEvents.length && !isLoadingStage && !isEmptyStage;
...@@ -71,12 +68,6 @@ export default { ...@@ -71,12 +68,6 @@ export default {
const { selectedStage } = this; const { selectedStage } = this;
return selectedStage && !selectedStage.isUserAllowed; return selectedStage && !selectedStage.isUserAllowed;
}, },
selectedStageReady() {
return !this.hasNoAccessError && this.selectedStage;
},
shouldDisplayPathNavigation() {
return this.selectedStage;
},
}, },
methods: { methods: {
...mapActions([ ...mapActions([
...@@ -92,8 +83,8 @@ export default { ...@@ -92,8 +83,8 @@ export default {
isActiveStage(stage) { isActiveStage(stage) {
return stage.slug === this.selectedStage.slug; return stage.slug === this.selectedStage.slug;
}, },
onSelectStage(stage) { selectStage(stage) {
if (this.isLoadingStage || this.selectedStage?.slug === stage?.slug) return; if (this.selectedStage === stage) return;
this.setSelectedStage(stage); this.setSelectedStage(stage);
if (!stage.isUserAllowed) { if (!stage.isUserAllowed) {
...@@ -115,23 +106,9 @@ export default { ...@@ -115,23 +106,9 @@ export default {
</script> </script>
<template> <template>
<div class="cycle-analytics"> <div class="cycle-analytics">
<path-navigation
v-if="shouldDisplayPathNavigation"
class="js-path-navigation gl-w-full gl-pb-2"
:loading="isLoading"
:stages="pathNavigationData"
:selected-stage="selectedStage"
:with-stage-counts="false"
@selected="(ev) => onSelectStage(ev)"
/>
<gl-loading-icon v-if="isLoading" size="lg" /> <gl-loading-icon v-if="isLoading" size="lg" />
<div v-else class="wrapper"> <div v-else class="wrapper">
<!-- <div class="card">
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="card-header">{{ __('Recent Project Activity') }}</div>
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div v-for="item in summary" :key="item.title" class="gl-flex-grow-1 gl-text-center"> <div v-for="item in summary" :key="item.title" class="gl-flex-grow-1 gl-text-center">
...@@ -162,12 +139,40 @@ export default { ...@@ -162,12 +139,40 @@ export default {
</div> </div>
</div> </div>
</div> </div>
<div class="stage-panel-container" data-testid="vsa-stage-table"> <div class="stage-panel-container">
<div class="card stage-panel gl-px-5"> <div class="card stage-panel">
<div class="card-header border-bottom-0"> <div class="card-header border-bottom-0">
<nav class="col-headers"> <nav class="col-headers">
<ul class="gl-display-flex gl-justify-content-space-between gl-list-style-none"> <ul>
<li> <li class="stage-header pl-5">
<span class="stage-name font-weight-bold">{{
s__('ProjectLifecycle|Stage')
}}</span>
<span
class="has-tooltip"
data-placement="top"
:title="__('The phase of the development lifecycle.')"
aria-hidden="true"
>
<gl-icon name="question-o" class="gl-text-gray-500" />
</span>
</li>
<li class="median-header">
<span class="stage-name font-weight-bold">{{ __('Median') }}</span>
<span
class="has-tooltip"
data-placement="top"
:title="
__(
'The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.',
)
"
aria-hidden="true"
>
<gl-icon name="question-o" class="gl-text-gray-500" />
</span>
</li>
<li class="event-header pl-3">
<span v-if="selectedStage" class="stage-name font-weight-bold">{{ <span v-if="selectedStage" class="stage-name font-weight-bold">{{
selectedStage.legend ? __(selectedStage.legend) : __('Related Issues') selectedStage.legend ? __(selectedStage.legend) : __('Related Issues')
}}</span> }}</span>
...@@ -182,7 +187,7 @@ export default { ...@@ -182,7 +187,7 @@ export default {
<gl-icon name="question-o" class="gl-text-gray-500" /> <gl-icon name="question-o" class="gl-text-gray-500" />
</span> </span>
</li> </li>
<li> <li class="total-time-header pr-5 text-right">
<span class="stage-name font-weight-bold">{{ __('Time') }}</span> <span class="stage-name font-weight-bold">{{ __('Time') }}</span>
<span <span
class="has-tooltip" class="has-tooltip"
...@@ -196,8 +201,22 @@ export default { ...@@ -196,8 +201,22 @@ export default {
</ul> </ul>
</nav> </nav>
</div> </div>
<div class="stage-panel-body"> <div class="stage-panel-body">
<section class="stage-events overflow-auto gl-w-full"> <nav class="stage-nav">
<ul>
<stage-nav-item
v-for="stage in stages"
:key="stage.title"
:title="stage.title"
:is-user-allowed="stage.isUserAllowed"
:value="stage.value"
:is-active="isActiveStage(stage)"
@select="selectStage(stage)"
/>
</ul>
</nav>
<section class="stage-events overflow-auto">
<gl-loading-icon v-show="isLoadingStage" size="lg" /> <gl-loading-icon v-show="isLoadingStage" size="lg" />
<template v-if="displayNoAccess"> <template v-if="displayNoAccess">
<gl-empty-state <gl-empty-state
......
export const DEFAULT_DAYS_TO_DISPLAY = 30; export const DEFAULT_DAYS_TO_DISPLAY = 30;
export const OVERVIEW_STAGE_ID = 'overview';
import { transformStagesForPathNavigation } from '../utils';
export const filterStagesByHiddenStatus = (stages = [], isHidden = true) =>
stages.filter(({ hidden = false }) => hidden === isHidden);
export const pathNavigationData = ({ stages, medians, stageCounts, selectedStage }) => {
return transformStagesForPathNavigation({
stages: filterStagesByHiddenStatus(stages, false),
medians,
stageCounts,
selectedStage,
});
};
...@@ -8,7 +8,6 @@ ...@@ -8,7 +8,6 @@
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import * as actions from './actions'; import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations'; import mutations from './mutations';
import state from './state'; import state from './state';
...@@ -17,7 +16,6 @@ Vue.use(Vuex); ...@@ -17,7 +16,6 @@ Vue.use(Vuex);
export default () => export default () =>
new Vuex.Store({ new Vuex.Store({
actions, actions,
getters,
mutations, mutations,
state, state,
}); });
import { decorateData, decorateEvents, formatMedianValues } from '../utils'; import { decorateData, decorateEvents } from '../utils';
import * as types from './mutation_types'; import * as types from './mutation_types';
export default { export default {
...@@ -20,10 +20,9 @@ export default { ...@@ -20,10 +20,9 @@ export default {
}, },
[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, data) { [types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, data) {
state.isLoading = false; state.isLoading = false;
const { stages, summary, medians } = decorateData(data); const { stages, summary } = decorateData(data);
state.stages = stages; state.stages = stages;
state.summary = summary; state.summary = summary;
state.medians = formatMedianValues(medians);
state.hasError = false; state.hasError = false;
}, },
[types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR](state) { [types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR](state) {
......
import { unescape } from 'lodash'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { sanitize } from '~/lib/dompurify';
import { roundToNearestHalf, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { parseSeconds } from '~/lib/utils/datetime_utility';
import { dasherize } from '~/lib/utils/text_utility'; import { dasherize } from '~/lib/utils/text_utility';
import { __, s__, sprintf } from '../locale'; import { __ } from '../locale';
import DEFAULT_EVENT_OBJECTS from './default_event_objects'; import DEFAULT_EVENT_OBJECTS from './default_event_objects';
const EMPTY_STAGE_TEXTS = { const EMPTY_STAGE_TEXTS = {
...@@ -43,17 +40,10 @@ const mapToEvent = (event, stage) => { ...@@ -43,17 +40,10 @@ const mapToEvent = (event, stage) => {
export const decorateEvents = (events, stage) => events.map((event) => mapToEvent(event, stage)); export const decorateEvents = (events, stage) => events.map((event) => mapToEvent(event, stage));
/* const mapToStage = (permissions, item) => {
* NOTE: We currently use the `name` field since the project level stages are in memory const slug = dasherize(item.name.toLowerCase());
* once we migrate to a default value stream https://gitlab.com/gitlab-org/gitlab/-/issues/326705
* we can use the `id` to identify which median we are using
*/
const mapToStage = (permissions, { name, ...rest }) => {
const slug = dasherize(name.toLowerCase());
return { return {
...rest, ...item,
name,
id: name,
slug, slug,
active: false, active: false,
isUserAllowed: permissions[slug], isUserAllowed: permissions[slug],
...@@ -63,95 +53,11 @@ const mapToStage = (permissions, { name, ...rest }) => { ...@@ -63,95 +53,11 @@ const mapToStage = (permissions, { name, ...rest }) => {
}; };
const mapToSummary = ({ value, ...rest }) => ({ ...rest, value: value || '-' }); const mapToSummary = ({ value, ...rest }) => ({ ...rest, value: value || '-' });
const mapToMedians = ({ id, value }) => ({ id, value });
export const decorateData = (data = {}) => { export const decorateData = (data = {}) => {
const { permissions, stats, summary } = data; const { permissions, stats, summary } = data;
const stages = stats?.map((item) => mapToStage(permissions, item)) || [];
return { return {
stages, stages: stats?.map((item) => mapToStage(permissions, item)) || [],
summary: summary?.map((item) => mapToSummary(item)) || [], summary: summary?.map((item) => mapToSummary(item)) || [],
medians: stages?.map((item) => mapToMedians(item)) || [],
}; };
}; };
/**
* Takes the stages and median data, combined with the selected stage, to build an
* array which is formatted to proivde the data required for the path navigation.
*
* @param {Array} stages - The stages available to the group / project
* @param {Object} medians - The median values for the stages available to the group / project
* @param {Object} stageCounts - The total item count for the stages available
* @param {Object} selectedStage - The currently selected stage
* @returns {Array} An array of stages formatted with data required for the path navigation
*/
export const transformStagesForPathNavigation = ({
stages,
medians,
stageCounts = {},
selectedStage,
}) => {
const formattedStages = stages.map((stage) => {
return {
metric: medians[stage?.id],
selected: stage.id === selectedStage.id,
stageCount: stageCounts && stageCounts[stage?.id],
icon: null,
...stage,
};
});
return formattedStages;
};
export const timeSummaryForPathNavigation = ({ seconds, hours, days, minutes, weeks, months }) => {
if (months) {
return sprintf(s__('ValueStreamAnalytics|%{value}M'), {
value: roundToNearestHalf(months),
});
} else if (weeks) {
return sprintf(s__('ValueStreamAnalytics|%{value}w'), {
value: roundToNearestHalf(weeks),
});
} else if (days) {
return sprintf(s__('ValueStreamAnalytics|%{value}d'), {
value: roundToNearestHalf(days),
});
} else if (hours) {
return sprintf(s__('ValueStreamAnalytics|%{value}h'), { value: hours });
} else if (minutes) {
return sprintf(s__('ValueStreamAnalytics|%{value}m'), { value: minutes });
} else if (seconds) {
return unescape(sanitize(s__('ValueStreamAnalytics|&lt;1m'), { ALLOWED_TAGS: [] }));
}
return '-';
};
/**
* Takes a raw median value in seconds and converts it to a string representation
* ie. converts 172800 => 2d (2 days)
*
* @param {Number} Median - The number of seconds for the median calculation
* @returns {String} String representation ie 2w
*/
export const medianTimeToParsedSeconds = (value) =>
timeSummaryForPathNavigation({
...parseSeconds(value, { daysPerWeek: 7, hoursPerDay: 24 }),
seconds: value,
});
/**
* Takes the raw median value arrays and converts them into a useful object
* containing the string for display in the path navigation
* ie. converts [{ id: 'test', value: 172800 }] => { 'test': '2d' }
*
* @param {Array} Medians - Array of stage median objects, each contains a `id`, `value` and `error`
* @returns {Object} Returns key value pair with the stage name and its display median value
*/
export const formatMedianValues = (medians = []) =>
medians.reduce((acc, { id, value = 0 }) => {
return {
...acc,
[id]: value ? medianTimeToParsedSeconds(value) : '-',
};
}, {});
...@@ -30,12 +30,32 @@ ...@@ -30,12 +30,32 @@
.col-headers { .col-headers {
ul { ul {
@include clearfix;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
li { li {
display: inline-block;
float: left;
line-height: 50px; line-height: 50px;
width: 20%;
}
.stage-header {
width: 20.5%;
}
.median-header {
width: 19.5%;
}
.event-header {
width: 45%;
}
.total-time-header {
width: 15%;
} }
} }
...@@ -100,6 +120,7 @@ ...@@ -100,6 +120,7 @@
} }
li { li {
@include clearfix;
list-style-type: none; list-style-type: none;
} }
...@@ -148,6 +169,7 @@ ...@@ -148,6 +169,7 @@
.events-description { .events-description {
line-height: 65px; line-height: 65px;
padding: 0 $gl-padding;
} }
.events-info { .events-info {
...@@ -156,6 +178,7 @@ ...@@ -156,6 +178,7 @@
} }
.stage-events { .stage-events {
width: 60%;
min-height: 467px; min-height: 467px;
} }
...@@ -167,8 +190,8 @@ ...@@ -167,8 +190,8 @@
.stage-event-item { .stage-event-item {
@include clearfix; @include clearfix;
list-style-type: none; list-style-type: none;
padding-bottom: $gl-padding; padding: 0 0 $gl-padding;
margin-bottom: $gl-padding; margin: 0 $gl-padding $gl-padding;
border-bottom: 1px solid var(--gray-50, $gray-50); border-bottom: 1px solid var(--gray-50, $gray-50);
&:last-child { &:last-child {
......
...@@ -8,5 +8,9 @@ class AnalyticsStageEntity < Grape::Entity ...@@ -8,5 +8,9 @@ class AnalyticsStageEntity < Grape::Entity
expose :legend expose :legend
expose :description expose :description
expose :project_median, as: :value expose :project_median, as: :value do |stage|
# median returns a BatchLoader instance which we first have to unwrap by using to_f
# we use to_f to make sure results below 1 are presented to the end-user
stage.project_median.to_f.nonzero? ? distance_of_time_in_words(stage.project_median) : nil
end
end end
<script> <script>
import { GlEmptyState } from '@gitlab/ui'; import { GlEmptyState } from '@gitlab/ui';
import { mapActions, mapState, mapGetters } from 'vuex'; import { mapActions, mapState, mapGetters } from 'vuex';
import PathNavigation from '~/cycle_analytics/components/path_navigation.vue';
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 DateRange from '../../shared/components/daterange.vue'; import DateRange from '../../shared/components/daterange.vue';
import ProjectsDropdownFilter from '../../shared/components/projects_dropdown_filter.vue'; import ProjectsDropdownFilter from '../../shared/components/projects_dropdown_filter.vue';
import { DATE_RANGE_LIMIT } from '../../shared/constants'; import { DATE_RANGE_LIMIT } from '../../shared/constants';
import { toYmd } from '../../shared/utils'; import { toYmd } from '../../shared/utils';
import { PROJECTS_PER_PAGE } from '../constants'; import { PROJECTS_PER_PAGE, OVERVIEW_STAGE_ID } from '../constants';
import DurationChart from './duration_chart.vue'; import DurationChart from './duration_chart.vue';
import FilterBar from './filter_bar.vue'; import FilterBar from './filter_bar.vue';
import Metrics from './metrics.vue'; import Metrics from './metrics.vue';
import PathNavigation from './path_navigation.vue';
import StageTableNew from './stage_table_new.vue'; import StageTableNew from './stage_table_new.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';
...@@ -58,6 +57,7 @@ export default { ...@@ -58,6 +57,7 @@ export default {
'errorCode', 'errorCode',
'startDate', 'startDate',
'endDate', 'endDate',
'medians',
'isLoadingValueStreams', 'isLoadingValueStreams',
'selectedStageError', 'selectedStageError',
'selectedValueStream', 'selectedValueStream',
...@@ -167,6 +167,7 @@ export default { ...@@ -167,6 +167,7 @@ export default {
<div v-if="!shouldRenderEmptyState" class="gl-max-w-full"> <div v-if="!shouldRenderEmptyState" class="gl-max-w-full">
<path-navigation <path-navigation
v-if="selectedStageReady" v-if="selectedStageReady"
:key="`path_navigation_key_${pathNavigationData.length}`"
class="js-path-navigation gl-w-full gl-pb-2" class="js-path-navigation gl-w-full gl-pb-2"
:loading="isLoading" :loading="isLoading"
:stages="pathNavigationData" :stages="pathNavigationData"
......
...@@ -32,17 +32,12 @@ export default { ...@@ -32,17 +32,12 @@ export default {
required: false, required: false,
default: () => {}, default: () => {},
}, },
withStageCounts: {
type: Boolean,
required: false,
default: true,
},
}, },
methods: { methods: {
showPopover({ id }) { showPopover({ id }) {
return id && id !== OVERVIEW_STAGE_ID; return id && id !== OVERVIEW_STAGE_ID;
}, },
hasStageCount({ stageCount = null }) { hasStageCount({ stageCount }) {
return stageCount !== null; return stageCount !== null;
}, },
}, },
...@@ -72,7 +67,7 @@ export default { ...@@ -72,7 +67,7 @@ export default {
<div class="gl-pb-4 gl-font-weight-bold">{{ pathItem.metric }}</div> <div class="gl-pb-4 gl-font-weight-bold">{{ pathItem.metric }}</div>
</div> </div>
</div> </div>
<div v-if="withStageCounts" class="gl-px-4"> <div class="gl-px-4">
<div class="gl-display-flex gl-justify-content-space-between"> <div class="gl-display-flex gl-justify-content-space-between">
<div class="gl-pr-4 gl-pb-4"> <div class="gl-pr-4 gl-pb-4">
{{ s__('ValueStreamEvent|Items in stage') }} {{ s__('ValueStreamEvent|Items in stage') }}
......
import { OVERVIEW_STAGE_ID } from '~/cycle_analytics/constants';
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
export const PROJECTS_PER_PAGE = 50; export const PROJECTS_PER_PAGE = 50;
...@@ -53,6 +52,7 @@ export const OVERVIEW_METRICS = { ...@@ -53,6 +52,7 @@ export const OVERVIEW_METRICS = {
export const FETCH_VALUE_STREAM_DATA = 'fetchValueStreamData'; export const FETCH_VALUE_STREAM_DATA = 'fetchValueStreamData';
export const OVERVIEW_STAGE_ID = 'overview';
export const OVERVIEW_STAGE_CONFIG = { export const OVERVIEW_STAGE_CONFIG = {
id: OVERVIEW_STAGE_ID, id: OVERVIEW_STAGE_ID,
slug: OVERVIEW_STAGE_ID, slug: OVERVIEW_STAGE_ID,
......
import dateFormat from 'dateformat'; import dateFormat from 'dateformat';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
import { OVERVIEW_STAGE_ID } from '~/cycle_analytics/constants';
import {
filterStagesByHiddenStatus,
pathNavigationData as basePathNavigationData,
} from '~/cycle_analytics/store/getters';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import httpStatus from '~/lib/utils/http_status'; import httpStatus from '~/lib/utils/http_status';
import { filterToQueryObject } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; import { filterToQueryObject } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import { dateFormats } from '../../shared/constants'; import { dateFormats } from '../../shared/constants';
import { DEFAULT_VALUE_STREAM_ID, OVERVIEW_STAGE_CONFIG, PAGINATION_TYPE } from '../constants'; import {
DEFAULT_VALUE_STREAM_ID,
OVERVIEW_STAGE_CONFIG,
PAGINATION_TYPE,
OVERVIEW_STAGE_ID,
} from '../constants';
import { transformStagesForPathNavigation } from '../utils';
export const hasNoAccessError = (state) => state.errorCode === httpStatus.FORBIDDEN; export const hasNoAccessError = (state) => state.errorCode === httpStatus.FORBIDDEN;
...@@ -55,6 +56,9 @@ export const paginationParams = ({ pagination: { page, sort, direction } }) => ( ...@@ -55,6 +56,9 @@ export const paginationParams = ({ pagination: { page, sort, direction } }) => (
page, page,
}); });
const filterStagesByHiddenStatus = (stages = [], isHidden = true) =>
stages.filter(({ hidden = false }) => hidden === isHidden);
export const hiddenStages = ({ stages }) => filterStagesByHiddenStatus(stages); export const hiddenStages = ({ stages }) => filterStagesByHiddenStatus(stages);
export const activeStages = ({ stages }) => filterStagesByHiddenStatus(stages, false); export const activeStages = ({ stages }) => filterStagesByHiddenStatus(stages, false);
...@@ -74,8 +78,8 @@ export const isOverviewStageSelected = ({ selectedStage }) => ...@@ -74,8 +78,8 @@ export const isOverviewStageSelected = ({ selectedStage }) =>
* https://gitlab.com/gitlab-org/gitlab/-/issues/216227 * https://gitlab.com/gitlab-org/gitlab/-/issues/216227
*/ */
export const pathNavigationData = ({ stages, medians, stageCounts, selectedStage }) => export const pathNavigationData = ({ stages, medians, stageCounts, selectedStage }) =>
basePathNavigationData({ transformStagesForPathNavigation({
stages: [OVERVIEW_STAGE_CONFIG, ...stages], stages: [OVERVIEW_STAGE_CONFIG, ...filterStagesByHiddenStatus(stages, false)],
medians, medians,
stageCounts, stageCounts,
selectedStage, selectedStage,
......
import dateFormat from 'dateformat'; import dateFormat from 'dateformat';
import { isNumber } from 'lodash'; import { unescape, isNumber } from 'lodash';
import { OVERVIEW_STAGE_ID } from '~/cycle_analytics/constants';
import { medianTimeToParsedSeconds } from '~/cycle_analytics/utils';
import createFlash, { hideFlash } from '~/flash'; import createFlash, { hideFlash } from '~/flash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { sanitize } from '~/lib/dompurify';
import { newDate, dayAfter, secondsToDays, getDatesInRange } from '~/lib/utils/datetime_utility'; import { convertObjectPropsToCamelCase, roundToNearestHalf } from '~/lib/utils/common_utils';
import {
newDate,
dayAfter,
secondsToDays,
getDatesInRange,
parseSeconds,
} 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, slugify } from '~/lib/utils/text_utility';
import { s__, sprintf } from '~/locale';
import { dateFormats } from '../shared/constants'; import { dateFormats } from '../shared/constants';
import { toYmd } from '../shared/utils'; import { toYmd } from '../shared/utils';
import { OVERVIEW_STAGE_ID } from './constants';
const EVENT_TYPE_LABEL = 'label'; const EVENT_TYPE_LABEL = 'label';
...@@ -350,6 +357,42 @@ export const throwIfUserForbidden = (error) => { ...@@ -350,6 +357,42 @@ export const throwIfUserForbidden = (error) => {
} }
}; };
export const timeSummaryForPathNavigation = ({ seconds, hours, days, minutes, weeks, months }) => {
if (months) {
return sprintf(s__('ValueStreamAnalytics|%{value}M'), {
value: roundToNearestHalf(months),
});
} else if (weeks) {
return sprintf(s__('ValueStreamAnalytics|%{value}w'), {
value: roundToNearestHalf(weeks),
});
} else if (days) {
return sprintf(s__('ValueStreamAnalytics|%{value}d'), {
value: roundToNearestHalf(days),
});
} else if (hours) {
return sprintf(s__('ValueStreamAnalytics|%{value}h'), { value: hours });
} else if (minutes) {
return sprintf(s__('ValueStreamAnalytics|%{value}m'), { value: minutes });
} else if (seconds) {
return unescape(sanitize(s__('ValueStreamAnalytics|&lt;1m'), { ALLOWED_TAGS: [] }));
}
return '-';
};
/**
* Takes a raw median value in seconds and converts it to a string representation
* ie. converts 172800 => 2d (2 days)
*
* @param {Number} Median - The number of seconds for the median calculation
* @returns {String} String representation ie 2w
*/
export const medianTimeToParsedSeconds = (value) =>
timeSummaryForPathNavigation({
...parseSeconds(value, { daysPerWeek: 7, hoursPerDay: 24 }),
seconds: value,
});
/** /**
* Takes the raw median value arrays and converts them into a useful object * Takes the raw median value arrays and converts them into a useful object
* containing the string for display in the path navigation, additionally * containing the string for display in the path navigation, additionally
...@@ -379,6 +422,35 @@ export const formatMedianValuesWithOverview = (medians = []) => { ...@@ -379,6 +422,35 @@ export const formatMedianValuesWithOverview = (medians = []) => {
}; };
}; };
/**
* Takes the stages and median data, combined with the selected stage, to build an
* array which is formatted to proivde the data required for the path navigation.
*
* @param {Array} stages - The stages available to the group / project
* @param {Object} medians - The median values for the stages available to the group / project
* @param {Object} stageCounts - The total item count for the stages available
* @param {Object} selectedStage - The currently selected stage
* @returns {Array} An array of stages formatted with data required for the path navigation
*/
export const transformStagesForPathNavigation = ({
stages,
medians,
stageCounts,
selectedStage,
}) => {
const formattedStages = stages.map((stage) => {
return {
metric: medians[stage?.id],
selected: stage.id === selectedStage.id,
stageCount: stageCounts[stage?.id],
icon: null,
...stage,
};
});
return formattedStages;
};
/** /**
* @typedef {Object} MetricData * @typedef {Object} MetricData
* @property {String} title - Title of the metric measured * @property {String} title - Title of the metric measured
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PathNavigation displays correctly loading is false matches the snapshot 1`] = `
<div
class="gl-path-nav"
data-testid="gl-path-nav"
style="--path-bg-color: white;"
>
<span
class="gl-path-fade gl-path-fade-left"
style="display: none;"
>
<button
aria-label="Scroll left"
class="gl-clear-icon-button"
>
<svg
aria-hidden="true"
class="gl-icon s32"
data-testid="chevron-left-icon"
role="img"
>
<use
href="#chevron-left"
/>
</svg>
</button>
</span>
<ul
class="gl-path-nav-list"
>
<li
class="gl-path-nav-list-item"
id="path-6-item-0"
>
<button
class="gl-path-button"
>
<svg
aria-hidden="true"
class="gl-mr-2 gl-icon s16"
data-testid="gl-path-item-icon"
role="img"
>
<use
href="#home"
/>
</svg>
Overview
<!---->
</button>
</li>
<li
class="gl-path-nav-list-item"
id="path-6-item-1"
>
<button
class="gl-path-button gl-path-active-item-indigo"
>
<!---->
Issue
<!---->
</button>
<div
class="gl-popover"
data-testid="stage-item-popover"
>
<div
class="gl-px-4"
>
<div
class="gl-display-flex gl-justify-content-space-between"
>
<div
class="gl-pr-4 gl-pb-4"
>
Stage time (median)
</div>
<div
class="gl-pb-4 gl-font-weight-bold"
>
</div>
</div>
</div>
<div
class="gl-px-4"
>
<div
class="gl-display-flex gl-justify-content-space-between"
>
<div
class="gl-pr-4 gl-pb-4"
>
Items in stage
</div>
<div
class="gl-pb-4 gl-font-weight-bold"
>
172800 items
</div>
</div>
</div>
<div
class="gl-px-4 gl-pt-4 gl-border-t-1 gl-border-t-solid gl-border-gray-50"
>
<div
class="gl-display-flex gl-flex-direction-row"
>
<div
class="gl-display-flex gl-flex-direction-column gl-pr-4 gl-pb-4 metric-label"
>
Start
</div>
<div
class="gl-display-flex gl-flex-direction-column gl-pb-4 stage-event-description"
>
<p
data-sourcepos="1:1-1:13"
dir="auto"
>
Issue created
</p>
</div>
</div>
<div
class="gl-display-flex gl-flex-direction-row"
>
<div
class="gl-display-flex gl-flex-direction-column gl-pr-4 metric-label"
>
Stop
</div>
<div
class="gl-display-flex gl-flex-direction-column stage-event-description"
>
<p
data-sourcepos="1:1-1:71"
dir="auto"
>
Issue first associated with a milestone or issue first added to a board
</p>
</div>
</div>
</div>
Issue
</div>
</li>
<li
class="gl-path-nav-list-item"
id="path-6-item-2"
>
<button
class="gl-path-button"
>
<!---->
Plan
<!---->
</button>
<div
class="gl-popover"
data-testid="stage-item-popover"
>
<div
class="gl-px-4"
>
<div
class="gl-display-flex gl-justify-content-space-between"
>
<div
class="gl-pr-4 gl-pb-4"
>
Stage time (median)
</div>
<div
class="gl-pb-4 gl-font-weight-bold"
>
</div>
</div>
</div>
<div
class="gl-px-4"
>
<div
class="gl-display-flex gl-justify-content-space-between"
>
<div
class="gl-pr-4 gl-pb-4"
>
Items in stage
</div>
<div
class="gl-pb-4 gl-font-weight-bold"
>
86400 items
</div>
</div>
</div>
<div
class="gl-px-4 gl-pt-4 gl-border-t-1 gl-border-t-solid gl-border-gray-50"
>
<div
class="gl-display-flex gl-flex-direction-row"
>
<div
class="gl-display-flex gl-flex-direction-column gl-pr-4 gl-pb-4 metric-label"
>
Start
</div>
<div
class="gl-display-flex gl-flex-direction-column gl-pb-4 stage-event-description"
>
<p
data-sourcepos="1:1-1:71"
dir="auto"
>
Issue first associated with a milestone or issue first added to a board
</p>
</div>
</div>
<div
class="gl-display-flex gl-flex-direction-row"
>
<div
class="gl-display-flex gl-flex-direction-column gl-pr-4 metric-label"
>
Stop
</div>
<div
class="gl-display-flex gl-flex-direction-column stage-event-description"
>
<p
data-sourcepos="1:1-1:33"
dir="auto"
>
Issue first mentioned in a commit
</p>
</div>
</div>
</div>
Plan
</div>
</li>
<li
class="gl-path-nav-list-item"
id="path-6-item-3"
>
<button
class="gl-path-button"
>
<!---->
Code
<!---->
</button>
<div
class="gl-popover"
data-testid="stage-item-popover"
>
<div
class="gl-px-4"
>
<div
class="gl-display-flex gl-justify-content-space-between"
>
<div
class="gl-pr-4 gl-pb-4"
>
Stage time (median)
</div>
<div
class="gl-pb-4 gl-font-weight-bold"
>
</div>
</div>
</div>
<div
class="gl-px-4"
>
<div
class="gl-display-flex gl-justify-content-space-between"
>
<div
class="gl-pr-4 gl-pb-4"
>
Items in stage
</div>
<div
class="gl-pb-4 gl-font-weight-bold"
>
129600 items
</div>
</div>
</div>
<div
class="gl-px-4 gl-pt-4 gl-border-t-1 gl-border-t-solid gl-border-gray-50"
>
<div
class="gl-display-flex gl-flex-direction-row"
>
<div
class="gl-display-flex gl-flex-direction-column gl-pr-4 gl-pb-4 metric-label"
>
Start
</div>
<div
class="gl-display-flex gl-flex-direction-column gl-pb-4 stage-event-description"
>
<p
data-sourcepos="1:1-1:33"
dir="auto"
>
Issue first mentioned in a commit
</p>
</div>
</div>
<div
class="gl-display-flex gl-flex-direction-row"
>
<div
class="gl-display-flex gl-flex-direction-column gl-pr-4 metric-label"
>
Stop
</div>
<div
class="gl-display-flex gl-flex-direction-column stage-event-description"
>
<p
data-sourcepos="1:1-1:21"
dir="auto"
>
Merge request created
</p>
</div>
</div>
</div>
Code
</div>
</li>
</ul>
<span
class="gl-path-fade gl-path-fade-right"
style="display: none;"
>
<button
aria-label="Scroll right"
class="gl-clear-icon-button"
>
<svg
aria-hidden="true"
class="gl-icon s32"
data-testid="chevron-right-icon"
role="img"
>
<use
href="#chevron-right"
/>
</svg>
</button>
</span>
</div>
`;
...@@ -7,6 +7,7 @@ import Component from 'ee/analytics/cycle_analytics/components/base.vue'; ...@@ -7,6 +7,7 @@ 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 FilterBar from 'ee/analytics/cycle_analytics/components/filter_bar.vue'; import FilterBar from 'ee/analytics/cycle_analytics/components/filter_bar.vue';
import Metrics from 'ee/analytics/cycle_analytics/components/metrics.vue'; import Metrics from 'ee/analytics/cycle_analytics/components/metrics.vue';
import PathNavigation from 'ee/analytics/cycle_analytics/components/path_navigation.vue';
import StageTableNew from 'ee/analytics/cycle_analytics/components/stage_table_new.vue'; import StageTableNew from 'ee/analytics/cycle_analytics/components/stage_table_new.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';
...@@ -19,7 +20,6 @@ import Daterange from 'ee/analytics/shared/components/daterange.vue'; ...@@ -19,7 +20,6 @@ import Daterange from 'ee/analytics/shared/components/daterange.vue';
import ProjectsDropdownFilter from 'ee/analytics/shared/components/projects_dropdown_filter.vue'; import ProjectsDropdownFilter from 'ee/analytics/shared/components/projects_dropdown_filter.vue';
import { toYmd } from 'ee/analytics/shared/utils'; import { toYmd } from 'ee/analytics/shared/utils';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import PathNavigation from '~/cycle_analytics/components/path_navigation.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import * as commonUtils from '~/lib/utils/common_utils'; import * as commonUtils from '~/lib/utils/common_utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
...@@ -101,7 +101,7 @@ async function shouldMergeUrlParams(wrapper, result) { ...@@ -101,7 +101,7 @@ async function shouldMergeUrlParams(wrapper, result) {
expect(commonUtils.historyPushState).toHaveBeenCalled(); expect(commonUtils.historyPushState).toHaveBeenCalled();
} }
describe('EE Value Stream Analytics component', () => { describe('Value Stream Analytics component', () => {
let wrapper; let wrapper;
let mock; let mock;
let store; let store;
......
import { GlPath, GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import Component from 'ee/analytics/cycle_analytics/components/path_navigation.vue';
import Component from '~/cycle_analytics/components/path_navigation.vue';
import { OVERVIEW_STAGE_ID } from '~/cycle_analytics/constants';
import { transformedStagePathData, issueStage } from '../mock_data'; import { transformedStagePathData, issueStage } from '../mock_data';
describe('Group PathNavigation', () => { describe('PathNavigation', () => {
let wrapper = null; let wrapper = null;
const createComponent = (props) => { const createComponent = (props) => {
return extendedWrapper( return mount(Component, {
mount(Component, { propsData: {
propsData: { stages: transformedStagePathData,
stages: transformedStagePathData, selectedStage: issueStage,
selectedStage: issueStage, loading: false,
loading: false, ...props,
...props, },
}, });
}),
);
}; };
const pathNavigationItems = () => { const pathNavigationTitles = () => {
return wrapper.findByTestId('gl-path-nav').findAll('li'); return wrapper.findAll('.gl-path-button');
}; };
const pathItemContent = () => pathNavigationItems().wrappers; const pathNavigationItems = () => {
return wrapper.findAll('.gl-path-nav-list-item');
};
const stagesWithCounts = transformedStagePathData.filter( const clickItemAt = (index) => {
(stage) => stage.id !== OVERVIEW_STAGE_ID, pathNavigationTitles().at(index).trigger('click');
); };
beforeEach(() => { beforeEach(() => {
wrapper = createComponent(); wrapper = createComponent();
...@@ -39,47 +38,99 @@ describe('Group PathNavigation', () => { ...@@ -39,47 +38,99 @@ describe('Group PathNavigation', () => {
wrapper = null; wrapper = null;
}); });
describe('popovers', () => { describe('displays correctly', () => {
beforeEach(() => { it('has the correct props', () => {
wrapper = createComponent({ stages: transformedStagePathData }); expect(wrapper.find(GlPath).props('items')).toMatchObject(transformedStagePathData);
}); });
it('renders popovers for all stages except for the overview stage', () => { it('contains all the expected stages', () => {
const [overviewStage, ...popoverStages] = pathItemContent(); const html = wrapper.find(GlPath).html();
expect(overviewStage.text()).toContain('Overview');
expect(overviewStage.find('[data-testid="stage-item-popover"]').exists()).toBe(false);
popoverStages.forEach((stage) => { transformedStagePathData.forEach((stage) => {
expect(stage.find('[data-testid="stage-item-popover"]').exists()).toBe(true); expect(html).toContain(stage.title);
}); });
}); });
it('shows the sanitized start event description for the first stage item', () => { describe('loading', () => {
const firstPopover = wrapper.findAll('[data-testid="stage-item-popover"]').at(0); describe('is false', () => {
const expectedStartEventDescription = 'Issue created'; it('displays the gl-path component', () => {
expect(firstPopover.text()).toContain(expectedStartEventDescription); expect(wrapper.find(GlPath).exists()).toBe(true);
}); });
it('shows the sanitized end event description for the first stage item', () => { it('hides the gl-skeleton-loading component', () => {
const firstPopover = wrapper.findAll('[data-testid="stage-item-popover"]').at(0); expect(wrapper.find(GlSkeletonLoading).exists()).toBe(false);
const expectedStartEventDescription = });
'Issue first associated with a milestone or issue first added to a board';
expect(firstPopover.text()).toContain(expectedStartEventDescription);
});
it('shows the median stage time for the first stage item', () => { it('matches the snapshot', () => {
const firstPopover = wrapper.findAll('[data-testid="stage-item-popover"]').at(0); expect(wrapper.element).toMatchSnapshot();
expect(firstPopover.text()).toContain('Stage time (median)'); });
});
describe('popovers', () => {
beforeEach(() => {
wrapper = createComponent({ stages: transformedStagePathData });
});
it('renders popovers for all stages except for the overview stage', () => {
const pathItemContent = pathNavigationItems().wrappers;
const [overviewStage, ...popoverStages] = pathItemContent;
expect(overviewStage.text()).toContain('Overview');
expect(overviewStage.find('[data-testid="stage-item-popover"]').exists()).toBe(false);
it('renders each stage with its stage count', () => { popoverStages.forEach((stage) => {
const popoverStages = pathItemContent().slice(1); // skip the first stage, the overview does not have a popover expect(stage.find('[data-testid="stage-item-popover"]').exists()).toBe(true);
popoverStages.forEach((stage, index) => { });
const content = stage.find('[data-testid="stage-item-popover"]').html(); });
expect(content).toContain('Items in stage');
expect(content).toContain(`${stagesWithCounts[index].stageCount} items`); it('shows the sanitized start event description for the first stage item', () => {
const firstPopover = wrapper.findAll('[data-testid="stage-item-popover"]').at(0);
const expectedStartEventDescription = 'Issue created';
expect(firstPopover.text()).toContain(expectedStartEventDescription);
});
it('shows the sanitized end event description for the first stage item', () => {
const firstPopover = wrapper.findAll('[data-testid="stage-item-popover"]').at(0);
const expectedStartEventDescription =
'Issue first associated with a milestone or issue first added to a board';
expect(firstPopover.text()).toContain(expectedStartEventDescription);
});
it('shows the median stage time for the first stage item', () => {
const firstPopover = wrapper.findAll('[data-testid="stage-item-popover"]').at(0);
expect(firstPopover.text()).toContain('Stage time (median)');
});
});
});
describe('is true', () => {
beforeEach(() => {
wrapper = createComponent({ loading: true });
});
it('hides the gl-path component', () => {
expect(wrapper.find(GlPath).exists()).toBe(false);
});
it('displays the gl-skeleton-loading component', () => {
expect(wrapper.find(GlSkeletonLoading).exists()).toBe(true);
});
}); });
}); });
}); });
describe('event handling', () => {
it('emits the selected event', () => {
expect(wrapper.emitted('selected')).toBeUndefined();
clickItemAt(0);
clickItemAt(1);
clickItemAt(2);
expect(wrapper.emitted().selected).toEqual([
[transformedStagePathData[0]],
[transformedStagePathData[1]],
[transformedStagePathData[2]],
]);
});
});
}); });
...@@ -12,20 +12,24 @@ import mutations from 'ee/analytics/cycle_analytics/store/mutations'; ...@@ -12,20 +12,24 @@ import mutations from 'ee/analytics/cycle_analytics/store/mutations';
import { import {
getTasksByTypeData, getTasksByTypeData,
transformRawTasksByTypeData, transformRawTasksByTypeData,
transformStagesForPathNavigation,
} from 'ee/analytics/cycle_analytics/utils'; } from 'ee/analytics/cycle_analytics/utils';
import { toYmd } from 'ee/analytics/shared/utils'; import { toYmd } from 'ee/analytics/shared/utils';
import { getJSONFixture } from 'helpers/fixtures'; import { getJSONFixture } from 'helpers/fixtures';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import {
getStageByTitle,
defaultStages,
rawStageMedians,
fixtureEndpoints,
} from 'jest/cycle_analytics/mock_data';
import { transformStagesForPathNavigation } from '~/cycle_analytics/utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { getDateInPast, getDatesInRange } from '~/lib/utils/datetime_utility'; import { getDateInPast, getDatesInRange } from '~/lib/utils/datetime_utility';
const fixtureEndpoints = {
customizableCycleAnalyticsStagesAndEvents: 'analytics/value_stream_analytics/stages.json', // customizable stages and events endpoint
stageEvents: (stage) => `analytics/value_stream_analytics/stages/${stage}/records.json`,
stageMedian: (stage) => `analytics/value_stream_analytics/stages/${stage}/median.json`,
stageCount: (stage) => `analytics/value_stream_analytics/stages/${stage}/count.json`,
recentActivityData: 'analytics/metrics/value_stream_analytics/summary.json',
timeMetricsData: 'analytics/metrics/value_stream_analytics/time_summary.json',
groupLabels: 'api/group_labels.json',
};
export const endpoints = { export const endpoints = {
groupLabels: /groups\/[A-Z|a-z|\d|\-|_]+\/-\/labels.json/, groupLabels: /groups\/[A-Z|a-z|\d|\-|_]+\/-\/labels.json/,
recentActivityData: /analytics\/value_stream_analytics\/summary/, recentActivityData: /analytics\/value_stream_analytics\/summary/,
...@@ -59,6 +63,9 @@ export const group = { ...@@ -59,6 +63,9 @@ export const group = {
export const currentGroup = convertObjectPropsToCamelCase(group, { deep: true }); export const currentGroup = convertObjectPropsToCamelCase(group, { deep: true });
const getStageByTitle = (stages, title) =>
stages.find((stage) => stage.title && stage.title.toLowerCase().trim() === title) || {};
export const recentActivityData = getJSONFixture(fixtureEndpoints.recentActivityData); export const recentActivityData = getJSONFixture(fixtureEndpoints.recentActivityData);
export const timeMetricsData = getJSONFixture(fixtureEndpoints.timeMetricsData); export const timeMetricsData = getJSONFixture(fixtureEndpoints.timeMetricsData);
...@@ -106,6 +113,8 @@ export const allowedStages = [issueStage, planStage, codeStage]; ...@@ -106,6 +113,8 @@ export const allowedStages = [issueStage, planStage, codeStage];
const deepCamelCase = (obj) => convertObjectPropsToCamelCase(obj, { deep: true }); const deepCamelCase = (obj) => convertObjectPropsToCamelCase(obj, { deep: true });
export const defaultStages = ['issue', 'plan', 'review', 'code', 'test', 'staging'];
const stageFixtures = defaultStages.reduce((acc, stage) => { const stageFixtures = defaultStages.reduce((acc, stage) => {
const events = getJSONFixture(fixtureEndpoints.stageEvents(stage)); const events = getJSONFixture(fixtureEndpoints.stageEvents(stage));
return { return {
...@@ -114,6 +123,11 @@ const stageFixtures = defaultStages.reduce((acc, stage) => { ...@@ -114,6 +123,11 @@ const stageFixtures = defaultStages.reduce((acc, stage) => {
}; };
}, {}); }, {});
export const rawStageMedians = defaultStages.map((id) => ({
id,
...getJSONFixture(fixtureEndpoints.stageMedian(id)),
}));
export const stageMedians = rawStageMedians.reduce( export const stageMedians = rawStageMedians.reduce(
(acc, { id, value }) => ({ (acc, { id, value }) => ({
...acc, ...acc,
...@@ -309,6 +323,8 @@ export const selectedProjects = [ ...@@ -309,6 +323,8 @@ export const selectedProjects = [
}, },
]; ];
export const pathNavIssueMetric = 172800;
export const initialPaginationState = { export const initialPaginationState = {
page: null, page: null,
hasNextPage: false, hasNextPage: false,
......
...@@ -529,7 +529,10 @@ describe('Value Stream Analytics actions', () => { ...@@ -529,7 +529,10 @@ describe('Value Stream Analytics actions', () => {
...state, ...state,
stages, stages,
currentGroup, currentGroup,
featureFlags: state.featureFlags, featureFlags: {
...state.featureFlags,
hasPathNavigation: true,
},
}; };
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
mock.onGet(endpoints.stageCount).reply(httpStatusCodes.OK, { events: [] }); mock.onGet(endpoints.stageCount).reply(httpStatusCodes.OK, { events: [] });
......
...@@ -13,12 +13,12 @@ import { ...@@ -13,12 +13,12 @@ import {
endDate, endDate,
allowedStages, allowedStages,
selectedProjects, selectedProjects,
transformedStagePathData,
issueStage, issueStage,
stageMedians, stageMedians,
stageCounts, stageCounts,
basePaginationResult, basePaginationResult,
initialPaginationState, initialPaginationState,
transformedStagePathData,
} from '../mock_data'; } from '../mock_data';
let state = null; let state = null;
......
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
import { OVERVIEW_STAGE_ID } from 'ee/analytics/cycle_analytics/constants';
import { import {
isStartEvent, isStartEvent,
isLabelEvent, isLabelEvent,
...@@ -14,14 +15,14 @@ import { ...@@ -14,14 +15,14 @@ import {
flattenTaskByTypeSeries, flattenTaskByTypeSeries,
orderByDate, orderByDate,
toggleSelectedLabel, toggleSelectedLabel,
transformStagesForPathNavigation,
prepareTimeMetricsData, prepareTimeMetricsData,
prepareStageErrors, prepareStageErrors,
timeSummaryForPathNavigation,
formatMedianValuesWithOverview, formatMedianValuesWithOverview,
medianTimeToParsedSeconds,
} from 'ee/analytics/cycle_analytics/utils'; } from 'ee/analytics/cycle_analytics/utils';
import { toYmd } from 'ee/analytics/shared/utils'; import { toYmd } from 'ee/analytics/shared/utils';
import { rawStageMedians } from 'jest/cycle_analytics/mock_data';
import { OVERVIEW_STAGE_ID } from '~/cycle_analytics/constants';
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 { slugify } from '~/lib/utils/text_utility';
import { import {
...@@ -37,7 +38,12 @@ import { ...@@ -37,7 +38,12 @@ import {
issueStage, issueStage,
rawCustomStage, rawCustomStage,
rawTasksByTypeData, rawTasksByTypeData,
allowedStages,
stageMediansWithNumericIds,
pathNavIssueMetric,
timeMetricsData, timeMetricsData,
rawStageMedians,
stageCounts,
} from './mock_data'; } from './mock_data';
const labelEventIds = labelEvents.map((ev) => ev.identifier); const labelEventIds = labelEvents.map((ev) => ev.identifier);
...@@ -335,6 +341,35 @@ describe('Value Stream Analytics utils', () => { ...@@ -335,6 +341,35 @@ describe('Value Stream Analytics utils', () => {
}); });
}); });
describe('transformStagesForPathNavigation', () => {
const stages = allowedStages;
const response = transformStagesForPathNavigation({
stages,
medians: stageMediansWithNumericIds,
selectedStage: issueStage,
stageCounts,
});
describe('transforms the data as expected', () => {
it('returns an array of stages', () => {
expect(Array.isArray(response)).toBe(true);
expect(response.length).toEqual(stages.length);
});
it('selects the correct stage', () => {
const selected = response.filter((stage) => stage.selected === true)[0];
expect(selected.title).toEqual(issueStage.title);
});
it('includes the correct metric for the associated stage', () => {
const issue = response.filter((stage) => stage.name === 'Issue')[0];
expect(issue.metric).toEqual(pathNavIssueMetric);
});
});
});
describe('prepareTimeMetricsData', () => { describe('prepareTimeMetricsData', () => {
let prepared; let prepared;
const [{ title: firstTitle }, { title: secondTitle }] = timeMetricsData; const [{ title: firstTitle }, { title: secondTitle }] = timeMetricsData;
...@@ -363,6 +398,37 @@ describe('Value Stream Analytics utils', () => { ...@@ -363,6 +398,37 @@ describe('Value Stream Analytics utils', () => {
}); });
}); });
describe('timeSummaryForPathNavigation', () => {
it.each`
unit | value | result
${'months'} | ${1.5} | ${'1.5M'}
${'weeks'} | ${1.25} | ${'1.5w'}
${'days'} | ${2} | ${'2d'}
${'hours'} | ${10} | ${'10h'}
${'minutes'} | ${20} | ${'20m'}
${'seconds'} | ${10} | ${'<1m'}
${'seconds'} | ${0} | ${'-'}
`('will format $value $unit to $result', ({ unit, value, result }) => {
expect(timeSummaryForPathNavigation({ [unit]: value })).toEqual(result);
});
});
describe('medianTimeToParsedSeconds', () => {
it.each`
value | result
${1036800} | ${'1w'}
${259200} | ${'3d'}
${172800} | ${'2d'}
${86400} | ${'1d'}
${1000} | ${'16m'}
${61} | ${'1m'}
${59} | ${'<1m'}
${0} | ${'-'}
`('will correctly parse $value seconds into $result', ({ value, result }) => {
expect(medianTimeToParsedSeconds(value)).toEqual(result);
});
});
describe('formatMedianValuesWithOverview', () => { describe('formatMedianValuesWithOverview', () => {
const calculatedMedians = formatMedianValuesWithOverview(rawStageMedians); const calculatedMedians = formatMedianValuesWithOverview(rawStageMedians);
......
...@@ -20391,6 +20391,9 @@ msgstr "" ...@@ -20391,6 +20391,9 @@ msgstr ""
msgid "Measured in bytes of code. Excludes generated and vendored code." msgid "Measured in bytes of code. Excludes generated and vendored code."
msgstr "" msgstr ""
msgid "Median"
msgstr ""
msgid "Medium Timeout Period" msgid "Medium Timeout Period"
msgstr "" msgstr ""
...@@ -25599,6 +25602,9 @@ msgstr "" ...@@ -25599,6 +25602,9 @@ msgstr ""
msgid "ProjectLastActivity|Never" msgid "ProjectLastActivity|Never"
msgstr "" msgstr ""
msgid "ProjectLifecycle|Stage"
msgstr ""
msgid "ProjectOverview|Fork" msgid "ProjectOverview|Fork"
msgstr "" msgstr ""
...@@ -32569,6 +32575,9 @@ msgstr "" ...@@ -32569,6 +32575,9 @@ msgstr ""
msgid "The password for your GitLab account on %{link_to_gitlab} has successfully been changed." msgid "The password for your GitLab account on %{link_to_gitlab} has successfully been changed."
msgstr "" msgstr ""
msgid "The phase of the development lifecycle."
msgstr ""
msgid "The pipeline has been deleted" msgid "The pipeline has been deleted"
msgstr "" msgstr ""
...@@ -32713,6 +32722,9 @@ msgstr "" ...@@ -32713,6 +32722,9 @@ msgstr ""
msgid "The username for the Jenkins server." msgid "The username for the Jenkins server."
msgstr "" msgstr ""
msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."
msgstr ""
msgid "The value of the provided variable exceeds the %{count} character limit" msgid "The value of the provided variable exceeds the %{count} character limit"
msgstr "" msgstr ""
......
...@@ -32,7 +32,7 @@ RSpec.describe 'Value Stream Analytics', :js do ...@@ -32,7 +32,7 @@ RSpec.describe 'Value Stream Analytics', :js do
end end
it 'shows active stage with empty message' do it 'shows active stage with empty message' do
expect(page).to have_selector('.gl-path-active-item-indigo', text: 'Issue') expect(page).to have_selector('.stage-nav-item.active', text: 'Issue')
expect(page).to have_content("We don't have enough data to show this stage.") expect(page).to have_content("We don't have enough data to show this stage.")
end end
end end
...@@ -171,7 +171,7 @@ RSpec.describe 'Value Stream Analytics', :js do ...@@ -171,7 +171,7 @@ RSpec.describe 'Value Stream Analytics', :js do
end end
def click_stage(stage_name) def click_stage(stage_name)
find('.gl-path-nav-list-item', text: stage_name).click find('.stage-nav li', text: stage_name).click
wait_for_requests wait_for_requests
end end
end end
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import Component from '~/cycle_analytics/components/base.vue';
import PathNavigation from '~/cycle_analytics/components/path_navigation.vue';
import createStore from '~/cycle_analytics/store';
const noDataSvgPath = 'path/to/no/data';
const noAccessSvgPath = 'path/to/no/access';
const localVue = createLocalVue();
localVue.use(Vuex);
let wrapper;
function createComponent() {
const store = createStore();
return extendedWrapper(
shallowMount(Component, {
localVue,
store,
propsData: {
noDataSvgPath,
noAccessSvgPath,
},
}),
);
}
const findPathNavigation = () => wrapper.findComponent(PathNavigation);
const findOverviewMetrics = () => wrapper.findByTestId('vsa-stage-overview-metrics');
const findStageTable = () => wrapper.findByTestId('vsa-stage-table');
describe('Value stream analytics component', () => {
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('renders the path navigation component', () => {
expect(findPathNavigation().exists()).toBe(true);
});
it('renders the overview metrics', () => {
expect(findOverviewMetrics().exists()).toBe(true);
});
it('renders the stage table', () => {
expect(findStageTable().exists()).toBe(true);
});
});
import { getJSONFixture } from 'helpers/fixtures';
import { transformStagesForPathNavigation } from '~/cycle_analytics/utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
export const fixtureEndpoints = {
customizableCycleAnalyticsStagesAndEvents: 'analytics/value_stream_analytics/stages.json', // customizable stages and events endpoint
stageEvents: (stage) => `analytics/value_stream_analytics/stages/${stage}/records.json`,
stageMedian: (stage) => `analytics/value_stream_analytics/stages/${stage}/median.json`,
stageCount: (stage) => `analytics/value_stream_analytics/stages/${stage}/count.json`,
recentActivityData: 'analytics/metrics/value_stream_analytics/summary.json',
timeMetricsData: 'analytics/metrics/value_stream_analytics/time_summary.json',
groupLabels: 'api/group_labels.json',
};
export const getStageByTitle = (stages, title) =>
stages.find((stage) => stage.title && stage.title.toLowerCase().trim() === title) || {};
export const defaultStages = ['issue', 'plan', 'review', 'code', 'test', 'staging'];
export const rawStageMedians = defaultStages.map((id) => ({
id,
...getJSONFixture(fixtureEndpoints.stageMedian(id)),
}));
export const summary = [ export const summary = [
{ value: '20', title: 'New Issues' }, { value: '20', title: 'New Issues' },
{ value: null, title: 'Commits' }, { value: null, title: 'Commits' },
...@@ -29,7 +8,6 @@ export const summary = [ ...@@ -29,7 +8,6 @@ export const summary = [
]; ];
const issueStage = { const issueStage = {
id: 'issue',
title: 'Issue', title: 'Issue',
name: 'issue', name: 'issue',
legend: '', legend: '',
...@@ -38,34 +16,30 @@ const issueStage = { ...@@ -38,34 +16,30 @@ const issueStage = {
}; };
const planStage = { const planStage = {
id: 'plan',
title: 'Plan', title: 'Plan',
name: 'plan', name: 'plan',
legend: '', legend: '',
description: 'Time before an issue starts implementation', description: 'Time before an issue starts implementation',
value: 75600, value: 'about 21 hours',
}; };
const codeStage = { const codeStage = {
id: 'code',
title: 'Code', title: 'Code',
name: 'code', name: 'code',
legend: '', legend: '',
description: 'Time until first merge request', description: 'Time until first merge request',
value: 172800, value: '2 days',
}; };
const testStage = { const testStage = {
id: 'test',
title: 'Test', title: 'Test',
name: 'test', name: 'test',
legend: '', legend: '',
description: 'Total test time for all commits/merges', description: 'Total test time for all commits/merges',
value: 17550, value: 'about 5 hours',
}; };
const reviewStage = { const reviewStage = {
id: 'review',
title: 'Review', title: 'Review',
name: 'review', name: 'review',
legend: '', legend: '',
...@@ -74,12 +48,11 @@ const reviewStage = { ...@@ -74,12 +48,11 @@ const reviewStage = {
}; };
const stagingStage = { const stagingStage = {
id: 'staging',
title: 'Staging', title: 'Staging',
name: 'staging', name: 'staging',
legend: '', legend: '',
description: 'From merge request merge until deploy to production', description: 'From merge request merge until deploy to production',
value: 172800, value: '2 days',
}; };
export const selectedStage = { export const selectedStage = {
...@@ -211,29 +184,3 @@ export const rawEvents = [ ...@@ -211,29 +184,3 @@ export const rawEvents = [
export const convertedEvents = rawEvents.map((ev) => export const convertedEvents = rawEvents.map((ev) =>
convertObjectPropsToCamelCase(ev, { deep: true }), convertObjectPropsToCamelCase(ev, { deep: true }),
); );
export const pathNavIssueMetric = 172800;
export const stageMediansWithNumericIds = rawStageMedians.reduce((acc, { id, value }) => {
const { id: stageId } = getStageByTitle(convertedData.stages, id);
return {
...acc,
[stageId]: value,
};
}, {});
export const stageMedians = rawStageMedians.reduce(
(acc, { id, value }) => ({
...acc,
[id]: value,
}),
{},
);
export const allowedStages = [issueStage, planStage, codeStage];
export const transformedProjectStagePathData = transformStagesForPathNavigation({
stages: allowedStages,
medians: stageMedians,
selectedStage: issueStage,
});
import { GlPath, GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import Component from '~/cycle_analytics/components/path_navigation.vue';
import { transformedProjectStagePathData, selectedStage } from './mock_data';
describe('Project PathNavigation', () => {
let wrapper = null;
const createComponent = (props) => {
return extendedWrapper(
mount(Component, {
propsData: {
stages: transformedProjectStagePathData,
selectedStage,
loading: false,
...props,
},
}),
);
};
const findPathNavigation = () => {
return wrapper.findByTestId('gl-path-nav');
};
const findPathNavigationItems = () => {
return findPathNavigation().findAll('li');
};
const findPathNavigationTitles = () => {
return findPathNavigation()
.findAll('li button')
.wrappers.map((w) => w.html());
};
const clickItemAt = (index) => {
findPathNavigationItems().at(index).find('button').trigger('click');
};
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('displays correctly', () => {
it('has the correct props', () => {
expect(wrapper.find(GlPath).props('items')).toMatchObject(transformedProjectStagePathData);
});
it('contains all the expected stages', () => {
const html = wrapper.find(GlPath).html();
transformedProjectStagePathData.forEach((stage) => {
expect(html).toContain(stage.title);
});
});
describe('loading', () => {
describe('is false', () => {
it('displays the gl-path component', () => {
expect(wrapper.find(GlPath).exists()).toBe(true);
});
it('hides the gl-skeleton-loading component', () => {
expect(wrapper.find(GlSkeletonLoading).exists()).toBe(false);
});
it('renders each stage', () => {
const result = findPathNavigationTitles();
expect(result.length).toBe(transformedProjectStagePathData.length);
});
it('renders each stage with its median', () => {
const result = findPathNavigationTitles();
transformedProjectStagePathData.forEach(({ title, metric }, index) => {
expect(result[index]).toContain(title);
expect(result[index]).toContain(metric);
});
});
describe('popovers', () => {
beforeEach(() => {
wrapper = createComponent({ stages: transformedProjectStagePathData });
});
it('renders popovers for all stages', () => {
const pathItemContent = findPathNavigationItems().wrappers;
pathItemContent.forEach((stage) => {
expect(stage.find('[data-testid="stage-item-popover"]').exists()).toBe(true);
});
});
it('shows the median stage time for the first stage item', () => {
const firstPopover = wrapper.findAll('[data-testid="stage-item-popover"]').at(0);
expect(firstPopover.text()).toContain('Stage time (median)');
});
});
});
describe('is true', () => {
beforeEach(() => {
wrapper = createComponent({ loading: true });
});
it('hides the gl-path component', () => {
expect(wrapper.find(GlPath).exists()).toBe(false);
});
it('displays the gl-skeleton-loading component', () => {
expect(wrapper.find(GlSkeletonLoading).exists()).toBe(true);
});
});
});
});
describe('event handling', () => {
it('emits the selected event', () => {
expect(wrapper.emitted('selected')).toBeUndefined();
clickItemAt(0);
clickItemAt(1);
clickItemAt(2);
expect(wrapper.emitted().selected).toEqual([
[transformedProjectStagePathData[0]],
[transformedProjectStagePathData[1]],
[transformedProjectStagePathData[2]],
]);
});
});
});
import * as getters from '~/cycle_analytics/store/getters';
import {
allowedStages,
stageMedians,
transformedProjectStagePathData,
selectedStage,
} from '../mock_data';
describe('Value stream analytics getters', () => {
describe('pathNavigationData', () => {
it('returns the transformed data', () => {
const state = { stages: allowedStages, medians: stageMedians, selectedStage };
expect(getters.pathNavigationData(state)).toEqual(transformedProjectStagePathData);
});
});
describe('filterStagesByHiddenStatus', () => {
const hiddenStages = [{ title: 'three', hidden: true }];
const visibleStages = [
{ title: 'one', hidden: false },
{ title: 'two', hidden: false },
];
const mockStages = [...visibleStages, ...hiddenStages];
it.each`
isHidden | result
${false} | ${visibleStages}
${undefined} | ${hiddenStages}
${true} | ${hiddenStages}
`('with isHidden=$isHidden returns matching stages', ({ isHidden, result }) => {
expect(getters.filterStagesByHiddenStatus(mockStages, isHidden)).toEqual(result);
});
});
});
import { import { decorateEvents, decorateData } from '~/cycle_analytics/utils';
decorateEvents, import { selectedStage, rawData, convertedData, rawEvents } from './mock_data';
decorateData,
transformStagesForPathNavigation,
timeSummaryForPathNavigation,
medianTimeToParsedSeconds,
formatMedianValues,
} from '~/cycle_analytics/utils';
import {
selectedStage,
rawData,
convertedData,
rawEvents,
allowedStages,
stageMediansWithNumericIds,
pathNavIssueMetric,
rawStageMedians,
} from './mock_data';
describe('Value stream analytics utils', () => { describe('Value stream analytics utils', () => {
describe('decorateEvents', () => { describe('decorateEvents', () => {
...@@ -90,73 +74,4 @@ describe('Value stream analytics utils', () => { ...@@ -90,73 +74,4 @@ describe('Value stream analytics utils', () => {
}); });
}); });
}); });
describe('transformStagesForPathNavigation', () => {
const stages = allowedStages;
const response = transformStagesForPathNavigation({
stages,
medians: stageMediansWithNumericIds,
selectedStage,
});
describe('transforms the data as expected', () => {
it('returns an array of stages', () => {
expect(Array.isArray(response)).toBe(true);
expect(response.length).toEqual(stages.length);
});
it('selects the correct stage', () => {
const selected = response.filter((stage) => stage.selected === true)[0];
expect(selected.title).toEqual(selectedStage.title);
});
it('includes the correct metric for the associated stage', () => {
const issue = response.filter((stage) => stage.name === 'issue')[0];
expect(issue.metric).toEqual(pathNavIssueMetric);
});
});
});
describe('timeSummaryForPathNavigation', () => {
it.each`
unit | value | result
${'months'} | ${1.5} | ${'1.5M'}
${'weeks'} | ${1.25} | ${'1.5w'}
${'days'} | ${2} | ${'2d'}
${'hours'} | ${10} | ${'10h'}
${'minutes'} | ${20} | ${'20m'}
${'seconds'} | ${10} | ${'<1m'}
${'seconds'} | ${0} | ${'-'}
`('will format $value $unit to $result', ({ unit, value, result }) => {
expect(timeSummaryForPathNavigation({ [unit]: value })).toEqual(result);
});
});
describe('medianTimeToParsedSeconds', () => {
it.each`
value | result
${1036800} | ${'1w'}
${259200} | ${'3d'}
${172800} | ${'2d'}
${86400} | ${'1d'}
${1000} | ${'16m'}
${61} | ${'1m'}
${59} | ${'<1m'}
${0} | ${'-'}
`('will correctly parse $value seconds into $result', ({ value, result }) => {
expect(medianTimeToParsedSeconds(value)).toEqual(result);
});
});
describe('formatMedianValues', () => {
const calculatedMedians = formatMedianValues(rawStageMedians);
it('returns an object with each stage and their median formatted for display', () => {
rawStageMedians.forEach(({ id, value }) => {
expect(calculatedMedians).toMatchObject({ [id]: medianTimeToParsedSeconds(value) });
});
});
});
}); });
...@@ -33,6 +33,6 @@ RSpec.describe CycleAnalytics::ProjectLevelStageAdapter, type: :model do ...@@ -33,6 +33,6 @@ RSpec.describe CycleAnalytics::ProjectLevelStageAdapter, type: :model do
end end
it 'presents the data as json' do it 'presents the data as json' do
expect(subject.as_json).to include({ title: 'Review', value: 1.hour }) expect(subject.as_json).to include({ title: 'Review', value: 'about 1 hour' })
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