Commit 2291e5c9 authored by Brandon Labuschagne's avatar Brandon Labuschagne Committed by Kushal Pandya

Add CA duration scatter plot

This commit adds the duration scatter plot to the cycle analytics
feature. It makes use of the already merged stage-dropdown-filter
component.
parent f3ea2259
......@@ -7,6 +7,8 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { PROJECTS_PER_PAGE, DEFAULT_DAYS_IN_PAST } from '../constants';
import GroupsDropdownFilter from '../../shared/components/groups_dropdown_filter.vue';
import ProjectsDropdownFilter from '../../shared/components/projects_dropdown_filter.vue';
import Scatterplot from '../../shared/components/scatterplot.vue';
import StageDropdownFilter from './stage_dropdown_filter.vue';
import SummaryTable from './summary_table.vue';
import StageTable from './stage_table.vue';
import { LAST_ACTIVITY_AT } from '../../shared/constants';
......@@ -14,13 +16,15 @@ import { LAST_ACTIVITY_AT } from '../../shared/constants';
export default {
name: 'CycleAnalytics',
components: {
GlEmptyState,
GlLoadingIcon,
GlEmptyState,
GroupsDropdownFilter,
ProjectsDropdownFilter,
SummaryTable,
StageTable,
GlDaterangePicker,
StageDropdownFilter,
Scatterplot,
},
mixins: [glFeatureFlagsMixin()],
props: {
......@@ -45,9 +49,11 @@ export default {
},
computed: {
...mapState([
'featureFlags',
'isLoading',
'isLoadingStage',
'isLoadingChartData',
'isLoadingDurationChart',
'isEmptyStage',
'isAddingCustomStage',
'isSavingCustomStage',
......@@ -64,7 +70,13 @@ export default {
'endDate',
'tasksByType',
]),
...mapGetters(['currentStage', 'defaultStage', 'hasNoAccessError', 'currentGroupPath']),
...mapGetters([
'currentStage',
'defaultStage',
'hasNoAccessError',
'currentGroupPath',
'durationChartPlottableData',
]),
shouldRenderEmptyState() {
return !this.selectedGroup;
},
......@@ -74,17 +86,26 @@ export default {
shouldDisplayFilters() {
return this.selectedGroup && !this.errorCode;
},
shouldDisplayDurationChart() {
return !this.isLoadingDurationChart && !this.isLoading;
},
dateRange: {
get() {
return { startDate: this.startDate, endDate: this.endDate };
},
set({ startDate, endDate }) {
this.setDateRange({ startDate, endDate });
this.setDateRange({
startDate,
endDate,
});
},
},
},
mounted() {
this.initDateRange();
this.setFeatureFlags({
hasDurationChart: this.glFeatures.cycleAnalyticsScatterplotEnabled,
});
},
methods: {
...mapActions([
......@@ -95,7 +116,6 @@ export default {
'setSelectedGroup',
'setSelectedProjects',
'setSelectedTimeframe',
'fetchStageData',
'setSelectedStageId',
'hideCustomStageForm',
'showCustomStageForm',
......@@ -104,6 +124,8 @@ export default {
'createCustomStage',
'updateStage',
'removeStage',
'updateSelectedDurationChartStages',
'setFeatureFlags',
]),
onGroupSelect(group) {
this.setSelectedGroup(group);
......@@ -136,6 +158,9 @@ export default {
onRemoveStage(id) {
this.removeStage(id);
},
onDurationStageSelect(stages) {
this.updateSelectedDurationChartStages(stages);
},
},
groupsQueryParams: {
min_access_level: featureAccessLevel.EVERYONE,
......@@ -238,6 +263,29 @@ export default {
/>
</div>
</div>
<template v-if="featureFlags.hasDurationChart">
<template v-if="shouldDisplayDurationChart">
<div class="mt-3 d-flex">
<h4 class="mt-0">{{ s__('CycleAnalytics|Days to completion') }}</h4>
<stage-dropdown-filter
v-if="stages.length"
class="ml-auto"
:stages="stages"
@selected="onDurationStageSelect"
/>
</div>
<scatterplot
v-if="durationChartPlottableData"
:x-axis-title="s__('CycleAnalytics|Date')"
:y-axis-title="s__('CycleAnalytics|Total days to completion')"
:scatter-data="durationChartPlottableData"
/>
<div v-else ref="duration-chart-no-data" class="bs-callout bs-callout-info">
{{ __('There is no data available. Please change your selection.') }}
</div>
</template>
<gl-loading-icon v-else-if="!isLoading" size="md" class="my-4 py-4" />
</template>
</div>
</div>
</template>
import dateFormat from 'dateformat';
import createFlash, { hideFlash } from '~/flash';
import { __ } from '~/locale';
import Api from 'ee/api';
import httpStatus from '~/lib/utils/http_status';
import * as types from './mutation_types';
import { nestQueryStringKeys } from '../utils';
import { dateFormats } from '../../shared/constants';
const removeError = () => {
const flashEl = document.querySelector('.flash-alert');
......@@ -11,22 +13,20 @@ const removeError = () => {
hideFlash(flashEl);
}
};
export const setFeatureFlags = ({ commit }, featureFlags) =>
commit(types.SET_FEATURE_FLAGS, featureFlags);
export const setSelectedGroup = ({ commit }, group) => commit(types.SET_SELECTED_GROUP, group);
export const setSelectedProjects = ({ commit }, projectIds) =>
commit(types.SET_SELECTED_PROJECTS, projectIds);
export const setSelectedStageId = ({ commit }, stageId) =>
commit(types.SET_SELECTED_STAGE_ID, stageId);
export const setDateRange = (
{ commit, dispatch, state },
{ skipFetch = false, startDate, endDate },
) => {
export const setDateRange = ({ commit, dispatch }, { skipFetch = false, startDate, endDate }) => {
commit(types.SET_DATE_RANGE, { startDate, endDate });
if (skipFetch) return false;
return dispatch('fetchCycleAnalyticsData', { state, dispatch });
return dispatch('fetchCycleAnalyticsData');
};
export const requestStageData = ({ commit }) => commit(types.REQUEST_STAGE_DATA);
......@@ -56,9 +56,12 @@ export const fetchStageData = ({ state, dispatch, getters }, slug) => {
};
export const requestCycleAnalyticsData = ({ commit }) => commit(types.REQUEST_CYCLE_ANALYTICS_DATA);
export const receiveCycleAnalyticsDataSuccess = ({ commit }) =>
export const receiveCycleAnalyticsDataSuccess = ({ commit, dispatch }) => {
commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS);
dispatch('fetchDurationData');
};
export const receiveCycleAnalyticsDataError = ({ commit }, { response }) => {
const { status } = response;
commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR, status);
......@@ -288,3 +291,66 @@ export const removeStage = ({ dispatch, state }, stageId) => {
.then(() => dispatch('receiveRemoveStageSuccess'))
.catch(error => dispatch('receiveRemoveStageError', error));
};
export const requestDurationData = ({ commit }) => commit(types.REQUEST_DURATION_DATA);
export const receiveDurationDataSuccess = ({ commit }, data) =>
commit(types.RECEIVE_DURATION_DATA_SUCCESS, data);
export const receiveDurationDataError = ({ commit }) => {
commit(types.RECEIVE_DURATION_DATA_ERROR);
createFlash(__('There was an error while fetching cycle analytics duration data.'));
};
export const fetchDurationData = ({ state, dispatch }) => {
dispatch('requestDurationData');
const {
featureFlags: { hasDurationChart },
stages,
startDate,
endDate,
selectedProjectIds,
selectedGroup: { fullPath },
} = state;
if (hasDurationChart) {
return Promise.all(
stages.map(stage => {
const { slug } = stage;
return Api.cycleAnalyticsDurationChart(slug, {
group_id: fullPath,
created_after: dateFormat(startDate, dateFormats.isoDate),
created_before: dateFormat(endDate, dateFormats.isoDate),
project_ids: selectedProjectIds,
}).then(({ data }) => ({
slug,
selected: true,
data,
}));
}),
)
.then(data => {
dispatch('receiveDurationDataSuccess', data);
})
.catch(() => dispatch('receiveDurationDataError'));
}
return false;
};
export const updateSelectedDurationChartStages = ({ state, commit }, stages) => {
const updatedDurationStageData = state.durationData.map(stage => {
const selected = stages.reduce((result, object) => {
if (object.slug === stage.slug) return true;
return result;
}, false);
return {
...stage,
selected,
};
});
commit(types.UPDATE_SELECTED_DURATION_CHART_STAGES, updatedDurationStageData);
};
import dateFormat from 'dateformat';
import httpStatus from '~/lib/utils/http_status';
import { dateFormats } from '../../shared/constants';
import { getDurationChartData } from '../utils';
export const currentStage = ({ stages, selectedStageId }) =>
stages.length && selectedStageId ? stages.find(stage => stage.id === selectedStageId) : null;
......@@ -20,3 +21,11 @@ export const cycleAnalyticsRequestParams = ({
created_after: startDate ? dateFormat(startDate, dateFormats.isoDate) : null,
created_before: endDate ? dateFormat(endDate, dateFormats.isoDate) : null,
});
export const durationChartPlottableData = state => {
const { durationData, startDate, endDate } = state;
const selectedStagesDurationData = durationData.filter(stage => stage.selected);
const plottableData = getDurationChartData(selectedStagesDurationData, startDate, endDate);
return plottableData.length ? plottableData : null;
};
export const SET_FEATURE_FLAGS = 'SET_FEATURE_FLAGS';
export const SET_SELECTED_GROUP = 'SET_SELECTED_GROUP';
export const SET_SELECTED_PROJECTS = 'SET_SELECTED_PROJECTS';
export const SET_SELECTED_STAGE_ID = 'SET_SELECTED_STAGE_ID';
export const SET_DATE_RANGE = 'SET_DATE_RANGE';
export const UPDATE_SELECTED_DURATION_CHART_STAGES = 'UPDATE_SELECTED_DURATION_CHART_STAGES';
export const REQUEST_CYCLE_ANALYTICS_DATA = 'REQUEST_CYCLE_ANALYTICS_DATA';
export const RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS = 'RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS';
export const RECEIVE_CYCLE_ANALYTICS_DATA_ERROR = 'RECEIVE_CYCLE_ANALYTICS_DATA_ERROR';
......@@ -42,3 +46,7 @@ export const RECEIVE_UPDATE_STAGE_RESPONSE = 'RECEIVE_UPDATE_STAGE_RESPONSE';
export const REQUEST_REMOVE_STAGE = 'REQUEST_REMOVE_STAGE';
export const RECEIVE_REMOVE_STAGE_RESPONSE = 'RECEIVE_REMOVE_STAGE_RESPONSE';
export const REQUEST_DURATION_DATA = 'REQUEST_DURATION_DATA';
export const RECEIVE_DURATION_DATA_SUCCESS = 'RECEIVE_DURATION_DATA_SUCCESS';
export const RECEIVE_DURATION_DATA_ERROR = 'RECEIVE_DURATION_DATA_ERROR';
......@@ -3,6 +3,9 @@ import * as types from './mutation_types';
import { transformRawStages } from '../utils';
export default {
[types.SET_FEATURE_FLAGS](state, featureFlags) {
state.featureFlags = featureFlags;
},
[types.SET_SELECTED_GROUP](state, group) {
state.selectedGroup = convertObjectPropsToCamelCase(group, { deep: true });
state.selectedProjectIds = [];
......@@ -17,6 +20,9 @@ export default {
state.startDate = startDate;
state.endDate = endDate;
},
[types.UPDATE_SELECTED_DURATION_CHART_STAGES](state, updatedDurationStageData) {
state.durationData = updatedDurationStageData;
},
[types.REQUEST_CYCLE_ANALYTICS_DATA](state) {
state.isLoading = true;
state.isAddingCustomStage = false;
......@@ -153,4 +159,15 @@ export default {
[types.RECEIVE_REMOVE_STAGE_RESPONSE](state) {
state.isLoading = false;
},
[types.REQUEST_DURATION_DATA](state) {
state.isLoadingDurationChart = true;
},
[types.RECEIVE_DURATION_DATA_SUCCESS](state, data) {
state.durationData = data;
state.isLoadingDurationChart = false;
},
[types.RECEIVE_DURATION_DATA_ERROR](state) {
state.durationData = [];
state.isLoadingDurationChart = false;
},
};
import { TASKS_BY_TYPE_SUBJECT_ISSUE } from '../constants';
export default () => ({
featureFlags: {},
startDate: null,
endDate: null,
isLoading: false,
isLoadingStage: false,
isLoadingChartData: false,
isLoadingDurationChart: false,
isEmptyStage: false,
errorCode: null,
......@@ -30,4 +33,6 @@ export default () => ({
labelIds: [],
data: [],
},
durationData: [],
});
......@@ -13,17 +13,24 @@ import SummaryTable from 'ee/analytics/cycle_analytics/components/summary_table.
import StageTable from 'ee/analytics/cycle_analytics/components/stage_table.vue';
import 'bootstrap';
import '~/gl_dropdown';
import Scatterplot from 'ee/analytics/shared/components/scatterplot.vue';
import waitForPromises from 'helpers/wait_for_promises';
import * as mockData from '../mock_data';
const noDataSvgPath = 'path/to/no/data';
const noAccessSvgPath = 'path/to/no/access';
const emptyStateSvgPath = 'path/to/empty/state';
const baseStagesEndpoint = '/-/analytics/cycle_analytics/stages';
const localVue = createLocalVue();
localVue.use(Vuex);
function createComponent({ opts = {}, shallow = true, withStageSelected = false } = {}) {
function createComponent({
opts = {},
shallow = true,
withStageSelected = false,
scatterplotEnabled = true,
} = {}) {
const func = shallow ? shallowMount : mount;
const comp = func(Component, {
localVue,
......@@ -33,6 +40,10 @@ function createComponent({ opts = {}, shallow = true, withStageSelected = false
emptyStateSvgPath,
noDataSvgPath,
noAccessSvgPath,
baseStagesEndpoint,
},
provide: {
glFeatures: { cycleAnalyticsScatterplotEnabled: scatterplotEnabled },
},
...opts,
});
......@@ -79,6 +90,10 @@ describe('Cycle Analytics component', () => {
expect(wrapper.find(StageTable).exists()).toBe(flag);
};
const displaysDurationScatterPlot = flag => {
expect(wrapper.find(Scatterplot).exists()).toBe(flag);
};
beforeEach(() => {
mock = new MockAdapter(axios);
wrapper = createComponent();
......@@ -141,6 +156,10 @@ describe('Cycle Analytics component', () => {
it('does not display the stage table', () => {
displaysStageTable(false);
});
it('does not display the duration scatter plot', () => {
displaysDurationScatterPlot(false);
});
});
describe('after a filter has been selected', () => {
......@@ -181,6 +200,39 @@ describe('Cycle Analytics component', () => {
expect(wrapper.find('.js-add-stage-button').exists()).toBe(false);
});
describe('with no durationData', () => {
it('displays the duration chart', () => {
expect(wrapper.find(Scatterplot).exists()).toBe(false);
});
it('displays the no data message', () => {
const element = wrapper.find({ ref: 'duration-chart-no-data' });
expect(element.exists()).toBe(true);
expect(element.text()).toBe(
'There is no data available. Please change your selection.',
);
});
});
describe('with durationData', () => {
beforeEach(() => {
wrapper.vm.$store.dispatch('setDateRange', {
skipFetch: true,
startDate: mockData.startDate,
endDate: mockData.endDate,
});
wrapper.vm.$store.dispatch(
'receiveDurationDataSuccess',
mockData.transformedDurationData,
);
});
it('displays the duration chart', () => {
expect(wrapper.find(Scatterplot).exists()).toBe(true);
});
});
describe('StageTable', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
......@@ -299,7 +351,7 @@ describe('Cycle Analytics component', () => {
describe('with failed requests while loading', () => {
const { full_path: groupId } = mockData.group;
function mockRequestCycleAnalyticsData(overrides = {}) {
function mockRequestCycleAnalyticsData(overrides = {}, includeDutationDataRequests = true) {
const defaultStatus = 200;
const defaultRequests = {
fetchSummaryData: {
......@@ -321,7 +373,7 @@ describe('Cycle Analytics component', () => {
status: defaultStatus,
// default first stage is issue
endpoint: '/groups/foo/-/cycle_analytics/events/issue.json',
response: { ...mockData.issueEvents },
response: [...mockData.issueEvents],
},
fetchTasksByTypeData: {
status: defaultStatus,
......@@ -334,6 +386,14 @@ describe('Cycle Analytics component', () => {
Object.values(defaultRequests).forEach(({ endpoint, status, response }) => {
mock.onGet(endpoint).replyOnce(status, response);
});
if (includeDutationDataRequests) {
mockData.defaultStages.forEach(stage => {
mock
.onGet(`${baseStagesEndpoint}/${stage}/duration_chart`)
.replyOnce(defaultStatus, [...mockData.rawDurationData]);
});
}
}
beforeEach(() => {
......@@ -429,5 +489,27 @@ describe('Cycle Analytics component', () => {
return selectGroupAndFindError('There was an error fetching data for the chart');
});
it('will display an error if the fetchDurationData request fails', () => {
expect(findFlashError()).toBeNull();
mockRequestCycleAnalyticsData({}, false);
mockData.defaultStages.forEach(stage => {
mock
.onGet(`${baseStagesEndpoint}/${stage}/duration_chart`)
.replyOnce(httpStatusCodes.NOT_FOUND, {
response: { status: httpStatusCodes.NOT_FOUND },
});
});
wrapper.vm.onGroupSelect(mockData.group);
return waitForPromises().catch(() => {
expect(findFlashError().innerText.trim()).toEqual(
'There was an error while fetching cycle analytics duration data.',
);
});
});
});
});
......@@ -13,6 +13,9 @@ import {
startDate,
endDate,
customizableStagesAndEvents,
rawDurationData,
transformedDurationData,
defaultStages,
} from '../mock_data';
const stageData = { events: [] };
......@@ -24,6 +27,7 @@ const endpoints = {
groupLabels: `/groups/${group.path}/-/labels`,
cycleAnalyticsData: `/groups/${group.path}/-/cycle_analytics`,
stageData: `/groups/${group.path}/-/cycle_analytics/events/${selectedStageSlug}.json`,
baseStagesEndpoint: '/-/analytics/cycle_analytics/stages',
};
const stageEndpoint = ({ stageId }) => `/-/analytics/cycle_analytics/stages/${stageId}`;
......@@ -40,6 +44,9 @@ describe('Cycle analytics actions', () => {
state = {
stages: [],
getters,
featureFlags: {
hasDurationChart: true,
},
};
mock = new MockAdapter(axios);
});
......@@ -51,6 +58,7 @@ describe('Cycle analytics actions', () => {
it.each`
action | type | stateKey | payload
${'setFeatureFlags'} | ${'SET_FEATURE_FLAGS'} | ${'featureFlags'} | ${{ hasDurationChart: true }}
${'setSelectedGroup'} | ${'SET_SELECTED_GROUP'} | ${'selectedGroup'} | ${'someNewGroup'}
${'setSelectedProjects'} | ${'SET_SELECTED_PROJECTS'} | ${'selectedProjectIds'} | ${[10, 20, 30, 40]}
${'setSelectedStageId'} | ${'SET_SELECTED_STAGE_ID'} | ${'selectedStageId'} | ${'someNewGroup'}
......@@ -71,14 +79,12 @@ describe('Cycle analytics actions', () => {
describe('setDateRange', () => {
it('sets the dates as expected and dispatches fetchCycleAnalyticsData', done => {
const dispatch = expect.any(Function);
testAction(
actions.setDateRange,
{ startDate, endDate },
state,
[{ type: types.SET_DATE_RANGE, payload: { startDate, endDate } }],
[{ type: 'fetchCycleAnalyticsData', payload: { dispatch, state } }],
[{ type: 'fetchCycleAnalyticsData' }],
done,
);
});
......@@ -230,6 +236,7 @@ describe('Cycle analytics actions', () => {
fetchSummaryData: overrides.fetchSummaryData || jest.fn().mockResolvedValue(),
receiveCycleAnalyticsDataSuccess:
overrides.receiveCycleAnalyticsDataSuccess || jest.fn().mockResolvedValue(),
fetchDurationData: overrides.fetchDurationData || jest.fn().mockResolvedValue(),
};
return {
mocks,
......@@ -238,7 +245,8 @@ describe('Cycle analytics actions', () => {
.mockImplementationOnce(mocks.requestCycleAnalyticsData)
.mockImplementationOnce(mocks.fetchGroupStagesAndEvents)
.mockImplementationOnce(mocks.fetchSummaryData)
.mockImplementationOnce(mocks.receiveCycleAnalyticsDataSuccess),
.mockImplementationOnce(mocks.receiveCycleAnalyticsDataSuccess)
.mockImplementationOnce(mocks.fetchDurationData),
};
}
......@@ -263,6 +271,7 @@ describe('Cycle analytics actions', () => {
expect(mocks.fetchGroupStagesAndEvents).toHaveBeenCalled();
expect(mocks.fetchSummaryData).toHaveBeenCalled();
expect(mocks.receiveCycleAnalyticsDataSuccess).toHaveBeenCalled();
expect(mocks.fetchDurationData).toHaveBeenCalled();
done();
})
.catch(done.fail);
......@@ -320,6 +329,34 @@ describe('Cycle analytics actions', () => {
.catch(done.fail);
});
it(`displays an error if fetchDurationData fails`, () => {
const { mockDispatchContext } = mockFetchCycleAnalyticsAction({
fetchDurationData: actions.fetchDurationData(
{
dispatch: jest
.fn()
.mockResolvedValueOnce()
.mockImplementation(actions.receiveDurationDataError({ commit: () => {} })),
commit: () => {},
state: { ...state, endpoints: { cycleAnalyticsStagesPath: '/this/is/fake' } },
getters,
},
{},
),
});
actions.fetchDurationData(
{
dispatch: mockDispatchContext,
state: { ...state, endpoints: { cycleAnalyticsStagesPath: '/this/is/fake' } },
commit: () => {},
},
{},
);
shouldFlashAMessage('There was an error while fetching cycle analytics duration data.');
});
describe('with an existing error', () => {
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
......@@ -439,6 +476,7 @@ describe('Cycle analytics actions', () => {
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
});
it(`commits the ${types.RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS} mutation`, done => {
testAction(
actions.receiveGroupStagesAndEventsSuccess,
......@@ -659,4 +697,184 @@ describe('Cycle analytics actions', () => {
done();
});
});
describe('fetchDurationData', () => {
beforeEach(() => {
defaultStages.forEach(stage => {
mock
.onGet(`${endpoints.baseStagesEndpoint}/${stage}/duration_chart`)
.replyOnce(200, [...rawDurationData]);
});
});
it("dispatches the 'requestDurationData' and 'receiveDurationDataSuccess' actions", done => {
const stateWithStages = {
...state,
stages: [stages[0], stages[1]],
selectedGroup,
startDate,
endDate,
};
testAction(
actions.fetchDurationData,
transformedDurationData,
stateWithStages,
[],
[
{ type: 'requestDurationData' },
{
type: 'receiveDurationDataSuccess',
payload: transformedDurationData,
},
],
done,
);
});
it("dispatches the 'requestDurationData' and 'receiveDurationDataError' actions when there is an error", done => {
const brokenState = {
...state,
stages: [
{
slug: 'oops',
},
],
selectedGroup,
startDate,
endDate,
};
testAction(
actions.fetchDurationData,
{},
brokenState,
[],
[{ type: 'requestDurationData' }, { type: 'receiveDurationDataError' }],
done,
);
});
});
describe('receiveDurationDataSuccess', () => {
const payload = { durationData: transformedDurationData, isLoadingDurationChart: false };
testAction(
actions.receiveDurationDataSuccess,
payload,
state,
[
{
type: types.RECEIVE_DURATION_DATA_SUCCESS,
payload,
},
],
[],
);
});
describe('receiveDurationDataError', () => {
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
});
it("commits the 'RECEIVE_DURATION_DATA_ERROR' mutation", () => {
testAction(
actions.receiveDurationDataError,
{},
state,
[
{
type: types.RECEIVE_DURATION_DATA_ERROR,
},
],
[],
);
});
it('will flash an error', () => {
actions.receiveDurationDataError({
commit: () => {},
});
shouldFlashAMessage('There was an error while fetching cycle analytics duration data.');
});
});
describe('updateSelectedDurationChartStages', () => {
it("commits the 'UPDATE_SELECTED_DURATION_CHART_STAGES' mutation with all the selected stages in the duration data", () => {
const stateWithDurationData = {
...state,
durationData: transformedDurationData,
};
testAction(
actions.updateSelectedDurationChartStages,
[...stages],
stateWithDurationData,
[
{
type: types.UPDATE_SELECTED_DURATION_CHART_STAGES,
payload: transformedDurationData,
},
],
[],
);
});
it("commits the 'UPDATE_SELECTED_DURATION_CHART_STAGES' mutation with all the selected and deselected stages in the duration data", () => {
const stateWithDurationData = {
...state,
durationData: transformedDurationData,
};
testAction(
actions.updateSelectedDurationChartStages,
[stages[0]],
stateWithDurationData,
[
{
type: types.UPDATE_SELECTED_DURATION_CHART_STAGES,
payload: [
transformedDurationData[0],
{
...transformedDurationData[1],
selected: false,
},
],
},
],
[],
);
});
it("commits the 'UPDATE_SELECTED_DURATION_CHART_STAGES' mutation with all deselected stages in the duration data", () => {
const stateWithDurationData = {
...state,
durationData: transformedDurationData,
};
testAction(
actions.updateSelectedDurationChartStages,
[],
stateWithDurationData,
[
{
type: types.UPDATE_SELECTED_DURATION_CHART_STAGES,
payload: [
{
...transformedDurationData[0],
selected: false,
},
{
...transformedDurationData[1],
selected: false,
},
],
},
],
[],
);
});
});
});
import * as getters from 'ee/analytics/cycle_analytics/store/getters';
import { allowedStages as stages, startDate, endDate } from '../mock_data';
import {
allowedStages as stages,
startDate,
endDate,
transformedDurationData,
durationChartPlottableData,
} from '../mock_data';
let state = null;
const selectedProjectIds = [5, 8, 11];
......@@ -135,4 +141,28 @@ describe('Cycle analytics getters', () => {
expect(getters.cycleAnalyticsRequestParams(state)).toMatchObject({ [param]: value });
});
});
describe('durationChartPlottableData', () => {
it('returns plottable data for selected stages', () => {
const stateWithDurationData = {
startDate,
endDate,
durationData: transformedDurationData,
};
expect(getters.durationChartPlottableData(stateWithDurationData)).toEqual(
durationChartPlottableData,
);
});
it('returns null if there is no plottable data for the selected stages', () => {
const stateWithDurationData = {
startDate,
endDate,
durationData: [],
};
expect(getters.durationChartPlottableData(stateWithDurationData)).toBeNull();
});
});
});
......@@ -17,6 +17,7 @@ import {
endDate,
customizableStagesAndEvents,
tasksByTypeData,
transformedDurationData,
} from '../mock_data';
let state = null;
......@@ -31,29 +32,31 @@ describe('Cycle analytics mutations', () => {
});
it.each`
mutation | stateKey | value
${types.HIDE_CUSTOM_STAGE_FORM} | ${'isAddingCustomStage'} | ${false}
${types.SHOW_CUSTOM_STAGE_FORM} | ${'isAddingCustomStage'} | ${true}
${types.REQUEST_STAGE_DATA} | ${'isLoadingStage'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isEmptyStage'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isLoadingStage'} | ${false}
${types.REQUEST_CYCLE_ANALYTICS_DATA} | ${'isLoading'} | ${true}
${types.REQUEST_GROUP_LABELS} | ${'labels'} | ${[]}
${types.RECEIVE_GROUP_LABELS_ERROR} | ${'labels'} | ${[]}
${types.RECEIVE_SUMMARY_DATA_ERROR} | ${'summary'} | ${[]}
${types.REQUEST_SUMMARY_DATA} | ${'summary'} | ${[]}
${types.RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR} | ${'stages'} | ${[]}
${types.REQUEST_GROUP_STAGES_AND_EVENTS} | ${'stages'} | ${[]}
${types.RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR} | ${'customStageFormEvents'} | ${[]}
${types.REQUEST_GROUP_STAGES_AND_EVENTS} | ${'customStageFormEvents'} | ${[]}
${types.REQUEST_CREATE_CUSTOM_STAGE} | ${'isSavingCustomStage'} | ${true}
${types.RECEIVE_CREATE_CUSTOM_STAGE_RESPONSE} | ${'isSavingCustomStage'} | ${false}
${types.REQUEST_TASKS_BY_TYPE_DATA} | ${'isLoadingChartData'} | ${true}
${types.RECEIVE_TASKS_BY_TYPE_DATA_ERROR} | ${'isLoadingChartData'} | ${false}
${types.REQUEST_UPDATE_STAGE} | ${'isLoading'} | ${true}
${types.RECEIVE_UPDATE_STAGE_RESPONSE} | ${'isLoading'} | ${false}
${types.REQUEST_REMOVE_STAGE} | ${'isLoading'} | ${true}
${types.RECEIVE_REMOVE_STAGE_RESPONSE} | ${'isLoading'} | ${false}
mutation | stateKey | value
${types.HIDE_CUSTOM_STAGE_FORM} | ${'isAddingCustomStage'} | ${false}
${types.SHOW_CUSTOM_STAGE_FORM} | ${'isAddingCustomStage'} | ${true}
${types.REQUEST_STAGE_DATA} | ${'isLoadingStage'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isEmptyStage'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isLoadingStage'} | ${false}
${types.REQUEST_CYCLE_ANALYTICS_DATA} | ${'isLoading'} | ${true}
${types.REQUEST_GROUP_LABELS} | ${'labels'} | ${[]}
${types.RECEIVE_GROUP_LABELS_ERROR} | ${'labels'} | ${[]}
${types.RECEIVE_SUMMARY_DATA_ERROR} | ${'summary'} | ${[]}
${types.REQUEST_SUMMARY_DATA} | ${'summary'} | ${[]}
${types.RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR} | ${'stages'} | ${[]}
${types.REQUEST_GROUP_STAGES_AND_EVENTS} | ${'stages'} | ${[]}
${types.RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR} | ${'customStageFormEvents'} | ${[]}
${types.REQUEST_GROUP_STAGES_AND_EVENTS} | ${'customStageFormEvents'} | ${[]}
${types.REQUEST_CREATE_CUSTOM_STAGE} | ${'isSavingCustomStage'} | ${true}
${types.RECEIVE_CREATE_CUSTOM_STAGE_RESPONSE} | ${'isSavingCustomStage'} | ${false}
${types.REQUEST_TASKS_BY_TYPE_DATA} | ${'isLoadingChartData'} | ${true}
${types.RECEIVE_TASKS_BY_TYPE_DATA_ERROR} | ${'isLoadingChartData'} | ${false}
${types.REQUEST_UPDATE_STAGE} | ${'isLoading'} | ${true}
${types.RECEIVE_UPDATE_STAGE_RESPONSE} | ${'isLoading'} | ${false}
${types.REQUEST_REMOVE_STAGE} | ${'isLoading'} | ${true}
${types.RECEIVE_REMOVE_STAGE_RESPONSE} | ${'isLoading'} | ${false}
${types.REQUEST_DURATION_DATA} | ${'isLoadingDurationChart'} | ${true}
${types.RECEIVE_DURATION_DATA_ERROR} | ${'isLoadingDurationChart'} | ${false}
`('$mutation will set $stateKey=$value', ({ mutation, stateKey, value }) => {
mutations[mutation](state);
......@@ -61,11 +64,13 @@ describe('Cycle analytics mutations', () => {
});
it.each`
mutation | payload | expectedState
${types.SET_SELECTED_GROUP} | ${{ fullPath: 'cool-beans' }} | ${{ selectedGroup: { fullPath: 'cool-beans' }, selectedProjectIds: [] }}
${types.SET_SELECTED_PROJECTS} | ${[606, 707, 808, 909]} | ${{ selectedProjectIds: [606, 707, 808, 909] }}
${types.SET_DATE_RANGE} | ${{ startDate, endDate }} | ${{ startDate, endDate }}
${types.SET_SELECTED_STAGE_ID} | ${'first-stage'} | ${{ selectedStageId: 'first-stage' }}
mutation | payload | expectedState
${types.SET_FEATURE_FLAGS} | ${{ hasDurationChart: true }} | ${{ featureFlags: { hasDurationChart: true } }}
${types.SET_SELECTED_GROUP} | ${{ fullPath: 'cool-beans' }} | ${{ selectedGroup: { fullPath: 'cool-beans' }, selectedProjectIds: [] }}
${types.SET_SELECTED_PROJECTS} | ${[606, 707, 808, 909]} | ${{ selectedProjectIds: [606, 707, 808, 909] }}
${types.SET_DATE_RANGE} | ${{ startDate, endDate }} | ${{ startDate, endDate }}
${types.SET_SELECTED_STAGE_ID} | ${'first-stage'} | ${{ selectedStageId: 'first-stage' }}
${types.UPDATE_SELECTED_DURATION_CHART_STAGES} | ${transformedDurationData} | ${{ durationData: transformedDurationData }}
`(
'$mutation with payload $payload will update state with $expectedState',
({ mutation, payload, expectedState }) => {
......@@ -243,4 +248,18 @@ describe('Cycle analytics mutations', () => {
expect(state.tasksByType.data).toEqual(tasksByTypeData);
});
});
describe(`${types.RECEIVE_DURATION_DATA_SUCCESS}`, () => {
it('sets the data correctly and falsifies isLoadingDurationChart', () => {
const stateWithData = {
isLoadingDurationChart: true,
durationData: [['something', 'random']],
};
mutations[types.RECEIVE_DURATION_DATA_SUCCESS](stateWithData, transformedDurationData);
expect(stateWithData.isLoadingDurationChart).toBe(false);
expect(stateWithData.durationData).toBe(transformedDurationData);
});
});
});
......@@ -5272,12 +5272,21 @@ msgstr ""
msgid "CycleAnalytics|All stages"
msgstr ""
msgid "CycleAnalytics|Date"
msgstr ""
msgid "CycleAnalytics|Days to completion"
msgstr ""
msgid "CycleAnalytics|No stages selected"
msgstr ""
msgid "CycleAnalytics|Stages"
msgstr ""
msgid "CycleAnalytics|Total days to completion"
msgstr ""
msgid "CycleAnalytics|group dropdown filter"
msgstr ""
......@@ -17642,6 +17651,9 @@ msgstr ""
msgid "There was an error while fetching cycle analytics data."
msgstr ""
msgid "There was an error while fetching cycle analytics duration data."
msgstr ""
msgid "There was an error while fetching cycle analytics summary data."
msgstr ""
......
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