Commit 429fde41 authored by Scott Hampton's avatar Scott Hampton

Merge branch '321238-fe-vsa-add-stage-time-to-the-horizontal-stage-items' into 'master'

Adds stage time to the path navigation

See merge request gitlab-org/gitlab!57451
parents 39d2ebc1 b965d09c
......@@ -680,6 +680,19 @@ export const roundOffFloat = (number, precision = 0) => {
return Math.round(number * multiplier) / multiplier;
};
/**
* Method to round values to the nearest half (0.5)
*
* Eg; roundToNearestHalf(3.141592) = 3, roundToNearestHalf(3.41592) = 3.5
*
* Refer to spec/javascripts/lib/utils/common_utils_spec.js for
* more supported examples.
*
* @param {Float} number
* @returns {Float|Number}
*/
export const roundToNearestHalf = (num) => Math.round(num * 2).toFixed() / 2;
/**
* Method to round down values with decimal places
* with provided precision.
......
......@@ -196,9 +196,20 @@ GitLab allows users to create multiple value streams, hide default stages and cr
> - It's enabled on GitLab.com.
> - For GitLab self-managed instances, GitLab administrators can opt to [disable it](../../../administration/feature_flags.md). **(FREE SELF)**
![Value stream path navigation](img/vsa_path_nav_v13_10.png "Value stream path navigation")
![Value stream path navigation](img/vsa_path_nav_v13_11.png "Value stream path navigation")
Stages are visually depicted as a horizontal process flow. Selecting a stage updates the content below the value stream.
Stages are visually depicted as a horizontal process flow. Selecting a stage updates the content
below the value stream.
The stage time is displayed next to the name of each stage, in the following format:
| Symbol | Description |
|--------|-------------|
| `m` | Minutes |
| `h` | Hours |
| `d` | Days |
| `w` | Weeks |
| `M` | Months |
Hovering over a stage item displays a popover with the following information:
......
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { transformRawStages, prepareStageErrors } from '../utils';
import { transformRawStages, prepareStageErrors, formatMedianValuesWithOverview } from '../utils';
import * as types from './mutation_types';
export default {
......@@ -49,6 +49,9 @@ export default {
state.medians = {};
},
[types.RECEIVE_STAGE_MEDIANS_SUCCESS](state, medians = []) {
if (state?.featureFlags?.hasPathNavigation) {
state.medians = formatMedianValuesWithOverview(medians);
} else {
state.medians = medians.reduce(
(acc, { id, value, error = null }) => ({
...acc,
......@@ -56,6 +59,7 @@ export default {
}),
{},
);
}
},
[types.RECEIVE_STAGE_MEDIANS_ERROR](state) {
state.medians = {};
......
import dateFormat from 'dateformat';
import { isNumber } from 'lodash';
import { unescape, isNumber } from 'lodash';
import createFlash, { hideFlash } from '~/flash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { sanitize } from '~/lib/dompurify';
import { convertObjectPropsToCamelCase, roundToNearestHalf } from '~/lib/utils/common_utils';
import {
newDate,
dayAfter,
......@@ -14,6 +15,7 @@ import { convertToSnakeCase, slugify } from '~/lib/utils/text_utility';
import { s__, sprintf } from '~/locale';
import { dateFormats } from '../shared/constants';
import { toYmd } from '../shared/utils';
import { OVERVIEW_STAGE_ID } from './constants';
const EVENT_TYPE_LABEL = 'label';
const ERROR_NAME_RESERVED = 'is reserved';
......@@ -358,6 +360,71 @@ export const throwIfUserForbidden = (error) => {
export const isStageNameExistsError = ({ status, errors }) =>
status === httpStatus.UNPROCESSABLE_ENTITY && errors?.name?.includes(ERROR_NAME_RESERVED);
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|<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, additionally
* the overview is calculated as a sum of all the stages.
* 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 formatMedianValuesWithOverview = (medians = []) => {
const calculatedMedians = medians.reduce(
(acc, { id, value = 0 }) => {
return {
...acc,
[id]: value ? medianTimeToParsedSeconds(value) : '-',
[OVERVIEW_STAGE_ID]: acc[OVERVIEW_STAGE_ID] + value,
};
},
{
[OVERVIEW_STAGE_ID]: 0,
},
);
const overviewMedian = calculatedMedians[OVERVIEW_STAGE_ID];
return {
...calculatedMedians,
[OVERVIEW_STAGE_ID]: overviewMedian ? medianTimeToParsedSeconds(overviewMedian) : '-',
};
};
/**
* 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.
......@@ -369,14 +436,8 @@ export const isStageNameExistsError = ({ status, errors }) =>
*/
export const transformStagesForPathNavigation = ({ stages, medians, selectedStage }) => {
const formattedStages = stages.map((stage) => {
const { days } = parseSeconds(medians[stage.id], {
daysPerWeek: 7,
hoursPerDay: 24,
limitToDays: true,
});
return {
metric: days ? sprintf(s__('ValueStreamAnalytics|%{days}d'), { days }) : null,
metric: medians[stage?.id],
selected: stage.title === selectedStage.title,
icon: null,
...stage,
......
---
title: Adds stage time calculation to VSA path navigation
merge_request: 57451
author:
type: fixed
......@@ -312,15 +312,15 @@ RSpec.describe 'Group value stream analytics filters and data', :js do
end
stages_with_data = [
{ title: 'Issue', description: 'Time before an issue gets scheduled', events_count: 1, median: '5 days' },
{ title: 'Code', description: 'Time until first merge request', events_count: 1, median: 'about 5 hours' },
{ title: 'Review', description: 'Time between merge request creation and merge/close', events_count: 1, median: 'about 1 hour' },
{ title: 'Staging', description: 'From merge request merge until deploy to production', events_count: 1, median: 'about 1 hour' }
{ title: 'Issue', description: 'Time before an issue gets scheduled', events_count: 1, time: '5d' },
{ title: 'Code', description: 'Time until first merge request', events_count: 1, time: '5h' },
{ title: 'Review', description: 'Time between merge request creation and merge/close', events_count: 1, time: '1h' },
{ title: 'Staging', description: 'From merge request merge until deploy to production', events_count: 1, time: '1h' }
]
stages_without_data = [
{ title: 'Plan', description: 'Time before an issue starts implementation', events_count: 0, median: 'Not enough data' },
{ title: 'Test', description: 'Total test time for all commits/merges', events_count: 0, median: 'Not enough data' }
{ title: 'Plan', description: 'Time before an issue starts implementation', events_count: 0, time: "-" },
{ title: 'Test', description: 'Total test time for all commits/merges', events_count: 0, time: "-" }
]
it 'each stage will display the events description when selected', :sidekiq_might_not_need_inline do
......@@ -352,7 +352,9 @@ RSpec.describe 'Group value stream analytics filters and data', :js do
[].concat(stages_without_data, stages_with_data).each do |stage|
select_stage(stage[:title])
expect(page.find('.js-path-navigation .gl-path-active-item-indigo').text).to eq(stage[:title])
stage_name = page.find('.js-path-navigation .gl-path-active-item-indigo').text
expect(stage_name).to include(stage[:title])
expect(stage_name).to include(stage[:time])
end
end
......
......@@ -118,20 +118,24 @@ const stageFixtures = defaultStages.reduce((acc, stage) => {
};
}, {});
export const stageMedians = defaultStages.reduce((acc, stage) => {
const { value } = getJSONFixture(fixtureEndpoints.stageMedian(stage));
return {
export const rawStageMedians = defaultStages.map((id) => ({
id,
...getJSONFixture(fixtureEndpoints.stageMedian(id)),
}));
export const stageMedians = rawStageMedians.reduce(
(acc, { id, value }) => ({
...acc,
[stage]: value,
};
}, {});
[id]: value,
}),
{},
);
export const stageMediansWithNumericIds = defaultStages.reduce((acc, stage) => {
const { value } = getJSONFixture(fixtureEndpoints.stageMedian(stage));
const { id } = getStageByTitle(dummyState.stages, stage);
export const stageMediansWithNumericIds = rawStageMedians.reduce((acc, { id, value }) => {
const { id: stageId } = getStageByTitle(dummyState.stages, id);
return {
...acc,
[id]: value,
[stageId]: value,
};
}, {});
......@@ -206,7 +210,7 @@ export const rawTasksByTypeData = transformRawTasksByTypeData(apiTasksByTypeData
export const transformedTasksByTypeData = getTasksByTypeData(apiTasksByTypeData);
export const transformedStagePathData = transformStagesForPathNavigation({
stages: [OVERVIEW_STAGE_CONFIG, ...allowedStages],
stages: [{ ...OVERVIEW_STAGE_CONFIG }, ...allowedStages],
medians,
selectedStage: issueStage,
});
......@@ -297,5 +301,4 @@ export const selectedProjects = [
},
];
// Value returned from JSON fixture is 172800 for issue stage which equals 2d
export const pathNavIssueMetric = '2d';
export const pathNavIssueMetric = 172800;
......@@ -184,6 +184,28 @@ describe('Value Stream Analytics mutations', () => {
2: { value: 10, error: null },
});
});
describe('with hasPathNavigation set to true', () => {
beforeEach(() => {
state = {
featureFlags: { hasPathNavigation: true },
medians: {},
};
mutations[types.RECEIVE_STAGE_MEDIANS_SUCCESS](state, [
{ id: 1, value: 7580 },
{ id: 2, value: 434340 },
]);
});
it('formats each stage median for display in the path navigation', () => {
expect(state.medians).toMatchObject({ 1: '2h', 2: '5d' });
});
it('calculates the overview median', () => {
expect(state.medians).toMatchObject({ overview: '5d' });
});
});
});
describe(`${types.INITIALIZE_VSA}`, () => {
......
import { isNumber } from 'lodash';
import { OVERVIEW_STAGE_ID } from 'ee/analytics/cycle_analytics/constants';
import {
isStartEvent,
isLabelEvent,
......@@ -17,6 +18,9 @@ import {
transformStagesForPathNavigation,
prepareTimeMetricsData,
prepareStageErrors,
timeSummaryForPathNavigation,
formatMedianValuesWithOverview,
medianTimeToParsedSeconds,
} from 'ee/analytics/cycle_analytics/utils';
import { toYmd } from 'ee/analytics/shared/utils';
import { getDatesInRange } from '~/lib/utils/datetime_utility';
......@@ -38,6 +42,7 @@ import {
stageMediansWithNumericIds,
pathNavIssueMetric,
timeMetricsData,
rawStageMedians,
} from './mock_data';
const labelEventIds = labelEvents.map((ev) => ev.identifier);
......@@ -390,4 +395,49 @@ 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', () => {
const calculatedMedians = formatMedianValuesWithOverview(rawStageMedians);
it('returns an object with each stage and their median formatted for display', () => {
rawStageMedians.forEach(({ id, value }) => {
expect(calculatedMedians).toMatchObject({ [id]: medianTimeToParsedSeconds(value) });
});
});
it('calculates a median for the overview stage', () => {
expect(calculatedMedians).toMatchObject({ [OVERVIEW_STAGE_ID]: '3w' });
});
});
});
......@@ -33624,7 +33624,22 @@ msgstr ""
msgid "ValueStreamAnalyticsStage|We don't have enough data to show this stage."
msgstr ""
msgid "ValueStreamAnalytics|%{days}d"
msgid "ValueStreamAnalytics|%{value}M"
msgstr ""
msgid "ValueStreamAnalytics|%{value}d"
msgstr ""
msgid "ValueStreamAnalytics|%{value}h"
msgstr ""
msgid "ValueStreamAnalytics|%{value}m"
msgstr ""
msgid "ValueStreamAnalytics|%{value}w"
msgstr ""
msgid "ValueStreamAnalytics|&lt;1m"
msgstr ""
msgid "ValueStreamAnalytics|Median time from first commit to issue closed."
......
......@@ -987,6 +987,16 @@ describe('common_utils', () => {
});
});
describe('roundToNearestHalf', () => {
it('Rounds decimals ot the nearest half', () => {
expect(commonUtils.roundToNearestHalf(3.141592)).toBe(3);
expect(commonUtils.roundToNearestHalf(3.41592)).toBe(3.5);
expect(commonUtils.roundToNearestHalf(1.27)).toBe(1.5);
expect(commonUtils.roundToNearestHalf(1.23)).toBe(1);
expect(commonUtils.roundToNearestHalf(1.778)).toBe(2);
});
});
describe('searchBy', () => {
const searchSpace = {
iid: 1,
......
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