Commit a1124f44 authored by Ezekiel Kigbo's avatar Ezekiel Kigbo

Add path navigation to project VSA

Cleanup styling for stage table

Minor update feature specs

Added getter specs for filterStagesByHiddenStatus
and some additional getters
parent 700e1ab5
<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 } from 'vuex'; import { mapActions, mapState, mapGetters } from 'vuex';
import PathNavigation from 'ee/analytics/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';
...@@ -29,6 +30,7 @@ export default { ...@@ -29,6 +30,7 @@ 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: {
...@@ -56,6 +58,7 @@ export default { ...@@ -56,6 +58,7 @@ 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;
...@@ -68,6 +71,12 @@ export default { ...@@ -68,6 +71,12 @@ 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([
...@@ -83,8 +92,8 @@ export default { ...@@ -83,8 +92,8 @@ export default {
isActiveStage(stage) { isActiveStage(stage) {
return stage.slug === this.selectedStage.slug; return stage.slug === this.selectedStage.slug;
}, },
selectStage(stage) { onSelectStage(stage) {
if (this.selectedStage === stage) return; if (this.isLoadingStage || this.selectedStage?.slug === stage?.slug) return;
this.setSelectedStage(stage); this.setSelectedStage(stage);
if (!stage.isUserAllowed) { if (!stage.isUserAllowed) {
...@@ -108,6 +117,15 @@ export default { ...@@ -108,6 +117,15 @@ export default {
<div class="cycle-analytics"> <div class="cycle-analytics">
<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">
<path-navigation
v-if="shouldDisplayPathNavigation"
:key="`path_navigation_key_${pathNavigationData.length}`"
class="js-path-navigation gl-w-full gl-pb-2"
:loading="isLoading"
:stages="pathNavigationData"
:selected-stage="selectedStage"
@selected="(ev) => onSelectStage(ev)"
/>
<div class="card"> <div class="card">
<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">
...@@ -140,39 +158,11 @@ export default { ...@@ -140,39 +158,11 @@ export default {
</div> </div>
</div> </div>
<div class="stage-panel-container"> <div class="stage-panel-container">
<div class="card stage-panel"> <div class="card stage-panel gl-px-5">
<div class="card-header border-bottom-0"> <div class="card-header border-bottom-0">
<nav class="col-headers"> <nav class="col-headers">
<ul> <ul class="gl-display-flex gl-justify-content-space-between gl-list-style-none">
<li class="stage-header pl-5"> <li>
<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>
...@@ -187,7 +177,7 @@ export default { ...@@ -187,7 +177,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 class="total-time-header pr-5 text-right"> <li>
<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"
...@@ -201,22 +191,8 @@ export default { ...@@ -201,22 +191,8 @@ export default {
</ul> </ul>
</nav> </nav>
</div> </div>
<div class="stage-panel-body"> <div class="stage-panel-body">
<nav class="stage-nav"> <section class="stage-events overflow-auto gl-w-full">
<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
......
import { transformStagesForPathNavigation } from '../utils';
export const filterStagesByHiddenStatus = (stages = [], isHidden = true) =>
stages.filter(({ hidden = false }) => hidden === isHidden);
export const pathNavigationData = ({ stages, medians, stageCounts, selectedStage }) => {
console.log('medians', medians);
return transformStagesForPathNavigation({
stages: filterStagesByHiddenStatus(stages, false),
medians,
stageCounts,
selectedStage,
});
};
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
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';
...@@ -16,6 +17,7 @@ Vue.use(Vuex); ...@@ -16,6 +17,7 @@ Vue.use(Vuex);
export default () => export default () =>
new Vuex.Store({ new Vuex.Store({
actions, actions,
getters,
mutations, mutations,
state, state,
}); });
import { decorateData, decorateEvents } from '../utils'; import { decorateData, decorateEvents, formatMedianValues } from '../utils';
import * as types from './mutation_types'; import * as types from './mutation_types';
export default { export default {
...@@ -20,9 +20,12 @@ export default { ...@@ -20,9 +20,12 @@ 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 } = decorateData(data); const { stages, summary, medians } = decorateData(data);
console.log('medians', medians);
console.log('formatMedianValues::medians', formatMedianValues(medians));
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 { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { roundToNearestHalf, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { dasherize } from '~/lib/utils/text_utility'; import { dasherize } from '~/lib/utils/text_utility';
import { __ } from '../locale'; import { unescape } from 'lodash';
import { sanitize } from '~/lib/dompurify';
import {
newDate,
dayAfter,
secondsToDays,
getDatesInRange,
parseSeconds,
} from '~/lib/utils/datetime_utility';
import { __, s__, sprintf } from '../locale';
import DEFAULT_EVENT_OBJECTS from './default_event_objects'; import DEFAULT_EVENT_OBJECTS from './default_event_objects';
import { OVERVIEW_STAGE_ID } from './constants';
const EMPTY_STAGE_TEXTS = { const EMPTY_STAGE_TEXTS = {
issue: __( issue: __(
...@@ -53,11 +63,96 @@ const mapToStage = (permissions, item) => { ...@@ -53,11 +63,96 @@ const mapToStage = (permissions, item) => {
}; };
const mapToSummary = ({ value, ...rest }) => ({ ...rest, value: value || '-' }); const mapToSummary = ({ value, ...rest }) => ({ ...rest, value: value || '-' });
const mapToMedians = ({ id, name, value }) => ({ id, name, value });
export const decorateData = (data = {}) => { export const decorateData = (data = {}) => {
const { permissions, stats, summary } = data; const { permissions, stats, summary } = data;
return { return {
stages: stats?.map((item) => mapToStage(permissions, item)) || [], stages: stats?.map((item) => mapToStage(permissions, item)) || [],
summary: summary?.map((item) => mapToSummary(item)) || [], summary: summary?.map((item) => mapToSummary(item)) || [],
medians: stats?.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,
}) => {
// TODO: do we need popovers for the project path
// - medians, start / end events + descriptions
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,32 +30,12 @@ ...@@ -30,32 +30,12 @@
.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%;
} }
} }
...@@ -120,7 +100,6 @@ ...@@ -120,7 +100,6 @@
} }
li { li {
@include clearfix;
list-style-type: none; list-style-type: none;
} }
...@@ -169,7 +148,6 @@ ...@@ -169,7 +148,6 @@
.events-description { .events-description {
line-height: 65px; line-height: 65px;
padding: 0 $gl-padding;
} }
.events-info { .events-info {
...@@ -178,7 +156,6 @@ ...@@ -178,7 +156,6 @@
} }
.stage-events { .stage-events {
width: 60%;
min-height: 467px; min-height: 467px;
} }
...@@ -190,8 +167,8 @@ ...@@ -190,8 +167,8 @@
.stage-event-item { .stage-event-item {
@include clearfix; @include clearfix;
list-style-type: none; list-style-type: none;
padding: 0 0 $gl-padding; padding-bottom: $gl-padding;
margin: 0 $gl-padding $gl-padding; margin-bottom: $gl-padding;
border-bottom: 1px solid var(--gray-50, $gray-50); border-bottom: 1px solid var(--gray-50, $gray-50);
&:last-child { &:last-child {
......
...@@ -57,7 +57,6 @@ export default { ...@@ -57,7 +57,6 @@ export default {
'errorCode', 'errorCode',
'startDate', 'startDate',
'endDate', 'endDate',
'medians',
'isLoadingValueStreams', 'isLoadingValueStreams',
'selectedStageError', 'selectedStageError',
'selectedValueStream', 'selectedValueStream',
......
import dateFormat from 'dateformat'; import dateFormat from 'dateformat';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
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';
...@@ -10,7 +14,6 @@ import { ...@@ -10,7 +14,6 @@ import {
PAGINATION_TYPE, PAGINATION_TYPE,
OVERVIEW_STAGE_ID, OVERVIEW_STAGE_ID,
} from '../constants'; } from '../constants';
import { transformStagesForPathNavigation } from '../utils';
export const hasNoAccessError = (state) => state.errorCode === httpStatus.FORBIDDEN; export const hasNoAccessError = (state) => state.errorCode === httpStatus.FORBIDDEN;
...@@ -56,9 +59,6 @@ export const paginationParams = ({ pagination: { page, sort, direction } }) => ( ...@@ -56,9 +59,6 @@ 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);
...@@ -78,8 +78,8 @@ export const isOverviewStageSelected = ({ selectedStage }) => ...@@ -78,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 }) =>
transformStagesForPathNavigation({ basePathNavigationData({
stages: [OVERVIEW_STAGE_CONFIG, ...filterStagesByHiddenStatus(stages, false)], stages: [OVERVIEW_STAGE_CONFIG, ...stages],
medians, medians,
stageCounts, stageCounts,
selectedStage, selectedStage,
......
import dateFormat from 'dateformat'; import dateFormat from 'dateformat';
import { unescape, isNumber } from 'lodash'; import { isNumber } from 'lodash';
import { medianTimeToParsedSeconds } from '~/cycle_analytics/utils';
import createFlash, { hideFlash } from '~/flash'; import createFlash, { hideFlash } from '~/flash';
import { sanitize } from '~/lib/dompurify'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { convertObjectPropsToCamelCase, roundToNearestHalf } from '~/lib/utils/common_utils'; import { newDate, dayAfter, secondsToDays, getDatesInRange } from '~/lib/utils/datetime_utility';
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'; import { OVERVIEW_STAGE_ID } from './constants';
...@@ -357,42 +350,6 @@ export const throwIfUserForbidden = (error) => { ...@@ -357,42 +350,6 @@ 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
...@@ -422,35 +379,6 @@ export const formatMedianValuesWithOverview = (medians = []) => { ...@@ -422,35 +379,6 @@ 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
......
...@@ -61,6 +61,7 @@ describe('PathNavigation', () => { ...@@ -61,6 +61,7 @@ describe('PathNavigation', () => {
expect(wrapper.find(GlSkeletonLoading).exists()).toBe(false); expect(wrapper.find(GlSkeletonLoading).exists()).toBe(false);
}); });
// TODO: make this test more granular
it('matches the snapshot', () => { it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot(); expect(wrapper.element).toMatchSnapshot();
}); });
......
...@@ -12,24 +12,20 @@ import mutations from 'ee/analytics/cycle_analytics/store/mutations'; ...@@ -12,24 +12,20 @@ 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/,
...@@ -63,9 +59,6 @@ export const group = { ...@@ -63,9 +59,6 @@ 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);
...@@ -113,8 +106,6 @@ export const allowedStages = [issueStage, planStage, codeStage]; ...@@ -113,8 +106,6 @@ 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 {
...@@ -123,11 +114,6 @@ const stageFixtures = defaultStages.reduce((acc, stage) => { ...@@ -123,11 +114,6 @@ 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,
...@@ -323,8 +309,6 @@ export const selectedProjects = [ ...@@ -323,8 +309,6 @@ export const selectedProjects = [
}, },
]; ];
export const pathNavIssueMetric = 172800;
export const initialPaginationState = { export const initialPaginationState = {
page: null, page: null,
hasNextPage: false, hasNextPage: false,
......
...@@ -15,7 +15,6 @@ import { ...@@ -15,7 +15,6 @@ import {
flattenTaskByTypeSeries, flattenTaskByTypeSeries,
orderByDate, orderByDate,
toggleSelectedLabel, toggleSelectedLabel,
transformStagesForPathNavigation,
prepareTimeMetricsData, prepareTimeMetricsData,
prepareStageErrors, prepareStageErrors,
timeSummaryForPathNavigation, timeSummaryForPathNavigation,
...@@ -23,6 +22,7 @@ import { ...@@ -23,6 +22,7 @@ import {
medianTimeToParsedSeconds, 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 { 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 {
...@@ -38,12 +38,7 @@ import { ...@@ -38,12 +38,7 @@ 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);
...@@ -341,35 +336,6 @@ describe('Value Stream Analytics utils', () => { ...@@ -341,35 +336,6 @@ 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;
......
...@@ -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('.stage-nav-item.active', text: 'Issue') expect(page).to have_selector('.gl-path-active-item-indigo', 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('.stage-nav li', text: stage_name).click find('.gl-path-nav-list-item', text: stage_name).click
wait_for_requests wait_for_requests
end end
end end
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' },
...@@ -184,3 +205,29 @@ export const rawEvents = [ ...@@ -184,3 +205,29 @@ 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 = 388800;
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 * as getters from '~/cycle_analytics/store/getters';
import {
allowedStages,
stageMedians,
transformedProjectStagePathData,
selectedStage,
} from '../mock_data';
// TODO: move path navigation component to CE ee/spec/frontend/analytics/cycle_analytics/components/path_navigation_spec.js
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 { decorateEvents, decorateData } from '~/cycle_analytics/utils'; import {
import { selectedStage, rawData, convertedData, rawEvents } from './mock_data'; decorateEvents,
decorateData,
transformStagesForPathNavigation,
} from '~/cycle_analytics/utils';
import {
selectedStage,
rawData,
convertedData,
rawEvents,
allowedStages,
stageMediansWithNumericIds,
pathNavIssueMetric,
stageCounts,
} from './mock_data';
describe('Value stream analytics utils', () => { describe('Value stream analytics utils', () => {
describe('decorateEvents', () => { describe('decorateEvents', () => {
...@@ -74,4 +87,33 @@ describe('Value stream analytics utils', () => { ...@@ -74,4 +87,33 @@ describe('Value stream analytics utils', () => {
}); });
}); });
}); });
describe('transformStagesForPathNavigation', () => {
const stages = allowedStages;
const response = transformStagesForPathNavigation({
stages,
medians: stageMediansWithNumericIds,
selectedStage,
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(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);
});
});
});
}); });
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