Commit 552bcfa3 authored by Ezekiel Kigbo's avatar Ezekiel Kigbo Committed by Mark Florian

Adds dedicated endpoints for project VSA

Adds requests for the dedicated project level
VSA endpoints to the api/analytics_api file.

Updates the loading indicators for the base vue
parent dd3d6bd2
import axios from '~/lib/utils/axios_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 = '/:request_path/-/analytics/value_stream_analytics/value_streams';
const PROJECT_VSA_STAGES_PATH = `${PROJECT_VSA_PATH_BASE}/:value_stream_id/stages`;
const PROJECT_VSA_STAGE_DATA_PATH = `${PROJECT_VSA_STAGES_PATH}/:stage_id`;
const buildProjectValueStreamPath = (projectPath, valueStreamId = null) => {
const buildProjectValueStreamPath = (requestPath, valueStreamId = null) => {
if (valueStreamId) {
return buildApiUrl(PROJECT_VSA_STAGES_PATH)
.replace(':project_path', projectPath)
.replace(':request_path', requestPath)
.replace(':value_stream_id', valueStreamId);
}
return buildApiUrl(PROJECT_VSA_PATH_BASE).replace(':project_path', projectPath);
return buildApiUrl(PROJECT_VSA_PATH_BASE).replace(':request_path', requestPath);
};
const buildGroupValueStreamPath = ({ groupId, valueStreamId = null, stageId = null }) =>
buildApiUrl(GROUP_VSA_PATH_BASE)
.replace(':id', groupId)
const buildValueStreamStageDataPath = ({ requestPath, valueStreamId = null, stageId = null }) =>
buildApiUrl(PROJECT_VSA_STAGE_DATA_PATH)
.replace(':request_path', requestPath)
.replace(':value_stream_id', valueStreamId)
.replace(':stage_id', stageId);
export const getProjectValueStreams = (projectPath) => {
const url = buildProjectValueStreamPath(projectPath);
export const getProjectValueStreams = (requestPath) => {
const url = buildProjectValueStreamPath(requestPath);
return axios.get(url);
};
export const getProjectValueStreamStages = (projectPath, valueStreamId) => {
const url = buildProjectValueStreamPath(projectPath, valueStreamId);
export const getProjectValueStreamStages = (requestPath, valueStreamId) => {
const url = buildProjectValueStreamPath(requestPath, valueStreamId);
return axios.get(url);
};
......@@ -45,7 +44,15 @@ export const getProjectValueStreamMetrics = (requestPath, params) =>
* 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 });
export const getValueStreamStageMedian = ({ requestPath, valueStreamId, stageId }, params = {}) => {
const stageBase = buildValueStreamStageDataPath({ requestPath, valueStreamId, stageId });
return axios.get(`${stageBase}/median`, { params });
};
export const getValueStreamStageRecords = (
{ requestPath, valueStreamId, stageId },
params = {},
) => {
const stageBase = buildValueStreamStageDataPath({ requestPath, valueStreamId, stageId });
return axios.get(`${stageBase}/records`, { params });
};
......@@ -42,7 +42,7 @@ export default {
'selectedStageError',
'stages',
'summary',
'startDate',
'daysInPast',
'permissions',
]),
...mapGetters(['pathNavigationData']),
......@@ -51,13 +51,15 @@ export default {
return selectedStageEvents.length && !isLoadingStage && !isEmptyStage;
},
displayNotEnoughData() {
return this.selectedStageReady && this.isEmptyStage;
return !this.isLoadingStage && this.isEmptyStage;
},
displayNoAccess() {
return this.selectedStageReady && !this.isUserAllowed(this.selectedStage.id);
return (
!this.isLoadingStage && this.selectedStage?.id && !this.isUserAllowed(this.selectedStage.id)
);
},
selectedStageReady() {
return !this.isLoadingStage && this.selectedStage;
displayPathNavigation() {
return this.isLoading || (this.selectedStage && this.pathNavigationData.length);
},
emptyStageTitle() {
if (this.displayNoAccess) {
......@@ -83,8 +85,8 @@ export default {
'setSelectedStage',
'setDateRange',
]),
handleDateSelect(startDate) {
this.setDateRange({ startDate });
handleDateSelect(daysInPast) {
this.setDateRange(daysInPast);
},
onSelectStage(stage) {
this.setSelectedStage(stage);
......@@ -101,15 +103,18 @@ export default {
dayRangeOptions: [7, 30, 90],
i18n: {
dropdownText: __('Last %{days} days'),
pageTitle: __('Value Stream Analytics'),
recentActivity: __('Recent Project Activity'),
},
};
</script>
<template>
<div class="cycle-analytics">
<h3>{{ $options.i18n.pageTitle }}</h3>
<path-navigation
v-if="selectedStageReady"
v-if="displayPathNavigation"
class="js-path-navigation gl-w-full gl-pb-2"
:loading="isLoading"
:loading="isLoading || isLoadingStage"
:stages="pathNavigationData"
:selected-stage="selectedStage"
:with-stage-counts="false"
......@@ -135,7 +140,7 @@ export default {
<button class="dropdown-menu-toggle" data-toggle="dropdown" type="button">
<span class="dropdown-label">
<gl-sprintf :message="$options.i18n.dropdownText">
<template #days>{{ startDate }}</template>
<template #days>{{ daysInPast }}</template>
</gl-sprintf>
<gl-icon name="chevron-down" class="dropdown-menu-toggle-icon gl-top-3" />
</span>
......
......@@ -52,7 +52,7 @@ export default {
selectedStage: {
type: Object,
required: false,
default: () => ({ custom: false }),
default: () => ({}),
},
isLoading: {
type: Boolean,
......@@ -102,7 +102,7 @@ export default {
},
computed: {
isEmptyStage() {
return !this.stageEvents.length;
return !this.selectedStage || !this.stageEvents.length;
},
emptyStateTitleText() {
return this.emptyStateTitle || NOT_ENOUGH_DATA_ERROR;
......
......@@ -20,11 +20,9 @@ export default () => {
store.dispatch('initializeVsa', {
projectId: parseInt(projectId, 10),
groupPath,
requestPath,
fullPath,
features: {
cycleAnalyticsForGroups:
(groupPath && gon?.licensed_features?.cycleAnalyticsForGroups) || false,
endpoints: {
requestPath,
fullPath,
},
});
......
import {
getProjectValueStreamStages,
getProjectValueStreams,
getProjectValueStreamStageData,
getProjectValueStreamMetrics,
getValueStreamStageMedian,
getValueStreamStageRecords,
} from '~/api/analytics_api';
import createFlash from '~/flash';
import { __ } from '~/locale';
import {
DEFAULT_DAYS_TO_DISPLAY,
DEFAULT_VALUE_STREAM,
I18N_VSA_ERROR_STAGE_MEDIAN,
} from '../constants';
import { DEFAULT_VALUE_STREAM, I18N_VSA_ERROR_STAGE_MEDIAN } from '../constants';
import * as types from './mutation_types';
export const setSelectedValueStream = ({ commit, dispatch }, valueStream) => {
commit(types.SET_SELECTED_VALUE_STREAM, valueStream);
return dispatch('fetchValueStreamStages');
return Promise.all([dispatch('fetchValueStreamStages'), dispatch('fetchCycleAnalyticsData')]);
};
export const fetchValueStreamStages = ({ commit, state }) => {
const { fullPath, selectedValueStream } = state;
const {
endpoints: { fullPath },
selectedValueStream: { id },
} = state;
commit(types.REQUEST_VALUE_STREAM_STAGES);
return getProjectValueStreamStages(fullPath, selectedValueStream.id)
return getProjectValueStreamStages(fullPath, id)
.then(({ data }) => commit(types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS, data))
.catch(({ response: { status } }) => {
commit(types.RECEIVE_VALUE_STREAM_STAGES_ERROR, status);
......@@ -41,16 +40,11 @@ export const receiveValueStreamsSuccess = ({ commit, dispatch }, data = []) => {
export const fetchValueStreams = ({ commit, dispatch, state }) => {
const {
fullPath,
features: { cycleAnalyticsForGroups },
endpoints: { fullPath },
} = state;
commit(types.REQUEST_VALUE_STREAMS);
const stageRequests = ['setSelectedStage'];
if (cycleAnalyticsForGroups) {
stageRequests.push('fetchStageMedians');
}
const stageRequests = ['setSelectedStage', 'fetchStageMedians'];
return getProjectValueStreams(fullPath)
.then(({ data }) => dispatch('receiveValueStreamsSuccess', data))
.then(() => Promise.all(stageRequests.map((r) => dispatch(r))))
......@@ -58,9 +52,10 @@ export const fetchValueStreams = ({ commit, dispatch, state }) => {
commit(types.RECEIVE_VALUE_STREAMS_ERROR, status);
});
};
export const fetchCycleAnalyticsData = ({
state: { requestPath },
state: {
endpoints: { requestPath },
},
getters: { legacyFilterParams },
commit,
}) => {
......@@ -76,18 +71,10 @@ export const fetchCycleAnalyticsData = ({
});
};
export const fetchStageData = ({
state: { requestPath, selectedStage },
getters: { legacyFilterParams },
commit,
}) => {
export const fetchStageData = ({ getters: { requestParams, filterParams }, commit }) => {
commit(types.REQUEST_STAGE_DATA);
return getProjectValueStreamStageData({
requestPath,
stageId: selectedStage.id,
params: legacyFilterParams,
})
return getValueStreamStageRecords(requestParams, filterParams)
.then(({ data }) => {
// when there's a query timeout, the request succeeds but the error is encoded in the response data
if (data?.error) {
......@@ -134,22 +121,32 @@ export const setSelectedStage = ({ dispatch, commit, state: { stages } }, select
return dispatch('fetchStageData');
};
const refetchData = (dispatch, commit) => {
commit(types.SET_LOADING, true);
export const setLoading = ({ commit }, value) => commit(types.SET_LOADING, value);
const refetchStageData = (dispatch) => {
return Promise.resolve()
.then(() => dispatch('fetchValueStreams'))
.then(() => dispatch('fetchCycleAnalyticsData'))
.finally(() => commit(types.SET_LOADING, false));
.then(() => dispatch('setLoading', true))
.then(() =>
Promise.all([
dispatch('fetchCycleAnalyticsData'),
dispatch('fetchStageData'),
dispatch('fetchStageMedians'),
]),
)
.finally(() => dispatch('setLoading', false));
};
export const setFilters = ({ dispatch, commit }) => refetchData(dispatch, commit);
export const setFilters = ({ dispatch }) => refetchStageData(dispatch);
export const setDateRange = ({ dispatch, commit }, { startDate = DEFAULT_DAYS_TO_DISPLAY }) => {
commit(types.SET_DATE_RANGE, { startDate });
return refetchData(dispatch, commit);
export const setDateRange = ({ dispatch, commit }, daysInPast) => {
commit(types.SET_DATE_RANGE, daysInPast);
return refetchStageData(dispatch);
};
export const initializeVsa = ({ commit, dispatch }, initialData = {}) => {
commit(types.INITIALIZE_VSA, initialData);
return refetchData(dispatch, commit);
return dispatch('setLoading', true)
.then(() => dispatch('fetchValueStreams'))
.finally(() => dispatch('setLoading', false));
};
......@@ -13,11 +13,11 @@ export const pathNavigationData = ({ stages, medians, stageCounts, selectedStage
export const requestParams = (state) => {
const {
selectedStage: { id: stageId = null },
groupPath: groupId,
endpoints: { fullPath },
selectedValueStream: { id: valueStreamId },
selectedStage: { id: stageId = null },
} = state;
return { valueStreamId, groupId, stageId };
return { requestPath: fullPath, valueStreamId, stageId };
};
const dateRangeParams = ({ createdAfter, createdBefore }) => ({
......@@ -25,15 +25,14 @@ const dateRangeParams = ({ createdAfter, createdBefore }) => ({
created_before: createdBefore ? dateFormat(createdBefore, dateFormats.isoDate) : null,
});
export const legacyFilterParams = ({ startDate }) => {
export const legacyFilterParams = ({ daysInPast }) => {
return {
'cycle_analytics[start_date]': startDate,
'cycle_analytics[start_date]': daysInPast,
};
};
export const filterParams = ({ id, ...rest }) => {
export const filterParams = (state) => {
return {
project_ids: [id],
...dateRangeParams(rest),
...dateRangeParams(state),
};
};
......@@ -4,15 +4,11 @@ import { decorateData, formatMedianValues, calculateFormattedDayInPast } from '.
import * as types from './mutation_types';
export default {
[types.INITIALIZE_VSA](state, { requestPath, fullPath, groupPath, projectId, features }) {
state.requestPath = requestPath;
state.fullPath = fullPath;
state.groupPath = groupPath;
state.id = projectId;
[types.INITIALIZE_VSA](state, { endpoints }) {
state.endpoints = endpoints;
const { now, past } = calculateFormattedDayInPast(DEFAULT_DAYS_TO_DISPLAY);
state.createdBefore = now;
state.createdAfter = past;
state.features = features;
},
[types.SET_LOADING](state, loadingState) {
state.isLoading = loadingState;
......@@ -23,9 +19,9 @@ export default {
[types.SET_SELECTED_STAGE](state, stage) {
state.selectedStage = stage;
},
[types.SET_DATE_RANGE](state, { startDate }) {
state.startDate = startDate;
const { now, past } = calculateFormattedDayInPast(startDate);
[types.SET_DATE_RANGE](state, daysInPast) {
state.daysInPast = daysInPast;
const { now, past } = calculateFormattedDayInPast(daysInPast);
state.createdBefore = now;
state.createdAfter = past;
},
......@@ -50,25 +46,16 @@ export default {
[types.REQUEST_CYCLE_ANALYTICS_DATA](state) {
state.isLoading = true;
state.hasError = false;
if (!state.features.cycleAnalyticsForGroups) {
state.medians = {};
}
},
[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, data) {
const { summary, medians } = decorateData(data);
if (!state.features.cycleAnalyticsForGroups) {
state.medians = formatMedianValues(medians);
}
state.permissions = data.permissions;
const { summary } = decorateData(data);
state.permissions = data?.permissions || {};
state.summary = summary;
state.hasError = false;
},
[types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR](state) {
state.isLoading = false;
state.hasError = true;
if (!state.features.cycleAnalyticsForGroups) {
state.medians = {};
}
},
[types.REQUEST_STAGE_DATA](state) {
state.isLoadingStage = true;
......@@ -76,7 +63,7 @@ export default {
state.selectedStageEvents = [];
state.hasError = false;
},
[types.RECEIVE_STAGE_DATA_SUCCESS](state, { events = [] }) {
[types.RECEIVE_STAGE_DATA_SUCCESS](state, events = []) {
state.isLoadingStage = false;
state.isEmptyStage = !events.length;
state.selectedStageEvents = events.map((ev) =>
......
import { DEFAULT_DAYS_TO_DISPLAY } from '../constants';
export default () => ({
features: {},
id: null,
requestPath: '',
fullPath: '',
startDate: DEFAULT_DAYS_TO_DISPLAY,
endpoints: {},
daysInPast: DEFAULT_DAYS_TO_DISPLAY,
createdAfter: null,
createdBefore: null,
stages: [],
......@@ -23,5 +21,4 @@ export default () => ({
isLoadingStage: false,
isEmptyStage: false,
permissions: {},
parentPath: null,
});
......@@ -8,13 +8,11 @@ import { parseSeconds } from '~/lib/utils/datetime_utility';
import { s__, sprintf } from '../locale';
const mapToSummary = ({ value, ...rest }) => ({ ...rest, value: value || '-' });
const mapToMedians = ({ name: id, value }) => ({ id, value });
export const decorateData = (data = {}) => {
const { stats: stages, summary } = data;
const { summary } = data;
return {
summary: summary?.map((item) => mapToSummary(item)) || [],
medians: stages?.map((item) => mapToMedians(item)) || [],
};
};
......
import Api from 'ee/api';
import { getValueStreamStageMedian } from '~/api/analytics_api';
import { getGroupValueStreamStageMedian } from 'ee/api/analytics_api';
import {
I18N_VSA_ERROR_STAGES,
I18N_VSA_ERROR_STAGE_MEDIAN,
......@@ -59,7 +59,7 @@ export const receiveStageMedianValuesError = ({ commit }, error) => {
};
const fetchStageMedian = ({ groupId, valueStreamId, stageId, params }) =>
getValueStreamStageMedian({ groupId, valueStreamId, stageId }, params).then(({ data }) => {
getGroupValueStreamStageMedian({ groupId, valueStreamId, stageId }, params).then(({ data }) => {
return {
id: stageId,
...(data?.error
......
import { buildApiUrl } from '~/api/api_utils';
import axios from '~/lib/utils/axios_utils';
const GROUP_VSA_PATH_BASE =
'/groups/:id/-/analytics/value_stream_analytics/value_streams/:value_stream_id/stages/:stage_id';
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 getGroupValueStreamStageMedian = (
{ groupId, valueStreamId, stageId },
params = {},
) => {
const stageBase = buildGroupValueStreamPath({ groupId, valueStreamId, stageId });
return axios.get(`${stageBase}/median`, { params });
};
......@@ -46,9 +46,9 @@ RSpec.describe 'Value Stream Analytics', :js do
@build = create_cycle(user, project, issue, mr, milestone, pipeline)
deploy_master(user, project)
issue.metrics.update!(first_mentioned_in_commit_at: issue.metrics.first_associated_with_milestone_at + 1.day)
issue.metrics.update!(first_mentioned_in_commit_at: issue.metrics.first_associated_with_milestone_at + 1.hour)
merge_request = issue.merge_requests_closing_issues.first.merge_request
merge_request.update!(created_at: issue.metrics.first_associated_with_milestone_at + 1.day)
merge_request.update!(created_at: issue.metrics.first_associated_with_milestone_at + 1.hour)
merge_request.metrics.update!(
latest_build_started_at: 4.hours.ago,
latest_build_finished_at: 3.hours.ago,
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
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>"`;
......@@ -8,7 +8,15 @@ import PathNavigation from '~/cycle_analytics/components/path_navigation.vue';
import StageTable from '~/cycle_analytics/components/stage_table.vue';
import { NOT_ENOUGH_DATA_ERROR } from '~/cycle_analytics/constants';
import initState from '~/cycle_analytics/store/state';
import { selectedStage, issueEvents } from './mock_data';
import {
permissions,
transformedProjectStagePathData,
selectedStage,
issueEvents,
createdBefore,
createdAfter,
currentGroup,
} from './mock_data';
const selectedStageEvents = issueEvents.events;
const noDataSvgPath = 'path/to/no/data';
......@@ -18,25 +26,31 @@ Vue.use(Vuex);
let wrapper;
function createStore({ initialState = {} }) {
const defaultState = {
permissions,
currentGroup,
createdBefore,
createdAfter,
};
function createStore({ initialState = {}, initialGetters = {} }) {
return new Vuex.Store({
state: {
...initState(),
permissions: {
[selectedStage.id]: true,
},
...defaultState,
...initialState,
},
getters: {
pathNavigationData: () => [],
pathNavigationData: () => transformedProjectStagePathData,
...initialGetters,
},
});
}
function createComponent({ initialState } = {}) {
function createComponent({ initialState, initialGetters } = {}) {
return extendedWrapper(
shallowMount(BaseComponent, {
store: createStore({ initialState }),
store: createStore({ initialState, initialGetters }),
propsData: {
noDataSvgPath,
noAccessSvgPath,
......@@ -57,16 +71,7 @@ const findEmptyStageTitle = () => wrapper.findComponent(GlEmptyState).props('tit
describe('Value stream analytics component', () => {
beforeEach(() => {
wrapper = createComponent({
initialState: {
isLoading: false,
isLoadingStage: false,
isEmptyStage: false,
selectedStageEvents,
selectedStage,
selectedStageError: '',
},
});
wrapper = createComponent({ initialState: { selectedStage, selectedStageEvents } });
});
afterEach(() => {
......@@ -102,7 +107,7 @@ describe('Value stream analytics component', () => {
});
it('renders the path navigation component with prop `loading` set to true', () => {
expect(findPathNavigation().html()).toMatchSnapshot();
expect(findPathNavigation().props('loading')).toBe(true);
});
it('does not render the overview metrics', () => {
......@@ -130,13 +135,19 @@ describe('Value stream analytics component', () => {
expect(tableWrapper.exists()).toBe(true);
expect(tableWrapper.find(GlLoadingIcon).exists()).toBe(true);
});
it('renders the path navigation loading state', () => {
expect(findPathNavigation().props('loading')).toBe(true);
});
});
describe('isEmptyStage = true', () => {
const emptyStageParams = {
isEmptyStage: true,
selectedStage: { ...selectedStage, emptyStageText: 'This stage is empty' },
};
beforeEach(() => {
wrapper = createComponent({
initialState: { selectedStage, isEmptyStage: true },
});
wrapper = createComponent({ initialState: emptyStageParams });
});
it('renders the empty stage with `Not enough data` message', () => {
......@@ -147,8 +158,7 @@ describe('Value stream analytics component', () => {
beforeEach(() => {
wrapper = createComponent({
initialState: {
selectedStage,
isEmptyStage: true,
...emptyStageParams,
selectedStageError: 'There is too much data to calculate',
},
});
......@@ -164,7 +174,9 @@ describe('Value stream analytics component', () => {
beforeEach(() => {
wrapper = createComponent({
initialState: {
selectedStage,
permissions: {
...permissions,
[selectedStage.id]: false,
},
},
......@@ -179,6 +191,7 @@ describe('Value stream analytics component', () => {
describe('without a selected stage', () => {
beforeEach(() => {
wrapper = createComponent({
initialGetters: { pathNavigationData: () => [] },
initialState: { selectedStage: null, isEmptyStage: true },
});
});
......@@ -187,7 +200,7 @@ describe('Value stream analytics component', () => {
expect(findStageTable().exists()).toBe(true);
});
it('does not render the path navigation component', () => {
it('does not render the path navigation', () => {
expect(findPathNavigation().exists()).toBe(false);
});
......
......@@ -2,39 +2,23 @@ import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import * as actions from '~/cycle_analytics/store/actions';
import * as getters from '~/cycle_analytics/store/getters';
import httpStatusCodes from '~/lib/utils/http_status';
import { allowedStages, selectedStage, selectedValueStream } from '../mock_data';
const mockRequestPath = 'some/cool/path';
const mockFullPath = '/namespace/-/analytics/value_stream_analytics/value_streams';
const mockStartDate = 30;
const mockRequestedDataActions = ['fetchValueStreams', 'fetchCycleAnalyticsData'];
const mockInitializeActionCommit = {
payload: { requestPath: mockRequestPath },
type: 'INITIALIZE_VSA',
};
const mockEndpoints = { fullPath: mockFullPath, requestPath: mockRequestPath };
const mockSetDateActionCommit = { payload: { startDate: mockStartDate }, type: 'SET_DATE_RANGE' };
const mockRequestedDataMutations = [
{
payload: true,
type: 'SET_LOADING',
},
{
payload: false,
type: 'SET_LOADING',
},
];
const features = {
cycleAnalyticsForGroups: true,
};
const defaultState = { ...getters, selectedValueStream };
describe('Project Value Stream Analytics actions', () => {
let state;
let mock;
beforeEach(() => {
state = {};
mock = new MockAdapter(axios);
});
......@@ -45,28 +29,62 @@ describe('Project Value Stream Analytics actions', () => {
const mutationTypes = (arr) => arr.map(({ type }) => type);
const mockFetchStageDataActions = [
{ type: 'setLoading', payload: true },
{ type: 'fetchCycleAnalyticsData' },
{ type: 'fetchStageData' },
{ type: 'fetchStageMedians' },
{ type: 'setLoading', payload: false },
];
describe.each`
action | payload | expectedActions | expectedMutations
${'initializeVsa'} | ${{ requestPath: mockRequestPath }} | ${mockRequestedDataActions} | ${[mockInitializeActionCommit, ...mockRequestedDataMutations]}
${'setDateRange'} | ${{ startDate: mockStartDate }} | ${mockRequestedDataActions} | ${[mockSetDateActionCommit, ...mockRequestedDataMutations]}
${'setSelectedStage'} | ${{ selectedStage }} | ${['fetchStageData']} | ${[{ type: 'SET_SELECTED_STAGE', payload: { selectedStage } }]}
${'setSelectedValueStream'} | ${{ selectedValueStream }} | ${['fetchValueStreamStages']} | ${[{ type: 'SET_SELECTED_VALUE_STREAM', payload: { selectedValueStream } }]}
action | payload | expectedActions | expectedMutations
${'setLoading'} | ${true} | ${[]} | ${[{ type: 'SET_LOADING', payload: true }]}
${'setDateRange'} | ${{ startDate: mockStartDate }} | ${mockFetchStageDataActions} | ${[mockSetDateActionCommit]}
${'setFilters'} | ${[]} | ${mockFetchStageDataActions} | ${[]}
${'setSelectedStage'} | ${{ selectedStage }} | ${[{ type: 'fetchStageData' }]} | ${[{ type: 'SET_SELECTED_STAGE', payload: { selectedStage } }]}
${'setSelectedValueStream'} | ${{ selectedValueStream }} | ${[{ type: 'fetchValueStreamStages' }, { type: 'fetchCycleAnalyticsData' }]} | ${[{ type: 'SET_SELECTED_VALUE_STREAM', payload: { selectedValueStream } }]}
`('$action', ({ action, payload, expectedActions, expectedMutations }) => {
const types = mutationTypes(expectedMutations);
it(`will dispatch ${expectedActions} and commit ${types}`, () =>
testAction({
action: actions[action],
state,
payload,
expectedMutations,
expectedActions: expectedActions.map((a) => ({ type: a })),
expectedActions,
}));
});
describe('initializeVsa', () => {
let mockDispatch;
let mockCommit;
const payload = { endpoints: mockEndpoints };
beforeEach(() => {
mockDispatch = jest.fn(() => Promise.resolve());
mockCommit = jest.fn();
});
it('will dispatch the setLoading and fetchValueStreams actions and commit INITIALIZE_VSA', async () => {
await actions.initializeVsa(
{
...state,
dispatch: mockDispatch,
commit: mockCommit,
},
payload,
);
expect(mockCommit).toHaveBeenCalledWith('INITIALIZE_VSA', { endpoints: mockEndpoints });
expect(mockDispatch).toHaveBeenCalledWith('setLoading', true);
expect(mockDispatch).toHaveBeenCalledWith('fetchValueStreams');
expect(mockDispatch).toHaveBeenCalledWith('setLoading', false);
});
});
describe('fetchCycleAnalyticsData', () => {
beforeEach(() => {
state = { requestPath: mockRequestPath };
state = { endpoints: mockEndpoints };
mock = new MockAdapter(axios);
mock.onGet(mockRequestPath).reply(httpStatusCodes.OK);
});
......@@ -85,7 +103,7 @@ describe('Project Value Stream Analytics actions', () => {
describe('with a failing request', () => {
beforeEach(() => {
state = { requestPath: mockRequestPath };
state = { endpoints: mockEndpoints };
mock = new MockAdapter(axios);
mock.onGet(mockRequestPath).reply(httpStatusCodes.BAD_REQUEST);
});
......@@ -105,11 +123,12 @@ describe('Project Value Stream Analytics actions', () => {
});
describe('fetchStageData', () => {
const mockStagePath = `${mockRequestPath}/events/${selectedStage.name}`;
const mockStagePath = /value_streams\/\w+\/stages\/\w+\/records/;
beforeEach(() => {
state = {
requestPath: mockRequestPath,
...defaultState,
endpoints: mockEndpoints,
startDate: mockStartDate,
selectedStage,
};
......@@ -131,7 +150,8 @@ describe('Project Value Stream Analytics actions', () => {
beforeEach(() => {
state = {
requestPath: mockRequestPath,
...defaultState,
endpoints: mockEndpoints,
startDate: mockStartDate,
selectedStage,
};
......@@ -155,7 +175,8 @@ describe('Project Value Stream Analytics actions', () => {
describe('with a failing request', () => {
beforeEach(() => {
state = {
requestPath: mockRequestPath,
...defaultState,
endpoints: mockEndpoints,
startDate: mockStartDate,
selectedStage,
};
......@@ -179,8 +200,7 @@ describe('Project Value Stream Analytics actions', () => {
beforeEach(() => {
state = {
features,
fullPath: mockFullPath,
endpoints: mockEndpoints,
};
mock = new MockAdapter(axios);
mock.onGet(mockValueStreamPath).reply(httpStatusCodes.OK);
......@@ -199,26 +219,6 @@ describe('Project Value Stream Analytics actions', () => {
],
}));
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', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
......@@ -271,7 +271,7 @@ describe('Project Value Stream Analytics actions', () => {
beforeEach(() => {
state = {
fullPath: mockFullPath,
endpoints: mockEndpoints,
selectedValueStream,
};
mock = new MockAdapter(axios);
......
......@@ -21,15 +21,12 @@ const convertedEvents = issueEvents.events;
const mockRequestPath = 'fake/request/path';
const mockCreatedAfter = '2020-06-18';
const mockCreatedBefore = '2020-07-18';
const features = {
cycleAnalyticsForGroups: true,
};
describe('Project Value Stream Analytics mutations', () => {
useFakeDate(2020, 6, 18);
beforeEach(() => {
state = { features };
state = {};
});
afterEach(() => {
......@@ -61,25 +58,45 @@ describe('Project Value Stream Analytics mutations', () => {
${types.REQUEST_STAGE_MEDIANS} | ${'medians'} | ${{}}
${types.RECEIVE_STAGE_MEDIANS_ERROR} | ${'medians'} | ${{}}
`('$mutation will set $stateKey to $value', ({ mutation, stateKey, value }) => {
mutations[mutation](state, {});
mutations[mutation](state);
expect(state).toMatchObject({ [stateKey]: value });
});
const mockInitialPayload = {
endpoints: { requestPath: mockRequestPath },
currentGroup: { title: 'cool-group' },
id: 1337,
};
const mockInitializedObj = {
endpoints: { requestPath: mockRequestPath },
createdAfter: mockCreatedAfter,
createdBefore: mockCreatedBefore,
};
it.each`
mutation | payload | stateKey | value
${types.INITIALIZE_VSA} | ${{ requestPath: mockRequestPath }} | ${'requestPath'} | ${mockRequestPath}
${types.SET_DATE_RANGE} | ${{ startDate: DEFAULT_DAYS_TO_DISPLAY }} | ${'startDate'} | ${DEFAULT_DAYS_TO_DISPLAY}
${types.SET_DATE_RANGE} | ${{ startDate: DEFAULT_DAYS_TO_DISPLAY }} | ${'createdAfter'} | ${mockCreatedAfter}
${types.SET_DATE_RANGE} | ${{ startDate: DEFAULT_DAYS_TO_DISPLAY }} | ${'createdBefore'} | ${mockCreatedBefore}
${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true}
${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false}
${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream}
${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 | stateKey | value
${types.INITIALIZE_VSA} | ${'endpoints'} | ${{ requestPath: mockRequestPath }}
${types.INITIALIZE_VSA} | ${'createdAfter'} | ${mockCreatedAfter}
${types.INITIALIZE_VSA} | ${'createdBefore'} | ${mockCreatedBefore}
`('$mutation will set $stateKey', ({ mutation, stateKey, value }) => {
mutations[mutation](state, { ...mockInitialPayload });
expect(state).toMatchObject({ ...mockInitializedObj, [stateKey]: value });
});
it.each`
mutation | payload | stateKey | value
${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'daysInPast'} | ${DEFAULT_DAYS_TO_DISPLAY}
${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdAfter'} | ${mockCreatedAfter}
${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdBefore'} | ${mockCreatedBefore}
${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true}
${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false}
${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream}
${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_STAGE_MEDIANS_SUCCESS} | ${rawStageMedians} | ${'medians'} | ${formattedStageMedians}
`(
'$mutation with $payload will set $stateKey to $value',
({ mutation, payload, stateKey, value }) => {
......@@ -97,41 +114,10 @@ describe('Project Value Stream Analytics mutations', () => {
});
it.each`
mutation | payload | stateKey | value
${types.RECEIVE_STAGE_DATA_SUCCESS} | ${{ events: [] }} | ${'isEmptyStage'} | ${true}
${types.RECEIVE_STAGE_DATA_SUCCESS} | ${{ events: rawEvents }} | ${'selectedStageEvents'} | ${convertedEvents}
${types.RECEIVE_STAGE_DATA_SUCCESS} | ${{ events: rawEvents }} | ${'isEmptyStage'} | ${false}
`(
'$mutation with $payload will set $stateKey to $value',
({ mutation, payload, stateKey, value }) => {
mutations[mutation](state, payload);
expect(state).toMatchObject({ [stateKey]: value });
},
);
});
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 | payload | stateKey | value
${types.RECEIVE_STAGE_DATA_SUCCESS} | ${[]} | ${'isEmptyStage'} | ${true}
${types.RECEIVE_STAGE_DATA_SUCCESS} | ${rawEvents} | ${'selectedStageEvents'} | ${convertedEvents}
${types.RECEIVE_STAGE_DATA_SUCCESS} | ${rawEvents} | ${'isEmptyStage'} | ${false}
`(
'$mutation with $payload will set $stateKey to $value',
({ mutation, payload, stateKey, value }) => {
......
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