Commit b965d09c authored by Ezekiel Kigbo's avatar Ezekiel Kigbo Committed by Scott Hampton

Display vsa stage time in the path navgiation

Prepares the median value for path navigation
and ensures we correctly round the values and
also display short units ie 7.5d = 7.5 days

Minor fix broken jest specs
parent 7b72698a
...@@ -680,6 +680,19 @@ export const roundOffFloat = (number, precision = 0) => { ...@@ -680,6 +680,19 @@ export const roundOffFloat = (number, precision = 0) => {
return Math.round(number * multiplier) / multiplier; 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 * Method to round down values with decimal places
* with provided precision. * with provided precision.
......
...@@ -196,9 +196,20 @@ GitLab allows users to create multiple value streams, hide default stages and cr ...@@ -196,9 +196,20 @@ GitLab allows users to create multiple value streams, hide default stages and cr
> - It's enabled on GitLab.com. > - It's enabled on GitLab.com.
> - For GitLab self-managed instances, GitLab administrators can opt to [disable it](../../../administration/feature_flags.md). **(FREE SELF)** > - 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: Hovering over a stage item displays a popover with the following information:
......
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { transformRawStages, prepareStageErrors } from '../utils'; import { transformRawStages, prepareStageErrors, formatMedianValuesWithOverview } from '../utils';
import * as types from './mutation_types'; import * as types from './mutation_types';
export default { export default {
...@@ -49,13 +49,17 @@ export default { ...@@ -49,13 +49,17 @@ export default {
state.medians = {}; state.medians = {};
}, },
[types.RECEIVE_STAGE_MEDIANS_SUCCESS](state, medians = []) { [types.RECEIVE_STAGE_MEDIANS_SUCCESS](state, medians = []) {
state.medians = medians.reduce( if (state?.featureFlags?.hasPathNavigation) {
(acc, { id, value, error = null }) => ({ state.medians = formatMedianValuesWithOverview(medians);
...acc, } else {
[id]: { value, error }, state.medians = medians.reduce(
}), (acc, { id, value, error = null }) => ({
{}, ...acc,
); [id]: { value, error },
}),
{},
);
}
}, },
[types.RECEIVE_STAGE_MEDIANS_ERROR](state) { [types.RECEIVE_STAGE_MEDIANS_ERROR](state) {
state.medians = {}; state.medians = {};
......
import dateFormat from 'dateformat'; import dateFormat from 'dateformat';
import { isNumber } from 'lodash'; import { unescape, isNumber } from 'lodash';
import createFlash, { hideFlash } from '~/flash'; 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 { import {
newDate, newDate,
dayAfter, dayAfter,
...@@ -14,6 +15,7 @@ import { convertToSnakeCase, slugify } from '~/lib/utils/text_utility'; ...@@ -14,6 +15,7 @@ import { convertToSnakeCase, slugify } from '~/lib/utils/text_utility';
import { s__, sprintf } from '~/locale'; 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';
const ERROR_NAME_RESERVED = 'is reserved'; const ERROR_NAME_RESERVED = 'is reserved';
...@@ -358,6 +360,71 @@ export const throwIfUserForbidden = (error) => { ...@@ -358,6 +360,71 @@ export const throwIfUserForbidden = (error) => {
export const isStageNameExistsError = ({ status, errors }) => export const isStageNameExistsError = ({ status, errors }) =>
status === httpStatus.UNPROCESSABLE_ENTITY && errors?.name?.includes(ERROR_NAME_RESERVED); 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 * 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. * array which is formatted to proivde the data required for the path navigation.
...@@ -369,14 +436,8 @@ export const isStageNameExistsError = ({ status, errors }) => ...@@ -369,14 +436,8 @@ export const isStageNameExistsError = ({ status, errors }) =>
*/ */
export const transformStagesForPathNavigation = ({ stages, medians, selectedStage }) => { export const transformStagesForPathNavigation = ({ stages, medians, selectedStage }) => {
const formattedStages = stages.map((stage) => { const formattedStages = stages.map((stage) => {
const { days } = parseSeconds(medians[stage.id], {
daysPerWeek: 7,
hoursPerDay: 24,
limitToDays: true,
});
return { return {
metric: days ? sprintf(s__('ValueStreamAnalytics|%{days}d'), { days }) : null, metric: medians[stage?.id],
selected: stage.title === selectedStage.title, selected: stage.title === selectedStage.title,
icon: null, icon: null,
...stage, ...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 ...@@ -312,15 +312,15 @@ RSpec.describe 'Group value stream analytics filters and data', :js do
end end
stages_with_data = [ stages_with_data = [
{ title: 'Issue', description: 'Time before an issue gets scheduled', events_count: 1, median: '5 days' }, { 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, median: 'about 5 hours' }, { 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, median: 'about 1 hour' }, { 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, median: 'about 1 hour' } { title: 'Staging', description: 'From merge request merge until deploy to production', events_count: 1, time: '1h' }
] ]
stages_without_data = [ stages_without_data = [
{ title: 'Plan', description: 'Time before an issue starts implementation', 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, median: 'Not enough data' } { 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 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 ...@@ -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| [].concat(stages_without_data, stages_with_data).each do |stage|
select_stage(stage[:title]) 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
end end
......
...@@ -118,20 +118,24 @@ const stageFixtures = defaultStages.reduce((acc, stage) => { ...@@ -118,20 +118,24 @@ const stageFixtures = defaultStages.reduce((acc, stage) => {
}; };
}, {}); }, {});
export const stageMedians = defaultStages.reduce((acc, stage) => { export const rawStageMedians = defaultStages.map((id) => ({
const { value } = getJSONFixture(fixtureEndpoints.stageMedian(stage)); id,
return { ...getJSONFixture(fixtureEndpoints.stageMedian(id)),
}));
export const stageMedians = rawStageMedians.reduce(
(acc, { id, value }) => ({
...acc, ...acc,
[stage]: value, [id]: value,
}; }),
}, {}); {},
);
export const stageMediansWithNumericIds = defaultStages.reduce((acc, stage) => { export const stageMediansWithNumericIds = rawStageMedians.reduce((acc, { id, value }) => {
const { value } = getJSONFixture(fixtureEndpoints.stageMedian(stage)); const { id: stageId } = getStageByTitle(dummyState.stages, id);
const { id } = getStageByTitle(dummyState.stages, stage);
return { return {
...acc, ...acc,
[id]: value, [stageId]: value,
}; };
}, {}); }, {});
...@@ -206,7 +210,7 @@ export const rawTasksByTypeData = transformRawTasksByTypeData(apiTasksByTypeData ...@@ -206,7 +210,7 @@ export const rawTasksByTypeData = transformRawTasksByTypeData(apiTasksByTypeData
export const transformedTasksByTypeData = getTasksByTypeData(apiTasksByTypeData); export const transformedTasksByTypeData = getTasksByTypeData(apiTasksByTypeData);
export const transformedStagePathData = transformStagesForPathNavigation({ export const transformedStagePathData = transformStagesForPathNavigation({
stages: [OVERVIEW_STAGE_CONFIG, ...allowedStages], stages: [{ ...OVERVIEW_STAGE_CONFIG }, ...allowedStages],
medians, medians,
selectedStage: issueStage, selectedStage: issueStage,
}); });
...@@ -297,5 +301,4 @@ export const selectedProjects = [ ...@@ -297,5 +301,4 @@ export const selectedProjects = [
}, },
]; ];
// Value returned from JSON fixture is 172800 for issue stage which equals 2d export const pathNavIssueMetric = 172800;
export const pathNavIssueMetric = '2d';
...@@ -184,6 +184,28 @@ describe('Value Stream Analytics mutations', () => { ...@@ -184,6 +184,28 @@ describe('Value Stream Analytics mutations', () => {
2: { value: 10, error: null }, 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}`, () => { describe(`${types.INITIALIZE_VSA}`, () => {
......
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
import { OVERVIEW_STAGE_ID } from 'ee/analytics/cycle_analytics/constants';
import { import {
isStartEvent, isStartEvent,
isLabelEvent, isLabelEvent,
...@@ -17,6 +18,9 @@ import { ...@@ -17,6 +18,9 @@ import {
transformStagesForPathNavigation, transformStagesForPathNavigation,
prepareTimeMetricsData, prepareTimeMetricsData,
prepareStageErrors, prepareStageErrors,
timeSummaryForPathNavigation,
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 { getDatesInRange } from '~/lib/utils/datetime_utility'; import { getDatesInRange } from '~/lib/utils/datetime_utility';
...@@ -38,6 +42,7 @@ import { ...@@ -38,6 +42,7 @@ import {
stageMediansWithNumericIds, stageMediansWithNumericIds,
pathNavIssueMetric, pathNavIssueMetric,
timeMetricsData, timeMetricsData,
rawStageMedians,
} from './mock_data'; } from './mock_data';
const labelEventIds = labelEvents.map((ev) => ev.identifier); const labelEventIds = labelEvents.map((ev) => ev.identifier);
...@@ -390,4 +395,49 @@ describe('Value Stream Analytics utils', () => { ...@@ -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' });
});
});
}); });
...@@ -33543,7 +33543,22 @@ msgstr "" ...@@ -33543,7 +33543,22 @@ msgstr ""
msgid "ValueStreamAnalyticsStage|We don't have enough data to show this stage." msgid "ValueStreamAnalyticsStage|We don't have enough data to show this stage."
msgstr "" 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 "" msgstr ""
msgid "ValueStreamAnalytics|Median time from first commit to issue closed." msgid "ValueStreamAnalytics|Median time from first commit to issue closed."
......
...@@ -987,6 +987,16 @@ describe('common_utils', () => { ...@@ -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', () => { describe('searchBy', () => {
const searchSpace = { const searchSpace = {
iid: 1, 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