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