Commit 160f182b authored by Ezekiel Kigbo's avatar Ezekiel Kigbo Committed by Enrique Alcántara

Fetch project VSA medians from group VSA endpoint

parent 856a51d3
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { buildApiUrl } from './api_utils'; import { buildApiUrl } from './api_utils';
const GROUP_VSA_PATH_BASE =
'/groups/:id/-/analytics/value_stream_analytics/value_streams/:value_stream_id/stages/:stage_id';
const PROJECT_VSA_PATH_BASE = '/:project_path/-/analytics/value_stream_analytics/value_streams'; const PROJECT_VSA_PATH_BASE = '/:project_path/-/analytics/value_stream_analytics/value_streams';
const PROJECT_VSA_STAGES_PATH = `${PROJECT_VSA_PATH_BASE}/:value_stream_id/stages`; const PROJECT_VSA_STAGES_PATH = `${PROJECT_VSA_PATH_BASE}/:value_stream_id/stages`;
...@@ -13,6 +15,12 @@ const buildProjectValueStreamPath = (projectPath, valueStreamId = null) => { ...@@ -13,6 +15,12 @@ const buildProjectValueStreamPath = (projectPath, valueStreamId = null) => {
return buildApiUrl(PROJECT_VSA_PATH_BASE).replace(':project_path', projectPath); return buildApiUrl(PROJECT_VSA_PATH_BASE).replace(':project_path', projectPath);
}; };
const buildGroupValueStreamPath = ({ groupId, valueStreamId = null, stageId = null }) =>
buildApiUrl(GROUP_VSA_PATH_BASE)
.replace(':id', groupId)
.replace(':value_stream_id', valueStreamId)
.replace(':stage_id', stageId);
export const getProjectValueStreams = (projectPath) => { export const getProjectValueStreams = (projectPath) => {
const url = buildProjectValueStreamPath(projectPath); const url = buildProjectValueStreamPath(projectPath);
return axios.get(url); return axios.get(url);
...@@ -30,3 +38,14 @@ export const getProjectValueStreamStageData = ({ requestPath, stageId, params }) ...@@ -30,3 +38,14 @@ export const getProjectValueStreamStageData = ({ requestPath, stageId, params })
export const getProjectValueStreamMetrics = (requestPath, params) => export const getProjectValueStreamMetrics = (requestPath, params) =>
axios.get(requestPath, { params }); axios.get(requestPath, { params });
/**
* Shared group VSA paths
* We share some endpoints across and group and project level VSA
* When used for project level VSA, requests should include the `project_id` in the params object
*/
export const getValueStreamStageMedian = ({ groupId, valueStreamId, stageId }, params = {}) => {
const stageBase = buildGroupValueStreamPath({ groupId, valueStreamId, stageId });
return axios.get(`${stageBase}/median`, { params });
};
...@@ -8,11 +8,24 @@ Vue.use(Translate); ...@@ -8,11 +8,24 @@ Vue.use(Translate);
export default () => { export default () => {
const store = createStore(); const store = createStore();
const el = document.querySelector('#js-cycle-analytics'); const el = document.querySelector('#js-cycle-analytics');
const { noAccessSvgPath, noDataSvgPath, requestPath, fullPath } = el.dataset; const {
noAccessSvgPath,
noDataSvgPath,
requestPath,
fullPath,
projectId,
groupPath,
} = el.dataset;
store.dispatch('initializeVsa', { store.dispatch('initializeVsa', {
projectId: parseInt(projectId, 10),
groupPath,
requestPath, requestPath,
fullPath, fullPath,
features: {
cycleAnalyticsForGroups:
(groupPath && gon?.licensed_features?.cycleAnalyticsForGroups) || false,
},
}); });
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
......
...@@ -3,6 +3,7 @@ import { ...@@ -3,6 +3,7 @@ import {
getProjectValueStreams, getProjectValueStreams,
getProjectValueStreamStageData, getProjectValueStreamStageData,
getProjectValueStreamMetrics, getProjectValueStreamMetrics,
getValueStreamStageMedian,
} from '~/api/analytics_api'; } from '~/api/analytics_api';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { __ } from '~/locale'; import { __ } from '~/locale';
...@@ -35,21 +36,33 @@ export const receiveValueStreamsSuccess = ({ commit, dispatch }, data = []) => { ...@@ -35,21 +36,33 @@ export const receiveValueStreamsSuccess = ({ commit, dispatch }, data = []) => {
}; };
export const fetchValueStreams = ({ commit, dispatch, state }) => { export const fetchValueStreams = ({ commit, dispatch, state }) => {
const { fullPath } = state; const {
fullPath,
features: { cycleAnalyticsForGroups },
} = state;
commit(types.REQUEST_VALUE_STREAMS); commit(types.REQUEST_VALUE_STREAMS);
const stageRequests = ['setSelectedStage'];
if (cycleAnalyticsForGroups) {
stageRequests.push('fetchStageMedians');
}
return getProjectValueStreams(fullPath) return getProjectValueStreams(fullPath)
.then(({ data }) => dispatch('receiveValueStreamsSuccess', data)) .then(({ data }) => dispatch('receiveValueStreamsSuccess', data))
.then(() => dispatch('setSelectedStage')) .then(() => Promise.all(stageRequests.map((r) => dispatch(r))))
.catch(({ response: { status } }) => { .catch(({ response: { status } }) => {
commit(types.RECEIVE_VALUE_STREAMS_ERROR, status); commit(types.RECEIVE_VALUE_STREAMS_ERROR, status);
}); });
}; };
export const fetchCycleAnalyticsData = ({ state: { requestPath, startDate }, commit }) => { export const fetchCycleAnalyticsData = ({
state: { requestPath },
getters: { legacyFilterParams },
commit,
}) => {
commit(types.REQUEST_CYCLE_ANALYTICS_DATA); commit(types.REQUEST_CYCLE_ANALYTICS_DATA);
return getProjectValueStreamMetrics(requestPath, { 'cycle_analytics[start_date]': startDate }) return getProjectValueStreamMetrics(requestPath, legacyFilterParams)
.then(({ data }) => commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS, data)) .then(({ data }) => commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS, data))
.catch(() => { .catch(() => {
commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR); commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR);
...@@ -59,13 +72,17 @@ export const fetchCycleAnalyticsData = ({ state: { requestPath, startDate }, com ...@@ -59,13 +72,17 @@ export const fetchCycleAnalyticsData = ({ state: { requestPath, startDate }, com
}); });
}; };
export const fetchStageData = ({ state: { requestPath, selectedStage, startDate }, commit }) => { export const fetchStageData = ({
state: { requestPath, selectedStage },
getters: { legacyFilterParams },
commit,
}) => {
commit(types.REQUEST_STAGE_DATA); commit(types.REQUEST_STAGE_DATA);
return getProjectValueStreamStageData({ return getProjectValueStreamStageData({
requestPath, requestPath,
stageId: selectedStage.id, stageId: selectedStage.id,
params: { 'cycle_analytics[start_date]': startDate }, params: legacyFilterParams,
}) })
.then(({ data }) => { .then(({ data }) => {
// when there's a query timeout, the request succeeds but the error is encoded in the response data // when there's a query timeout, the request succeeds but the error is encoded in the response data
...@@ -78,6 +95,37 @@ export const fetchStageData = ({ state: { requestPath, selectedStage, startDate ...@@ -78,6 +95,37 @@ export const fetchStageData = ({ state: { requestPath, selectedStage, startDate
.catch(() => commit(types.RECEIVE_STAGE_DATA_ERROR)); .catch(() => commit(types.RECEIVE_STAGE_DATA_ERROR));
}; };
const getStageMedians = ({ stageId, vsaParams, filterParams = {} }) => {
return getValueStreamStageMedian({ ...vsaParams, stageId }, filterParams).then(({ data }) => ({
id: stageId,
value: data?.value || null,
}));
};
export const fetchStageMedians = ({
state: { stages },
getters: { requestParams: vsaParams, filterParams },
commit,
}) => {
commit(types.REQUEST_STAGE_MEDIANS);
return Promise.all(
stages.map(({ id: stageId }) =>
getStageMedians({
vsaParams,
stageId,
filterParams,
}),
),
)
.then((data) => commit(types.RECEIVE_STAGE_MEDIANS_SUCCESS, data))
.catch((error) => {
commit(types.RECEIVE_STAGE_MEDIANS_ERROR, error);
createFlash({
message: __('There was an error fetching median data for stages'),
});
});
};
export const setSelectedStage = ({ dispatch, commit, state: { stages } }, selectedStage = null) => { export const setSelectedStage = ({ dispatch, commit, state: { stages } }, selectedStage = null) => {
const stage = selectedStage || stages[0]; const stage = selectedStage || stages[0];
commit(types.SET_SELECTED_STAGE, stage); commit(types.SET_SELECTED_STAGE, stage);
......
import dateFormat from 'dateformat';
import { dateFormats } from '~/analytics/shared/constants';
import { transformStagesForPathNavigation, filterStagesByHiddenStatus } from '../utils'; import { transformStagesForPathNavigation, filterStagesByHiddenStatus } from '../utils';
export const pathNavigationData = ({ stages, medians, stageCounts, selectedStage }) => { export const pathNavigationData = ({ stages, medians, stageCounts, selectedStage }) => {
...@@ -8,3 +10,30 @@ export const pathNavigationData = ({ stages, medians, stageCounts, selectedStage ...@@ -8,3 +10,30 @@ export const pathNavigationData = ({ stages, medians, stageCounts, selectedStage
selectedStage, selectedStage,
}); });
}; };
export const requestParams = (state) => {
const {
selectedStage: { id: stageId = null },
groupPath: groupId,
selectedValueStream: { id: valueStreamId },
} = state;
return { valueStreamId, groupId, stageId };
};
const dateRangeParams = ({ createdAfter, createdBefore }) => ({
created_after: createdAfter ? dateFormat(createdAfter, dateFormats.isoDate) : null,
created_before: createdBefore ? dateFormat(createdBefore, dateFormats.isoDate) : null,
});
export const legacyFilterParams = ({ startDate }) => {
return {
'cycle_analytics[start_date]': startDate,
};
};
export const filterParams = ({ id, ...rest }) => {
return {
project_ids: [id],
...dateRangeParams(rest),
};
};
...@@ -20,3 +20,7 @@ export const RECEIVE_CYCLE_ANALYTICS_DATA_ERROR = 'RECEIVE_CYCLE_ANALYTICS_DATA_ ...@@ -20,3 +20,7 @@ export const RECEIVE_CYCLE_ANALYTICS_DATA_ERROR = 'RECEIVE_CYCLE_ANALYTICS_DATA_
export const REQUEST_STAGE_DATA = 'REQUEST_STAGE_DATA'; export const REQUEST_STAGE_DATA = 'REQUEST_STAGE_DATA';
export const RECEIVE_STAGE_DATA_SUCCESS = 'RECEIVE_STAGE_DATA_SUCCESS'; export const RECEIVE_STAGE_DATA_SUCCESS = 'RECEIVE_STAGE_DATA_SUCCESS';
export const RECEIVE_STAGE_DATA_ERROR = 'RECEIVE_STAGE_DATA_ERROR'; export const RECEIVE_STAGE_DATA_ERROR = 'RECEIVE_STAGE_DATA_ERROR';
export const REQUEST_STAGE_MEDIANS = 'REQUEST_STAGE_MEDIANS';
export const RECEIVE_STAGE_MEDIANS_SUCCESS = 'RECEIVE_STAGE_MEDIANS_SUCCESS';
export const RECEIVE_STAGE_MEDIANS_ERROR = 'RECEIVE_STAGE_MEDIANS_ERROR';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { decorateData, decorateEvents, formatMedianValues } from '../utils'; import { DEFAULT_DAYS_TO_DISPLAY } from '../constants';
import {
decorateData,
decorateEvents,
formatMedianValues,
calculateFormattedDayInPast,
} from '../utils';
import * as types from './mutation_types'; import * as types from './mutation_types';
export default { export default {
[types.INITIALIZE_VSA](state, { requestPath, fullPath }) { [types.INITIALIZE_VSA](state, { requestPath, fullPath, groupPath, projectId, features }) {
state.requestPath = requestPath; state.requestPath = requestPath;
state.fullPath = fullPath; state.fullPath = fullPath;
state.groupPath = groupPath;
state.id = projectId;
const { now, past } = calculateFormattedDayInPast(DEFAULT_DAYS_TO_DISPLAY);
state.createdBefore = now;
state.createdAfter = past;
state.features = features;
}, },
[types.SET_LOADING](state, loadingState) { [types.SET_LOADING](state, loadingState) {
state.isLoading = loadingState; state.isLoading = loadingState;
...@@ -18,6 +30,9 @@ export default { ...@@ -18,6 +30,9 @@ export default {
}, },
[types.SET_DATE_RANGE](state, { startDate }) { [types.SET_DATE_RANGE](state, { startDate }) {
state.startDate = startDate; state.startDate = startDate;
const { now, past } = calculateFormattedDayInPast(startDate);
state.createdBefore = now;
state.createdAfter = past;
}, },
[types.REQUEST_VALUE_STREAMS](state) { [types.REQUEST_VALUE_STREAMS](state) {
state.valueStreams = []; state.valueStreams = [];
...@@ -46,17 +61,25 @@ export default { ...@@ -46,17 +61,25 @@ export default {
[types.REQUEST_CYCLE_ANALYTICS_DATA](state) { [types.REQUEST_CYCLE_ANALYTICS_DATA](state) {
state.isLoading = true; state.isLoading = true;
state.hasError = false; state.hasError = false;
if (!state.features.cycleAnalyticsForGroups) {
state.medians = {};
}
}, },
[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, data) { [types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, data) {
const { summary, medians } = decorateData(data); const { summary, medians } = decorateData(data);
if (!state.features.cycleAnalyticsForGroups) {
state.medians = formatMedianValues(medians);
}
state.permissions = data.permissions; state.permissions = data.permissions;
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) {
state.isLoading = false; state.isLoading = false;
state.hasError = true; state.hasError = true;
if (!state.features.cycleAnalyticsForGroups) {
state.medians = {};
}
}, },
[types.REQUEST_STAGE_DATA](state) { [types.REQUEST_STAGE_DATA](state) {
state.isLoadingStage = true; state.isLoadingStage = true;
...@@ -78,4 +101,13 @@ export default { ...@@ -78,4 +101,13 @@ export default {
state.hasError = true; state.hasError = true;
state.selectedStageError = error; state.selectedStageError = error;
}, },
[types.REQUEST_STAGE_MEDIANS](state) {
state.medians = {};
},
[types.RECEIVE_STAGE_MEDIANS_SUCCESS](state, medians) {
state.medians = formatMedianValues(medians);
},
[types.RECEIVE_STAGE_MEDIANS_ERROR](state) {
state.medians = {};
},
}; };
import { DEFAULT_DAYS_TO_DISPLAY } from '../constants'; import { DEFAULT_DAYS_TO_DISPLAY } from '../constants';
export default () => ({ export default () => ({
features: {},
id: null,
requestPath: '', requestPath: '',
fullPath: '', fullPath: '',
startDate: DEFAULT_DAYS_TO_DISPLAY, startDate: DEFAULT_DAYS_TO_DISPLAY,
createdAfter: null,
createdBefore: null,
stages: [], stages: [],
summary: [], summary: [],
analytics: [], analytics: [],
...@@ -19,4 +23,5 @@ export default () => ({ ...@@ -19,4 +23,5 @@ export default () => ({
isLoadingStage: false, isLoadingStage: false,
isEmptyStage: false, isEmptyStage: false,
permissions: {}, permissions: {},
parentPath: null,
}); });
import dateFormat from 'dateformat';
import { unescape } from 'lodash'; import { unescape } from 'lodash';
import { dateFormats } from '~/analytics/shared/constants';
import { sanitize } from '~/lib/dompurify'; import { sanitize } from '~/lib/dompurify';
import { roundToNearestHalf, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { roundToNearestHalf, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { getDateInPast } from '~/lib/utils/datetime/date_calculation_utility';
import { parseSeconds } from '~/lib/utils/datetime_utility'; import { parseSeconds } from '~/lib/utils/datetime_utility';
import { s__, sprintf } from '../locale'; import { s__, sprintf } from '../locale';
import DEFAULT_EVENT_OBJECTS from './default_event_objects'; import DEFAULT_EVENT_OBJECTS from './default_event_objects';
...@@ -115,3 +118,20 @@ export const formatMedianValues = (medians = []) => ...@@ -115,3 +118,20 @@ export const formatMedianValues = (medians = []) =>
export const filterStagesByHiddenStatus = (stages = [], isHidden = true) => export const filterStagesByHiddenStatus = (stages = [], isHidden = true) =>
stages.filter(({ hidden = false }) => hidden === isHidden); stages.filter(({ hidden = false }) => hidden === isHidden);
const toIsoFormat = (d) => dateFormat(d, dateFormats.isoDate);
/**
* Takes an integer specifying the number of days to subtract
* from the date specified will return the 2 dates, formatted as ISO dates
*
* @param {Number} daysInPast - Number of days in the past to subtract
* @param {Date} [today=new Date] - Date to subtract days from, defaults to today
* @returns {Object} Returns 'now' and the 'past' date formatted as ISO dates
*/
export const calculateFormattedDayInPast = (daysInPast, today = new Date()) => {
return {
now: toIsoFormat(today),
past: toIsoFormat(getDateInPast(today, daysInPast)),
};
};
...@@ -13,6 +13,10 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController ...@@ -13,6 +13,10 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
feature_category :planning_analytics feature_category :planning_analytics
before_action do
push_licensed_feature(:cycle_analytics_for_groups) if project.licensed_feature_available?(:cycle_analytics_for_groups)
end
def show def show
@cycle_analytics = Analytics::CycleAnalytics::ProjectLevel.new(project: @project, options: options(cycle_analytics_project_params)) @cycle_analytics = Analytics::CycleAnalytics::ProjectLevel.new(project: @project, options: options(cycle_analytics_project_params))
......
- page_title _("Value Stream Analytics") - page_title _("Value Stream Analytics")
- add_page_specific_style 'page_bundles/cycle_analytics' - add_page_specific_style 'page_bundles/cycle_analytics'
- svgs = { empty_state_svg_path: image_path("illustrations/analytics/cycle-analytics-empty-chart.svg"), no_data_svg_path: image_path("illustrations/analytics/cycle-analytics-empty-chart.svg"), no_access_svg_path: image_path("illustrations/analytics/no-access.svg") } - svgs = { empty_state_svg_path: image_path("illustrations/analytics/cycle-analytics-empty-chart.svg"), no_data_svg_path: image_path("illustrations/analytics/cycle-analytics-empty-chart.svg"), no_access_svg_path: image_path("illustrations/analytics/no-access.svg") }
- initial_data = { request_path: project_cycle_analytics_path(@project), full_path: @project.full_path }.merge!(svgs) - initial_data = { project_id: @project.id, group_path: @project.group&.path, request_path: project_cycle_analytics_path(@project), full_path: @project.full_path }.merge!(svgs)
#js-cycle-analytics{ data: initial_data } #js-cycle-analytics{ data: initial_data }
import Api from 'ee/api'; import Api from 'ee/api';
import { getValueStreamStageMedian } from '~/api/analytics_api';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils'; import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils';
import httpStatus from '~/lib/utils/http_status'; import httpStatus from '~/lib/utils/http_status';
...@@ -99,7 +100,7 @@ export const receiveStageMedianValuesError = ({ commit }, error) => { ...@@ -99,7 +100,7 @@ export const receiveStageMedianValuesError = ({ commit }, error) => {
}; };
const fetchStageMedian = ({ groupId, valueStreamId, stageId, params }) => const fetchStageMedian = ({ groupId, valueStreamId, stageId, params }) =>
Api.cycleAnalyticsStageMedian({ groupId, valueStreamId, stageId, params }).then(({ data }) => { getValueStreamStageMedian({ groupId, valueStreamId, stageId }, params).then(({ data }) => {
return { return {
id: stageId, id: stageId,
...(data?.error ...(data?.error
......
...@@ -169,12 +169,6 @@ export default { ...@@ -169,12 +169,6 @@ export default {
return axios.get(url, { params }); return axios.get(url, { params });
}, },
cycleAnalyticsStageMedian({ groupId, valueStreamId, stageId, params = {} }) {
const stageBase = this.cycleAnalyticsStageUrl({ groupId, valueStreamId, stageId });
const url = `${stageBase}/median`;
return axios.get(url, { params });
},
cycleAnalyticsStageCount({ groupId, valueStreamId, stageId, params = {} }) { cycleAnalyticsStageCount({ groupId, valueStreamId, stageId, params = {} }) {
const stageBase = this.cycleAnalyticsStageUrl({ groupId, valueStreamId, stageId }); const stageBase = this.cycleAnalyticsStageUrl({ groupId, valueStreamId, stageId });
const url = `${stageBase}/count`; const url = `${stageBase}/count`;
......
...@@ -455,29 +455,6 @@ describe('Api', () => { ...@@ -455,29 +455,6 @@ describe('Api', () => {
}); });
}); });
describe('cycleAnalyticsStageMedian', () => {
it('fetches stage events', (done) => {
const response = { value: '5 days ago' };
const params = { ...defaultParams };
const expectedUrl = valueStreamBaseUrl({
id: valueStreamId,
resource: `stages/${stageId}/median`,
});
mock.onGet(expectedUrl).reply(httpStatus.OK, response);
Api.cycleAnalyticsStageMedian({ groupId, valueStreamId, stageId, params })
.then((responseObj) =>
expectRequestWithCorrectParameters(responseObj, {
response,
params,
expectedUrl,
}),
)
.then(done)
.catch(done.fail);
});
});
describe('cycleAnalyticsDurationChart', () => { describe('cycleAnalyticsDurationChart', () => {
it('fetches stage duration data', (done) => { it('fetches stage duration data', (done) => {
const response = []; const response = [];
......
...@@ -174,6 +174,15 @@ export const stageMedians = { ...@@ -174,6 +174,15 @@ export const stageMedians = {
staging: 388800, staging: 388800,
}; };
export const formattedStageMedians = {
issue: '2d',
plan: '1d',
review: '1w',
code: '1d',
test: '3d',
staging: '4d',
};
export const allowedStages = [issueStage, planStage, codeStage]; export const allowedStages = [issueStage, planStage, codeStage];
export const transformedProjectStagePathData = [ export const transformedProjectStagePathData = [
......
...@@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; ...@@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper'; import testAction from 'helpers/vuex_action_helper';
import * as actions from '~/cycle_analytics/store/actions'; import * as actions from '~/cycle_analytics/store/actions';
import httpStatusCodes from '~/lib/utils/http_status'; import httpStatusCodes from '~/lib/utils/http_status';
import { selectedStage, selectedValueStream } from '../mock_data'; import { allowedStages, selectedStage, selectedValueStream } from '../mock_data';
const mockRequestPath = 'some/cool/path'; const mockRequestPath = 'some/cool/path';
const mockFullPath = '/namespace/-/analytics/value_stream_analytics/value_streams'; const mockFullPath = '/namespace/-/analytics/value_stream_analytics/value_streams';
...@@ -25,6 +25,10 @@ const mockRequestedDataMutations = [ ...@@ -25,6 +25,10 @@ const mockRequestedDataMutations = [
}, },
]; ];
const features = {
cycleAnalyticsForGroups: true,
};
describe('Project Value Stream Analytics actions', () => { describe('Project Value Stream Analytics actions', () => {
let state; let state;
let mock; let mock;
...@@ -175,6 +179,7 @@ describe('Project Value Stream Analytics actions', () => { ...@@ -175,6 +179,7 @@ describe('Project Value Stream Analytics actions', () => {
beforeEach(() => { beforeEach(() => {
state = { state = {
features,
fullPath: mockFullPath, fullPath: mockFullPath,
}; };
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
...@@ -187,9 +192,33 @@ describe('Project Value Stream Analytics actions', () => { ...@@ -187,9 +192,33 @@ describe('Project Value Stream Analytics actions', () => {
state, state,
payload: {}, payload: {},
expectedMutations: [{ type: 'REQUEST_VALUE_STREAMS' }], expectedMutations: [{ type: 'REQUEST_VALUE_STREAMS' }],
expectedActions: [{ type: 'receiveValueStreamsSuccess' }, { type: 'setSelectedStage' }], expectedActions: [
{ type: 'receiveValueStreamsSuccess' },
{ type: 'setSelectedStage' },
{ type: 'fetchStageMedians' },
],
})); }));
describe('with cycleAnalyticsForGroups=false', () => {
beforeEach(() => {
state = {
features: { cycleAnalyticsForGroups: false },
fullPath: mockFullPath,
};
mock = new MockAdapter(axios);
mock.onGet(mockValueStreamPath).reply(httpStatusCodes.OK);
});
it("does not dispatch the 'fetchStageMedians' request", () =>
testAction({
action: actions.fetchValueStreams,
state,
payload: {},
expectedMutations: [{ type: 'REQUEST_VALUE_STREAMS' }],
expectedActions: [{ type: 'receiveValueStreamsSuccess' }, { type: 'setSelectedStage' }],
}));
});
describe('with a failing request', () => { describe('with a failing request', () => {
beforeEach(() => { beforeEach(() => {
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
...@@ -280,4 +309,59 @@ describe('Project Value Stream Analytics actions', () => { ...@@ -280,4 +309,59 @@ describe('Project Value Stream Analytics actions', () => {
})); }));
}); });
}); });
describe('fetchStageMedians', () => {
const mockValueStreamPath = /median/;
const stageMediansPayload = [
{ id: 'issue', value: null },
{ id: 'plan', value: null },
{ id: 'code', value: null },
];
const stageMedianError = new Error(
`Request failed with status code ${httpStatusCodes.BAD_REQUEST}`,
);
beforeEach(() => {
state = {
fullPath: mockFullPath,
selectedValueStream,
stages: allowedStages,
};
mock = new MockAdapter(axios);
mock.onGet(mockValueStreamPath).reply(httpStatusCodes.OK);
});
it(`commits the 'REQUEST_STAGE_MEDIANS' and 'RECEIVE_STAGE_MEDIANS_SUCCESS' mutations`, () =>
testAction({
action: actions.fetchStageMedians,
state,
payload: {},
expectedMutations: [
{ type: 'REQUEST_STAGE_MEDIANS' },
{ type: 'RECEIVE_STAGE_MEDIANS_SUCCESS', payload: stageMediansPayload },
],
expectedActions: [],
}));
describe('with a failing request', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
mock.onGet(mockValueStreamPath).reply(httpStatusCodes.BAD_REQUEST);
});
it(`commits the 'RECEIVE_VALUE_STREAM_STAGES_ERROR' mutation`, () =>
testAction({
action: actions.fetchStageMedians,
state,
payload: {},
expectedMutations: [
{ type: 'REQUEST_STAGE_MEDIANS' },
{ type: 'RECEIVE_STAGE_MEDIANS_ERROR', payload: stageMedianError },
],
expectedActions: [],
}));
});
});
}); });
import { useFakeDate } from 'helpers/fake_date';
import { DEFAULT_DAYS_TO_DISPLAY } from '~/cycle_analytics/constants';
import * as types from '~/cycle_analytics/store/mutation_types'; import * as types from '~/cycle_analytics/store/mutation_types';
import mutations from '~/cycle_analytics/store/mutations'; import mutations from '~/cycle_analytics/store/mutations';
import { import {
...@@ -9,15 +11,23 @@ import { ...@@ -9,15 +11,23 @@ import {
selectedValueStream, selectedValueStream,
rawValueStreamStages, rawValueStreamStages,
valueStreamStages, valueStreamStages,
rawStageMedians,
formattedStageMedians,
} from '../mock_data'; } from '../mock_data';
let state; let state;
const mockRequestPath = 'fake/request/path'; const mockRequestPath = 'fake/request/path';
const mockStartData = '2021-04-20'; const mockCreatedAfter = '2020-06-18';
const mockCreatedBefore = '2020-07-18';
const features = {
cycleAnalyticsForGroups: true,
};
describe('Project Value Stream Analytics mutations', () => { describe('Project Value Stream Analytics mutations', () => {
useFakeDate(2020, 6, 18);
beforeEach(() => { beforeEach(() => {
state = {}; state = { features };
}); });
afterEach(() => { afterEach(() => {
...@@ -46,6 +56,8 @@ describe('Project Value Stream Analytics mutations', () => { ...@@ -46,6 +56,8 @@ describe('Project Value Stream Analytics mutations', () => {
${types.RECEIVE_STAGE_DATA_ERROR} | ${'selectedStageEvents'} | ${[]} ${types.RECEIVE_STAGE_DATA_ERROR} | ${'selectedStageEvents'} | ${[]}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'hasError'} | ${true} ${types.RECEIVE_STAGE_DATA_ERROR} | ${'hasError'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isEmptyStage'} | ${true} ${types.RECEIVE_STAGE_DATA_ERROR} | ${'isEmptyStage'} | ${true}
${types.REQUEST_STAGE_MEDIANS} | ${'medians'} | ${{}}
${types.RECEIVE_STAGE_MEDIANS_ERROR} | ${'medians'} | ${{}}
`('$mutation will set $stateKey to $value', ({ mutation, stateKey, value }) => { `('$mutation will set $stateKey to $value', ({ mutation, stateKey, value }) => {
mutations[mutation](state, {}); mutations[mutation](state, {});
...@@ -53,15 +65,19 @@ describe('Project Value Stream Analytics mutations', () => { ...@@ -53,15 +65,19 @@ describe('Project Value Stream Analytics mutations', () => {
}); });
it.each` it.each`
mutation | payload | stateKey | value mutation | payload | stateKey | value
${types.INITIALIZE_VSA} | ${{ requestPath: mockRequestPath }} | ${'requestPath'} | ${mockRequestPath} ${types.INITIALIZE_VSA} | ${{ requestPath: mockRequestPath }} | ${'requestPath'} | ${mockRequestPath}
${types.SET_DATE_RANGE} | ${{ startDate: mockStartData }} | ${'startDate'} | ${mockStartData} ${types.SET_DATE_RANGE} | ${{ startDate: DEFAULT_DAYS_TO_DISPLAY }} | ${'startDate'} | ${DEFAULT_DAYS_TO_DISPLAY}
${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true} ${types.SET_DATE_RANGE} | ${{ startDate: DEFAULT_DAYS_TO_DISPLAY }} | ${'createdAfter'} | ${mockCreatedAfter}
${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false} ${types.SET_DATE_RANGE} | ${{ startDate: DEFAULT_DAYS_TO_DISPLAY }} | ${'createdBefore'} | ${mockCreatedBefore}
${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream} ${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true}
${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${rawData} | ${'summary'} | ${convertedData.summary} ${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false}
${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]} ${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream}
${types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS} | ${{ stages: rawValueStreamStages }} | ${'stages'} | ${valueStreamStages} ${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${rawData} | ${'summary'} | ${convertedData.summary}
${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]}
${types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS} | ${{ stages: rawValueStreamStages }} | ${'stages'} | ${valueStreamStages}
${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]}
${types.RECEIVE_STAGE_MEDIANS_SUCCESS} | ${rawStageMedians} | ${'medians'} | ${formattedStageMedians}
`( `(
'$mutation with $payload will set $stateKey to $value', '$mutation with $payload will set $stateKey to $value',
({ mutation, payload, stateKey, value }) => { ({ mutation, payload, stateKey, value }) => {
...@@ -92,4 +108,35 @@ describe('Project Value Stream Analytics mutations', () => { ...@@ -92,4 +108,35 @@ describe('Project Value Stream Analytics mutations', () => {
}, },
); );
}); });
describe('with cycleAnalyticsForGroups=false', () => {
useFakeDate(2020, 6, 18);
beforeEach(() => {
state = { features: { cycleAnalyticsForGroups: false } };
});
const formattedMedians = {
code: '2d',
issue: '-',
plan: '21h',
review: '-',
staging: '2d',
test: '4h',
};
it.each`
mutation | payload | stateKey | value
${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${rawData} | ${'medians'} | ${formattedMedians}
${types.REQUEST_CYCLE_ANALYTICS_DATA} | ${{}} | ${'medians'} | ${{}}
${types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR} | ${{}} | ${'medians'} | ${{}}
`(
'$mutation with $payload will set $stateKey to $value',
({ mutation, payload, stateKey, value }) => {
mutations[mutation](state, payload);
expect(state).toMatchObject({ [stateKey]: value });
},
);
});
}); });
import { useFakeDate } from 'helpers/fake_date';
import { import {
decorateEvents, decorateEvents,
decorateData, decorateData,
...@@ -6,6 +7,7 @@ import { ...@@ -6,6 +7,7 @@ import {
medianTimeToParsedSeconds, medianTimeToParsedSeconds,
formatMedianValues, formatMedianValues,
filterStagesByHiddenStatus, filterStagesByHiddenStatus,
calculateFormattedDayInPast,
} from '~/cycle_analytics/utils'; } from '~/cycle_analytics/utils';
import { import {
selectedStage, selectedStage,
...@@ -149,4 +151,12 @@ describe('Value stream analytics utils', () => { ...@@ -149,4 +151,12 @@ describe('Value stream analytics utils', () => {
expect(filterStagesByHiddenStatus(mockStages, isHidden)).toEqual(result); expect(filterStagesByHiddenStatus(mockStages, isHidden)).toEqual(result);
}); });
}); });
describe('calculateFormattedDayInPast', () => {
useFakeDate(1815, 11, 10);
it('will return 2 dates, now and past', () => {
expect(calculateFormattedDayInPast(5)).toEqual({ now: '1815-12-10', past: '1815-12-05' });
});
});
}); });
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