Commit 4d2ce49d authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '331910-fix-foss-migrate-project-vsa-navigation-to-path-navigation' into 'master'

Fix Migrate project VSA navigation to path navigation [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!62630
parents 0219bbdd 61e75049
<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 '~/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,17 +58,19 @@ export default { ...@@ -56,17 +58,19 @@ 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;
}, },
displayNotEnoughData() { displayNotEnoughData() {
const { selectedStage, isEmptyStage, isLoadingStage } = this; return this.selectedStageReady && this.isEmptyStage;
return selectedStage && isEmptyStage && !isLoadingStage;
}, },
displayNoAccess() { displayNoAccess() {
const { selectedStage } = this; return this.selectedStageReady && !this.selectedStage.isUserAllowed;
return selectedStage && !selectedStage.isUserAllowed; },
selectedStageReady() {
return !this.isLoadingStage && this.selectedStage;
}, },
}, },
methods: { methods: {
...@@ -83,8 +87,8 @@ export default { ...@@ -83,8 +87,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) {
...@@ -106,9 +110,23 @@ export default { ...@@ -106,9 +110,23 @@ export default {
</script> </script>
<template> <template>
<div class="cycle-analytics"> <div class="cycle-analytics">
<path-navigation
v-if="selectedStageReady"
class="js-path-navigation gl-w-full gl-pb-2"
:loading="isLoading"
:stages="pathNavigationData"
:selected-stage="selectedStage"
:with-stage-counts="false"
@selected="onSelectStage"
/>
<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">
...@@ -139,40 +157,12 @@ export default { ...@@ -139,40 +157,12 @@ export default {
</div> </div>
</div> </div>
</div> </div>
<div class="stage-panel-container"> <div class="stage-panel-container" data-testid="vsa-stage-table">
<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,45 +191,31 @@ export default { ...@@ -201,45 +191,31 @@ 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 gl-overflow-auto gl-w-full">
<ul> <gl-loading-icon v-if="isLoadingStage" size="lg" />
<stage-nav-item <template v-else>
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" />
<template v-if="displayNoAccess">
<gl-empty-state <gl-empty-state
v-if="displayNoAccess"
class="js-empty-state" class="js-empty-state"
:title="__('You need permission.')" :title="__('You need permission.')"
:svg-path="noAccessSvgPath" :svg-path="noAccessSvgPath"
:description="__('Want to see the data? Please ask an administrator for access.')" :description="__('Want to see the data? Please ask an administrator for access.')"
/> />
</template>
<template v-else> <template v-else>
<template v-if="displayNotEnoughData">
<gl-empty-state <gl-empty-state
v-if="displayNotEnoughData"
class="js-empty-state" class="js-empty-state"
:description="selectedStage.emptyStageText" :description="selectedStage.emptyStageText"
:svg-path="noDataSvgPath" :svg-path="noDataSvgPath"
:title="__('We don\'t have enough data to show this stage.')" :title="__('We don\'t have enough data to show this stage.')"
/> />
</template>
<template v-if="displayStageEvents">
<component <component
:is="selectedStage.component" :is="selectedStage.component"
v-if="displayStageEvents"
:stage="selectedStage" :stage="selectedStage"
:items="selectedStageEvents" :items="selectedStageEvents"
data-testid="stage-table-events"
/> />
</template> </template>
</template> </template>
......
...@@ -32,12 +32,17 @@ export default { ...@@ -32,12 +32,17 @@ 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 }) { hasStageCount({ stageCount = null }) {
return stageCount !== null; return stageCount !== null;
}, },
}, },
...@@ -67,7 +72,7 @@ export default { ...@@ -67,7 +72,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 class="gl-px-4"> <div v-if="withStageCounts" 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') }}
......
export const DEFAULT_DAYS_TO_DISPLAY = 30; export const DEFAULT_DAYS_TO_DISPLAY = 30;
export const OVERVIEW_STAGE_ID = 'overview';
import { transformStagesForPathNavigation, filterStagesByHiddenStatus } from '../utils';
export const pathNavigationData = ({ stages, medians, stageCounts, selectedStage }) => {
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,10 @@ export default { ...@@ -20,9 +20,10 @@ 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);
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 { unescape } from 'lodash';
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 { __ } from '../locale'; import { __, s__, sprintf } 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 = {
...@@ -40,10 +43,17 @@ const mapToEvent = (event, stage) => { ...@@ -40,10 +43,17 @@ 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) => { /*
const slug = dasherize(item.name.toLowerCase()); * NOTE: We currently use the `name` field since the project level stages are in memory
* 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 {
...item, ...rest,
name,
id: name,
slug, slug,
active: false, active: false,
isUserAllowed: permissions[slug], isUserAllowed: permissions[slug],
...@@ -53,11 +63,98 @@ const mapToStage = (permissions, item) => { ...@@ -53,11 +63,98 @@ const mapToStage = (permissions, item) => {
}; };
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: stats?.map((item) => mapToStage(permissions, item)) || [], stages,
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, // Also could null === null cause an issue here?
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) : '-',
};
}, {});
export const filterStagesByHiddenStatus = (stages = [], isHidden = true) =>
stages.filter(({ hidden = false }) => hidden === isHidden);
...@@ -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 {
......
...@@ -8,9 +8,5 @@ class AnalyticsStageEntity < Grape::Entity ...@@ -8,9 +8,5 @@ class AnalyticsStageEntity < Grape::Entity
expose :legend expose :legend
expose :description expose :description
expose :project_median, as: :value do |stage| expose :project_median, as: :value
# 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, OVERVIEW_STAGE_ID } from '../constants'; import { PROJECTS_PER_PAGE } 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 StageTable from './stage_table.vue'; import StageTable from './stage_table.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';
...@@ -57,7 +58,6 @@ export default { ...@@ -57,7 +58,6 @@ export default {
'errorCode', 'errorCode',
'startDate', 'startDate',
'endDate', 'endDate',
'medians',
'isLoadingValueStreams', 'isLoadingValueStreams',
'selectedStageError', 'selectedStageError',
'selectedValueStream', 'selectedValueStream',
...@@ -177,7 +177,6 @@ export default { ...@@ -177,7 +177,6 @@ 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"
......
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;
...@@ -52,7 +53,6 @@ export const OVERVIEW_METRICS = { ...@@ -52,7 +53,6 @@ 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 { pathNavigationData as basePathNavigationData } from '~/cycle_analytics/store/getters';
import { filterStagesByHiddenStatus } from '~/cycle_analytics/utils';
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 { import { DEFAULT_VALUE_STREAM_ID, OVERVIEW_STAGE_CONFIG, PAGINATION_TYPE } from '../constants';
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;
...@@ -56,9 +53,6 @@ export const paginationParams = ({ pagination: { page, sort, direction } }) => ( ...@@ -56,9 +53,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 +72,8 @@ export const isOverviewStageSelected = ({ selectedStage }) => ...@@ -78,8 +72,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 { OVERVIEW_STAGE_ID } from '~/cycle_analytics/constants';
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';
const EVENT_TYPE_LABEL = 'label'; const EVENT_TYPE_LABEL = 'label';
...@@ -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
......
// 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,16 +7,16 @@ import Component from 'ee/analytics/cycle_analytics/components/base.vue'; ...@@ -7,16 +7,16 @@ 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 StageTable from 'ee/analytics/cycle_analytics/components/stage_table.vue'; import StageTable from 'ee/analytics/cycle_analytics/components/stage_table.vue';
import TypeOfWorkCharts from 'ee/analytics/cycle_analytics/components/type_of_work_charts.vue'; import TypeOfWorkCharts from 'ee/analytics/cycle_analytics/components/type_of_work_charts.vue';
import ValueStreamSelect from 'ee/analytics/cycle_analytics/components/value_stream_select.vue'; import ValueStreamSelect from 'ee/analytics/cycle_analytics/components/value_stream_select.vue';
import { OVERVIEW_STAGE_ID } from 'ee/analytics/cycle_analytics/constants';
import createStore from 'ee/analytics/cycle_analytics/store'; import createStore from 'ee/analytics/cycle_analytics/store';
import Daterange from 'ee/analytics/shared/components/daterange.vue'; 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 { OVERVIEW_STAGE_ID } from '~/cycle_analytics/constants';
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';
...@@ -98,7 +98,7 @@ async function shouldMergeUrlParams(wrapper, result) { ...@@ -98,7 +98,7 @@ async function shouldMergeUrlParams(wrapper, result) {
expect(commonUtils.historyPushState).toHaveBeenCalled(); expect(commonUtils.historyPushState).toHaveBeenCalled();
} }
describe('Value Stream Analytics component', () => { describe('EE 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 Component from 'ee/analytics/cycle_analytics/components/path_navigation.vue'; import { extendedWrapper } from 'helpers/vue_test_utils_helper';
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('PathNavigation', () => { describe('Group PathNavigation', () => {
let wrapper = null; let wrapper = null;
const createComponent = (props) => { const createComponent = (props) => {
return mount(Component, { return extendedWrapper(
mount(Component, {
propsData: { propsData: {
stages: transformedStagePathData, stages: transformedStagePathData,
selectedStage: issueStage, selectedStage: issueStage,
loading: false, loading: false,
...props, ...props,
}, },
}); }),
}; );
const pathNavigationTitles = () => {
return wrapper.findAll('.gl-path-button');
}; };
const pathNavigationItems = () => { const pathNavigationItems = () => {
return wrapper.findAll('.gl-path-nav-list-item'); return wrapper.findByTestId('gl-path-nav').findAll('li');
}; };
const clickItemAt = (index) => { const pathItemContent = () => pathNavigationItems().wrappers.map(extendedWrapper);
pathNavigationTitles().at(index).trigger('click'); const firstPopover = () => wrapper.findAllByTestId('stage-item-popover').at(0);
};
const stagesWithCounts = transformedStagePathData.filter(
(stage) => stage.id !== OVERVIEW_STAGE_ID,
);
beforeEach(() => { beforeEach(() => {
wrapper = createComponent(); wrapper = createComponent();
...@@ -38,99 +40,44 @@ describe('PathNavigation', () => { ...@@ -38,99 +40,44 @@ describe('PathNavigation', () => {
wrapper = null; wrapper = null;
}); });
describe('displays correctly', () => {
it('has the correct props', () => {
expect(wrapper.find(GlPath).props('items')).toMatchObject(transformedStagePathData);
});
it('contains all the expected stages', () => {
const html = wrapper.find(GlPath).html();
transformedStagePathData.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('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
describe('popovers', () => { describe('popovers', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({ stages: transformedStagePathData }); wrapper = createComponent({ stages: transformedStagePathData });
}); });
it('renders popovers for all stages except for the overview stage', () => { it('renders popovers for all stages except for the overview stage', () => {
const pathItemContent = pathNavigationItems().wrappers; const [overviewStage, ...popoverStages] = pathItemContent();
const [overviewStage, ...popoverStages] = pathItemContent;
expect(overviewStage.text()).toContain('Overview'); expect(overviewStage.text()).toContain('Overview');
expect(overviewStage.find('[data-testid="stage-item-popover"]').exists()).toBe(false); expect(overviewStage.findByTestId('stage-item-popover').exists()).toBe(false);
popoverStages.forEach((stage) => { popoverStages.forEach((stage) => {
expect(stage.find('[data-testid="stage-item-popover"]').exists()).toBe(true); expect(stage.findByTestId('stage-item-popover').exists()).toBe(true);
}); });
}); });
it('shows the sanitized start event description for the first stage item', () => { 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'; const expectedStartEventDescription = 'Issue created';
expect(firstPopover.text()).toContain(expectedStartEventDescription); expect(firstPopover().text()).toContain(expectedStartEventDescription);
}); });
it('shows the sanitized end event description for the first stage item', () => { 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 = const expectedStartEventDescription =
'Issue first associated with a milestone or issue first added to a board'; 'Issue first associated with a milestone or issue first added to a board';
expect(firstPopover.text()).toContain(expectedStartEventDescription); expect(firstPopover().text()).toContain(expectedStartEventDescription);
}); });
it('shows the median stage time for the first stage item', () => { 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)');
expect(firstPopover.text()).toContain('Stage time (median)');
});
});
}); });
describe('is true', () => { it('renders each stage with its stage count', () => {
beforeEach(() => { const popoverStages = pathItemContent().slice(1); // skip the first stage, the overview does not have a popover
wrapper = createComponent({ loading: true }); popoverStages.forEach((stage, index) => {
}); const content = stage.findByTestId('stage-item-popover').html();
expect(content).toContain('Items in stage');
it('hides the gl-path component', () => { expect(content).toContain(`${stagesWithCounts[index].stageCount} items`);
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,11 +12,12 @@ import mutations from 'ee/analytics/cycle_analytics/store/mutations'; ...@@ -12,11 +12,12 @@ 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 } 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';
...@@ -63,9 +64,6 @@ export const group = { ...@@ -63,9 +64,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 +111,6 @@ export const allowedStages = [issueStage, planStage, codeStage]; ...@@ -113,8 +111,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 +119,6 @@ const stageFixtures = defaultStages.reduce((acc, stage) => { ...@@ -123,11 +119,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,
......
...@@ -527,10 +527,7 @@ describe('Value Stream Analytics actions', () => { ...@@ -527,10 +527,7 @@ describe('Value Stream Analytics actions', () => {
...state, ...state,
stages, stages,
currentGroup, currentGroup,
featureFlags: { featureFlags: state.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,
...@@ -15,14 +14,14 @@ import { ...@@ -15,14 +14,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 {
...@@ -38,12 +37,7 @@ import { ...@@ -38,12 +37,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 +335,6 @@ describe('Value Stream Analytics utils', () => { ...@@ -341,35 +335,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;
...@@ -398,37 +363,6 @@ describe('Value Stream Analytics utils', () => { ...@@ -398,37 +363,6 @@ 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);
......
...@@ -20474,9 +20474,6 @@ msgstr "" ...@@ -20474,9 +20474,6 @@ 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 ""
...@@ -25679,9 +25676,6 @@ msgstr "" ...@@ -25679,9 +25676,6 @@ msgstr ""
msgid "ProjectLastActivity|Never" msgid "ProjectLastActivity|Never"
msgstr "" msgstr ""
msgid "ProjectLifecycle|Stage"
msgstr ""
msgid "ProjectOverview|Fork" msgid "ProjectOverview|Fork"
msgstr "" msgstr ""
...@@ -32679,9 +32673,6 @@ msgstr "" ...@@ -32679,9 +32673,6 @@ 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 ""
...@@ -32826,9 +32817,6 @@ msgstr "" ...@@ -32826,9 +32817,6 @@ 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('.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
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Value stream analytics component isEmptyStage = true renders the empty stage with \`Not enough data\` message 1`] = `"<gl-empty-state-stub title=\\"We don't have enough data to show this stage.\\" svgpath=\\"path/to/no/data\\" description=\\"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.\\" class=\\"js-empty-state\\"></gl-empty-state-stub>"`;
exports[`Value stream analytics component isLoading = true renders the path navigation component with prop \`loading\` set to true 1`] = `"<path-navigation-stub loading=\\"true\\" stages=\\"\\" selectedstage=\\"[object Object]\\" class=\\"js-path-navigation gl-w-full gl-pb-2\\"></path-navigation-stub>"`;
exports[`Value stream analytics component without enough permissions renders the empty stage with \`You need permission\` message 1`] = `"<gl-empty-state-stub title=\\"You need permission.\\" svgpath=\\"path/to/no/access\\" description=\\"Want to see the data? Please ask an administrator for access.\\" class=\\"js-empty-state\\"></gl-empty-state-stub>"`;
import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import BaseComponent from '~/cycle_analytics/components/base.vue';
import PathNavigation from '~/cycle_analytics/components/path_navigation.vue';
import initState from '~/cycle_analytics/store/state';
import { selectedStage, convertedEvents as selectedStageEvents } from './mock_data';
const noDataSvgPath = 'path/to/no/data';
const noAccessSvgPath = 'path/to/no/access';
Vue.use(Vuex);
let wrapper;
function createStore({ initialState = {} }) {
return new Vuex.Store({
state: {
...initState(),
...initialState,
},
getters: {
pathNavigationData: () => [],
},
});
}
function createComponent({ initialState } = {}) {
return extendedWrapper(
shallowMount(BaseComponent, {
store: createStore({ initialState }),
propsData: {
noDataSvgPath,
noAccessSvgPath,
},
}),
);
}
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findPathNavigation = () => wrapper.findComponent(PathNavigation);
const findOverviewMetrics = () => wrapper.findByTestId('vsa-stage-overview-metrics');
const findStageTable = () => wrapper.findByTestId('vsa-stage-table');
const findEmptyStage = () => wrapper.findComponent(GlEmptyState);
const findStageEvents = () => wrapper.findByTestId('stage-table-events');
describe('Value stream analytics component', () => {
beforeEach(() => {
wrapper = createComponent({
initialState: {
isLoading: false,
isLoadingStage: false,
isEmptyStage: false,
selectedStageEvents,
selectedStage,
},
});
});
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);
});
it('renders the stage table events', () => {
expect(findEmptyStage().exists()).toBe(false);
expect(findStageEvents().exists()).toBe(true);
});
it('does not render the loading icon', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
describe('isLoading = true', () => {
beforeEach(() => {
wrapper = createComponent({
initialState: { isLoading: true },
});
});
it('renders the path navigation component with prop `loading` set to true', () => {
expect(findPathNavigation().html()).toMatchSnapshot();
});
it('does not render the overview metrics', () => {
expect(findOverviewMetrics().exists()).toBe(false);
});
it('does not render the stage table', () => {
expect(findStageTable().exists()).toBe(false);
});
it('renders the loading icon', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
});
describe('isLoadingStage = true', () => {
beforeEach(() => {
wrapper = createComponent({
initialState: { isLoadingStage: true },
});
});
it('renders the stage table with a loading icon', () => {
const tableWrapper = findStageTable();
expect(tableWrapper.exists()).toBe(true);
expect(tableWrapper.find(GlLoadingIcon).exists()).toBe(true);
});
});
describe('isEmptyStage = true', () => {
beforeEach(() => {
wrapper = createComponent({
initialState: { selectedStage, isEmptyStage: true },
});
});
it('renders the empty stage with `Not enough data` message', () => {
expect(findEmptyStage().html()).toMatchSnapshot();
});
});
describe('without enough permissions', () => {
beforeEach(() => {
wrapper = createComponent({
initialState: { selectedStage: { ...selectedStage, isUserAllowed: false } },
});
});
it('renders the empty stage with `You need permission` message', () => {
expect(findEmptyStage().html()).toMatchSnapshot();
});
});
describe('without a selected stage', () => {
beforeEach(() => {
wrapper = createComponent({
initialState: { selectedStage: null, isEmptyStage: true },
});
});
it('renders the stage table', () => {
expect(findStageTable().exists()).toBe(true);
});
it('does not render the path navigation component', () => {
expect(findPathNavigation().exists()).toBe(false);
});
it('does not render the stage table events', () => {
expect(findStageEvents().exists()).toBe(false);
});
it('does not render the loading icon', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
});
});
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
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 summary = [ export const summary = [
{ value: '20', title: 'New Issues' }, { value: '20', title: 'New Issues' },
{ value: null, title: 'Commits' }, { value: null, title: 'Commits' },
...@@ -8,6 +13,7 @@ export const summary = [ ...@@ -8,6 +13,7 @@ export const summary = [
]; ];
const issueStage = { const issueStage = {
id: 'issue',
title: 'Issue', title: 'Issue',
name: 'issue', name: 'issue',
legend: '', legend: '',
...@@ -16,30 +22,34 @@ const issueStage = { ...@@ -16,30 +22,34 @@ 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: 'about 21 hours', value: 75600,
}; };
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: '2 days', value: 172800,
}; };
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: 'about 5 hours', value: 17550,
}; };
const reviewStage = { const reviewStage = {
id: 'review',
title: 'Review', title: 'Review',
name: 'review', name: 'review',
legend: '', legend: '',
...@@ -48,11 +58,12 @@ const reviewStage = { ...@@ -48,11 +58,12 @@ 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: '2 days', value: 172800,
}; };
export const selectedStage = { export const selectedStage = {
...@@ -184,3 +195,64 @@ export const rawEvents = [ ...@@ -184,3 +195,64 @@ 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 rawStageMedians = [
{ id: 'issue', value: 172800 },
{ id: 'plan', value: 86400 },
{ id: 'review', value: 1036800 },
{ id: 'code', value: 129600 },
{ id: 'test', value: 259200 },
{ id: 'staging', value: 388800 },
];
export const stageMedians = {
issue: 172800,
plan: 86400,
review: 1036800,
code: 129600,
test: 259200,
staging: 388800,
};
export const allowedStages = [issueStage, planStage, codeStage];
export const transformedProjectStagePathData = [
{
metric: 172800,
selected: true,
stageCount: undefined,
icon: null,
id: 'issue',
title: 'Issue',
name: 'issue',
legend: '',
description: 'Time before an issue gets scheduled',
value: null,
},
{
metric: 86400,
selected: false,
stageCount: undefined,
icon: null,
id: 'plan',
title: 'Plan',
name: 'plan',
legend: '',
description: 'Time before an issue starts implementation',
value: 75600,
},
{
metric: 129600,
selected: false,
stageCount: undefined,
icon: null,
id: 'code',
title: 'Code',
name: 'code',
legend: '',
description: 'Time until first merge request',
value: 172800,
},
];
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');
};
const pathItemContent = () => findPathNavigationItems().wrappers.map(extendedWrapper);
const firstPopover = () => wrapper.findAllByTestId('stage-item-popover').at(0);
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 stageContent = findPathNavigationTitles();
transformedProjectStagePathData.forEach((stage, index) => {
expect(stageContent[index]).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', () => {
pathItemContent().forEach((stage) => {
expect(stage.findByTestId('stage-item-popover').exists()).toBe(true);
});
});
it('shows the median stage time for the first stage item', () => {
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);
});
});
});
import { decorateEvents, decorateData } from '~/cycle_analytics/utils'; import {
import { selectedStage, rawData, convertedData, rawEvents } from './mock_data'; decorateEvents,
decorateData,
transformStagesForPathNavigation,
timeSummaryForPathNavigation,
medianTimeToParsedSeconds,
formatMedianValues,
filterStagesByHiddenStatus,
} from '~/cycle_analytics/utils';
import {
selectedStage,
rawData,
convertedData,
rawEvents,
allowedStages,
stageMedians,
pathNavIssueMetric,
rawStageMedians,
} from './mock_data';
describe('Value stream analytics utils', () => { describe('Value stream analytics utils', () => {
describe('decorateEvents', () => { describe('decorateEvents', () => {
...@@ -74,4 +91,91 @@ describe('Value stream analytics utils', () => { ...@@ -74,4 +91,91 @@ describe('Value stream analytics utils', () => {
}); });
}); });
}); });
describe('transformStagesForPathNavigation', () => {
const stages = allowedStages;
const response = transformStagesForPathNavigation({
stages,
medians: stageMedians,
selectedStage,
});
describe('transforms the data as expected', () => {
it('returns an array of stages', () => {
expect(Array.isArray(response)).toBe(true);
expect(response.length).toBe(stages.length);
});
it('selects the correct stage', () => {
const selected = response.filter((stage) => stage.selected === true)[0];
expect(selected.title).toBe(selectedStage.title);
});
it('includes the correct metric for the associated stage', () => {
const issue = response.filter((stage) => stage.name === 'issue')[0];
expect(issue.metric).toBe(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 })).toBe(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)).toBe(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) });
});
});
});
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(filterStagesByHiddenStatus(mockStages, isHidden)).toEqual(result);
});
});
}); });
...@@ -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: 'about 1 hour' }) expect(subject.as_json).to include({ title: 'Review', value: 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