Commit 684b03d0 authored by Brandon Labuschagne's avatar Brandon Labuschagne

Add rolling median line to CA duration chart

In order to do this, we need to fetch data offset from
the selected dates. The offset will be equal to the number
of days that the data is being displayed for.

I.e. if we display data for 30 days, we will need to fetch
data for 60 days.

We are also introducing a feature flag specifically for
the median line.
parent c60e8aa1
......@@ -74,6 +74,7 @@ export default {
'currentGroupPath',
'durationChartPlottableData',
'tasksByTypeChartData',
'durationChartMedianData',
]),
shouldRenderEmptyState() {
return !this.selectedGroup;
......@@ -121,6 +122,7 @@ export default {
this.initDateRange();
this.setFeatureFlags({
hasDurationChart: this.glFeatures.cycleAnalyticsScatterplotEnabled,
hasDurationChartMedian: this.glFeatures.cycleAnalyticsScatterplotMedianEnabled,
hasTasksByTypeChart: this.glFeatures.tasksByTypeChart,
});
},
......@@ -303,6 +305,7 @@ export default {
:y-axis-title="s__('CycleAnalytics|Total days to completion')"
:tooltip-date-format="$options.durationChartTooltipDateFormat"
:scatter-data="durationChartPlottableData"
:median-line-data="durationChartMedianData"
/>
<div v-else ref="duration-chart-no-data" class="bs-callout bs-callout-info">
{{ __('There is no data available. Please change your selection.') }}
......
import dateFormat from 'dateformat';
import Api from 'ee/api';
import { getDayDifference, getDateInPast } from '~/lib/utils/datetime_utility';
import createFlash, { hideFlash } from '~/flash';
import { __ } from '~/locale';
import httpStatus from '~/lib/utils/http_status';
import * as types from './mutation_types';
import { dateFormats } from '../../shared/constants';
import { nestQueryStringKeys } from '../utils';
const removeError = () => {
......@@ -375,9 +378,13 @@ export const removeStage = ({ dispatch, state }, stageId) => {
export const requestDurationData = ({ commit }) => commit(types.REQUEST_DURATION_DATA);
export const receiveDurationDataSuccess = ({ commit }, data) =>
export const receiveDurationDataSuccess = ({ commit, state, dispatch }, data) => {
commit(types.RECEIVE_DURATION_DATA_SUCCESS, data);
const { featureFlags: { hasDurationChartMedian = false } = {} } = state;
if (hasDurationChartMedian) dispatch('fetchDurationMedianData');
};
export const receiveDurationDataError = ({ commit }) => {
commit(types.RECEIVE_DURATION_DATA_ERROR);
createFlash(__('There was an error while fetching cycle analytics duration data.'));
......@@ -417,8 +424,56 @@ export const fetchDurationData = ({ state, dispatch, getters }) => {
.catch(() => dispatch('receiveDurationDataError'));
};
export const requestDurationMedianData = ({ commit }) => commit(types.REQUEST_DURATION_MEDIAN_DATA);
export const receiveDurationMedianDataSuccess = ({ commit }, data) =>
commit(types.RECEIVE_DURATION_MEDIAN_DATA_SUCCESS, data);
export const receiveDurationMedianDataError = ({ commit }) => {
commit(types.RECEIVE_DURATION_MEDIAN_DATA_ERROR);
createFlash(__('There was an error while fetching cycle analytics duration median data.'));
};
export const fetchDurationMedianData = ({ state, dispatch }) => {
dispatch('requestDurationMedianData');
const {
stages,
selectedGroup: { fullPath },
startDate,
endDate,
selectedProjectIds,
} = state;
const offsetValue = getDayDifference(new Date(startDate), new Date(endDate));
const offsetCreatedAfter = getDateInPast(new Date(startDate), offsetValue);
const offsetCreatedBefore = getDateInPast(new Date(endDate), offsetValue);
return Promise.all(
stages.map(stage => {
const { slug } = stage;
return Api.cycleAnalyticsDurationChart(slug, {
group_id: fullPath,
created_after: dateFormat(offsetCreatedAfter, dateFormats.isoDate),
created_before: dateFormat(offsetCreatedBefore, dateFormats.isoDate),
project_ids: selectedProjectIds,
}).then(({ data }) => ({
slug,
selected: true,
data,
}));
}),
)
.then(data => {
dispatch('receiveDurationMedianDataSuccess', data);
})
.catch(() => dispatch('receiveDurationMedianDataError'));
};
export const updateSelectedDurationChartStages = ({ state, commit }, stages) => {
const updatedDurationStageData = state.durationData.map(stage => {
const setSelectedPropertyOnStages = data =>
data.map(stage => {
const selected = stages.reduce((result, object) => {
if (object.slug === stage.slug) return true;
return result;
......@@ -430,5 +485,12 @@ export const updateSelectedDurationChartStages = ({ state, commit }, stages) =>
};
});
commit(types.UPDATE_SELECTED_DURATION_CHART_STAGES, updatedDurationStageData);
const { durationData, durationMedianData } = state;
const updatedDurationStageData = setSelectedPropertyOnStages(durationData);
const updatedDurationStageMedianData = setSelectedPropertyOnStages(durationMedianData);
commit(types.UPDATE_SELECTED_DURATION_CHART_STAGES, {
updatedDurationStageData,
updatedDurationStageMedianData,
});
};
import dateFormat from 'dateformat';
import httpStatus from '~/lib/utils/http_status';
import { dateFormats } from '../../shared/constants';
import { getDurationChartData, getTasksByTypeData } from '../utils';
import { getDurationChartData, getDurationChartMedianData, getTasksByTypeData } from '../utils';
export const hasNoAccessError = state => state.errorCode === httpStatus.FORBIDDEN;
......@@ -26,6 +26,18 @@ export const durationChartPlottableData = state => {
return plottableData.length ? plottableData : null;
};
export const durationChartMedianData = state => {
const { durationMedianData, startDate, endDate } = state;
const selectedStagesDurationMedianData = durationMedianData.filter(stage => stage.selected);
const plottableData = getDurationChartMedianData(
selectedStagesDurationMedianData,
startDate,
endDate,
);
return plottableData.length ? plottableData : [];
};
export const tasksByTypeChartData = ({ tasksByType, startDate, endDate }) => {
if (tasksByType && tasksByType.data.length) {
return getTasksByTypeData({
......
......@@ -54,3 +54,7 @@ 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';
export const REQUEST_DURATION_MEDIAN_DATA = 'REQUEST_DURATION_MEDIAN_DATA';
export const RECEIVE_DURATION_MEDIAN_DATA_SUCCESS = 'RECEIVE_DURATION_MEDIAN_DATA_SUCCESS';
export const RECEIVE_DURATION_MEDIAN_DATA_ERROR = 'RECEIVE_DURATION_MEDIAN_DATA_ERROR';
......@@ -20,8 +20,12 @@ export default {
state.startDate = startDate;
state.endDate = endDate;
},
[types.UPDATE_SELECTED_DURATION_CHART_STAGES](state, updatedDurationStageData) {
[types.UPDATE_SELECTED_DURATION_CHART_STAGES](
state,
{ updatedDurationStageData, updatedDurationStageMedianData },
) {
state.durationData = updatedDurationStageData;
state.durationMedianData = updatedDurationStageMedianData;
},
[types.REQUEST_CYCLE_ANALYTICS_DATA](state) {
state.isLoading = true;
......@@ -175,4 +179,15 @@ export default {
state.durationData = [];
state.isLoadingDurationChart = false;
},
[types.REQUEST_DURATION_MEDIAN_DATA](state) {
state.isLoadingDurationChartMedianData = true;
},
[types.RECEIVE_DURATION_MEDIAN_DATA_SUCCESS](state, data) {
state.durationMedianData = data;
state.isLoadingDurationChartMedianData = false;
},
[types.RECEIVE_DURATION_MEDIAN_DATA_ERROR](state) {
state.durationMedianData = [];
state.isLoadingDurationChartMedianData = false;
},
};
......@@ -10,6 +10,7 @@ export default () => ({
isLoadingStage: false,
isLoadingTasksByTypeChart: false,
isLoadingDurationChart: false,
isLoadingDurationChartMedianData: false,
isEmptyStage: false,
errorCode: null,
......@@ -37,4 +38,5 @@ export default () => ({
},
durationData: [],
durationMedianData: [],
});
......@@ -7,6 +7,7 @@ class Analytics::CycleAnalyticsController < Analytics::ApplicationController
before_action do
push_frontend_feature_flag(:customizable_cycle_analytics)
push_frontend_feature_flag(:cycle_analytics_scatterplot_enabled)
push_frontend_feature_flag(:cycle_analytics_scatterplot_median_enabled)
push_frontend_feature_flag(:tasks_by_type_chart)
end
end
......@@ -14,7 +14,9 @@ import {
endDate,
customizableStagesAndEvents,
rawDurationData,
rawDurationMedianData,
transformedDurationData,
transformedDurationMedianData,
} from '../mock_data';
const stageData = { events: [] };
......@@ -44,12 +46,13 @@ describe('Cycle analytics actions', () => {
beforeEach(() => {
state = {
startDate: '2019-01-14',
endDate: '2019-02-15',
startDate,
endDate,
stages: [],
featureFlags: {
hasDurationChart: true,
hasTasksByTypeChart: true,
hasDurationChartMedian: true,
},
};
mock = new MockAdapter(axios);
......@@ -851,21 +854,51 @@ describe('Cycle analytics actions', () => {
});
describe('receiveDurationDataSuccess', () => {
const payload = { durationData: transformedDurationData, isLoadingDurationChart: false };
describe('with hasDurationChartMedian feature flag enabled', () => {
it('commits the transformed duration data and dispatches fetchDurationMedianData', () => {
testAction(
actions.receiveDurationDataSuccess,
payload,
transformedDurationData,
state,
[
{
type: types.RECEIVE_DURATION_DATA_SUCCESS,
payload,
payload: transformedDurationData,
},
],
[
{
type: 'fetchDurationMedianData',
},
],
);
});
});
describe('with hasDurationChartMedian feature flag disabled', () => {
const disabledState = {
...state,
featureFlags: {
hasDurationChartMedian: false,
},
};
it('commits the transformed duration data', () => {
testAction(
actions.receiveDurationDataSuccess,
transformedDurationData,
disabledState,
[
{
type: types.RECEIVE_DURATION_DATA_SUCCESS,
payload: transformedDurationData,
},
],
[],
);
});
});
});
describe('receiveDurationDataError', () => {
beforeEach(() => {
......@@ -900,6 +933,7 @@ describe('Cycle analytics actions', () => {
const stateWithDurationData = {
...state,
durationData: transformedDurationData,
durationMedianData: transformedDurationMedianData,
};
testAction(
......@@ -909,7 +943,10 @@ describe('Cycle analytics actions', () => {
[
{
type: types.UPDATE_SELECTED_DURATION_CHART_STAGES,
payload: transformedDurationData,
payload: {
updatedDurationStageData: transformedDurationData,
updatedDurationStageMedianData: transformedDurationMedianData,
},
},
],
[],
......@@ -920,6 +957,7 @@ describe('Cycle analytics actions', () => {
const stateWithDurationData = {
...state,
durationData: transformedDurationData,
durationMedianData: transformedDurationMedianData,
};
testAction(
......@@ -929,13 +967,22 @@ describe('Cycle analytics actions', () => {
[
{
type: types.UPDATE_SELECTED_DURATION_CHART_STAGES,
payload: [
payload: {
updatedDurationStageData: [
transformedDurationData[0],
{
...transformedDurationData[1],
selected: false,
},
],
updatedDurationStageMedianData: [
transformedDurationMedianData[0],
{
...transformedDurationMedianData[1],
selected: false,
},
],
},
},
],
[],
......@@ -946,6 +993,7 @@ describe('Cycle analytics actions', () => {
const stateWithDurationData = {
...state,
durationData: transformedDurationData,
durationMedianData: transformedDurationMedianData,
};
testAction(
......@@ -955,7 +1003,8 @@ describe('Cycle analytics actions', () => {
[
{
type: types.UPDATE_SELECTED_DURATION_CHART_STAGES,
payload: [
payload: {
updatedDurationStageData: [
{
...transformedDurationData[0],
selected: false,
......@@ -965,9 +1014,141 @@ describe('Cycle analytics actions', () => {
selected: false,
},
],
updatedDurationStageMedianData: [
{
...transformedDurationMedianData[0],
selected: false,
},
{
...transformedDurationMedianData[1],
selected: false,
},
],
},
},
],
[],
);
});
});
describe('fetchDurationMedianData', () => {
beforeEach(() => {
mock.onGet(endpoints.durationData).reply(200, [...rawDurationMedianData]);
});
it('dispatches requestDurationMedianData when called', done => {
const stateWithStages = {
...state,
stages: [stages[0], stages[1]],
selectedGroup,
};
const dispatch = jest.fn();
actions
.fetchDurationMedianData({
dispatch,
state: stateWithStages,
})
.then(() => {
expect(dispatch).toHaveBeenNthCalledWith(1, 'requestDurationMedianData');
done();
})
.catch(done.fail);
});
it('dispatches the receiveDurationMedianDataSuccess action on success', done => {
const stateWithStages = {
...state,
stages: [stages[0], stages[1]],
selectedGroup,
};
const dispatch = jest.fn();
actions
.fetchDurationMedianData({
dispatch,
state: stateWithStages,
})
.then(() => {
expect(dispatch).toHaveBeenCalledWith(
'receiveDurationMedianDataSuccess',
transformedDurationMedianData,
);
done();
})
.catch(done.fail);
});
it('dispatches the receiveDurationMedianDataError action when there is an error', done => {
const brokenState = {
...state,
stages: [
{
id: 'oops',
},
],
selectedGroup,
};
const dispatch = jest.fn();
actions
.fetchDurationMedianData({
dispatch,
state: brokenState,
})
.then(() => {
expect(dispatch).toHaveBeenCalledWith('receiveDurationMedianDataError');
done();
})
.catch(done.fail);
});
});
describe('receiveDurationMedianDataSuccess', () => {
it('commits the transformed duration median data', done => {
testAction(
actions.receiveDurationMedianDataSuccess,
transformedDurationMedianData,
state,
[
{
type: types.RECEIVE_DURATION_MEDIAN_DATA_SUCCESS,
payload: transformedDurationMedianData,
},
],
[],
done,
);
});
});
describe('receiveDurationMedianDataError', () => {
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
});
it("commits the 'RECEIVE_DURATION_MEDIAN_DATA_ERROR' mutation", () => {
testAction(
actions.receiveDurationMedianDataError,
{},
state,
[
{
type: types.RECEIVE_DURATION_MEDIAN_DATA_ERROR,
},
],
[],
);
});
it('will flash an error', () => {
actions.receiveDurationMedianDataError({
commit: () => {},
});
shouldFlashAMessage(
'There was an error while fetching cycle analytics duration median data.',
);
});
});
......
......@@ -3,7 +3,9 @@ import {
startDate,
endDate,
transformedDurationData,
transformedDurationMedianData,
durationChartPlottableData,
durationChartPlottableMedianData,
} from '../mock_data';
let state = null;
......@@ -95,4 +97,28 @@ describe('Cycle analytics getters', () => {
expect(getters.durationChartPlottableData(stateWithDurationData)).toBeNull();
});
});
describe('durationChartPlottableMedianData', () => {
it('returns plottable median data for selected stages', () => {
const stateWithDurationMedianData = {
startDate,
endDate,
durationMedianData: transformedDurationMedianData,
};
expect(getters.durationChartMedianData(stateWithDurationMedianData)).toEqual(
durationChartPlottableMedianData,
);
});
it('returns an empty array if there is no plottable median data for the selected stages', () => {
const stateWithDurationMedianData = {
startDate,
endDate,
durationMedianData: [],
};
expect(getters.durationChartMedianData(stateWithDurationMedianData)).toEqual([]);
});
});
});
......@@ -19,6 +19,7 @@ import {
tasksByTypeData,
transformedDurationData,
transformedTasksByTypeData,
transformedDurationMedianData,
} from '../mock_data';
let state = null;
......@@ -61,6 +62,7 @@ describe('Cycle analytics mutations', () => {
${types.RECEIVE_DURATION_DATA_ERROR} | ${'isLoadingDurationChart'} | ${false}
${types.REQUEST_STAGE_MEDIANS} | ${'medians'} | ${{}}
${types.RECEIVE_STAGE_MEDIANS_ERROR} | ${'medians'} | ${{}}
${types.REQUEST_DURATION_MEDIAN_DATA} | ${'isLoadingDurationChartMedianData'} | ${true}
`('$mutation will set $stateKey=$value', ({ mutation, stateKey, value }) => {
mutations[mutation](state);
......@@ -74,7 +76,7 @@ describe('Cycle analytics mutations', () => {
${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' }} | ${{ selectedStage: { id: 'first-stage' } }}
${types.UPDATE_SELECTED_DURATION_CHART_STAGES} | ${transformedDurationData} | ${{ durationData: transformedDurationData }}
${types.UPDATE_SELECTED_DURATION_CHART_STAGES} | ${{ updatedDurationStageData: transformedDurationData, updatedDurationStageMedianData: transformedDurationMedianData }} | ${{ durationData: transformedDurationData, durationMedianData: transformedDurationMedianData }}
`(
'$mutation with payload $payload will update state with $expectedState',
({ mutation, payload, expectedState }) => {
......@@ -218,6 +220,37 @@ describe('Cycle analytics mutations', () => {
});
});
describe(`${types.RECEIVE_DURATION_MEDIAN_DATA_SUCCESS}`, () => {
it('sets the data correctly and falsifies isLoadingDurationChartMedianData', () => {
const stateWithData = {
isLoadingDurationChartMedianData: true,
durationMedianData: [['something', 'random']],
};
mutations[types.RECEIVE_DURATION_MEDIAN_DATA_SUCCESS](
stateWithData,
transformedDurationMedianData,
);
expect(stateWithData.isLoadingDurationChartMedianData).toBe(false);
expect(stateWithData.durationMedianData).toBe(transformedDurationMedianData);
});
});
describe(`${types.RECEIVE_DURATION_MEDIAN_DATA_ERROR}`, () => {
it('falsifies isLoadingDurationChartMedianData and sets durationMedianData to an empty array', () => {
const stateWithData = {
isLoadingDurationChartMedianData: true,
durationMedianData: [['something', 'random']],
};
mutations[types.RECEIVE_DURATION_MEDIAN_DATA_ERROR](stateWithData);
expect(stateWithData.isLoadingDurationChartMedianData).toBe(false);
expect(stateWithData.durationMedianData).toStrictEqual([]);
});
});
describe(`${types.RECEIVE_STAGE_MEDIANS_SUCCESS}`, () => {
it('sets each id as a key in the median object with the corresponding value', () => {
const stateWithData = {
......
......@@ -18647,6 +18647,9 @@ msgstr ""
msgid "There was an error while fetching cycle analytics duration data."
msgstr ""
msgid "There was an error while fetching cycle analytics duration median 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