Commit f7c12df5 authored by Mike Greiling's avatar Mike Greiling

Merge branch '13076-fetch-group-stages-and-events' into 'master'

Fetch cycle analytics stages groups and events separately

See merge request gitlab-org/gitlab!18514
parents 2c93bad0 82ed9302
...@@ -138,6 +138,14 @@ export const stripHtml = (string, replace = '') => { ...@@ -138,6 +138,14 @@ export const stripHtml = (string, replace = '') => {
*/ */
export const convertToCamelCase = string => string.replace(/(_\w)/g, s => s[1].toUpperCase()); export const convertToCamelCase = string => string.replace(/(_\w)/g, s => s[1].toUpperCase());
/**
* Converts camelCase string to snake_case
*
* @param {*} string
*/
export const convertToSnakeCase = string =>
slugifyWithUnderscore(string.match(/([a-zA-Z][^A-Z]*)/g).join(' '));
/** /**
* Converts a sentence to lower case from the second word onwards * Converts a sentence to lower case from the second word onwards
* e.g. Hello World => Hello world * e.g. Hello World => Hello world
......
<script> <script>
import { GlEmptyState, GlDaterangePicker } from '@gitlab/ui'; import { GlEmptyState, GlDaterangePicker, GlLoadingIcon } from '@gitlab/ui';
import { mapActions, mapState, mapGetters } from 'vuex'; import { mapActions, mapState, mapGetters } from 'vuex';
import { getDateInPast } from '~/lib/utils/datetime_utility'; import { getDateInPast } from '~/lib/utils/datetime_utility';
import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants'; import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants';
...@@ -14,6 +14,7 @@ export default { ...@@ -14,6 +14,7 @@ export default {
name: 'CycleAnalytics', name: 'CycleAnalytics',
components: { components: {
GlEmptyState, GlEmptyState,
GlLoadingIcon,
GroupsDropdownFilter, GroupsDropdownFilter,
ProjectsDropdownFilter, ProjectsDropdownFilter,
SummaryTable, SummaryTable,
...@@ -49,7 +50,7 @@ export default { ...@@ -49,7 +50,7 @@ export default {
'isAddingCustomStage', 'isAddingCustomStage',
'selectedGroup', 'selectedGroup',
'selectedProjectIds', 'selectedProjectIds',
'selectedStageName', 'selectedStageId',
'stages', 'stages',
'summary', 'summary',
'labels', 'labels',
...@@ -83,7 +84,7 @@ export default { ...@@ -83,7 +84,7 @@ export default {
}, },
methods: { methods: {
...mapActions([ ...mapActions([
'fetchGroupLabels', 'fetchCustomStageFormData',
'fetchCycleAnalyticsData', 'fetchCycleAnalyticsData',
'fetchStageData', 'fetchStageData',
'setCycleAnalyticsDataEndpoint', 'setCycleAnalyticsDataEndpoint',
...@@ -92,7 +93,7 @@ export default { ...@@ -92,7 +93,7 @@ export default {
'setSelectedProjects', 'setSelectedProjects',
'setSelectedTimeframe', 'setSelectedTimeframe',
'fetchStageData', 'fetchStageData',
'setSelectedStageName', 'setSelectedStageId',
'hideCustomStageForm', 'hideCustomStageForm',
'showCustomStageForm', 'showCustomStageForm',
'setDateRange', 'setDateRange',
...@@ -101,7 +102,6 @@ export default { ...@@ -101,7 +102,6 @@ export default {
this.setCycleAnalyticsDataEndpoint(group.full_path); this.setCycleAnalyticsDataEndpoint(group.full_path);
this.setSelectedGroup(group); this.setSelectedGroup(group);
this.fetchCycleAnalyticsData(); this.fetchCycleAnalyticsData();
this.fetchGroupLabels(this.currentGroupPath);
}, },
onProjectsSelect(projects) { onProjectsSelect(projects) {
const projectIds = projects.map(value => value.id); const projectIds = projects.map(value => value.id);
...@@ -110,7 +110,7 @@ export default { ...@@ -110,7 +110,7 @@ export default {
}, },
onStageSelect(stage) { onStageSelect(stage) {
this.hideCustomStageForm(); this.hideCustomStageForm();
this.setSelectedStageName(stage.name); this.setSelectedStageId(stage.id);
this.setStageDataEndpoint(this.currentStage.slug); this.setStageDataEndpoint(this.currentStage.slug);
this.fetchStageData(this.currentStage.name); this.fetchStageData(this.currentStage.name);
}, },
...@@ -196,24 +196,29 @@ export default { ...@@ -196,24 +196,29 @@ export default {
" "
/> />
<div v-else-if="!errorCode"> <div v-else-if="!errorCode">
<summary-table class="js-summary-table" :items="summary" /> <div v-if="isLoading">
<stage-table <gl-loading-icon class="mt-4" size="md" />
v-if="currentStage" </div>
class="js-stage-table" <div v-else>
:current-stage="currentStage" <summary-table class="js-summary-table" :items="summary" />
:stages="stages" <stage-table
:is-loading="isLoadingStage" v-if="currentStage"
:is-empty-stage="isEmptyStage" class="js-stage-table"
:is-adding-custom-stage="isAddingCustomStage" :current-stage="currentStage"
:current-stage-events="currentStageEvents" :stages="stages"
:custom-stage-form-events="customStageFormEvents" :is-loading="isLoadingStage"
:labels="labels" :is-empty-stage="isEmptyStage"
:no-data-svg-path="noDataSvgPath" :is-adding-custom-stage="isAddingCustomStage"
:no-access-svg-path="noAccessSvgPath" :current-stage-events="currentStageEvents"
:can-edit-stages="hasCustomizableCycleAnalytics" :custom-stage-form-events="customStageFormEvents"
@selectStage="onStageSelect" :labels="labels"
@showAddStageForm="onShowAddStageForm" :no-data-svg-path="noDataSvgPath"
/> :no-access-svg-path="noAccessSvgPath"
:can-edit-stages="hasCustomizableCycleAnalytics"
@selectStage="onStageSelect"
@showAddStageForm="onShowAddStageForm"
/>
</div>
</div> </div>
</div> </div>
</div> </div>
......
...@@ -40,13 +40,13 @@ export default { ...@@ -40,13 +40,13 @@ export default {
<limit-warning :count="events.length" /> <limit-warning :count="events.length" />
</div> </div>
<stage-build-item <stage-build-item
v-if="isCurrentStage(stage.name, STAGE_NAME_TEST)" v-if="isCurrentStage(stage.slug, STAGE_NAME_TEST)"
:stage="stage" :stage="stage"
:events="events" :events="events"
:with-build-status="true" :with-build-status="true"
/> />
<stage-build-item <stage-build-item
v-else-if="isCurrentStage(stage.name, STAGE_NAME_STAGING)" v-else-if="isCurrentStage(stage.slug, STAGE_NAME_STAGING)"
:stage="stage" :stage="stage"
:events="events" :events="events"
/> />
......
...@@ -17,10 +17,6 @@ export default { ...@@ -17,10 +17,6 @@ export default {
default: false, default: false,
required: false, required: false,
}, },
isUserAllowed: {
type: Boolean,
required: true,
},
title: { title: {
type: String, type: String,
required: true, required: true,
...@@ -41,7 +37,7 @@ export default { ...@@ -41,7 +37,7 @@ export default {
return this.value && this.value.length > 0; return this.value && this.value.length > 0;
}, },
editable() { editable() {
return this.isUserAllowed && this.canEdit; return this.canEdit;
}, },
}, },
}; };
...@@ -54,13 +50,8 @@ export default { ...@@ -54,13 +50,8 @@ export default {
{{ title }} {{ title }}
</div> </div>
<div class="stage-nav-item-cell stage-median mr-4"> <div class="stage-nav-item-cell stage-median mr-4">
<template v-if="isUserAllowed"> <span v-if="hasValue">{{ value }}</span>
<span v-if="hasValue">{{ value }}</span> <span v-else class="stage-empty">{{ __('Not enough data') }}</span>
<span v-else class="stage-empty">{{ __('Not enough data') }}</span>
</template>
<template v-else>
<span class="not-available">{{ __('Not available') }}</span>
</template>
</div> </div>
<template v-slot:dropdown-options> <template v-slot:dropdown-options>
<template v-if="isDefaultStage"> <template v-if="isDefaultStage">
......
...@@ -71,7 +71,7 @@ export default { ...@@ -71,7 +71,7 @@ export default {
}, },
computed: { computed: {
stageName() { stageName() {
return this.currentStage ? this.currentStage.legend : __('Related Issues'); return this.currentStage ? this.currentStage.title : __('Related Issues');
}, },
shouldDisplayStage() { shouldDisplayStage() {
const { currentStageEvents = [], isLoading, isEmptyStage } = this; const { currentStageEvents = [], isLoading, isEmptyStage } = this;
...@@ -138,8 +138,8 @@ export default { ...@@ -138,8 +138,8 @@ export default {
:key="`ca-stage-title-${stage.title}`" :key="`ca-stage-title-${stage.title}`"
:title="stage.title" :title="stage.title"
:value="stage.value" :value="stage.value"
:is-active="!isAddingCustomStage && stage.name === currentStage.name" :is-active="!isAddingCustomStage && stage.id === currentStage.id"
:is-user-allowed="stage.isUserAllowed" :is-default-stage="!stage.custom"
@select="selectStage(stage)" @select="selectStage(stage)"
/> />
<add-stage-button <add-stage-button
...@@ -151,12 +151,6 @@ export default { ...@@ -151,12 +151,6 @@ export default {
</nav> </nav>
<div class="section stage-events"> <div class="section stage-events">
<gl-loading-icon v-if="isLoading" class="mt-4" size="md" /> <gl-loading-icon v-if="isLoading" class="mt-4" size="md" />
<gl-empty-state
v-else-if="currentStage && !currentStage.isUserAllowed"
:title="__('You need permission.')"
:description="__('Want to see the data? Please ask an administrator for access.')"
:svg-path="noAccessSvgPath"
/>
<custom-stage-form <custom-stage-form
v-else-if="isAddingCustomStage" v-else-if="isAddingCustomStage"
:events="customStageFormEvents" :events="customStageFormEvents"
......
import dateFormat from 'dateformat';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import createFlash, { hideFlash } from '~/flash'; import createFlash, { hideFlash } from '~/flash';
import { __ } from '~/locale'; import { __ } from '~/locale';
import Api from '~/api'; import Api from '~/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 { dateFormats } from '../../shared/constants'; import { nestQueryStringKeys } from '../utils';
const removeError = () => { const removeError = () => {
const flashEl = document.querySelector('.flash-alert'); const flashEl = document.querySelector('.flash-alert');
...@@ -22,8 +21,8 @@ export const setStageDataEndpoint = ({ commit }, stageSlug) => ...@@ -22,8 +21,8 @@ export const setStageDataEndpoint = ({ commit }, stageSlug) =>
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 setSelectedStageName = ({ commit }, stageName) => export const setSelectedStageId = ({ commit }, stageId) =>
commit(types.SET_SELECTED_STAGE_NAME, stageName); commit(types.SET_SELECTED_STAGE_ID, stageId);
export const setDateRange = ( export const setDateRange = (
{ commit, dispatch, state }, { commit, dispatch, state },
...@@ -42,38 +41,24 @@ export const receiveStageDataSuccess = ({ commit }, data) => ...@@ -42,38 +41,24 @@ export const receiveStageDataSuccess = ({ commit }, data) =>
export const receiveStageDataError = ({ commit }) => { export const receiveStageDataError = ({ commit }) => {
commit(types.RECEIVE_STAGE_DATA_ERROR); commit(types.RECEIVE_STAGE_DATA_ERROR);
createFlash(__('There was an error while fetching cycle analytics data.')); createFlash(__('There was an error fetching data for the selected stage'));
}; };
export const fetchStageData = ({ state, dispatch }) => { export const fetchStageData = ({ state, dispatch, getters }) => {
const { cycleAnalyticsRequestParams = {} } = getters;
dispatch('requestStageData'); dispatch('requestStageData');
axios axios
.get(state.endpoints.stageData, { .get(state.endpoints.stageData, {
params: { params: nestQueryStringKeys(cycleAnalyticsRequestParams, 'cycle_analytics'),
'cycle_analytics[created_after]': dateFormat(state.startDate, dateFormats.isoDate),
'cycle_analytics[created_before]': dateFormat(state.endDate, dateFormats.isoDate),
'cycle_analytics[project_ids]': state.selectedProjectIds,
},
}) })
.then(({ data }) => dispatch('receiveStageDataSuccess', data)) .then(({ data }) => dispatch('receiveStageDataSuccess', data))
.catch(error => dispatch('receiveStageDataError', error)); .catch(error => dispatch('receiveStageDataError', error));
}; };
export const requestCycleAnalyticsData = ({ commit }) => commit(types.REQUEST_CYCLE_ANALYTICS_DATA); export const requestCycleAnalyticsData = ({ commit }) => commit(types.REQUEST_CYCLE_ANALYTICS_DATA);
export const receiveCycleAnalyticsDataSuccess = ({ state, commit, dispatch }, data) => { export const receiveCycleAnalyticsDataSuccess = ({ commit }) =>
commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS, data); commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS);
const { stages = [] } = state;
if (stages && stages.length) {
removeError();
const { slug } = stages[0];
dispatch('setStageDataEndpoint', slug);
dispatch('fetchStageData');
} else {
createFlash(__('There was an error while fetching cycle analytics data.'));
}
};
export const receiveCycleAnalyticsDataError = ({ commit }, { response }) => { export const receiveCycleAnalyticsDataError = ({ commit }, { response }) => {
const { status } = response; const { status } = response;
...@@ -83,21 +68,41 @@ export const receiveCycleAnalyticsDataError = ({ commit }, { response }) => { ...@@ -83,21 +68,41 @@ export const receiveCycleAnalyticsDataError = ({ commit }, { response }) => {
createFlash(__('There was an error while fetching cycle analytics data.')); createFlash(__('There was an error while fetching cycle analytics data.'));
}; };
export const fetchCycleAnalyticsData = ({ state, dispatch }) => { export const fetchCycleAnalyticsData = ({ dispatch }) => {
dispatch('requestCycleAnalyticsData'); removeError();
return dispatch('requestCycleAnalyticsData')
.then(() => dispatch('fetchGroupLabels')) // fetch group label data
.then(() => dispatch('fetchGroupStagesAndEvents')) // fetch stage data
.then(() => dispatch('fetchSummaryData')) // fetch summary data and stage medians
.then(() => dispatch('receiveCycleAnalyticsDataSuccess'))
.catch(error => dispatch('receiveCycleAnalyticsDataError', error));
};
axios export const requestSummaryData = ({ commit }) => commit(types.REQUEST_SUMMARY_DATA);
export const receiveSummaryDataError = ({ commit }, error) => {
commit(types.RECEIVE_SUMMARY_DATA_ERROR, error);
createFlash(__('There was an error while fetching cycle analytics summary data.'));
};
export const receiveSummaryDataSuccess = ({ commit }, data) =>
commit(types.RECEIVE_SUMMARY_DATA_SUCCESS, data);
export const fetchSummaryData = ({ state, dispatch, getters }) => {
const { cycleAnalyticsRequestParams = {} } = getters;
dispatch('requestSummaryData');
return axios
.get(state.endpoints.cycleAnalyticsData, { .get(state.endpoints.cycleAnalyticsData, {
params: { params: nestQueryStringKeys(cycleAnalyticsRequestParams, 'cycle_analytics'),
'cycle_analytics[created_after]': dateFormat(state.startDate, dateFormats.isoDate),
'cycle_analytics[created_before]': dateFormat(state.endDate, dateFormats.isoDate),
'cycle_analytics[project_ids]': state.selectedProjectIds,
},
}) })
.then(({ data }) => dispatch('receiveCycleAnalyticsDataSuccess', data)) .then(({ data }) => dispatch('receiveSummaryDataSuccess', data))
.catch(error => dispatch('receiveCycleAnalyticsDataError', error)); .catch(error => dispatch('receiveSummaryDataError', error));
}; };
export const requestGroupStagesAndEvents = ({ commit }) =>
commit(types.REQUEST_GROUP_STAGES_AND_EVENTS);
export const hideCustomStageForm = ({ commit }) => commit(types.HIDE_CUSTOM_STAGE_FORM); export const hideCustomStageForm = ({ commit }) => commit(types.HIDE_CUSTOM_STAGE_FORM);
export const showCustomStageForm = ({ commit }) => commit(types.SHOW_CUSTOM_STAGE_FORM); export const showCustomStageForm = ({ commit }) => commit(types.SHOW_CUSTOM_STAGE_FORM);
...@@ -111,10 +116,44 @@ export const receiveGroupLabelsError = ({ commit }, error) => { ...@@ -111,10 +116,44 @@ export const receiveGroupLabelsError = ({ commit }, error) => {
export const requestGroupLabels = ({ commit }) => commit(types.REQUEST_GROUP_LABELS); export const requestGroupLabels = ({ commit }) => commit(types.REQUEST_GROUP_LABELS);
export const fetchGroupLabels = ({ dispatch }, groupPath) => { export const fetchGroupLabels = ({ dispatch, state }) => {
dispatch('requestGroupLabels'); dispatch('requestGroupLabels');
const {
selectedGroup: { fullPath },
} = state;
return Api.groupLabels(groupPath) return Api.groupLabels(fullPath)
.then(data => dispatch('receiveGroupLabelsSuccess', data)) .then(data => dispatch('receiveGroupLabelsSuccess', data))
.catch(error => dispatch('receiveGroupLabelsError', error)); .catch(error => dispatch('receiveGroupLabelsError', error));
}; };
export const receiveGroupStagesAndEventsError = ({ commit }) => {
commit(types.RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR);
createFlash(__('There was an error fetching cycle analytics stages.'));
};
export const receiveGroupStagesAndEventsSuccess = ({ state, commit, dispatch }, data) => {
commit(types.RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS, data);
const { stages = [] } = state;
if (stages && stages.length) {
const { slug } = stages[0];
dispatch('setStageDataEndpoint', slug);
dispatch('fetchStageData');
} else {
createFlash(__('There was an error while fetching cycle analytics data.'));
}
};
export const fetchGroupStagesAndEvents = ({ state, dispatch, getters }) => {
const {
cycleAnalyticsRequestParams: { created_after, project_ids },
} = getters;
dispatch('requestGroupStagesAndEvents');
return axios
.get(state.endpoints.cycleAnalyticsStagesAndEvents, {
params: nestQueryStringKeys({ start_date: created_after, project_ids }, 'cycle_analytics'),
})
.then(({ data }) => dispatch('receiveGroupStagesAndEventsSuccess', data))
.catch(error => dispatch('receiveGroupStagesAndEventsError', error));
};
import dateFormat from 'dateformat';
import httpStatus from '~/lib/utils/http_status'; import httpStatus from '~/lib/utils/http_status';
import { dateFormats } from '../../shared/constants';
export const currentStage = ({ stages, selectedStageName }) => export const currentStage = ({ stages, selectedStageId }) =>
stages.length && selectedStageName stages.length && selectedStageId ? stages.find(stage => stage.id === selectedStageId) : null;
? stages.find(stage => stage.name === selectedStageName)
: null;
export const defaultStage = state => (state.stages.length ? state.stages[0] : null); export const defaultStage = state => (state.stages.length ? state.stages[0] : null);
export const hasNoAccessError = state => state.errorCode === httpStatus.FORBIDDEN; export const hasNoAccessError = state => state.errorCode === httpStatus.FORBIDDEN;
export const currentGroupPath = ({ selectedGroup }) => export const currentGroupPath = ({ selectedGroup }) =>
selectedGroup && selectedGroup.fullPath ? selectedGroup.fullPath : null; selectedGroup && selectedGroup.fullPath ? selectedGroup.fullPath : null;
export const cycleAnalyticsRequestParams = ({
startDate = null,
endDate = null,
selectedProjectIds = [],
}) => ({
project_ids: selectedProjectIds,
created_after: startDate ? dateFormat(startDate, dateFormats.isoDate) : null,
created_before: endDate ? dateFormat(endDate, dateFormats.isoDate) : null,
});
...@@ -3,7 +3,7 @@ export const SET_STAGE_DATA_ENDPOINT = 'SET_STAGE_DATA_ENDPOINT'; ...@@ -3,7 +3,7 @@ export const SET_STAGE_DATA_ENDPOINT = 'SET_STAGE_DATA_ENDPOINT';
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_NAME = 'SET_SELECTED_STAGE_NAME'; 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';
...@@ -21,3 +21,11 @@ export const SHOW_CUSTOM_STAGE_FORM = 'SHOW_CUSTOM_STAGE_FORM'; ...@@ -21,3 +21,11 @@ export const SHOW_CUSTOM_STAGE_FORM = 'SHOW_CUSTOM_STAGE_FORM';
export const REQUEST_GROUP_LABELS = 'REQUEST_GROUP_LABELS'; export const REQUEST_GROUP_LABELS = 'REQUEST_GROUP_LABELS';
export const RECEIVE_GROUP_LABELS_SUCCESS = 'RECEIVE_GROUP_LABELS_SUCCESS'; export const RECEIVE_GROUP_LABELS_SUCCESS = 'RECEIVE_GROUP_LABELS_SUCCESS';
export const RECEIVE_GROUP_LABELS_ERROR = 'RECEIVE_GROUP_LABELS_ERROR'; export const RECEIVE_GROUP_LABELS_ERROR = 'RECEIVE_GROUP_LABELS_ERROR';
export const REQUEST_SUMMARY_DATA = 'REQUEST_SUMMARY_DATA';
export const RECEIVE_SUMMARY_DATA_SUCCESS = 'RECEIVE_SUMMARY_DATA_SUCCESS';
export const RECEIVE_SUMMARY_DATA_ERROR = 'RECEIVE_SUMMARY_DATA_ERROR';
export const REQUEST_GROUP_STAGES_AND_EVENTS = 'REQUEST_GROUP_STAGES_AND_EVENTS';
export const RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS = 'RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS';
export const RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR = 'RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR';
import { dasherize } from '~/lib/utils/text_utility';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import * as types from './mutation_types'; import * as types from './mutation_types';
import { EMPTY_STAGE_TEXT } from '../constants'; import { transformRawStages } from '../utils';
export default { export default {
[types.SET_CYCLE_ANALYTICS_DATA_ENDPOINT](state, groupPath) { [types.SET_CYCLE_ANALYTICS_DATA_ENDPOINT](state, groupPath) {
// TODO: this endpoint will be removed when the /-/analytics endpoints are ready
// https://gitlab.com/gitlab-org/gitlab/issues/34751
state.endpoints.cycleAnalyticsData = `/groups/${groupPath}/-/cycle_analytics`; state.endpoints.cycleAnalyticsData = `/groups/${groupPath}/-/cycle_analytics`;
state.endpoints.cycleAnalyticsStagesAndEvents = `/-/analytics/cycle_analytics/stages?group_id=${groupPath}`;
}, },
[types.SET_STAGE_DATA_ENDPOINT](state, stageSlug) { [types.SET_STAGE_DATA_ENDPOINT](state, stageSlug) {
state.endpoints.stageData = `${state.endpoints.cycleAnalyticsData}/events/${stageSlug}.json`; // TODO: this endpoint will be replaced with a /-/analytics... endpoint when backend is ready
// https://gitlab.com/gitlab-org/gitlab/issues/34751
const { fullPath } = state.selectedGroup;
state.endpoints.stageData = `/groups/${fullPath}/-/cycle_analytics/events/${stageSlug}.json`;
}, },
[types.SET_SELECTED_GROUP](state, group) { [types.SET_SELECTED_GROUP](state, group) {
state.selectedGroup = convertObjectPropsToCamelCase(group, { deep: true }); state.selectedGroup = convertObjectPropsToCamelCase(group, { deep: true });
...@@ -17,8 +22,8 @@ export default { ...@@ -17,8 +22,8 @@ export default {
[types.SET_SELECTED_PROJECTS](state, projectIds) { [types.SET_SELECTED_PROJECTS](state, projectIds) {
state.selectedProjectIds = projectIds; state.selectedProjectIds = projectIds;
}, },
[types.SET_SELECTED_STAGE_NAME](state, stageName) { [types.SET_SELECTED_STAGE_ID](state, stageId) {
state.selectedStageName = stageName; state.selectedStageId = stageId;
}, },
[types.SET_DATE_RANGE](state, { startDate, endDate }) { [types.SET_DATE_RANGE](state, { startDate, endDate }) {
state.startDate = startDate; state.startDate = startDate;
...@@ -28,27 +33,7 @@ export default { ...@@ -28,27 +33,7 @@ export default {
state.isLoading = true; state.isLoading = true;
state.isAddingCustomStage = false; state.isAddingCustomStage = false;
}, },
[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, data) { [types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state) {
state.summary = data.summary.map(item => ({
...item,
value: item.value || '-',
}));
state.stages = data.stats.map(item => {
const slug = dasherize(item.name.toLowerCase());
return {
...item,
isUserAllowed: data.permissions[slug],
emptyStageText: EMPTY_STAGE_TEXT[slug],
slug,
};
});
if (state.stages.length) {
const { name } = state.stages[0];
state.selectedStageName = name;
}
state.errorCode = null; state.errorCode = null;
state.isLoading = false; state.isLoading = false;
}, },
...@@ -58,13 +43,15 @@ export default { ...@@ -58,13 +43,15 @@ export default {
}, },
[types.REQUEST_STAGE_DATA](state) { [types.REQUEST_STAGE_DATA](state) {
state.isLoadingStage = true; state.isLoadingStage = true;
state.isEmptyStage = false;
}, },
[types.RECEIVE_STAGE_DATA_SUCCESS](state, data = {}) { [types.RECEIVE_STAGE_DATA_SUCCESS](state, data = {}) {
const { events = [] } = data; const { events = [] } = data;
state.currentStageEvents = events.map(({ name = '', ...rest }) => state.currentStageEvents = events.map(({ name = '', ...rest }) =>
convertObjectPropsToCamelCase({ title: name, ...rest }, { deep: true }), convertObjectPropsToCamelCase({ title: name, ...rest }, { deep: true }),
); );
state.isEmptyStage = state.currentStageEvents.length === 0; state.isEmptyStage = !events.length;
state.isLoadingStage = false; state.isLoadingStage = false;
}, },
[types.RECEIVE_STAGE_DATA_ERROR](state) { [types.RECEIVE_STAGE_DATA_ERROR](state) {
...@@ -86,4 +73,51 @@ export default { ...@@ -86,4 +73,51 @@ export default {
[types.SHOW_CUSTOM_STAGE_FORM](state) { [types.SHOW_CUSTOM_STAGE_FORM](state) {
state.isAddingCustomStage = true; state.isAddingCustomStage = true;
}, },
[types.RECEIVE_SUMMARY_DATA_ERROR](state) {
state.summary = [];
},
[types.REQUEST_SUMMARY_DATA](state) {
state.summary = [];
},
[types.RECEIVE_SUMMARY_DATA_SUCCESS](state, data) {
const { stages } = state;
const { summary, stats } = data;
state.summary = summary.map(item => ({
...item,
value: item.value || '-',
}));
/*
* Medians will eventually be fetched from a separate endpoint, which will
* include the median calculations for the custom stages, for now we will
* grab the medians from the group level cycle analytics endpoint, which does
* not include the custom stages
* https://gitlab.com/gitlab-org/gitlab/issues/34751
*/
state.stages = stages.map(stage => {
const stat = stats.find(m => m.name === stage.slug);
return { ...stage, value: stat ? stat.value : null };
});
},
[types.REQUEST_GROUP_STAGES_AND_EVENTS](state) {
state.stages = [];
state.customStageFormEvents = [];
},
[types.RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR](state) {
state.stages = [];
state.customStageFormEvents = [];
},
[types.RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS](state, data) {
const { events = [], stages = [] } = data;
state.stages = transformRawStages(stages);
state.customStageFormEvents = events.map(ev =>
convertObjectPropsToCamelCase(ev, { deep: true }),
);
if (state.stages.length) {
const { id } = state.stages[0];
state.selectedStageId = id;
}
},
}; };
...@@ -2,6 +2,8 @@ export default () => ({ ...@@ -2,6 +2,8 @@ export default () => ({
endpoints: { endpoints: {
cycleAnalyticsData: null, cycleAnalyticsData: null,
stageData: null, stageData: null,
cycleAnalyticsStagesAndEvents: null,
summaryData: null,
}, },
startDate: null, startDate: null,
...@@ -17,7 +19,7 @@ export default () => ({ ...@@ -17,7 +19,7 @@ export default () => ({
selectedGroup: null, selectedGroup: null,
selectedProjectIds: [], selectedProjectIds: [],
selectedStageName: null, selectedStageId: null,
currentStageEvents: [], currentStageEvents: [],
......
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { convertToSnakeCase } from '~/lib/utils/text_utility';
import { isString } from 'underscore';
const EVENT_TYPE_LABEL = 'label'; const EVENT_TYPE_LABEL = 'label';
export const isStartEvent = ev => Boolean(ev) && Boolean(ev.canBeStartEvent) && ev.canBeStartEvent; export const isStartEvent = ev => Boolean(ev) && Boolean(ev.canBeStartEvent) && ev.canBeStartEvent;
...@@ -24,3 +28,20 @@ export const isLabelEvent = (labelEvents = [], ev = null) => ...@@ -24,3 +28,20 @@ export const isLabelEvent = (labelEvents = [], ev = null) =>
export const getLabelEventsIdentifiers = (events = []) => export const getLabelEventsIdentifiers = (events = []) =>
events.filter(ev => ev.type && ev.type === EVENT_TYPE_LABEL).map(i => i.identifier); events.filter(ev => ev.type && ev.type === EVENT_TYPE_LABEL).map(i => i.identifier);
export const transformRawStages = (stages = []) =>
stages
.map(({ title, ...rest }) => ({
...convertObjectPropsToCamelCase(rest, { deep: true }),
slug: convertToSnakeCase(title),
title,
}))
.sort((a, b) => a.id > b.id);
export const nestQueryStringKeys = (obj = null, targetKey = '') => {
if (!obj || !isString(targetKey) || !targetKey.length) return {};
return Object.entries(obj).reduce((prev, [key, value]) => {
const customKey = `${targetKey}[${key}]`;
return { ...prev, [customKey]: value };
}, {});
};
...@@ -51,11 +51,17 @@ describe 'Group Cycle Analytics', :js do ...@@ -51,11 +51,17 @@ describe 'Group Cycle Analytics', :js do
end end
end end
def wait_for_stages_to_load
expect(page).to have_selector '.js-stage-table'
end
# TODO: Followup should have tests for stub_licensed_features(cycle_analytics_for_groups: false) # TODO: Followup should have tests for stub_licensed_features(cycle_analytics_for_groups: false)
def select_group def select_group
dropdown = page.find('.dropdown-groups') dropdown = page.find('.dropdown-groups')
dropdown.click dropdown.click
dropdown.find('a').click dropdown.find('a').click
wait_for_requests
end end
def select_project def select_project
...@@ -216,12 +222,7 @@ describe 'Group Cycle Analytics', :js do ...@@ -216,12 +222,7 @@ describe 'Group Cycle Analytics', :js do
dropdown.click dropdown.click
dropdown.find('a').click dropdown.find('a').click
# Make capybara wait until all the .stage-nav-item elements are rendered wait_for_stages_to_load
# We should have NUMBER_OF_STAGES + 1 (button)
expect(page).to have_selector(
'.stage-nav-item',
count: Gitlab::Analytics::CycleAnalytics::DefaultStages.all.size + 1
)
end end
context 'Add a stage button' do context 'Add a stage button' do
......
import { createLocalVue, shallowMount, mount } from '@vue/test-utils'; import { createLocalVue, shallowMount, mount } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
import Vue from 'vue'; import Vue from 'vue';
import httpStatusCodes from '~/lib/utils/http_status';
import store from 'ee/analytics/cycle_analytics/store'; import store from 'ee/analytics/cycle_analytics/store';
import Component from 'ee/analytics/cycle_analytics/components/base.vue'; import Component from 'ee/analytics/cycle_analytics/components/base.vue';
import { GlEmptyState, GlDaterangePicker } from '@gitlab/ui'; import { GlEmptyState, GlDaterangePicker } from '@gitlab/ui';
...@@ -41,8 +42,8 @@ function createComponent({ opts = {}, shallow = true, withStageSelected = false ...@@ -41,8 +42,8 @@ function createComponent({ opts = {}, shallow = true, withStageSelected = false
...mockData.group, ...mockData.group,
}); });
comp.vm.$store.dispatch('receiveCycleAnalyticsDataSuccess', { comp.vm.$store.dispatch('receiveGroupStagesAndEventsSuccess', {
...mockData.cycleAnalyticsData, ...mockData.customizableStagesAndEvents,
}); });
comp.vm.$store.dispatch('receiveStageDataSuccess', { comp.vm.$store.dispatch('receiveStageDataSuccess', {
...@@ -296,6 +297,40 @@ describe('Cycle Analytics component', () => { ...@@ -296,6 +297,40 @@ describe('Cycle Analytics component', () => {
}); });
describe('with failed requests while loading', () => { describe('with failed requests while loading', () => {
const { full_path: groupId } = mockData.group;
function mockRequestCycleAnalyticsData(overrides = {}) {
const defaultStatus = 200;
const defaultRequests = {
fetchSummaryData: {
status: defaultStatus,
endpoint: `/groups/${groupId}/-/cycle_analytics`,
response: { ...mockData.cycleAnalyticsData },
},
fetchGroupStagesAndEvents: {
status: defaultStatus,
endpoint: `/-/analytics/cycle_analytics/stages?group_id=${groupId}`,
response: { ...mockData.customizableStagesAndEvents },
},
fetchGroupLabels: {
status: defaultStatus,
endpoint: `/groups/${groupId}/-/labels`,
response: { ...mockData.groupLabels },
},
fetchStageData: {
status: defaultStatus,
// default first stage is issue
endpoint: '/groups/foo/-/cycle_analytics/events/issue.json',
response: { ...mockData.issueEvents },
},
...overrides,
};
Object.values(defaultRequests).forEach(({ endpoint, status, response }) => {
mock.onGet(endpoint).replyOnce(status, response);
});
}
beforeEach(() => { beforeEach(() => {
setFixtures('<div class="flash-container"></div>'); setFixtures('<div class="flash-container"></div>');
...@@ -310,20 +345,22 @@ describe('Cycle Analytics component', () => { ...@@ -310,20 +345,22 @@ describe('Cycle Analytics component', () => {
const findFlashError = () => document.querySelector('.flash-container .flash-text'); const findFlashError = () => document.querySelector('.flash-container .flash-text');
it('will display an error if the fetchCycleAnalyticsData request fails', () => { it('will display an error if the fetchSummaryData request fails', () => {
expect(findFlashError()).toBeNull(); expect(findFlashError()).toBeNull();
mock mockRequestCycleAnalyticsData({
.onGet('/groups/foo/-/labels') fetchSummaryData: {
.replyOnce(200, { response: { ...mockData.groupLabels } }) status: httpStatusCodes.NOT_FOUND,
.onGet('/groups/foo/-/cycle_analytics') endpoint: `/groups/${groupId}/-/cycle_analytics`,
.replyOnce(500, { response: { status: 500 } }); response: { response: { status: httpStatusCodes.NOT_FOUND } },
},
});
wrapper.vm.onGroupSelect(mockData.group); wrapper.vm.onGroupSelect(mockData.group);
return waitForPromises().then(() => { return waitForPromises().then(() => {
expect(findFlashError().innerText.trim()).toEqual( expect(findFlashError().innerText.trim()).toEqual(
'There was an error while fetching cycle analytics data.', 'There was an error while fetching cycle analytics summary data.',
); );
}); });
}); });
...@@ -331,7 +368,12 @@ describe('Cycle Analytics component', () => { ...@@ -331,7 +368,12 @@ describe('Cycle Analytics component', () => {
it('will display an error if the fetchGroupLabels request fails', () => { it('will display an error if the fetchGroupLabels request fails', () => {
expect(findFlashError()).toBeNull(); expect(findFlashError()).toBeNull();
mock.onGet('/groups/foo/-/labels').replyOnce(404, { response: { status: 404 } }); mockRequestCycleAnalyticsData({
fetchGroupLabels: {
status: httpStatusCodes.NOT_FOUND,
response: { response: { status: httpStatusCodes.NOT_FOUND } },
},
});
wrapper.vm.onGroupSelect(mockData.group); wrapper.vm.onGroupSelect(mockData.group);
...@@ -341,5 +383,45 @@ describe('Cycle Analytics component', () => { ...@@ -341,5 +383,45 @@ describe('Cycle Analytics component', () => {
); );
}); });
}); });
it('will display an error if the fetchGroupStagesAndEvents request fails', () => {
expect(findFlashError()).toBeNull();
mockRequestCycleAnalyticsData({
fetchGroupStagesAndEvents: {
endPoint: '/-/analytics/cycle_analytics/stages',
status: httpStatusCodes.NOT_FOUND,
response: { response: { status: httpStatusCodes.NOT_FOUND } },
},
});
wrapper.vm.onGroupSelect(mockData.group);
return waitForPromises().then(() => {
expect(findFlashError().innerText.trim()).toEqual(
'There was an error fetching cycle analytics stages.',
);
});
});
it('will display an error if the fetchStageData request fails', () => {
expect(findFlashError()).toBeNull();
mockRequestCycleAnalyticsData({
fetchStageData: {
endPoint: `/groups/${groupId}/-/cycle_analytics/events/issue.json`,
status: httpStatusCodes.NOT_FOUND,
response: { response: { status: httpStatusCodes.NOT_FOUND } },
},
});
wrapper.vm.onGroupSelect(mockData.group);
return waitForPromises().then(() => {
expect(findFlashError().innerText.trim()).toEqual(
'There was an error fetching data for the selected stage',
);
});
});
}); });
}); });
...@@ -29,7 +29,6 @@ function createComponent(props = {}, shallow = false) { ...@@ -29,7 +29,6 @@ function createComponent(props = {}, shallow = false) {
labels: groupLabels, labels: groupLabels,
isLoading: false, isLoading: false,
isEmptyStage: false, isEmptyStage: false,
isUserAllowed: true,
isAddingCustomStage: false, isAddingCustomStage: false,
noDataSvgPath, noDataSvgPath,
noAccessSvgPath, noAccessSvgPath,
...@@ -159,45 +158,9 @@ describe('StageTable', () => { ...@@ -159,45 +158,9 @@ describe('StageTable', () => {
expect(wrapper.find($sel.illustration).html()).toContain(noDataSvgPath); expect(wrapper.find($sel.illustration).html()).toContain(noDataSvgPath);
}); });
it('will display the no data title', () => { it('will display the no data message', () => {
expect(wrapper.html()).toContain("We don't have enough data to show this stage."); expect(wrapper.html()).toContain("We don't have enough data to show this stage.");
}); });
it('will display the no data description', () => {
expect(wrapper.html()).toContain(
'The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.',
);
});
});
describe('isUserAllowed = false', () => {
beforeEach(() => {
wrapper = createComponent({
currentStage: {
...issueStage,
isUserAllowed: false,
},
});
});
afterEach(() => {
wrapper.destroy();
});
it('will render the no access illustration', () => {
expect(wrapper.find($sel.illustration).exists()).toBeTruthy();
expect(wrapper.find($sel.illustration).html()).toContain(noAccessSvgPath);
});
it('will display the no access title', () => {
expect(wrapper.html()).toContain('You need permission.');
});
it('will display the no access description', () => {
expect(wrapper.html()).toContain(
'Want to see the data? Please ask an administrator for access.',
);
});
}); });
describe('canEditStages = true', () => { describe('canEditStages = true', () => {
......
...@@ -7,6 +7,16 @@ import { getDateInPast } from '~/lib/utils/datetime_utility'; ...@@ -7,6 +7,16 @@ import { getDateInPast } from '~/lib/utils/datetime_utility';
import { DEFAULT_DAYS_IN_PAST } from 'ee/analytics/cycle_analytics/constants'; import { DEFAULT_DAYS_IN_PAST } from 'ee/analytics/cycle_analytics/constants';
import { mockLabels } from '../../../../../spec/javascripts/vue_shared/components/sidebar/labels_select/mock_data'; import { mockLabels } from '../../../../../spec/javascripts/vue_shared/components/sidebar/labels_select/mock_data';
/*
* With the new API endpoints (analytics/cycle_analytics) we will
* fetch stages, cycleEvents and summary data from different endpoints
*/
const endpoints = {
cycleAnalyticsData: 'cycle_analytics/mock_data.json', // existing cycle analytics data
customizableCycleAnalyticsStagesAndEvents: 'analytics/cycle_analytics/stages.json', // customizable stages and events endpoint
stageEvents: stage => `cycle_analytics/events/${stage}.json`,
};
export const groupLabels = mockLabels.map(({ title, ...rest }) => ({ ...rest, name: title })); export const groupLabels = mockLabels.map(({ title, ...rest }) => ({ ...rest, name: title }));
export const group = { export const group = {
...@@ -17,22 +27,26 @@ export const group = { ...@@ -17,22 +27,26 @@ export const group = {
avatar_url: `${TEST_HOST}/images/home/nasa.svg`, avatar_url: `${TEST_HOST}/images/home/nasa.svg`,
}; };
const getStageBySlug = (stages, slug) => stages.find(stage => stage.slug === slug) || {}; const getStageById = (stages, id) => stages.find(stage => stage.id === id) || {};
export const cycleAnalyticsData = getJSONFixture('cycle_analytics/mock_data.json'); export const cycleAnalyticsData = getJSONFixture(endpoints.cycleAnalyticsData);
export const customizableStagesAndEvents = getJSONFixture(
endpoints.customizableCycleAnalyticsStagesAndEvents,
);
const dummyState = {}; const dummyState = {};
// prepare the raw stage data for our components // prepare the raw stage data for our components
mutations[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](dummyState, cycleAnalyticsData); mutations[types.RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS](dummyState, customizableStagesAndEvents);
export const issueStage = getStageBySlug(dummyState.stages, 'issue'); export const issueStage = getStageById(dummyState.stages, 'issue');
export const planStage = getStageBySlug(dummyState.stages, 'plan'); export const planStage = getStageById(dummyState.stages, 'plan');
export const reviewStage = getStageBySlug(dummyState.stages, 'review'); export const reviewStage = getStageById(dummyState.stages, 'review');
export const codeStage = getStageBySlug(dummyState.stages, 'code'); export const codeStage = getStageById(dummyState.stages, 'code');
export const testStage = getStageBySlug(dummyState.stages, 'test'); export const testStage = getStageById(dummyState.stages, 'test');
export const stagingStage = getStageBySlug(dummyState.stages, 'staging'); export const stagingStage = getStageById(dummyState.stages, 'staging');
export const productionStage = getStageBySlug(dummyState.stages, 'production'); export const productionStage = getStageById(dummyState.stages, 'production');
export const allowedStages = [issueStage, planStage, codeStage]; export const allowedStages = [issueStage, planStage, codeStage];
...@@ -43,14 +57,14 @@ const deepCamelCase = obj => convertObjectPropsToCamelCase(obj, { deep: true }); ...@@ -43,14 +57,14 @@ const deepCamelCase = obj => convertObjectPropsToCamelCase(obj, { deep: true });
const defaultStages = ['issue', 'plan', 'review', 'code', 'test', 'staging', 'production']; const defaultStages = ['issue', 'plan', 'review', 'code', 'test', 'staging', 'production'];
const stageFixtures = defaultStages.reduce((acc, stage) => { const stageFixtures = defaultStages.reduce((acc, stage) => {
const { events } = getJSONFixture(`cycle_analytics/events/${stage}.json`); const { events } = getJSONFixture(endpoints.stageEvents(stage));
return { return {
...acc, ...acc,
[stage]: deepCamelCase(events), [stage]: deepCamelCase(events),
}; };
}, {}); }, {});
export const endDate = new Date(Date.now()); export const endDate = new Date(2019, 0, 14);
export const startDate = getDateInPast(endDate, DEFAULT_DAYS_IN_PAST); export const startDate = getDateInPast(endDate, DEFAULT_DAYS_IN_PAST);
export const issueEvents = stageFixtures.issue; export const issueEvents = stageFixtures.issue;
...@@ -61,7 +75,7 @@ export const testEvents = stageFixtures.test; ...@@ -61,7 +75,7 @@ export const testEvents = stageFixtures.test;
export const stagingEvents = stageFixtures.staging; export const stagingEvents = stageFixtures.staging;
export const productionEvents = stageFixtures.production; export const productionEvents = stageFixtures.production;
const { events: rawCustomStageEvents } = getJSONFixture('analytics/cycle_analytics/stages.json'); const { events: rawCustomStageEvents } = customizableStagesAndEvents;
const camelCasedStageEvents = rawCustomStageEvents.map(deepCamelCase); const camelCasedStageEvents = rawCustomStageEvents.map(deepCamelCase);
export const customStageStartEvents = camelCasedStageEvents.filter(ev => ev.canBeStartEvent); export const customStageStartEvents = camelCasedStageEvents.filter(ev => ev.canBeStartEvent);
...@@ -74,6 +88,7 @@ export const customStageStopEvents = camelCasedStageEvents.filter(ev => ...@@ -74,6 +88,7 @@ export const customStageStopEvents = camelCasedStageEvents.filter(ev =>
); );
// TODO: the shim below should be removed once we have label events seeding // TODO: the shim below should be removed once we have label events seeding
// https://gitlab.com/gitlab-org/gitlab/issues/33112
export const labelStartEvent = { ...customStageStartEvents[0], type: 'label' }; export const labelStartEvent = { ...customStageStartEvents[0], type: 'label' };
const firstAllowedStopEvent = labelStartEvent.allowedEndEvents[0]; const firstAllowedStopEvent = labelStartEvent.allowedEndEvents[0];
// We need to enusre that the stop event can be applied to the start event // We need to enusre that the stop event can be applied to the start event
......
...@@ -3,6 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; ...@@ -3,6 +3,7 @@ import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper'; import testAction from 'helpers/vuex_action_helper';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import createFlash from '~/flash'; import createFlash from '~/flash';
import * as getters from 'ee/analytics/cycle_analytics/store/getters';
import * as actions from 'ee/analytics/cycle_analytics/store/actions'; import * as actions from 'ee/analytics/cycle_analytics/store/actions';
import * as types from 'ee/analytics/cycle_analytics/store/mutation_types'; import * as types from 'ee/analytics/cycle_analytics/store/mutation_types';
import { import {
...@@ -12,6 +13,7 @@ import { ...@@ -12,6 +13,7 @@ import {
groupLabels, groupLabels,
startDate, startDate,
endDate, endDate,
customizableStagesAndEvents,
} from '../mock_data'; } from '../mock_data';
const stageData = { events: [] }; const stageData = { events: [] };
...@@ -19,6 +21,7 @@ const error = new Error('Request failed with status code 404'); ...@@ -19,6 +21,7 @@ const error = new Error('Request failed with status code 404');
const groupPath = 'cool-group'; const groupPath = 'cool-group';
const groupLabelsEndpoint = `/groups/${groupPath}/-/labels`; const groupLabelsEndpoint = `/groups/${groupPath}/-/labels`;
const flashErrorMessage = 'There was an error while fetching cycle analytics data.'; const flashErrorMessage = 'There was an error while fetching cycle analytics data.';
const selectedGroup = { fullPath: groupPath };
describe('Cycle analytics actions', () => { describe('Cycle analytics actions', () => {
let state; let state;
...@@ -35,12 +38,14 @@ describe('Cycle analytics actions', () => { ...@@ -35,12 +38,14 @@ describe('Cycle analytics actions', () => {
stageData: `${TEST_HOST}/groups/${group.path}/-/cycle_analytics/events/${cycleAnalyticsData.stats[0].name}.json`, stageData: `${TEST_HOST}/groups/${group.path}/-/cycle_analytics/events/${cycleAnalyticsData.stats[0].name}.json`,
}, },
stages: [], stages: [],
getters,
}; };
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
}); });
afterEach(() => { afterEach(() => {
mock.restore(); mock.restore();
state = { ...state, selectedGroup: null };
}); });
it.each` it.each`
...@@ -49,7 +54,7 @@ describe('Cycle analytics actions', () => { ...@@ -49,7 +54,7 @@ describe('Cycle analytics actions', () => {
${'setStageDataEndpoint'} | ${'SET_STAGE_DATA_ENDPOINT'} | ${'endpoints.stageData'} | ${'new_stage_name'} ${'setStageDataEndpoint'} | ${'SET_STAGE_DATA_ENDPOINT'} | ${'endpoints.stageData'} | ${'new_stage_name'}
${'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]}
${'setSelectedStageName'} | ${'SET_SELECTED_STAGE_NAME'} | ${'selectedStageName'} | ${'someNewGroup'} ${'setSelectedStageId'} | ${'SET_SELECTED_STAGE_ID'} | ${'selectedStageId'} | ${'someNewGroup'}
`('$action should set $stateKey with $payload and type $type', ({ action, type, payload }) => { `('$action should set $stateKey with $payload and type $type', ({ action, type, payload }) => {
testAction( testAction(
actions[action], actions[action],
...@@ -164,19 +169,20 @@ describe('Cycle analytics actions', () => { ...@@ -164,19 +169,20 @@ describe('Cycle analytics actions', () => {
commit: () => {}, commit: () => {},
}); });
shouldFlashAnError(); shouldFlashAnError('There was an error fetching data for the selected stage');
}); });
}); });
describe('fetchGroupLabels', () => { describe('fetchGroupLabels', () => {
beforeEach(() => { beforeEach(() => {
state = { ...state, selectedGroup };
mock.onGet(groupLabelsEndpoint).replyOnce(200, groupLabels); mock.onGet(groupLabelsEndpoint).replyOnce(200, groupLabels);
}); });
it('dispatches receiveGroupLabels if the request succeeds', done => { it('dispatches receiveGroupLabels if the request succeeds', done => {
testAction( testAction(
actions.fetchGroupLabels, actions.fetchGroupLabels,
groupPath, null,
state, state,
[], [],
[ [
...@@ -193,8 +199,8 @@ describe('Cycle analytics actions', () => { ...@@ -193,8 +199,8 @@ describe('Cycle analytics actions', () => {
it('dispatches receiveGroupLabelsError if the request fails', done => { it('dispatches receiveGroupLabelsError if the request fails', done => {
testAction( testAction(
actions.fetchGroupLabels, actions.fetchGroupLabels,
'this-path-does-not-exist', null,
state, { ...state, selectedGroup: { fullPath: null } },
[], [],
[ [
{ type: 'requestGroupLabels' }, { type: 'requestGroupLabels' },
...@@ -222,132 +228,157 @@ describe('Cycle analytics actions', () => { ...@@ -222,132 +228,157 @@ describe('Cycle analytics actions', () => {
}); });
describe('fetchCycleAnalyticsData', () => { describe('fetchCycleAnalyticsData', () => {
function mockFetchCycleAnalyticsAction(overrides = {}) {
const mocks = {
requestCycleAnalyticsData:
overrides.requestCycleAnalyticsData || jest.fn().mockResolvedValue(),
fetchGroupStagesAndEvents:
overrides.fetchGroupStagesAndEvents || jest.fn().mockResolvedValue(),
fetchSummaryData: overrides.fetchSummaryData || jest.fn().mockResolvedValue(),
receiveCycleAnalyticsDataSuccess:
overrides.receiveCycleAnalyticsDataSuccess || jest.fn().mockResolvedValue(),
};
return {
mocks,
mockDispatchContext: jest
.fn()
.mockImplementationOnce(mocks.requestCycleAnalyticsData)
.mockImplementationOnce(mocks.fetchGroupStagesAndEvents)
.mockImplementationOnce(mocks.fetchSummaryData)
.mockImplementationOnce(mocks.receiveCycleAnalyticsDataSuccess),
};
}
beforeEach(() => { beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
mock.onGet(state.endpoints.cycleAnalyticsData).replyOnce(200, cycleAnalyticsData); mock.onGet(state.endpoints.cycleAnalyticsData).replyOnce(200, cycleAnalyticsData);
state = { ...state, selectedGroup, startDate, endDate };
}); });
it('dispatches receiveCycleAnalyticsDataSuccess with received data', done => { it(`dispatches actions for required cycle analytics data`, done => {
testAction( const { mocks, mockDispatchContext } = mockFetchCycleAnalyticsAction();
actions.fetchCycleAnalyticsData,
null, actions
state, .fetchCycleAnalyticsData({
[], dispatch: mockDispatchContext,
[ state: {},
{ type: 'requestCycleAnalyticsData' }, commit: () => {},
{ })
type: 'receiveCycleAnalyticsDataSuccess', .then(() => {
payload: { ...cycleAnalyticsData }, expect(mockDispatchContext).toHaveBeenCalled();
}, expect(mocks.requestCycleAnalyticsData).toHaveBeenCalled();
], expect(mocks.fetchGroupStagesAndEvents).toHaveBeenCalled();
done, expect(mocks.fetchSummaryData).toHaveBeenCalled();
); expect(mocks.receiveCycleAnalyticsDataSuccess).toHaveBeenCalled();
done();
})
.catch(done.fail);
}); });
it('dispatches receiveCycleAnalyticsError on error', done => { it(`displays an error if fetchSummaryData fails`, done => {
const brokenState = { const { mockDispatchContext } = mockFetchCycleAnalyticsAction({
...state, fetchSummaryData: actions.fetchSummaryData({
endpoints: { dispatch: jest
cycleAnalyticsData: 'this will break', .fn()
}, .mockResolvedValueOnce()
}; .mockImplementation(actions.receiveSummaryDataError({ commit: () => {} })),
commit: () => {},
state: { ...state, endpoints: { cycleAnalyticsData: '/this/is/fake' } },
getters,
}),
});
testAction( actions
actions.fetchCycleAnalyticsData, .fetchCycleAnalyticsData({
null, dispatch: mockDispatchContext,
brokenState, state: {},
[], commit: () => {},
[ })
{ type: 'requestCycleAnalyticsData' }, .then(() => {
{ shouldFlashAnError('There was an error while fetching cycle analytics summary data.');
type: 'receiveCycleAnalyticsDataError', done();
payload: error, })
}, .catch(done.fail);
],
done,
);
}); });
describe('requestCycleAnalyticsData', () => { it(`displays an error if fetchGroupStagesAndEvents fails`, done => {
it(`commits the ${types.REQUEST_CYCLE_ANALYTICS_DATA} mutation`, done => { const { mockDispatchContext } = mockFetchCycleAnalyticsAction({
testAction( fetchGroupStagesAndEvents: actions.fetchGroupStagesAndEvents({
actions.requestCycleAnalyticsData, dispatch: jest
{ ...cycleAnalyticsData }, .fn()
state, .mockResolvedValueOnce()
[ .mockImplementation(actions.receiveGroupStagesAndEventsError({ commit: () => {} })),
{ commit: () => {},
type: types.REQUEST_CYCLE_ANALYTICS_DATA, state: { ...state, endpoints: { cycleAnalyticsData: '/this/is/fake' } },
}, getters,
], }),
[],
done,
);
}); });
});
});
describe('receiveCycleAnalyticsDataSuccess', () => { actions
beforeEach(() => { .fetchCycleAnalyticsData({
setFixtures('<div class="flash-container"></div>'); dispatch: mockDispatchContext,
}); state: {},
it(`commits the ${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} mutation`, done => { commit: () => {},
testAction( })
actions.receiveCycleAnalyticsDataSuccess, .then(() => {
{ ...cycleAnalyticsData }, shouldFlashAnError('There was an error fetching cycle analytics stages.');
state, done();
[ })
{ .catch(done.fail);
type: types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS,
payload: { ...cycleAnalyticsData },
},
],
[],
done,
);
}); });
it('removes an existing flash error if present', () => { describe('with an existing error', () => {
const commit = jest.fn(); beforeEach(() => {
const dispatch = jest.fn(); setFixtures('<div class="flash-container"></div>');
const stateWithStages = { });
...state,
stages,
};
createFlash(flashErrorMessage);
const flashAlert = document.querySelector('.flash-alert'); it('removes an existing flash error if present', done => {
const { mockDispatchContext } = mockFetchCycleAnalyticsAction();
createFlash(flashErrorMessage);
expect(flashAlert).toBeVisible(); const flashAlert = document.querySelector('.flash-alert');
actions.receiveCycleAnalyticsDataSuccess({ commit, dispatch, state: stateWithStages }); expect(flashAlert).toBeVisible();
expect(flashAlert.style.opacity).toBe('0'); actions
.fetchCycleAnalyticsData({
dispatch: mockDispatchContext,
state: {},
commit: () => {},
})
.then(() => {
expect(flashAlert.style.opacity).toBe('0');
done();
})
.catch(done.fail);
});
}); });
it("dispatches the 'setStageDataEndpoint' and 'fetchStageData' actions", done => { it("dispatches the 'setStageDataEndpoint' and 'fetchStageData' actions", done => {
const { slug } = stages[0]; const { id } = stages[0];
const stateWithStages = { const stateWithStages = {
...state, ...state,
stages, stages,
}; };
testAction( testAction(
actions.receiveCycleAnalyticsDataSuccess, actions.receiveGroupStagesAndEventsSuccess,
{ ...cycleAnalyticsData }, { ...customizableStagesAndEvents },
stateWithStages, stateWithStages,
[ [
{ {
type: types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS, type: types.RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS,
payload: { ...cycleAnalyticsData }, payload: { ...customizableStagesAndEvents },
}, },
], ],
[{ type: 'setStageDataEndpoint', payload: slug }, { type: 'fetchStageData' }], [{ type: 'setStageDataEndpoint', payload: id }, { type: 'fetchStageData' }],
done, done,
); );
}); });
it('will flash an error when there are no stages', () => { it('will flash an error when there are no stages', () => {
[[], null].forEach(emptyStages => { [[], null].forEach(emptyStages => {
actions.receiveCycleAnalyticsDataSuccess( actions.receiveGroupStagesAndEventsSuccess(
{ {
commit: () => {}, commit: () => {},
state: { stages: emptyStages }, state: { stages: emptyStages },
...@@ -364,6 +395,7 @@ describe('Cycle analytics actions', () => { ...@@ -364,6 +395,7 @@ describe('Cycle analytics actions', () => {
beforeEach(() => { beforeEach(() => {
setFixtures('<div class="flash-container"></div>'); setFixtures('<div class="flash-container"></div>');
}); });
it(`commits the ${types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR} mutation on a 403 response`, done => { it(`commits the ${types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR} mutation on a 403 response`, done => {
const response = { status: 403 }; const response = { status: 403 };
testAction( testAction(
...@@ -410,4 +442,61 @@ describe('Cycle analytics actions', () => { ...@@ -410,4 +442,61 @@ describe('Cycle analytics actions', () => {
shouldFlashAnError(); shouldFlashAnError();
}); });
}); });
describe('receiveGroupStagesAndEventsSuccess', () => {
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
});
it(`commits the ${types.RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS} mutation`, done => {
testAction(
actions.receiveGroupStagesAndEventsSuccess,
{ ...customizableStagesAndEvents },
state,
[
{
type: types.RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS,
payload: { ...customizableStagesAndEvents },
},
],
[],
done,
);
});
it("dispatches the 'setStageDataEndpoint' and 'fetchStageData' actions", done => {
const { id } = stages[0];
const stateWithStages = {
...state,
stages,
};
testAction(
actions.receiveGroupStagesAndEventsSuccess,
{ ...customizableStagesAndEvents },
stateWithStages,
[
{
type: types.RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS,
payload: { ...customizableStagesAndEvents },
},
],
[{ type: 'setStageDataEndpoint', payload: id }, { type: 'fetchStageData' }],
done,
);
});
it('will flash an error when there are no stages', () => {
[[], null].forEach(emptyStages => {
actions.receiveGroupStagesAndEventsSuccess(
{
commit: () => {},
state: { stages: emptyStages },
},
{},
);
shouldFlashAnError();
});
});
});
}); });
import * as getters from 'ee/analytics/cycle_analytics/store/getters'; import * as getters from 'ee/analytics/cycle_analytics/store/getters';
import { allowedStages as stages } from '../mock_data'; import { allowedStages as stages, startDate, endDate } from '../mock_data';
let state = null; let state = null;
const selectedProjectIds = [5, 8, 11];
describe('Cycle analytics getters', () => { describe('Cycle analytics getters', () => {
describe('with default state', () => { describe('with default state', () => {
beforeEach(() => { beforeEach(() => {
state = { state = {
stages: [], stages: [],
selectedStageName: null, selectedStageId: null,
}; };
}); });
...@@ -33,7 +34,7 @@ describe('Cycle analytics getters', () => { ...@@ -33,7 +34,7 @@ describe('Cycle analytics getters', () => {
beforeEach(() => { beforeEach(() => {
state = { state = {
stages, stages,
selectedStageName: null, selectedStageId: null,
}; };
}); });
...@@ -58,7 +59,7 @@ describe('Cycle analytics getters', () => { ...@@ -58,7 +59,7 @@ describe('Cycle analytics getters', () => {
beforeEach(() => { beforeEach(() => {
state = { state = {
stages, stages,
selectedStageName: stages[2].name, selectedStageId: stages[2].id,
}; };
}); });
...@@ -111,4 +112,27 @@ describe('Cycle analytics getters', () => { ...@@ -111,4 +112,27 @@ describe('Cycle analytics getters', () => {
}); });
}); });
}); });
describe('cycleAnalyticsRequestParams', () => {
beforeEach(() => {
const fullPath = 'cool-beans';
state = {
selectedGroup: {
fullPath,
},
startDate,
endDate,
selectedProjectIds,
};
});
it.each`
param | value
${'created_after'} | ${'2018-12-15'}
${'created_before'} | ${'2019-01-14'}
${'project_ids'} | ${[5, 8, 11]}
`('should return the $param with value $value', ({ param, value }) => {
expect(getters.cycleAnalyticsRequestParams(state)).toMatchObject({ [param]: value });
});
});
}); });
...@@ -15,6 +15,7 @@ import { ...@@ -15,6 +15,7 @@ import {
groupLabels, groupLabels,
startDate, startDate,
endDate, endDate,
customizableStagesAndEvents,
} from '../mock_data'; } from '../mock_data';
let state = null; let state = null;
...@@ -29,15 +30,21 @@ describe('Cycle analytics mutations', () => { ...@@ -29,15 +30,21 @@ describe('Cycle analytics mutations', () => {
}); });
it.each` it.each`
mutation | stateKey | value mutation | stateKey | value
${types.REQUEST_STAGE_DATA} | ${'isLoadingStage'} | ${true} ${types.HIDE_CUSTOM_STAGE_FORM} | ${'isAddingCustomStage'} | ${false}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isEmptyStage'} | ${true} ${types.SHOW_CUSTOM_STAGE_FORM} | ${'isAddingCustomStage'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isLoadingStage'} | ${false} ${types.REQUEST_STAGE_DATA} | ${'isLoadingStage'} | ${true}
${types.REQUEST_CYCLE_ANALYTICS_DATA} | ${'isLoading'} | ${true} ${types.RECEIVE_STAGE_DATA_ERROR} | ${'isEmptyStage'} | ${true}
${types.HIDE_CUSTOM_STAGE_FORM} | ${'isAddingCustomStage'} | ${false} ${types.RECEIVE_STAGE_DATA_ERROR} | ${'isLoadingStage'} | ${false}
${types.SHOW_CUSTOM_STAGE_FORM} | ${'isAddingCustomStage'} | ${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.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'} | ${[]}
`('$mutation will set $stateKey=$value', ({ mutation, stateKey, value }) => { `('$mutation will set $stateKey=$value', ({ mutation, stateKey, value }) => {
mutations[mutation](state); mutations[mutation](state);
...@@ -46,12 +53,12 @@ describe('Cycle analytics mutations', () => { ...@@ -46,12 +53,12 @@ describe('Cycle analytics mutations', () => {
it.each` it.each`
mutation | payload | expectedState mutation | payload | expectedState
${types.SET_CYCLE_ANALYTICS_DATA_ENDPOINT} | ${'cool-beans'} | ${{ endpoints: { cycleAnalyticsData: '/groups/cool-beans/-/cycle_analytics' } }} ${types.SET_CYCLE_ANALYTICS_DATA_ENDPOINT} | ${'cool-beans'} | ${{ endpoints: { cycleAnalyticsStagesAndEvents: '/-/analytics/cycle_analytics/stages?group_id=cool-beans' } }}
${types.SET_STAGE_DATA_ENDPOINT} | ${'rad-stage'} | ${{ endpoints: { stageData: '/fake/api/events/rad-stage.json' } }} ${types.SET_STAGE_DATA_ENDPOINT} | ${'rad-stage'} | ${{ endpoints: { stageData: '/groups/rad-stage/-/cycle_analytics/events/rad-stage.json' } }}
${types.SET_SELECTED_GROUP} | ${{ fullPath: 'cool-beans' }} | ${{ selectedGroup: { fullPath: 'cool-beans' }, selectedProjectIds: [] }} ${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_SELECTED_PROJECTS} | ${[606, 707, 808, 909]} | ${{ selectedProjectIds: [606, 707, 808, 909] }}
${types.SET_DATE_RANGE} | ${{ startDate, endDate }} | ${{ startDate, endDate }} ${types.SET_DATE_RANGE} | ${{ startDate, endDate }} | ${{ startDate, endDate }}
${types.SET_SELECTED_STAGE_NAME} | ${'first-stage'} | ${{ selectedStageName: 'first-stage' }} ${types.SET_SELECTED_STAGE_ID} | ${'first-stage'} | ${{ selectedStageId: 'first-stage' }}
`( `(
'$mutation with payload $payload will update state with $expectedState', '$mutation with payload $payload will update state with $expectedState',
({ mutation, payload, expectedState }) => { ({ mutation, payload, expectedState }) => {
...@@ -110,11 +117,18 @@ describe('Cycle analytics mutations', () => { ...@@ -110,11 +117,18 @@ describe('Cycle analytics mutations', () => {
expect(state.errorCode).toBe(null); expect(state.errorCode).toBe(null);
expect(state.isLoading).toBe(false); expect(state.isLoading).toBe(false);
}); });
});
describe(`${types.RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS}`, () => {
describe('with data', () => { describe('with data', () => {
it('will convert the stats object to stages', () => { beforeEach(() => {
mutations[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, cycleAnalyticsData); mutations[types.RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS](
state,
customizableStagesAndEvents,
);
});
it('will convert the stats object to stages', () => {
[issueStage, planStage, codeStage, stagingStage, reviewStage, productionStage].forEach( [issueStage, planStage, codeStage, stagingStage, reviewStage, productionStage].forEach(
stage => { stage => {
expect(state.stages).toContainEqual(stage); expect(state.stages).toContainEqual(stage);
...@@ -122,25 +136,48 @@ describe('Cycle analytics mutations', () => { ...@@ -122,25 +136,48 @@ describe('Cycle analytics mutations', () => {
); );
}); });
it('will set the selectedStageName to the name of the first stage', () => { it('will set the selectedStageId to the id of the first stage', () => {
mutations[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, cycleAnalyticsData); expect(state.selectedStageId).toEqual('issue');
expect(state.selectedStageName).toEqual('issue');
}); });
});
});
it('will set each summary item with a value of 0 to "-"', () => { describe(`${types.RECEIVE_SUMMARY_DATA_SUCCESS}`, () => {
// { value: '-', title: 'New Issues' }, { value: '-', title: 'Deploys' } beforeEach(() => {
state = { stages: [{ slug: 'plan' }, { slug: 'issue' }, { slug: 'test' }] };
mutations[types.RECEIVE_SUMMARY_DATA_SUCCESS](state, {
...cycleAnalyticsData,
summary: [{ value: 0, title: 'New Issues' }, { value: 0, title: 'Deploys' }],
stats: [
{
name: 'issue',
value: '1 day ago',
},
{
name: 'plan',
value: '6 months ago',
},
{
name: 'test',
value: null,
},
],
});
});
mutations[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, { it('will set each summary item with a value of 0 to "-"', () => {
...cycleAnalyticsData, expect(state.summary).toEqual([
summary: [{ value: 0, title: 'New Issues' }, { value: 0, title: 'Deploys' }], { value: '-', title: 'New Issues' },
}); { value: '-', title: 'Deploys' },
]);
});
expect(state.summary).toEqual([ it('will set the median value for each stage', () => {
{ value: '-', title: 'New Issues' }, expect(state.stages).toEqual([
{ value: '-', title: 'Deploys' }, { slug: 'plan', value: '6 months ago' },
]); { slug: 'issue', value: '1 day ago' },
}); { slug: 'test', value: null },
]);
}); });
}); });
......
...@@ -5,6 +5,7 @@ import { ...@@ -5,6 +5,7 @@ import {
eventToOption, eventToOption,
eventsByIdentifier, eventsByIdentifier,
getLabelEventsIdentifiers, getLabelEventsIdentifiers,
nestQueryStringKeys,
} from 'ee/analytics/cycle_analytics/utils'; } from 'ee/analytics/cycle_analytics/utils';
import { import {
customStageEvents as events, customStageEvents as events,
...@@ -103,4 +104,30 @@ describe('Cycle analytics utils', () => { ...@@ -103,4 +104,30 @@ describe('Cycle analytics utils', () => {
expect(eventsByIdentifier([], labelEvents)).toEqual([]); expect(eventsByIdentifier([], labelEvents)).toEqual([]);
}); });
}); });
describe('nestQueryStringKeys', () => {
const targetKey = 'foo';
const obj = { bar: 10, baz: 'awesome', qux: false, boo: ['lol', 'something'] };
it('will return an object with each key nested under the targetKey', () => {
expect(nestQueryStringKeys(obj, targetKey)).toEqual({
'foo[bar]': 10,
'foo[baz]': 'awesome',
'foo[qux]': false,
'foo[boo]': ['lol', 'something'],
});
});
it('returns an empty object if the targetKey is not a valid string', () => {
['', null, {}, []].forEach(badStr => {
expect(nestQueryStringKeys(obj, badStr)).toEqual({});
});
});
it('will return an empty object if given an empty object', () => {
[{}, null, [], ''].forEach(tarObj => {
expect(nestQueryStringKeys(tarObj, targetKey)).toEqual({});
});
});
});
}); });
...@@ -16967,6 +16967,12 @@ msgstr "" ...@@ -16967,6 +16967,12 @@ msgstr ""
msgid "There was an error fetching configuration for charts" msgid "There was an error fetching configuration for charts"
msgstr "" msgstr ""
msgid "There was an error fetching cycle analytics stages."
msgstr ""
msgid "There was an error fetching data for the selected stage"
msgstr ""
msgid "There was an error fetching label data for the selected group" msgid "There was an error fetching label data for the selected group"
msgstr "" msgstr ""
...@@ -17009,6 +17015,9 @@ msgstr "" ...@@ -17009,6 +17015,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 summary data."
msgstr ""
msgid "There was an error with the reCAPTCHA. Please solve the reCAPTCHA again." msgid "There was an error with the reCAPTCHA. Please solve the reCAPTCHA again."
msgstr "" msgstr ""
......
...@@ -90,6 +90,19 @@ describe('text_utility', () => { ...@@ -90,6 +90,19 @@ describe('text_utility', () => {
}); });
}); });
describe('convertToSnakeCase', () => {
it.each`
txt | result
${'snakeCase'} | ${'snake_case'}
${'snake Case'} | ${'snake_case'}
${'snake case'} | ${'snake_case'}
${'snake_case'} | ${'snake_case'}
${'snakeCasesnake Case'} | ${'snake_casesnake_case'}
`('converts string $txt to $result string', ({ txt, result }) => {
expect(textUtils.convertToSnakeCase(txt)).toEqual(result);
});
});
describe('convertToSentenceCase', () => { describe('convertToSentenceCase', () => {
it('converts Sentence Case to Sentence case', () => { it('converts Sentence Case to Sentence case', () => {
expect(textUtils.convertToSentenceCase('Hello World')).toBe('Hello world'); expect(textUtils.convertToSentenceCase('Hello World')).toBe('Hello world');
......
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