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) => { ...@@ -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' });
});
});
}); });
...@@ -33624,7 +33624,22 @@ msgstr "" ...@@ -33624,7 +33624,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