Commit 0379cc17 authored by Mark Florian's avatar Mark Florian

Merge branch '335953-add-dedicated-project-vsa-endpoints' into 'master'

Add dedicated endpoints for project VSA

See merge request gitlab-org/gitlab!67204
parents 10227591 552bcfa3
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